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