Apply wahoo-route-update: PUT on re-push instead of duplicate POST
Re-pushing an edited route to Wahoo now updates the existing remote route via PUT against the stored remote_id instead of POSTing a new copy. external_id drops the version suffix and identifies the logical route. sync_pushes is keyed by (user, route, provider) and tracks last_pushed_version for the "local newer" UI state. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
4853a116ff
commit
db3eeed60f
14 changed files with 435 additions and 99 deletions
50
packages/db/migrations/0001_wahoo_route_update.sql
Normal file
50
packages/db/migrations/0001_wahoo_route_update.sql
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
-- Data migration for the wahoo-route-update change.
|
||||
--
|
||||
-- Why this lives outside drizzle-kit push:
|
||||
-- `drizzle-kit push --force` will happily DROP `route_version` and try to add
|
||||
-- the new unique index on (user_id, route_id, provider), but if any user has
|
||||
-- successfully pushed multiple versions of a route the duplicate-row check on
|
||||
-- the new unique would fail. We need to backfill `last_pushed_version` and
|
||||
-- collapse rows BEFORE drizzle-kit reshapes the table.
|
||||
--
|
||||
-- Idempotent: safe to run before every deploy until the old `route_version`
|
||||
-- column is gone. Once `route_version` is dropped (i.e. drizzle-kit push has
|
||||
-- run after this script), the body is a no-op.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'journal'
|
||||
AND table_name = 'sync_pushes'
|
||||
AND column_name = 'route_version'
|
||||
) THEN
|
||||
-- 1. Add the new column (nullable) if drizzle-kit push hasn't yet.
|
||||
ALTER TABLE journal.sync_pushes
|
||||
ADD COLUMN IF NOT EXISTS last_pushed_version integer;
|
||||
|
||||
-- 2. Backfill last_pushed_version from route_version for every row.
|
||||
UPDATE journal.sync_pushes
|
||||
SET last_pushed_version = route_version
|
||||
WHERE last_pushed_version IS NULL;
|
||||
|
||||
-- 3. Collapse rows: per (user_id, route_id, provider), keep the row with
|
||||
-- the highest route_version. Ties broken by created_at desc so we keep
|
||||
-- the most recent. The kept row's remote_id (if any) is the live one.
|
||||
DELETE FROM journal.sync_pushes a
|
||||
USING journal.sync_pushes b
|
||||
WHERE a.user_id = b.user_id
|
||||
AND a.route_id = b.route_id
|
||||
AND a.provider = b.provider
|
||||
AND (
|
||||
a.route_version < b.route_version
|
||||
OR (a.route_version = b.route_version AND a.created_at < b.created_at)
|
||||
);
|
||||
|
||||
-- 4. Drop the old unique index that includes route_version. The new one
|
||||
-- on (user_id, route_id, provider) is created by drizzle-kit push from
|
||||
-- the schema definition.
|
||||
DROP INDEX IF EXISTS journal.sync_pushes_user_route_version_provider_unique;
|
||||
END IF;
|
||||
END $$;
|
||||
37
packages/db/src/migrate-data.ts
Normal file
37
packages/db/src/migrate-data.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// Runs hand-written SQL data migrations from packages/db/migrations/*.sql in
|
||||
// lexical order. Each file is expected to be idempotent (safe to re-run);
|
||||
// there is no migrations table.
|
||||
//
|
||||
// Why this exists: we use `drizzle-kit push --force` for schema, which is
|
||||
// fine for column adds and renames but unsafe when a unique-key change
|
||||
// requires collapsing existing rows first. The wahoo-route-update change is
|
||||
// the first such case.
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import postgres from "postgres";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const migrationsDir = path.resolve(__dirname, "..", "migrations");
|
||||
|
||||
async function main() {
|
||||
const url = process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails";
|
||||
const sql = postgres(url);
|
||||
try {
|
||||
const files = readdirSync(migrationsDir)
|
||||
.filter((f) => f.endsWith(".sql"))
|
||||
.sort();
|
||||
for (const f of files) {
|
||||
const body = readFileSync(path.join(migrationsDir, f), "utf8");
|
||||
console.log(`[migrate-data] applying ${f}`);
|
||||
await sql.unsafe(body);
|
||||
}
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -220,10 +220,11 @@ export const syncImports = journalSchema.table("sync_imports", {
|
|||
});
|
||||
|
||||
// Tracks outbound route pushes to external providers (Wahoo today). One row
|
||||
// per (user, route, version, provider) — push idempotency lives here. A row
|
||||
// with `pushedAt` set means we have a confirmed remote_id and won't re-call
|
||||
// the provider; a row with `error` set and no `pushedAt` is a failed attempt
|
||||
// that can be retried in place.
|
||||
// per (user, route, provider) — a logical Wahoo route is per-route, not
|
||||
// per-version. `lastPushedVersion` records which local version is currently
|
||||
// reflected on the remote side; `remoteId` lets subsequent pushes PUT in
|
||||
// place. A row with `error` set and no `pushedAt` is a failed attempt that
|
||||
// can be retried (POST if `remoteId` is null, PUT otherwise).
|
||||
export const syncPushes = journalSchema.table(
|
||||
"sync_pushes",
|
||||
{
|
||||
|
|
@ -234,20 +235,19 @@ export const syncPushes = journalSchema.table(
|
|||
routeId: text("route_id")
|
||||
.notNull()
|
||||
.references(() => routes.id, { onDelete: "cascade" }),
|
||||
routeVersion: integer("route_version").notNull(),
|
||||
provider: text("provider").notNull(),
|
||||
externalId: text("external_id").notNull(),
|
||||
remoteId: text("remote_id"),
|
||||
lastPushedVersion: integer("last_pushed_version"),
|
||||
pushedAt: timestamp("pushed_at", { withTimezone: true }),
|
||||
error: text("error"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
userRouteVersionProviderUnique: uniqueIndex("sync_pushes_user_route_version_provider_unique").on(
|
||||
userRouteProviderUnique: uniqueIndex("sync_pushes_user_route_provider_unique").on(
|
||||
t.userId,
|
||||
t.routeId,
|
||||
t.routeVersion,
|
||||
t.provider,
|
||||
),
|
||||
}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue