@opengeni/db 0.28.9 → 0.31.1

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 (163) hide show
  1. package/dist/attached-browser-devices.d.ts +42 -0
  2. package/dist/attempt-tool-catalogs.d.ts +16 -0
  3. package/dist/browser-auth.d.ts +91 -0
  4. package/dist/browser-identities.d.ts +128 -0
  5. package/dist/browser-sessions.d.ts +272 -0
  6. package/dist/browser-state-artifacts.d.ts +17 -0
  7. package/dist/capability-components.d.ts +37 -0
  8. package/dist/capability-integrations.d.ts +192 -0
  9. package/dist/chunk-F5FT7BEZ.js +3535 -0
  10. package/dist/chunk-F5FT7BEZ.js.map +1 -0
  11. package/dist/chunk-ISI2TIUG.js +639 -0
  12. package/dist/chunk-ISI2TIUG.js.map +1 -0
  13. package/dist/chunk-MWDVXQOL.js +11639 -0
  14. package/dist/chunk-MWDVXQOL.js.map +1 -0
  15. package/dist/chunk-NBE6CHRI.js +955 -0
  16. package/dist/chunk-NBE6CHRI.js.map +1 -0
  17. package/dist/{chunk-VHBFHMPC.js → chunk-YHZXQ7HP.js} +754 -11
  18. package/dist/chunk-YHZXQ7HP.js.map +1 -0
  19. package/dist/codemode-operations.d.ts +117 -0
  20. package/dist/codex-token-resolver.d.ts +2 -2
  21. package/dist/company-profile-schema.d.ts +798 -0
  22. package/dist/company-profile.d.ts +268 -0
  23. package/dist/computer-sessions.d.ts +109 -0
  24. package/dist/connection-token-resolver.d.ts +9 -4
  25. package/dist/database.d.ts +52 -3
  26. package/dist/editable-artifact-durable-export.d.ts +153 -0
  27. package/dist/editable-artifact-durable-export.js +788 -0
  28. package/dist/editable-artifact-durable-export.js.map +1 -0
  29. package/dist/editable-artifact-materialization.d.ts +123 -0
  30. package/dist/editable-artifacts-schema.d.ts +4152 -0
  31. package/dist/editable-artifacts.d.ts +610 -0
  32. package/dist/editable-artifacts.js +26 -0
  33. package/dist/editable-artifacts.js.map +1 -0
  34. package/dist/generated-images.d.ts +139 -0
  35. package/dist/index.d.ts +698 -111
  36. package/dist/index.js +27573 -10322
  37. package/dist/index.js.map +1 -1
  38. package/dist/insights.d.ts +51 -4
  39. package/dist/integration-bindings.d.ts +70 -0
  40. package/dist/integration-features.d.ts +99 -0
  41. package/dist/interaction-revisions.d.ts +3 -0
  42. package/dist/interaction-schema.d.ts +5372 -0
  43. package/dist/knowledge-source-sync-schema.d.ts +1679 -0
  44. package/dist/knowledge-source-sync.d.ts +274 -0
  45. package/dist/memory-governance.d.ts +2 -2
  46. package/dist/memory-slack-delivery.d.ts +101 -0
  47. package/dist/new-session-drafts.d.ts +2 -2
  48. package/dist/pack-components.d.ts +76 -0
  49. package/dist/pack-installations.d.ts +86 -0
  50. package/dist/persistence-errors.d.ts +3 -3
  51. package/dist/plugin-packages.d.ts +126 -0
  52. package/dist/preference-registry.d.ts +1 -1
  53. package/dist/provision-roles.d.ts +9 -1
  54. package/dist/provision-roles.js +1 -1
  55. package/dist/runtime-posture.d.ts +18 -6
  56. package/dist/schema.d.ts +18342 -9099
  57. package/dist/schema.js +165 -1
  58. package/dist/scoped-knowledge.d.ts +97 -1
  59. package/dist/session-control.d.ts +4 -4
  60. package/dist/session-queue-commands.d.ts +19 -11
  61. package/dist/session-realtime-context.d.ts +2 -2
  62. package/dist/session-realtime-ledger.d.ts +4 -4
  63. package/dist/session-realtime-mirror.d.ts +2 -2
  64. package/dist/session-realtime-state.d.ts +1 -1
  65. package/dist/session-realtime-terminal.d.ts +1 -1
  66. package/dist/session-realtime.d.ts +7 -7
  67. package/dist/session-tool-call-settlement.d.ts +1 -1
  68. package/dist/slack-user-link-access.d.ts +54 -0
  69. package/dist/transcription-recordings.d.ts +2 -2
  70. package/dist/turn-initiator.d.ts +2 -2
  71. package/dist/video-generation.d.ts +216 -0
  72. package/dist/video-generation.js +66 -0
  73. package/dist/video-generation.js.map +1 -0
  74. package/dist/workspace-artifacts.d.ts +1 -1
  75. package/dist/workspace-instruction-policies.d.ts +1 -1
  76. package/dist/workspace-learning-policy-schema.d.ts +932 -0
  77. package/dist/workspace-learning-policy.d.ts +161 -0
  78. package/drizzle/0183_model_call_provider_cost_estimates.sql +39 -0
  79. package/drizzle/0184_sandbox_drain_teardown_fence.sql +682 -0
  80. package/drizzle/0185_temporal_schedule_cleanup_outbox.sql +168 -0
  81. package/drizzle/0186_sandbox_capture_provider_contract.sql +400 -0
  82. package/drizzle/0187_generated_image_artifacts.sql +173 -0
  83. package/drizzle/0188_image_generation_retention_failure.sql +25 -0
  84. package/drizzle/0189_retained_session_image_formats.sql +13 -0
  85. package/drizzle/0190_timeline_annotations.sql +31 -0
  86. package/drizzle/0191_editable_artifact_engine.sql +3853 -0
  87. package/drizzle/0192_editable_artifact_live_tickets.sql +338 -0
  88. package/drizzle/0193_editable_artifact_authorization.sql +190 -0
  89. package/drizzle/0194_editable_artifact_durable_exports.sql +160 -0
  90. package/drizzle/0195_editable_artifact_import_authorization.sql +203 -0
  91. package/drizzle/0196_rig_provider_images.sql +152 -0
  92. package/drizzle/0197_knowledge_source_sync_schedules.sql +523 -0
  93. package/drizzle/0198_memory_slack_publication_delivery.sql +393 -0
  94. package/drizzle/0199_workspace_learning_policy.sql +980 -0
  95. package/drizzle/0201_company_profile_authority.sql +849 -0
  96. package/drizzle/0202_document_index_checkpoints.sql +85 -0
  97. package/drizzle/0203_durable_video_generation.sql +353 -0
  98. package/drizzle/0204_video_generation_funding.sql +44 -0
  99. package/drizzle/0205_attempt_tool_catalogs.sql +230 -0
  100. package/drizzle/0206_browser_sessions.sql +646 -0
  101. package/drizzle/0207_browser_identities.sql +379 -0
  102. package/drizzle/0208_attached_browser_devices.sql +165 -0
  103. package/drizzle/0209_computer_sessions.sql +575 -0
  104. package/drizzle/0210_browser_auth_network_interventions.sql +441 -0
  105. package/drizzle/0211_editable_artifact_session_links.sql +58 -0
  106. package/drizzle/0212_slack_installation_bindings.sql +348 -0
  107. package/drizzle/0213_slack_user_link_access_requests.sql +177 -0
  108. package/drizzle/0214_session_activity_commit_gate.sql +262 -0
  109. package/drizzle/0215_capabilities_platform.sql +980 -0
  110. package/drizzle/0216_pack_component_ownership.sql +235 -0
  111. package/drizzle/0217_capability_definition_delete_authority.sql +32 -0
  112. package/package.json +18 -5
  113. package/src/attached-browser-devices.ts +371 -0
  114. package/src/attempt-tool-catalogs.ts +131 -0
  115. package/src/browser-auth.ts +1896 -0
  116. package/src/browser-identities.ts +1168 -0
  117. package/src/browser-sessions.ts +2005 -0
  118. package/src/browser-state-artifacts.ts +59 -0
  119. package/src/capability-components.ts +166 -0
  120. package/src/capability-integrations.ts +1663 -0
  121. package/src/codemode-operations.ts +674 -0
  122. package/src/company-profile-schema.ts +143 -0
  123. package/src/company-profile.ts +968 -0
  124. package/src/computer-sessions.ts +1130 -0
  125. package/src/connection-token-resolver.ts +270 -25
  126. package/src/database.ts +427 -23
  127. package/src/editable-artifact-durable-export.ts +1201 -0
  128. package/src/editable-artifact-materialization.ts +893 -0
  129. package/src/editable-artifacts-schema.ts +879 -0
  130. package/src/editable-artifacts.ts +5404 -0
  131. package/src/generated-images.ts +740 -0
  132. package/src/index.ts +5917 -1182
  133. package/src/insights.ts +160 -13
  134. package/src/integration-bindings.ts +455 -0
  135. package/src/integration-features.ts +859 -0
  136. package/src/interaction-revisions.ts +40 -0
  137. package/src/interaction-schema.ts +1303 -0
  138. package/src/knowledge-source-sync-schema.ts +192 -0
  139. package/src/knowledge-source-sync.ts +1922 -0
  140. package/src/memory-slack-delivery.ts +801 -0
  141. package/src/pack-components.ts +954 -0
  142. package/src/pack-installations.ts +929 -0
  143. package/src/persistence-errors.ts +4 -7
  144. package/src/plugin-packages.ts +1186 -0
  145. package/src/provision-roles.ts +302 -1
  146. package/src/runtime-posture-cli.ts +9 -0
  147. package/src/runtime-posture.ts +508 -5
  148. package/src/schema.ts +5117 -3119
  149. package/src/scoped-knowledge-schema.ts +1 -1
  150. package/src/scoped-knowledge.ts +655 -3
  151. package/src/session-control.ts +8 -5
  152. package/src/session-queue-commands.ts +187 -14
  153. package/src/session-realtime-context.ts +2 -2
  154. package/src/session-realtime-ledger.ts +3 -3
  155. package/src/session-realtime.ts +7 -7
  156. package/src/session-tool-call-settlement.ts +9 -0
  157. package/src/slack-user-link-access.ts +702 -0
  158. package/src/video-generation.ts +1452 -0
  159. package/src/workspace-learning-policy-schema.ts +139 -0
  160. package/src/workspace-learning-policy.ts +595 -0
  161. package/dist/chunk-P6CUHXQZ.js +0 -7248
  162. package/dist/chunk-P6CUHXQZ.js.map +0 -1
  163. package/dist/chunk-VHBFHMPC.js.map +0 -1
package/src/database.ts CHANGED
@@ -29,6 +29,17 @@ import * as schema from "./schema";
29
29
 
30
30
  export type Database = PgDatabase<any, typeof schema>;
31
31
 
32
+ declare const sessionActivityDatabaseBrand: unique symbol;
33
+
34
+ /**
35
+ * A workspace-scoped transaction whose session activity commit gate is open.
36
+ * Session-row writers accept this narrower handle so new call sites cannot
37
+ * accidentally bypass the once-per-transaction revision finalizer.
38
+ */
39
+ export type SessionActivityDatabase = Database & {
40
+ readonly [sessionActivityDatabaseBrand]: true;
41
+ };
42
+
32
43
  export type DbClient = {
33
44
  db: Database;
34
45
  close: () => Promise<void>;
@@ -39,6 +50,11 @@ export type RlsContext = {
39
50
  workspaceId?: string | null;
40
51
  };
41
52
 
53
+ type RlsContextSettings = {
54
+ accountId: string;
55
+ workspaceId: string;
56
+ };
57
+
42
58
  /**
43
59
  * RLS posture for the connection OpenGeni's query layer runs over (Step I, §7.7).
44
60
  *
@@ -144,6 +160,103 @@ export async function rawRows<T extends Record<string, unknown>>(
144
160
  throw new Error("Unsupported database execute() result shape");
145
161
  }
146
162
 
163
+ export type SandboxProviderReadLockIdentity = {
164
+ workspaceId: string;
165
+ sandboxGroupId: string;
166
+ leaseEpoch: number;
167
+ instanceId: string;
168
+ };
169
+
170
+ export class SandboxProviderReadLockUnavailableError extends Error {
171
+ constructor() {
172
+ super("Sandbox provider reads are temporarily busy. Retry the request.");
173
+ this.name = "SandboxProviderReadLockUnavailableError";
174
+ }
175
+ }
176
+
177
+ const SANDBOX_PROVIDER_READ_LOCK_WAIT_MS = 15_000;
178
+ const SANDBOX_PROVIDER_READ_LOCK_POLL_MAX_MS = 200;
179
+
180
+ function sandboxProviderReadLockKey(identity: SandboxProviderReadLockIdentity): string {
181
+ return [
182
+ "sandbox-provider-read",
183
+ identity.workspaceId,
184
+ identity.sandboxGroupId,
185
+ identity.leaseEpoch,
186
+ identity.instanceId,
187
+ ].join(":");
188
+ }
189
+
190
+ async function waitForSandboxProviderReadLockPoll(
191
+ delayMs: number,
192
+ signal?: AbortSignal | undefined,
193
+ ): Promise<void> {
194
+ if (signal?.aborted) {
195
+ throw signal.reason ?? new DOMException("The operation was aborted", "AbortError");
196
+ }
197
+ await new Promise<void>((resolve, reject) => {
198
+ let settled = false;
199
+ let timer: ReturnType<typeof setTimeout>;
200
+ const cleanup = () => signal?.removeEventListener("abort", abort);
201
+ const finish = () => {
202
+ if (settled) return;
203
+ settled = true;
204
+ cleanup();
205
+ resolve();
206
+ };
207
+ const abort = () => {
208
+ if (settled) return;
209
+ settled = true;
210
+ clearTimeout(timer);
211
+ cleanup();
212
+ reject(signal?.reason ?? new DOMException("The operation was aborted", "AbortError"));
213
+ };
214
+ timer = setTimeout(finish, delayMs);
215
+ signal?.addEventListener("abort", abort, { once: true });
216
+ if (signal?.aborted) abort();
217
+ });
218
+ }
219
+
220
+ /**
221
+ * Cross-process exclusive boundary for one exact live provider instance's
222
+ * API-direct read request. Modal accepts concurrent commands from one attached
223
+ * handle, but independent API replicas reconstruct different handles; issuing
224
+ * overlapping command groups through those handles can make an otherwise valid
225
+ * read fail transiently. Keep concurrency inside one batched request while
226
+ * serializing only separate requests that target the same lease identity.
227
+ *
228
+ * The callback intentionally runs while one transaction-pinned DB connection
229
+ * holds the advisory lock. The direct lease holder separately prevents drain,
230
+ * and the abort-aware bounded poll prevents a disconnected HTTP request from
231
+ * occupying the connection indefinitely.
232
+ */
233
+ export async function withSandboxProviderReadLock<T>(
234
+ db: Database,
235
+ identity: SandboxProviderReadLockIdentity,
236
+ signal: AbortSignal | undefined,
237
+ fn: () => Promise<T>,
238
+ ): Promise<T> {
239
+ const key = sandboxProviderReadLockKey(identity);
240
+ const deadline = Date.now() + SANDBOX_PROVIDER_READ_LOCK_WAIT_MS;
241
+ return await db.transaction(async (lockedDb) => {
242
+ let pollMs = 20;
243
+ while (true) {
244
+ if (signal?.aborted) {
245
+ throw signal.reason ?? new DOMException("The operation was aborted", "AbortError");
246
+ }
247
+ const [row] = await rawRows<{ acquired: boolean }>(
248
+ lockedDb,
249
+ sql`select pg_try_advisory_xact_lock(hashtextextended(${key}, 0)) as acquired`,
250
+ );
251
+ if (row?.acquired === true) return await fn();
252
+ const remainingMs = deadline - Date.now();
253
+ if (remainingMs <= 0) throw new SandboxProviderReadLockUnavailableError();
254
+ await waitForSandboxProviderReadLockPoll(Math.min(pollMs, remainingMs), signal);
255
+ pollMs = Math.min(pollMs * 2, SANDBOX_PROVIDER_READ_LOCK_POLL_MAX_MS);
256
+ }
257
+ });
258
+ }
259
+
147
260
  export function createDb(databaseUrl: string, options: CreateDbOptions = {}): DbClient {
148
261
  // `prepare: false` is REQUIRED for Azure Database for PostgreSQL Flexible
149
262
  // Server's transaction-pooling PgBouncer: postgres-js's default named prepared
@@ -219,14 +332,68 @@ export async function setRlsContext(db: Database, context: RlsContext): Promise<
219
332
  await db.execute(sql`select set_config('opengeni.sandbox_recovery_protocol_v2', '1', true)`);
220
333
  }
221
334
 
335
+ async function readRlsContextSettings(db: Database): Promise<RlsContextSettings> {
336
+ const [settings] = await rawRows<{
337
+ account_id: string | null;
338
+ workspace_id: string | null;
339
+ }>(
340
+ db,
341
+ sql`select
342
+ current_setting('opengeni.account_id', true) as account_id,
343
+ current_setting('opengeni.workspace_id', true) as workspace_id`,
344
+ );
345
+ return {
346
+ accountId: settings?.account_id ?? "",
347
+ workspaceId: settings?.workspace_id ?? "",
348
+ };
349
+ }
350
+
351
+ async function assertRlsContextApplied(db: Database, context: RlsContext): Promise<void> {
352
+ const applied = await readRlsContextSettings(db);
353
+ const expectedWorkspaceId = context.workspaceId ?? "";
354
+ if (applied.accountId !== context.accountId) {
355
+ throw new Error(
356
+ `RLS context not applied on the active backend: expected account ${context.accountId}, got "${applied.accountId}"`,
357
+ );
358
+ }
359
+ if (applied.workspaceId !== expectedWorkspaceId) {
360
+ throw new Error(
361
+ `RLS context not applied on the active backend: expected workspace "${expectedWorkspaceId}", got "${applied.workspaceId}"`,
362
+ );
363
+ }
364
+ }
365
+
366
+ async function restoreRlsContextSettings(
367
+ db: Database,
368
+ settings: RlsContextSettings,
369
+ ): Promise<void> {
370
+ const [restored] = await rawRows<{
371
+ account_id: string;
372
+ workspace_id: string;
373
+ }>(
374
+ db,
375
+ sql`select
376
+ set_config('opengeni.account_id', ${settings.accountId}, true) as account_id,
377
+ set_config('opengeni.workspace_id', ${settings.workspaceId}, true) as workspace_id`,
378
+ );
379
+ if (
380
+ restored?.account_id !== settings.accountId ||
381
+ restored.workspace_id !== settings.workspaceId
382
+ ) {
383
+ throw new Error("RLS context could not be restored after a nested scope");
384
+ }
385
+ }
386
+
222
387
  export async function withRlsContext<T>(
223
388
  db: Database,
224
389
  context: RlsContext,
225
390
  fn: (db: Database) => Promise<T>,
226
391
  transactionConfig?: PgTransactionConfig,
227
392
  ): Promise<T> {
393
+ const restoreParentScope = isTransactionHandle(db);
228
394
  return await db.transaction(async (tx) => {
229
395
  const scoped = tx as unknown as Database;
396
+ const parentScope = restoreParentScope ? await readRlsContextSettings(scoped) : null;
230
397
  await setRlsContext(scoped, context);
231
398
  // Defense-in-depth: read the LOCAL GUC back on THIS backend BEFORE running
232
399
  // the scoped query. The set_config and this read share one db.transaction,
@@ -237,28 +404,13 @@ export async function withRlsContext<T>(
237
404
  // manufacturing a phantom "no active subscription" from a credential that is
238
405
  // in fact active. Convert that silent false into a loud, root-cause-bearing
239
406
  // error so the caller can retry rather than permanently mis-decide.
240
- const applied = await tx.execute<{
241
- account_id: string | null;
242
- workspace_id: string | null;
243
- }>(
244
- sql`select
245
- current_setting('opengeni.account_id', true) as account_id,
246
- current_setting('opengeni.workspace_id', true) as workspace_id`,
247
- );
248
- const appliedAccountId = applied[0]?.account_id ?? "";
249
- const expectedWorkspaceId = context.workspaceId ?? "";
250
- const appliedWorkspaceId = applied[0]?.workspace_id ?? "";
251
- if (appliedAccountId !== context.accountId) {
252
- throw new Error(
253
- `RLS context not applied on the active backend: expected account ${context.accountId}, got "${appliedAccountId}"`,
254
- );
255
- }
256
- if (appliedWorkspaceId !== expectedWorkspaceId) {
257
- throw new Error(
258
- `RLS context not applied on the active backend: expected workspace "${expectedWorkspaceId}", got "${appliedWorkspaceId}"`,
259
- );
260
- }
261
- return await fn(scoped);
407
+ await assertRlsContextApplied(scoped, context);
408
+ const value = await fn(scoped);
409
+ // A nested transaction is a savepoint, and SET LOCAL survives successful
410
+ // savepoint release. Restore only the tenant scope the nested helper owns;
411
+ // writer/protocol capabilities intentionally remain transaction-wide.
412
+ if (parentScope) await restoreRlsContextSettings(scoped, parentScope);
413
+ return value;
262
414
  }, transactionConfig);
263
415
  }
264
416
 
@@ -306,8 +458,238 @@ export async function withWorkspaceRls<T>(
306
458
  db: Database,
307
459
  workspaceId: string,
308
460
  fn: (db: Database) => Promise<T>,
461
+ transactionConfig?: PgTransactionConfig,
462
+ ): Promise<T> {
463
+ return await withRlsContext(
464
+ db,
465
+ await rlsContextForWorkspace(db, workspaceId),
466
+ fn,
467
+ transactionConfig,
468
+ );
469
+ }
470
+
471
+ type SessionActivityGate = {
472
+ db: SessionActivityDatabase;
473
+ owner: boolean;
474
+ };
475
+
476
+ function isTransactionHandle(db: Database): boolean {
477
+ return typeof (db as Database & { rollback?: unknown }).rollback === "function";
478
+ }
479
+
480
+ async function assertSessionActivityGateEntry(db: Database): Promise<void> {
481
+ if (!isTransactionHandle(db)) return;
482
+ const [gate] = await rawRows<{ state: string | null }>(
483
+ db,
484
+ sql`
485
+ select nullif(
486
+ current_setting('opengeni.session_activity_gate_state', true),
487
+ ''
488
+ ) as state
489
+ `,
490
+ );
491
+ if (gate?.state === null || gate === undefined) {
492
+ throw new Error("Session activity commit gate must start at the outer transaction boundary");
493
+ }
494
+ }
495
+
496
+ async function beginSessionActivityGate(
497
+ db: Database,
498
+ workspaceId: string,
499
+ ): Promise<SessionActivityGate> {
500
+ const [gate] = await rawRows<{
501
+ prior_state: string | null;
502
+ prior_workspace_id: string | null;
503
+ }>(
504
+ db,
505
+ sql`
506
+ with current_gate as materialized (
507
+ select
508
+ nullif(current_setting('opengeni.session_activity_gate_state', true), '') as prior_state,
509
+ nullif(current_setting('opengeni.session_activity_gate_workspace_id', true), '') as prior_workspace_id
510
+ )
511
+ select
512
+ prior_state,
513
+ prior_workspace_id,
514
+ case
515
+ when prior_state is null
516
+ then set_config('opengeni.session_activity_gate_state', 'open', true)
517
+ else prior_state
518
+ end as effective_state,
519
+ case
520
+ when prior_state is null
521
+ then set_config('opengeni.session_activity_gate_workspace_id', ${workspaceId}, true)
522
+ else prior_workspace_id
523
+ end as effective_workspace_id
524
+ from current_gate
525
+ `,
526
+ );
527
+ if (!gate) {
528
+ throw new Error("Session activity commit gate could not be opened");
529
+ }
530
+ if (gate.prior_state === null) {
531
+ return { db: db as SessionActivityDatabase, owner: true };
532
+ }
533
+ if (gate.prior_state === "open" && gate.prior_workspace_id === workspaceId) {
534
+ return { db: db as SessionActivityDatabase, owner: false };
535
+ }
536
+ throw new Error("Session activity commit gate is already owned by another transaction scope");
537
+ }
538
+
539
+ async function finalizeSessionActivityGate(
540
+ db: SessionActivityDatabase,
541
+ workspaceId: string,
542
+ ): Promise<void> {
543
+ // Resolve every deferred FK/constraint before taking the shared workspace
544
+ // counter. The only row writes after that point stamp session rows already
545
+ // modified and therefore already owned by this transaction.
546
+ await db.execute(
547
+ sql`select set_config('opengeni.session_activity_gate_state', 'preparing', true)`,
548
+ );
549
+ await db.execute(sql`set constraints all immediate`);
550
+ // The first flush proves every pre-existing deferred obligation. Re-defer
551
+ // only our commit guard so the marker-clearing writes below queue a fresh
552
+ // proof against the final row state, then force that proof before returning.
553
+ await db.execute(
554
+ sql`set constraints sessions_activity_insert_commit_guard, sessions_activity_update_commit_guard deferred`,
555
+ );
556
+ const [result] = await rawRows<{
557
+ had_pending: boolean;
558
+ pending_count: number;
559
+ revision: string | null;
560
+ stamped_count: number;
561
+ }>(
562
+ db,
563
+ sql`
564
+ with gate as materialized (
565
+ select set_config('opengeni.session_activity_gate_state', 'finalizing', true) as state
566
+ ),
567
+ pending as materialized (
568
+ select ${schema.sessions.id}
569
+ from ${schema.sessions}
570
+ cross join gate
571
+ where ${schema.sessions.workspaceId} = ${workspaceId}
572
+ and ${schema.sessions.activityRevisionPendingXid}
573
+ = pg_current_xact_id()::text::bigint
574
+ ),
575
+ advanced as (
576
+ update ${schema.workspaceSessionActivityRevisions} as counter
577
+ set revision = counter.revision + 1
578
+ where counter.workspace_id = ${workspaceId}
579
+ and exists (select 1 from pending)
580
+ returning counter.revision
581
+ ),
582
+ stamped as (
583
+ update ${schema.sessions} as session
584
+ set
585
+ activity_revision = advanced.revision,
586
+ activity_revision_pending_xid = null
587
+ from advanced
588
+ where session.workspace_id = ${workspaceId}
589
+ and session.activity_revision_pending_xid
590
+ = pg_current_xact_id()::text::bigint
591
+ returning session.id
592
+ ),
593
+ stats as materialized (
594
+ select
595
+ exists (select 1 from pending) as had_pending,
596
+ (select count(*)::integer from pending) as pending_count,
597
+ (select revision::text from advanced) as revision,
598
+ (select count(*)::integer from stamped) as stamped_count
599
+ ),
600
+ finalized as materialized (
601
+ select
602
+ had_pending,
603
+ pending_count,
604
+ revision,
605
+ stamped_count,
606
+ set_config('opengeni.session_activity_gate_state', 'finalized', true) as state
607
+ from stats
608
+ )
609
+ select had_pending, pending_count, revision, stamped_count
610
+ from finalized
611
+ `,
612
+ );
613
+ if (!result) {
614
+ throw new Error("Session activity commit gate did not return a finalization result");
615
+ }
616
+ if (result.had_pending && result.revision === null) {
617
+ throw new Error("Workspace session activity counter is unavailable");
618
+ }
619
+ if (result.stamped_count !== result.pending_count) {
620
+ throw new Error("Session activity commit gate did not stamp every pending row");
621
+ }
622
+ await db.execute(
623
+ sql`set constraints sessions_activity_insert_commit_guard, sessions_activity_update_commit_guard immediate`,
624
+ );
625
+ }
626
+
627
+ /**
628
+ * Run one workspace transaction with explicit once-per-transaction session
629
+ * activity finalization. Nested callers reuse the outer gate; only its owner
630
+ * finalizes immediately before the enclosing RLS transaction commits.
631
+ */
632
+ export async function withSessionActivityRlsContext<T>(
633
+ db: Database,
634
+ context: RlsContext & { workspaceId: string },
635
+ fn: (db: SessionActivityDatabase) => Promise<T>,
636
+ transactionConfig?: PgTransactionConfig,
637
+ ): Promise<T> {
638
+ await assertSessionActivityGateEntry(db);
639
+ return await withRlsContext(
640
+ db,
641
+ context,
642
+ async (scopedDb) => {
643
+ const gate = await beginSessionActivityGate(scopedDb, context.workspaceId);
644
+ const value = await fn(gate.db);
645
+ // The finalizer must never trust tenant GUCs that arbitrary callback code
646
+ // could have changed. Nested RLS helpers restore their parent scope; this
647
+ // assertion is the fail-closed commit-boundary proof for every other path.
648
+ await assertRlsContextApplied(gate.db, context);
649
+ if (gate.owner) {
650
+ await finalizeSessionActivityGate(gate.db, context.workspaceId);
651
+ }
652
+ return value;
653
+ },
654
+ transactionConfig,
655
+ );
656
+ }
657
+
658
+ export async function withWorkspaceSessionActivityRls<T>(
659
+ db: Database,
660
+ workspaceId: string,
661
+ fn: (db: SessionActivityDatabase) => Promise<T>,
662
+ transactionConfig?: PgTransactionConfig,
663
+ ): Promise<T> {
664
+ return await withSessionActivityRlsContext(
665
+ db,
666
+ { ...(await rlsContextForWorkspace(db, workspaceId)), workspaceId },
667
+ fn,
668
+ transactionConfig,
669
+ );
670
+ }
671
+
672
+ /**
673
+ * Open one nested savepoint without losing the gate brand. Drizzle correctly
674
+ * preserves the backend-local gate GUCs, but its structural transaction type
675
+ * cannot express that provenance; keep the single trusted rebrand here.
676
+ */
677
+ export async function withSessionActivitySavepoint<T>(
678
+ db: SessionActivityDatabase,
679
+ fn: (db: SessionActivityDatabase) => Promise<T>,
309
680
  ): Promise<T> {
310
- return await withRlsContext(db, await rlsContextForWorkspace(db, workspaceId), fn);
681
+ return await db.transaction(async (tx) => await fn(tx as unknown as SessionActivityDatabase));
682
+ }
683
+
684
+ export async function retrySessionActivityRls<T>(
685
+ db: Database,
686
+ workspaceId: string,
687
+ options: IdempotentPersistenceTransactionOptions,
688
+ fn: (db: SessionActivityDatabase) => Promise<T>,
689
+ ): Promise<T> {
690
+ return await runIdempotentPersistenceTransaction(options, async () => {
691
+ return await withWorkspaceSessionActivityRls(db, workspaceId, fn);
692
+ });
311
693
  }
312
694
 
313
695
  export async function retryWorkspacePersistence<T>(
@@ -359,6 +741,28 @@ export async function withWorkspaceSubjectRls<T>(
359
741
  );
360
742
  }
361
743
 
744
+ export async function withWorkspaceSubjectSessionActivityRls<T>(
745
+ db: Database,
746
+ workspaceId: string,
747
+ subjectId: string,
748
+ fn: (db: SessionActivityDatabase) => Promise<T>,
749
+ transactionConfig?: PgTransactionConfig,
750
+ ): Promise<T> {
751
+ if (!subjectId.trim()) {
752
+ throw new Error("withWorkspaceSubjectSessionActivityRls: a non-empty subjectId is required");
753
+ }
754
+ const context = await rlsContextForWorkspace(db, workspaceId);
755
+ return await withSessionActivityRlsContext(
756
+ db,
757
+ { ...context, workspaceId },
758
+ async (scopedDb) => {
759
+ await setSubjectRlsContext(scopedDb, subjectId);
760
+ return await fn(scopedDb);
761
+ },
762
+ transactionConfig,
763
+ );
764
+ }
765
+
362
766
  /** Apply and verify actor-private RLS on an already transaction-pinned handle. */
363
767
  export async function setSubjectRlsContext(db: Database, subjectId: string): Promise<void> {
364
768
  if (!subjectId.trim()) {