@opengeni/db 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/{chunk-OGCE6O2X.js → chunk-7LDU7F5P.js} +31 -3
  2. package/dist/chunk-7LDU7F5P.js.map +1 -0
  3. package/dist/chunk-O3D7DABC.js +2314 -0
  4. package/dist/chunk-O3D7DABC.js.map +1 -0
  5. package/dist/{chunk-57MLICFR.js → chunk-YFQ7SGE4.js} +18 -6
  6. package/dist/chunk-YFQ7SGE4.js.map +1 -0
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.js +13129 -3496
  9. package/dist/index.js.map +1 -1
  10. package/dist/migrate.js +1 -1
  11. package/dist/provision-roles.d.ts +1836 -222
  12. package/dist/provision-roles.js +1 -1
  13. package/dist/{schema-Dsz6UHNv.d.ts → schema-Dp2MbBxx.d.ts} +7480 -3305
  14. package/dist/schema.d.ts +1 -1
  15. package/dist/schema.js +33 -5
  16. package/drizzle/0043_credit_balance_observability.sql +22 -0
  17. package/drizzle/0044_reap_dead_turn_holders.sql +104 -0
  18. package/drizzle/0045_workspace_captures.sql +83 -0
  19. package/drizzle/0045_workspace_memory_v1.sql +71 -0
  20. package/drizzle/0046_variable_sets_rename.sql +56 -0
  21. package/drizzle/0047_rigs.sql +151 -0
  22. package/drizzle/0048_rig_runtime.sql +9 -0
  23. package/drizzle/0049_enrollment_went_offline.sql +28 -0
  24. package/drizzle/0050_enrollment_op_stream.sql +1 -0
  25. package/drizzle/0051_codex_pin_source.sql +48 -0
  26. package/drizzle/0052_file_upload_cleanup.sql +91 -0
  27. package/drizzle/0053_codex_credential_leases.sql +230 -0
  28. package/drizzle/0054_session_pins.sql +85 -0
  29. package/drizzle/0055_session_list_snapshots.sql +73 -0
  30. package/drizzle/0056_workspace_model_policies.sql +48 -0
  31. package/drizzle/0057_durable_queue_control.sql +536 -0
  32. package/drizzle/0058_turn_admission_usage_enrollment.sql +158 -0
  33. package/drizzle/0059_workspace_pause_control_kind.sql +12 -0
  34. package/drizzle/0060_session_system_update_deferral.sql +10 -0
  35. package/drizzle/0061_session_workflow_wake_outbox.sql +157 -0
  36. package/drizzle/0062_session_list_snapshot_reaper.sql +48 -0
  37. package/package.json +17 -13
  38. package/src/codex-token-resolver.ts +58 -23
  39. package/src/connection-token-resolver.ts +146 -57
  40. package/src/environment-crypto.ts +5 -1
  41. package/src/event-payload-sanitizer.ts +29 -1
  42. package/src/index.ts +22460 -6492
  43. package/src/memory-domain.ts +218 -0
  44. package/src/migrate.ts +58 -3
  45. package/src/provision-roles.ts +46 -17
  46. package/src/schema.ts +2557 -1121
  47. package/src/session-control-cutover-audit.ts +1203 -0
  48. package/dist/chunk-57MLICFR.js.map +0 -1
  49. package/dist/chunk-OGCE6O2X.js.map +0 -1
  50. package/dist/chunk-ZIUCA2IO.js +0 -1268
  51. package/dist/chunk-ZIUCA2IO.js.map +0 -1
@@ -0,0 +1,158 @@
1
+ -- One-way production remediation after the 0057 session-control cutover.
2
+ --
3
+ -- 1. Provider response usage has exactly one authoritative current event. SDK
4
+ -- wrapper duplicates stay in the audit log with an explicit association.
5
+ -- 2. Workflow repair enrolls every durable state that needs a session workflow,
6
+ -- including approval/capacity waits and pending control settlement.
7
+
8
+ ALTER TABLE "session_events"
9
+ DROP CONSTRAINT "session_events_turn_association_check";
10
+
11
+ ALTER TABLE "session_events"
12
+ ADD COLUMN "duplicate_of_event_id" uuid,
13
+ ADD COLUMN "duplicate_reason" text;
14
+
15
+ ALTER TABLE "session_events"
16
+ ADD CONSTRAINT "session_events_duplicate_of_event_fk"
17
+ FOREIGN KEY ("duplicate_of_event_id")
18
+ REFERENCES "session_events"("id") ON DELETE RESTRICT;
19
+
20
+ ALTER TABLE "session_events"
21
+ ADD CONSTRAINT "session_events_turn_association_check"
22
+ CHECK (
23
+ "turn_association" IS NULL
24
+ OR "turn_association" IN ('current', 'late_rejected', 'duplicate')
25
+ );
26
+
27
+ -- Keep the earliest event as the authoritative observation. Every later event
28
+ -- for the same turn/provider-response source remains queryable and points to
29
+ -- that canonical row; nothing is deleted or disguised as a late attempt write.
30
+ WITH ranked AS (
31
+ SELECT
32
+ e."id",
33
+ first_value(e."id") OVER (
34
+ PARTITION BY
35
+ e."workspace_id",
36
+ e."session_id",
37
+ e."turn_id",
38
+ e."payload" ->> 'sourceKey'
39
+ ORDER BY e."sequence", e."id"
40
+ ) AS "canonical_id",
41
+ row_number() OVER (
42
+ PARTITION BY
43
+ e."workspace_id",
44
+ e."session_id",
45
+ e."turn_id",
46
+ e."payload" ->> 'sourceKey'
47
+ ORDER BY e."sequence", e."id"
48
+ ) AS "ordinal"
49
+ FROM "session_events" e
50
+ WHERE e."type" = 'agent.model.usage'
51
+ AND e."turn_association" = 'current'
52
+ AND e."turn_id" IS NOT NULL
53
+ AND nullif(e."payload" ->> 'sourceKey', '') IS NOT NULL
54
+ )
55
+ UPDATE "session_events" e
56
+ SET "turn_association" = 'duplicate',
57
+ "duplicate_of_event_id" = ranked."canonical_id",
58
+ "duplicate_reason" = 'duplicate_provider_response_usage'
59
+ FROM ranked
60
+ WHERE e."id" = ranked."id"
61
+ AND ranked."ordinal" > 1;
62
+
63
+ ALTER TABLE "session_events"
64
+ ADD CONSTRAINT "session_events_duplicate_classification_check"
65
+ CHECK (
66
+ (
67
+ "turn_association" = 'duplicate'
68
+ AND "type" = 'agent.model.usage'
69
+ AND "duplicate_of_event_id" IS NOT NULL
70
+ AND "duplicate_of_event_id" <> "id"
71
+ AND nullif("duplicate_reason", '') IS NOT NULL
72
+ )
73
+ OR (
74
+ "turn_association" IS DISTINCT FROM 'duplicate'
75
+ AND "duplicate_of_event_id" IS NULL
76
+ AND "duplicate_reason" IS NULL
77
+ )
78
+ );
79
+
80
+ CREATE UNIQUE INDEX "session_events_current_model_usage_source_uq"
81
+ ON "session_events" (
82
+ "workspace_id",
83
+ "session_id",
84
+ "turn_id",
85
+ (("payload" ->> 'sourceKey'))
86
+ )
87
+ WHERE "type" = 'agent.model.usage'
88
+ AND "turn_association" = 'current'
89
+ AND "turn_id" IS NOT NULL
90
+ AND nullif("payload" ->> 'sourceKey', '') IS NOT NULL;
91
+
92
+ -- The old name described database claimability, not the actual operational
93
+ -- contract. A workflow must also be enrolled while it waits for approval or
94
+ -- capacity, and any pending Pause/Steer control needs a workflow to settle it.
95
+ DROP FUNCTION opengeni_private.list_claimable_sessions(integer);
96
+
97
+ CREATE FUNCTION opengeni_private.list_enrollable_sessions(p_limit integer)
98
+ RETURNS TABLE (
99
+ account_id uuid,
100
+ workspace_id uuid,
101
+ session_id uuid,
102
+ temporal_workflow_id text
103
+ )
104
+ LANGUAGE sql
105
+ SECURITY DEFINER
106
+ AS $$
107
+ SELECT DISTINCT
108
+ s.account_id,
109
+ s.workspace_id,
110
+ s.id,
111
+ coalesce(s.temporal_workflow_id, 'session-' || s.id::text)
112
+ FROM sessions s
113
+ JOIN workspaces w ON w.id = s.workspace_id
114
+ WHERE
115
+ -- Controls are durable intent. Even a closed inference gate must enroll a
116
+ -- workflow long enough to settle the pending Pause/Steer fence.
117
+ s.pending_control_event_id IS NOT NULL
118
+ OR (
119
+ s.control_state = 'active'
120
+ AND (
121
+ w.inference_state = 'active'
122
+ OR s.workspace_run_exception_generation = w.inference_generation
123
+ )
124
+ AND (
125
+ EXISTS (
126
+ SELECT 1 FROM session_turns t
127
+ WHERE t.workspace_id = s.workspace_id
128
+ AND t.session_id = s.id
129
+ AND t.status IN ('queued', 'recovering', 'waiting_capacity', 'requires_action')
130
+ )
131
+ OR EXISTS (
132
+ SELECT 1 FROM session_system_updates u
133
+ WHERE u.workspace_id = s.workspace_id
134
+ AND u.session_id = s.id
135
+ AND u.state = 'pending'
136
+ )
137
+ OR EXISTS (
138
+ SELECT 1 FROM session_goals g
139
+ WHERE g.workspace_id = s.workspace_id
140
+ AND g.session_id = s.id
141
+ AND g.status = 'active'
142
+ )
143
+ )
144
+ )
145
+ ORDER BY s.id
146
+ LIMIT greatest(1, least(coalesce(p_limit, 1000), 10000));
147
+ $$;
148
+
149
+ REVOKE ALL ON FUNCTION opengeni_private.list_enrollable_sessions(integer) FROM PUBLIC;
150
+
151
+ DO $$
152
+ DECLARE target_schema text := current_schema();
153
+ BEGIN
154
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
155
+ GRANT EXECUTE ON FUNCTION opengeni_private.list_enrollable_sessions(integer)
156
+ TO opengeni_app;
157
+ END IF;
158
+ END $$;
@@ -0,0 +1,12 @@
1
+ -- Workspace Pause is a first-class durable control kind. The 0057 constraint
2
+ -- predates that control path and must match the canonical runtime state machine.
3
+
4
+ ALTER TABLE "sessions"
5
+ DROP CONSTRAINT "sessions_pending_control_kind_check";
6
+
7
+ ALTER TABLE "sessions"
8
+ ADD CONSTRAINT "sessions_pending_control_kind_check"
9
+ CHECK (
10
+ "pending_control_kind" IS NULL
11
+ OR "pending_control_kind" IN ('pause', 'workspace_pause', 'steer')
12
+ );
@@ -0,0 +1,10 @@
1
+ -- A failed internal-only inference preserves ordinary internal updates without
2
+ -- making them independently runnable again. They become eligible when a real
3
+ -- prompt or a genuinely new pending internal update starts the next inference.
4
+
5
+ ALTER TABLE "session_system_updates"
6
+ DROP CONSTRAINT "system_updates_state_check";
7
+
8
+ ALTER TABLE "session_system_updates"
9
+ ADD CONSTRAINT "system_updates_state_check"
10
+ CHECK ("state" IN ('pending', 'deferred', 'delivered', 'cancelled', 'failed'));
@@ -0,0 +1,157 @@
1
+ -- OPE-50: replace the blind periodic scan of "enrollable" sessions with a
2
+ -- transactional, revisioned, coalescing delivery ledger. Postgres is the
3
+ -- durable work source; a Temporal signal is an idempotent nudge.
4
+
5
+ CREATE TABLE "session_workflow_wake_outbox" (
6
+ "session_id" uuid PRIMARY KEY,
7
+ "account_id" uuid NOT NULL,
8
+ "workspace_id" uuid NOT NULL,
9
+ "temporal_workflow_id" text NOT NULL,
10
+ "wake_revision" bigint NOT NULL DEFAULT 1,
11
+ "delivered_revision" bigint NOT NULL DEFAULT 0,
12
+ "reason" text NOT NULL,
13
+ "attempts" integer NOT NULL DEFAULT 0,
14
+ "next_attempt_at" timestamptz NOT NULL DEFAULT now(),
15
+ "last_error" text,
16
+ "created_at" timestamptz NOT NULL DEFAULT now(),
17
+ "updated_at" timestamptz NOT NULL DEFAULT now(),
18
+ CONSTRAINT "session_workflow_wake_outbox_revision_check"
19
+ CHECK ("wake_revision" > 0 AND "delivered_revision" >= 0
20
+ AND "delivered_revision" <= "wake_revision"),
21
+ CONSTRAINT "session_workflow_wake_outbox_workspace_account_fk"
22
+ FOREIGN KEY ("workspace_id", "account_id")
23
+ REFERENCES "workspaces"("id", "account_id") ON DELETE CASCADE,
24
+ CONSTRAINT "session_workflow_wake_outbox_workspace_session_fk"
25
+ FOREIGN KEY ("workspace_id", "session_id")
26
+ REFERENCES "sessions"("workspace_id", "id") ON DELETE CASCADE
27
+ );
28
+
29
+ CREATE UNIQUE INDEX "session_workflow_wake_outbox_workspace_session_uq"
30
+ ON "session_workflow_wake_outbox" ("workspace_id", "session_id");
31
+ CREATE INDEX "session_workflow_wake_outbox_pending_idx"
32
+ ON "session_workflow_wake_outbox" ("next_attempt_at", "updated_at", "session_id")
33
+ WHERE "wake_revision" > "delivered_revision";
34
+
35
+ ALTER TABLE "session_workflow_wake_outbox" ENABLE ROW LEVEL SECURITY;
36
+ ALTER TABLE "session_workflow_wake_outbox" FORCE ROW LEVEL SECURITY;
37
+ CREATE POLICY workspace_isolation ON "session_workflow_wake_outbox"
38
+ USING (opengeni_private.workspace_rls_visible(account_id, workspace_id))
39
+ WITH CHECK (opengeni_private.workspace_rls_visible(account_id, workspace_id));
40
+
41
+ -- Workspace control retries now persist the exact control deliveries they own.
42
+ -- Canonicalize pre-cutover receipts once so runtime code has one result shape;
43
+ -- any still-pending historical control is independently seeded below.
44
+ UPDATE "runtime_control_operations"
45
+ SET "result" = jsonb_set("result", '{controls}', '[]'::jsonb, true)
46
+ WHERE "scope" = 'workspace'
47
+ AND jsonb_typeof("result") = 'object'
48
+ AND NOT ("result" ? 'controls');
49
+
50
+ -- Claim only committed revisions that still need delivery. The due timestamp
51
+ -- is advanced before delivery, so a process death becomes retryable after the
52
+ -- bounded backoff. Acknowledgements are revision-scoped: an old delivery can
53
+ -- never hide a newer committed wake.
54
+ DO $migration$
55
+ DECLARE target_schema text := current_schema();
56
+ BEGIN
57
+ EXECUTE format($create$
58
+ CREATE FUNCTION opengeni_private.claim_session_workflow_wakes(p_limit integer)
59
+ RETURNS TABLE (
60
+ account_id uuid,
61
+ workspace_id uuid,
62
+ session_id uuid,
63
+ temporal_workflow_id text,
64
+ wake_revision bigint,
65
+ control_event_id uuid
66
+ )
67
+ LANGUAGE plpgsql
68
+ SECURITY DEFINER
69
+ SET search_path = pg_catalog
70
+ AS $function$
71
+ BEGIN
72
+ RETURN QUERY
73
+ WITH due AS (
74
+ SELECT o.session_id
75
+ FROM %1$I.session_workflow_wake_outbox o
76
+ WHERE o.wake_revision > o.delivered_revision
77
+ AND o.next_attempt_at <= now()
78
+ ORDER BY o.next_attempt_at, o.updated_at, o.session_id
79
+ FOR UPDATE SKIP LOCKED
80
+ LIMIT greatest(1, least(coalesce(p_limit, 100), 1000))
81
+ )
82
+ UPDATE %1$I.session_workflow_wake_outbox o
83
+ SET attempts = o.attempts + 1,
84
+ next_attempt_at = now() + make_interval(
85
+ secs => least(300, greatest(1, power(2, least(o.attempts, 8))::integer))
86
+ ),
87
+ updated_at = now()
88
+ FROM due
89
+ WHERE o.session_id = due.session_id
90
+ RETURNING o.account_id, o.workspace_id, o.session_id,
91
+ o.temporal_workflow_id, o.wake_revision,
92
+ (SELECT s.pending_control_event_id FROM %1$I.sessions s WHERE s.id = o.session_id)
93
+ AS control_event_id;
94
+ END $function$;
95
+ $create$, target_schema);
96
+ END $migration$;
97
+ REVOKE ALL ON FUNCTION opengeni_private.claim_session_workflow_wakes(integer) FROM PUBLIC;
98
+
99
+ -- One-time cutover seed. This is intentionally the final use of an eligibility
100
+ -- scan: it converts already-committed pre-outbox work into explicit revisions.
101
+ -- Runtime repair after this migration reads only the outbox.
102
+ INSERT INTO "session_workflow_wake_outbox" (
103
+ "session_id", "account_id", "workspace_id", "temporal_workflow_id", "reason"
104
+ )
105
+ SELECT DISTINCT
106
+ s."id",
107
+ s."account_id",
108
+ s."workspace_id",
109
+ coalesce(s."temporal_workflow_id", 'session-' || s."id"::text),
110
+ 'cutover_seed'
111
+ FROM "sessions" s
112
+ JOIN "workspaces" w ON w."id" = s."workspace_id"
113
+ WHERE
114
+ s."pending_control_event_id" IS NOT NULL
115
+ OR (
116
+ s."control_state" = 'active'
117
+ AND (
118
+ w."inference_state" = 'active'
119
+ OR s."workspace_run_exception_generation" = w."inference_generation"
120
+ )
121
+ AND (
122
+ EXISTS (
123
+ SELECT 1 FROM "session_turns" t
124
+ WHERE t."workspace_id" = s."workspace_id"
125
+ AND t."session_id" = s."id"
126
+ AND t."status" IN ('queued', 'recovering', 'waiting_capacity', 'requires_action')
127
+ )
128
+ OR EXISTS (
129
+ SELECT 1 FROM "session_system_updates" u
130
+ WHERE u."workspace_id" = s."workspace_id"
131
+ AND u."session_id" = s."id"
132
+ AND u."state" = 'pending'
133
+ )
134
+ OR EXISTS (
135
+ SELECT 1 FROM "session_goals" g
136
+ WHERE g."workspace_id" = s."workspace_id"
137
+ AND g."session_id" = s."id"
138
+ AND g."status" = 'active'
139
+ )
140
+ OR s."compact_requested" = true
141
+ )
142
+ );
143
+
144
+ DROP FUNCTION opengeni_private.list_enrollable_sessions(integer);
145
+
146
+ DO $$
147
+ DECLARE target_schema text := current_schema();
148
+ BEGIN
149
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
150
+ EXECUTE format(
151
+ 'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE %I.session_workflow_wake_outbox TO opengeni_app',
152
+ target_schema
153
+ );
154
+ GRANT EXECUTE ON FUNCTION opengeni_private.claim_session_workflow_wakes(integer)
155
+ TO opengeni_app;
156
+ END IF;
157
+ END $$;
@@ -0,0 +1,48 @@
1
+ -- OPE-44: session list reads are serializable and subject-scoped. Performing a
2
+ -- global expired-row delete inside every read makes concurrent API replicas
3
+ -- conflict on the same rows. Move TTL cleanup to one bounded SKIP LOCKED
4
+ -- reaper operation and keep the request transaction strictly request-local.
5
+
6
+ CREATE INDEX "session_list_snapshots_expiry_reaper_idx"
7
+ ON "session_list_snapshots" ("expires_at", "id");
8
+
9
+ DO $migration$
10
+ DECLARE target_schema text := current_schema();
11
+ BEGIN
12
+ EXECUTE format($create$
13
+ CREATE FUNCTION opengeni_private.reap_expired_session_list_snapshots(p_limit integer)
14
+ RETURNS integer
15
+ LANGUAGE plpgsql
16
+ SECURITY DEFINER
17
+ SET search_path = pg_catalog
18
+ AS $function$
19
+ DECLARE deleted_count integer;
20
+ BEGIN
21
+ WITH victims AS (
22
+ SELECT s.id
23
+ FROM %1$I.session_list_snapshots s
24
+ WHERE s.expires_at < now()
25
+ ORDER BY s.expires_at, s.id
26
+ FOR UPDATE SKIP LOCKED
27
+ LIMIT greatest(1, least(coalesce(p_limit, 500), 5000))
28
+ ), deleted AS (
29
+ DELETE FROM %1$I.session_list_snapshots s
30
+ USING victims
31
+ WHERE s.id = victims.id
32
+ RETURNING s.id
33
+ )
34
+ SELECT count(*)::integer INTO deleted_count FROM deleted;
35
+ RETURN deleted_count;
36
+ END $function$;
37
+ $create$, target_schema);
38
+ END $migration$;
39
+
40
+ REVOKE ALL ON FUNCTION opengeni_private.reap_expired_session_list_snapshots(integer) FROM PUBLIC;
41
+
42
+ DO $$
43
+ BEGIN
44
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
45
+ GRANT EXECUTE ON FUNCTION opengeni_private.reap_expired_session_list_snapshots(integer)
46
+ TO opengeni_app;
47
+ END IF;
48
+ END $$;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/db",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "OpenGeni persistence: Drizzle schema, RLS-scoped query layer, the SQL migration runner, and role provisioning.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -8,6 +8,11 @@
8
8
  "url": "git+https://github.com/Cloudgeni-ai/opengeni.git",
9
9
  "directory": "packages/db"
10
10
  },
11
+ "files": [
12
+ "dist",
13
+ "src",
14
+ "drizzle"
15
+ ],
11
16
  "type": "module",
12
17
  "sideEffects": false,
13
18
  "main": "./dist/index.js",
@@ -26,19 +31,15 @@
26
31
  "types": "./dist/migrate.d.ts",
27
32
  "import": "./dist/migrate.js"
28
33
  },
34
+ "./session-control-cutover-audit": {
35
+ "types": "./dist/session-control-cutover-audit.d.ts",
36
+ "import": "./dist/session-control-cutover-audit.js"
37
+ },
29
38
  "./provision-roles": {
30
39
  "types": "./dist/provision-roles.d.ts",
31
40
  "import": "./dist/provision-roles.js"
32
41
  }
33
42
  },
34
- "files": [
35
- "dist",
36
- "src",
37
- "drizzle"
38
- ],
39
- "engines": {
40
- "node": ">=18"
41
- },
42
43
  "publishConfig": {
43
44
  "access": "public",
44
45
  "provenance": true
@@ -47,15 +48,18 @@
47
48
  "generate": "drizzle-kit generate",
48
49
  "migrate": "bun src/migrate.ts",
49
50
  "provision-roles": "bun src/provision-roles.ts",
50
- "typecheck": "tsc --noEmit",
51
+ "typecheck": "tsgo --noEmit",
51
52
  "build": "tsup",
52
53
  "prepublishOnly": "bash ../../scripts/prepublish-guard"
53
54
  },
54
55
  "dependencies": {
55
- "@opengeni/codex": "^0.2.1",
56
- "@opengeni/config": "^0.3.0",
57
- "@opengeni/contracts": "^0.9.0",
56
+ "@opengeni/codex": "^0.2.2",
57
+ "@opengeni/config": "^0.5.0",
58
+ "@opengeni/contracts": "^0.10.0",
58
59
  "drizzle-orm": "^0.45.2",
59
60
  "postgres": "^3.4.7"
61
+ },
62
+ "engines": {
63
+ "node": ">=18"
60
64
  }
61
65
  }
@@ -12,14 +12,10 @@
12
12
  // refresh helpers and the @opengeni/config key — keeping the refresh-CAS + RLS
13
13
  // invariants co-located with the rows they protect.
14
14
  //
15
- // CROSS-PROCESS SAFETY is preserved unchanged: the single-flight `inflight` map is
16
- // process-module-scoped, so worker and api each get their own — that is CORRECT
17
- // (each process coalesces its own concurrent refreshes). The real cross-process
18
- // guard is the (id, version) CAS inside recordCodexTokenRefresh: if the api
19
- // refreshes a token while a worker turn refreshes the same account, the loser's
20
- // CAS writes 0 rows (stale version) and it re-reads the winner's token, so the
21
- // one-time refresh token is never double-spent. RLS is untouched (every accessor
22
- // wraps withWorkspaceRls internally).
15
+ // CROSS-PROCESS SAFETY: the process-module `inflight` map coalesces local callers,
16
+ // while a Postgres advisory transaction lock serializes API/worker replicas. A
17
+ // waiter re-reads after taking that lock and skips refresh when the version moved.
18
+ // The existing (id,version) CAS remains the final stale-family write fence.
23
19
 
24
20
  import { environmentsEncryptionKeyBytes, type Settings } from "@opengeni/config";
25
21
  import {
@@ -40,6 +36,7 @@ import {
40
36
  recordCodexAccountUsage,
41
37
  recordCodexTokenRefresh,
42
38
  setCodexCredentialStatus,
39
+ withCodexCredentialRefreshLock,
43
40
  type CodexCredentialForRun,
44
41
  type Database,
45
42
  } from "./index";
@@ -63,6 +60,7 @@ export type CodexAuthDeps = {
63
60
  refresh: typeof refreshCodexToken;
64
61
  encrypt: typeof encryptEnvironmentValue;
65
62
  keyBytes: typeof environmentsEncryptionKeyBytes;
63
+ withRefreshLock: typeof withCodexCredentialRefreshLock;
66
64
  };
67
65
 
68
66
  const defaultDeps: CodexAuthDeps = {
@@ -72,6 +70,7 @@ const defaultDeps: CodexAuthDeps = {
72
70
  refresh: refreshCodexToken,
73
71
  encrypt: encryptEnvironmentValue,
74
72
  keyBytes: environmentsEncryptionKeyBytes,
73
+ withRefreshLock: withCodexCredentialRefreshLock,
75
74
  };
76
75
 
77
76
  export function buildCodexTokenResolver(
@@ -92,7 +91,10 @@ export function buildCodexTokenResolver(
92
91
  isFedramp: cred.isFedramp,
93
92
  });
94
93
 
95
- const performRefresh = async (cred: CodexCredentialForRun): Promise<CodexTokenSnapshot> => {
94
+ const performRefresh = async (
95
+ refreshDb: Database,
96
+ cred: CodexCredentialForRun,
97
+ ): Promise<CodexTokenSnapshot> => {
96
98
  try {
97
99
  const next = await deps.refresh(cred.tokens.refreshToken);
98
100
  const tokens = {
@@ -107,7 +109,7 @@ export function buildCodexTokenResolver(
107
109
  // Compare-and-set on the loaded (id, version): if a disconnect→reconnect
108
110
  // replaced the row mid-refresh, this writes 0 rows and we must NOT clobber
109
111
  // the new credential with our now-defunct rotated tokens.
110
- const persisted = await deps.recordRefresh(db, {
112
+ const persisted = await deps.recordRefresh(refreshDb, {
111
113
  id: cred.id,
112
114
  version: cred.version,
113
115
  workspaceId,
@@ -119,40 +121,73 @@ export function buildCodexTokenResolver(
119
121
  // The row changed under us. Our rotated tokens belong to a stale family;
120
122
  // fall back to whatever is connected NOW (a reconnect leaves an active
121
123
  // row). If nothing active remains, a relogin is genuinely required.
122
- const current = await deps.loadCredential(db, settings, workspaceId, credentialId);
124
+ const current = await deps.loadCredential(refreshDb, settings, workspaceId, credentialId);
123
125
  if (current && current.status === "active") {
124
126
  return snapshot(current);
125
127
  }
126
- throw new CodexReloginRequired("Codex credential changed during token refresh; reconnect required.");
128
+ throw new CodexReloginRequired(
129
+ "Codex credential changed during token refresh; reconnect required.",
130
+ );
127
131
  }
128
- return { accessToken: tokens.access_token, chatgptAccountId: cred.chatgptAccountId, isFedramp: cred.isFedramp };
132
+ return {
133
+ accessToken: tokens.access_token,
134
+ chatgptAccountId: cred.chatgptAccountId,
135
+ isFedramp: cred.isFedramp,
136
+ };
129
137
  } catch (error) {
130
138
  if (error instanceof CodexReloginRequired) {
131
139
  // Stamp needs_relogin ONLY if the row we refreshed is STILL current
132
140
  // (compare-and-set on the loaded id+version). A relogin triggered by the
133
141
  // OLD token family must never stamp needs_relogin onto a freshly
134
142
  // reconnected credential.
135
- await deps.setStatus(db, workspaceId, "needs_relogin", error.message, { id: cred.id, version: cred.version });
143
+ await deps.setStatus(refreshDb, workspaceId, "needs_relogin", error.message, {
144
+ id: cred.id,
145
+ version: cred.version,
146
+ });
136
147
  }
137
148
  throw error;
138
149
  }
139
150
  };
140
151
 
141
- // ALL refreshes — whether triggered by proactive staleness (getToken) or by a
142
- // 401 retry (refresh) — coalesce onto one in-flight promise per credential
143
- // instance, so concurrent calls can never double-spend the one-time refresh
144
- // token (which would trigger refresh_token_reused -> needs_relogin).
152
+ // ALL refreshes — whether proactive or a 401 retry — coalesce locally and then
153
+ // serialize globally before any rotating refresh token reaches the provider.
145
154
  const doRefresh = (cred: CodexCredentialForRun): Promise<CodexTokenSnapshot> => {
146
155
  const key = `${cred.id}:${cred.version}`;
147
156
  const existing = inflight.get(key);
148
157
  if (existing) {
149
158
  return existing;
150
159
  }
151
- const promise = performRefresh(cred).finally(() => {
152
- if (inflight.get(key) === promise) {
153
- inflight.delete(key);
154
- }
155
- });
160
+ const promise = deps
161
+ .withRefreshLock(db, workspaceId, credentialId, async (lockedDb) => {
162
+ try {
163
+ const current = await deps.loadCredential(lockedDb, settings, workspaceId, credentialId);
164
+ if (!current || current.status !== "active") {
165
+ throw new CodexReloginRequired(
166
+ "Codex credential became unavailable while waiting to refresh.",
167
+ );
168
+ }
169
+ if (current.version !== cred.version) {
170
+ return { ok: true as const, value: snapshot(current) };
171
+ }
172
+ return { ok: true as const, value: await performRefresh(lockedDb, current) };
173
+ } catch (error) {
174
+ // withCodexCredentialRefreshLock uses an advisory TRANSACTION lock.
175
+ // performRefresh may persist `needs_relogin` before surfacing a
176
+ // permanent OAuth failure; throwing from this callback would roll that
177
+ // status write back with the outer transaction. Return the failure so
178
+ // the transaction commits, then rethrow after the lock is released.
179
+ return { ok: false as const, error };
180
+ }
181
+ })
182
+ .then((outcome) => {
183
+ if (!outcome.ok) throw outcome.error;
184
+ return outcome.value;
185
+ })
186
+ .finally(() => {
187
+ if (inflight.get(key) === promise) {
188
+ inflight.delete(key);
189
+ }
190
+ });
156
191
  inflight.set(key, promise);
157
192
  return promise;
158
193
  };