From c7a09e865d60ad8ff4cba4d322beb3e02fc97e11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 30 Apr 2026 22:33:43 +0200 Subject: [PATCH] Add sync_pushes table and granted_scopes column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync_pushes tracks outbound route pushes to providers, keyed by (user_id, route_id, route_version, provider). Successful rows hold the remote_id; failed rows hold the error and can be retried in place. The unique index makes idempotent push trivial. granted_scopes records the OAuth scope set we requested at exchangeCode time. Wahoo doesn't return a scope field in token responses and grants scopes all-or-nothing, so the requested set is the source of truth. Defaults to an empty array, which means any pre-existing connection will be flagged as scope-mismatched on the first routes_write push — the intended UX for the slice 3 re-auth flow. Adjusts tasks 2.3/2.4 in the spec to match repo reality: this project runs `drizzle-kit push --force` schema-first, with no checked-in migrations directory. Co-Authored-By: Claude Opus 4.7 --- openspec/changes/wahoo-route-push/tasks.md | 8 ++-- packages/db/src/schema/journal.ts | 43 ++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/openspec/changes/wahoo-route-push/tasks.md b/openspec/changes/wahoo-route-push/tasks.md index 2d44cbf..7917559 100644 --- a/openspec/changes/wahoo-route-push/tasks.md +++ b/openspec/changes/wahoo-route-push/tasks.md @@ -11,10 +11,10 @@ ## 2. Database schema for push tracking -- [ ] 2.1 Add `sync_pushes` table to `packages/db/src/schema/journal.ts` with columns from design.md §3 and a unique index on `(user_id, route_id, route_version, provider)` -- [ ] 2.2 Add a `granted_scopes text[]` column to `sync_connections` in the same schema file, defaulting to an empty array, and backfill existing rows with the legacy scopes (`workouts_read`, `user_read`, `offline_data`) -- [ ] 2.3 Generate the Drizzle migration via `pnpm --filter @trails-cool/db db:generate` (migration files land in `packages/db/migrations/`) -- [ ] 2.4 Apply locally via `pnpm db:push` and verify with `pnpm db:studio` +- [x] 2.1 Add `sync_pushes` table to `packages/db/src/schema/journal.ts` with columns from design.md §3 and a unique index on `(user_id, route_id, route_version, provider)` +- [x] 2.2 Add a `granted_scopes text[]` column to `sync_connections` in the same schema file, defaulting to an empty array. (Backfill happens at `exchangeCode` time in slice 3 — pre-existing connections will be recognized as scope-mismatched on first push and trigger re-auth, which is the intended UX.) +- [x] 2.3 ~~Generate Drizzle migration files~~ — N/A: this repo runs `drizzle-kit push --force` schema-first, no checked-in `migrations/` directory. The schema edit is the migration source. +- [ ] 2.4 Apply locally via `pnpm db:push` against a running dev Postgres and verify with `pnpm db:studio` — deferred: requires `pnpm dev:services` (Docker) which isn't running in this workspace. CI applies on deploy. ## 3. Wahoo provider scope upgrade diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts index f4679c3..e0618b8 100644 --- a/packages/db/src/schema/journal.ts +++ b/packages/db/src/schema/journal.ts @@ -196,6 +196,15 @@ export const syncConnections = journalSchema.table("sync_connections", { refreshToken: text("refresh_token").notNull(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), providerUserId: text("provider_user_id"), + // Scopes the user actually granted at OAuth time. Wahoo doesn't return a + // scope field in token responses and grants scopes all-or-nothing, so we + // record the requested scope set on exchangeCode. Used to detect when an + // existing connection predates a scope upgrade (e.g. routes_write) and + // needs a re-auth before a new push call can succeed. + grantedScopes: text("granted_scopes") + .array() + .notNull() + .default(sql`ARRAY[]::text[]`), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); @@ -210,6 +219,40 @@ export const syncImports = journalSchema.table("sync_imports", { importedAt: timestamp("imported_at", { withTimezone: true }).notNull().defaultNow(), }); +// 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. +export const syncPushes = journalSchema.table( + "sync_pushes", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + 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"), + 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( + t.userId, + t.routeId, + t.routeVersion, + t.provider, + ), + }), +); + // Social follow relation. Always originates from a local user (`followerId`). // The followed side is keyed by an actor IRI for federation forward-compat — // today every IRI is local (`https://{DOMAIN}/users/{username}`); future