Schema rename + ConnectedServiceManager foundation (groups 1-2)
Implements tasks 1.1-1.4 and 2.1-2.5 of deepen-connected-services:
DB:
- Rename journal.sync_connections -> journal.connected_services.
- Add credential_kind discriminator (oauth | web-login | device) and
credentials JSONB (shape per kind), status column, and a unique index
on (user_id, provider) lifting the previously app-only invariant
into the DB.
- Idempotent backfill in 0002_connected_services.sql moves existing
Wahoo rows' tokens into the JSONB blob with credential_kind='oauth'.
Code:
- New apps/journal/app/lib/connected-services/ module:
- types.ts: ConnectedService, CredentialKind, OAuthCredentials,
NeedsRelinkError, CredentialAdapter, ProviderOAuthConfig, etc.
- credential-adapters/oauth.ts: standard OAuth2 refresh_token flow,
revoke endpoint, 4xx -> NeedsRelinkError / 5xx -> transient.
- manager.ts: ConnectedServiceManager (link, unlink, withFreshCredentials,
markNeedsRelink). Centralizes credential lifecycle in one chokepoint.
- registry.ts: ProviderManifest type + capability seam interfaces
(Importer, RoutePusher, WebhookReceiver). Manifests register
themselves at import time.
Tests:
- manager.test.ts (8 tests): refresh-on-expired, refresh-fail->needs_relink,
ConnectionNotActiveError, link/unlink, revoke is best-effort.
- credential-adapters/oauth.test.ts (10 tests): refresh contract,
refresh_token retention, 4xx vs 5xx behaviour, revoke.
- All 18 new tests pass.
Compatibility:
- apps/journal/app/lib/sync/connections.server.ts is now a thin shim
translating the legacy TokenSet API onto the JSONB-shaped table so
existing callers (routes, pushes.server.ts) keep working until tasks
5.x migrate them to the manager. To be deleted in task 5.6.
Pre-existing journal test failures (12) are unrelated to this change:
they pre-date this PR and stem from a workspace resolution issue with
@trails-cool/fit (verified by running tests against main).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cfba3146e2
commit
6de516718d
11 changed files with 1155 additions and 70 deletions
89
packages/db/migrations/0002_connected_services.sql
Normal file
89
packages/db/migrations/0002_connected_services.sql
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
-- Data migration for the deepen-connected-services change.
|
||||
--
|
||||
-- Why this lives outside drizzle-kit push:
|
||||
-- We're (a) renaming `sync_connections` → `connected_services`, (b) collapsing
|
||||
-- the OAuth-shaped columns (access_token, refresh_token, expires_at) into a
|
||||
-- polymorphic `credentials` JSONB blob discriminated by `credential_kind`,
|
||||
-- and (c) adding a `status` column. drizzle-kit push would happily DROP the
|
||||
-- old columns before we backfill, losing every active Wahoo connection.
|
||||
--
|
||||
-- This script reshapes the table into the new layout BEFORE drizzle-kit push
|
||||
-- diffs against the schema. After it runs, drizzle-kit push has nothing left
|
||||
-- to do for this table.
|
||||
--
|
||||
-- Idempotent: safe to run before every deploy.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
-- 1. Rename table if it still has the old name.
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'journal' AND table_name = 'sync_connections'
|
||||
) THEN
|
||||
ALTER TABLE journal.sync_connections RENAME TO connected_services;
|
||||
END IF;
|
||||
|
||||
-- Bail out if the renamed table doesn't exist yet (fresh DB; drizzle-kit
|
||||
-- push will create connected_services directly from the schema).
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'journal' AND table_name = 'connected_services'
|
||||
) THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- 2. Add the new columns (nullable initially so backfill can populate them).
|
||||
ALTER TABLE journal.connected_services
|
||||
ADD COLUMN IF NOT EXISTS credential_kind text,
|
||||
ADD COLUMN IF NOT EXISTS credentials jsonb,
|
||||
ADD COLUMN IF NOT EXISTS status text;
|
||||
|
||||
-- 3. Backfill from old token columns if they still exist. Existing rows are
|
||||
-- all OAuth (Wahoo), so credential_kind = 'oauth' and the JSONB blob
|
||||
-- holds {access_token, refresh_token, expires_at}.
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'journal'
|
||||
AND table_name = 'connected_services'
|
||||
AND column_name = 'access_token'
|
||||
) THEN
|
||||
UPDATE journal.connected_services
|
||||
SET credential_kind = 'oauth',
|
||||
credentials = jsonb_build_object(
|
||||
'access_token', access_token,
|
||||
'refresh_token', refresh_token,
|
||||
'expires_at', to_char(expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
),
|
||||
status = COALESCE(status, 'active')
|
||||
WHERE credential_kind IS NULL;
|
||||
END IF;
|
||||
|
||||
-- 4. Set NOT NULL on the new columns now that they're populated.
|
||||
ALTER TABLE journal.connected_services
|
||||
ALTER COLUMN credential_kind SET NOT NULL,
|
||||
ALTER COLUMN credentials SET NOT NULL,
|
||||
ALTER COLUMN status SET NOT NULL;
|
||||
|
||||
-- 5. CHECK constraints. Drop-then-add for idempotency.
|
||||
ALTER TABLE journal.connected_services
|
||||
DROP CONSTRAINT IF EXISTS connected_services_credential_kind_check;
|
||||
ALTER TABLE journal.connected_services
|
||||
ADD CONSTRAINT connected_services_credential_kind_check
|
||||
CHECK (credential_kind IN ('oauth', 'web-login', 'device'));
|
||||
|
||||
ALTER TABLE journal.connected_services
|
||||
DROP CONSTRAINT IF EXISTS connected_services_status_check;
|
||||
ALTER TABLE journal.connected_services
|
||||
ADD CONSTRAINT connected_services_status_check
|
||||
CHECK (status IN ('active', 'needs_relink', 'revoked'));
|
||||
|
||||
-- 6. Default for status (so future inserts that omit it get 'active').
|
||||
ALTER TABLE journal.connected_services
|
||||
ALTER COLUMN status SET DEFAULT 'active';
|
||||
|
||||
-- 7. Unique (user_id, provider) — the spec invariant "at most one row per
|
||||
-- (user, provider)" was previously enforced only at the application
|
||||
-- layer. Lift it into the DB.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS connected_services_user_provider_unique
|
||||
ON journal.connected_services (user_id, provider);
|
||||
END $$;
|
||||
|
|
@ -186,27 +186,42 @@ export const oauthTokens = journalSchema.table("oauth_tokens", {
|
|||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const syncConnections = journalSchema.table("sync_connections", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
provider: text("provider").notNull(),
|
||||
accessToken: text("access_token").notNull(),
|
||||
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(),
|
||||
});
|
||||
// External-service connections (OAuth, web-login, mobile-paired devices).
|
||||
// `credential_kind` discriminates the shape of `credentials` JSONB:
|
||||
// - 'oauth': { access_token, refresh_token, expires_at }
|
||||
// - 'web-login': { email, encrypted_password, session_jar } (Komoot, future)
|
||||
// - 'device': {} (Apple Health, future)
|
||||
// See docs/adr/0001 and CONTEXT.md (Connected Services).
|
||||
export const connectedServices = journalSchema.table(
|
||||
"connected_services",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
provider: text("provider").notNull(),
|
||||
credentialKind: text("credential_kind").notNull(),
|
||||
credentials: jsonb("credentials").notNull(),
|
||||
// 'active' | 'needs_relink' | 'revoked'. Manager flips to 'needs_relink'
|
||||
// when CredentialAdapter.refresh returns a permanent failure.
|
||||
status: text("status").notNull().default("active"),
|
||||
providerUserId: text("provider_user_id"),
|
||||
// OAuth-only. Promoted out of the credentials JSONB because feature gates
|
||||
// (e.g. routes_write check on push) read this on every call.
|
||||
grantedScopes: text("granted_scopes")
|
||||
.array()
|
||||
.notNull()
|
||||
.default(sql`ARRAY[]::text[]`),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
userProviderUnique: uniqueIndex("connected_services_user_provider_unique").on(
|
||||
t.userId,
|
||||
t.provider,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
export const syncImports = journalSchema.table("sync_imports", {
|
||||
id: text("id").primaryKey(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue