@opengeni/db 0.28.1 → 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 (187) 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-DBS5HPEW.js → chunk-YHZXQ7HP.js} +760 -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/environment-crypto.d.ts +8 -4
  35. package/dist/generated-images.d.ts +139 -0
  36. package/dist/index.d.ts +958 -126
  37. package/dist/index.js +31204 -12172
  38. package/dist/index.js.map +1 -1
  39. package/dist/insights.d.ts +51 -4
  40. package/dist/integration-bindings.d.ts +70 -0
  41. package/dist/integration-features.d.ts +99 -0
  42. package/dist/interaction-revisions.d.ts +3 -0
  43. package/dist/interaction-schema.d.ts +5372 -0
  44. package/dist/knowledge-source-sync-schema.d.ts +1679 -0
  45. package/dist/knowledge-source-sync.d.ts +274 -0
  46. package/dist/lossless-columns.d.ts +58 -0
  47. package/dist/lossless-json.d.ts +39 -0
  48. package/dist/memory-domain.d.ts +3 -6
  49. package/dist/memory-governance.d.ts +2 -2
  50. package/dist/memory-slack-delivery.d.ts +101 -0
  51. package/dist/new-session-drafts.d.ts +2 -2
  52. package/dist/pack-components.d.ts +76 -0
  53. package/dist/pack-installations.d.ts +86 -0
  54. package/dist/persistence-errors.d.ts +7 -14
  55. package/dist/plugin-packages.d.ts +126 -0
  56. package/dist/preference-registry.d.ts +1 -1
  57. package/dist/provision-roles.d.ts +9 -1
  58. package/dist/provision-roles.js +1 -1
  59. package/dist/runtime-posture.d.ts +18 -6
  60. package/dist/schema.d.ts +19122 -8731
  61. package/dist/schema.js +173 -1
  62. package/dist/scoped-knowledge.d.ts +97 -1
  63. package/dist/session-control.d.ts +4 -4
  64. package/dist/session-queue-commands.d.ts +19 -11
  65. package/dist/session-realtime-context.d.ts +2 -2
  66. package/dist/session-realtime-ledger.d.ts +4 -4
  67. package/dist/session-realtime-mirror.d.ts +2 -2
  68. package/dist/session-realtime-state.d.ts +1 -1
  69. package/dist/session-realtime-terminal.d.ts +8 -1
  70. package/dist/session-realtime.d.ts +7 -7
  71. package/dist/session-tool-call-settlement.d.ts +1 -1
  72. package/dist/slack-user-link-access.d.ts +54 -0
  73. package/dist/transcription-recordings-schema.d.ts +44 -6
  74. package/dist/transcription-recordings.d.ts +3 -3
  75. package/dist/turn-initiator.d.ts +2 -2
  76. package/dist/video-generation.d.ts +216 -0
  77. package/dist/video-generation.js +66 -0
  78. package/dist/video-generation.js.map +1 -0
  79. package/dist/workspace-artifacts.d.ts +1 -1
  80. package/dist/workspace-instruction-policies.d.ts +1 -1
  81. package/dist/workspace-learning-policy-schema.d.ts +932 -0
  82. package/dist/workspace-learning-policy.d.ts +161 -0
  83. package/drizzle/0065_enrollment_credential_generation.sql +10 -0
  84. package/drizzle/0140_retained_screenshot_artifacts.sql +192 -0
  85. package/drizzle/0176_lossless_canonical_json.sql +975 -0
  86. package/drizzle/0177_session_events_workspace_turn_type_index.sql +5 -0
  87. package/drizzle/0178_permissioned_secret_reads.sql +14 -0
  88. package/drizzle/0179_slack_private_shortcut_delivery_gate.sql +85 -0
  89. package/drizzle/0180_retained_screenshot_lifecycle_fences.sql +273 -0
  90. package/drizzle/0181_connected_machine_removal.sql +52 -0
  91. package/drizzle/0182_connected_machine_remove_session_default.sql +77 -0
  92. package/drizzle/0183_model_call_provider_cost_estimates.sql +39 -0
  93. package/drizzle/0184_sandbox_drain_teardown_fence.sql +682 -0
  94. package/drizzle/0185_temporal_schedule_cleanup_outbox.sql +168 -0
  95. package/drizzle/0186_sandbox_capture_provider_contract.sql +400 -0
  96. package/drizzle/0187_generated_image_artifacts.sql +173 -0
  97. package/drizzle/0188_image_generation_retention_failure.sql +25 -0
  98. package/drizzle/0189_retained_session_image_formats.sql +13 -0
  99. package/drizzle/0190_timeline_annotations.sql +31 -0
  100. package/drizzle/0191_editable_artifact_engine.sql +3853 -0
  101. package/drizzle/0192_editable_artifact_live_tickets.sql +338 -0
  102. package/drizzle/0193_editable_artifact_authorization.sql +190 -0
  103. package/drizzle/0194_editable_artifact_durable_exports.sql +160 -0
  104. package/drizzle/0195_editable_artifact_import_authorization.sql +203 -0
  105. package/drizzle/0196_rig_provider_images.sql +152 -0
  106. package/drizzle/0197_knowledge_source_sync_schedules.sql +523 -0
  107. package/drizzle/0198_memory_slack_publication_delivery.sql +393 -0
  108. package/drizzle/0199_workspace_learning_policy.sql +980 -0
  109. package/drizzle/0201_company_profile_authority.sql +849 -0
  110. package/drizzle/0202_document_index_checkpoints.sql +85 -0
  111. package/drizzle/0203_durable_video_generation.sql +353 -0
  112. package/drizzle/0204_video_generation_funding.sql +44 -0
  113. package/drizzle/0205_attempt_tool_catalogs.sql +230 -0
  114. package/drizzle/0206_browser_sessions.sql +646 -0
  115. package/drizzle/0207_browser_identities.sql +379 -0
  116. package/drizzle/0208_attached_browser_devices.sql +165 -0
  117. package/drizzle/0209_computer_sessions.sql +575 -0
  118. package/drizzle/0210_browser_auth_network_interventions.sql +441 -0
  119. package/drizzle/0211_editable_artifact_session_links.sql +58 -0
  120. package/drizzle/0212_slack_installation_bindings.sql +348 -0
  121. package/drizzle/0213_slack_user_link_access_requests.sql +177 -0
  122. package/drizzle/0214_session_activity_commit_gate.sql +262 -0
  123. package/drizzle/0215_capabilities_platform.sql +980 -0
  124. package/drizzle/0216_pack_component_ownership.sql +235 -0
  125. package/drizzle/0217_capability_definition_delete_authority.sql +32 -0
  126. package/package.json +18 -5
  127. package/src/attached-browser-devices.ts +371 -0
  128. package/src/attempt-tool-catalogs.ts +131 -0
  129. package/src/browser-auth.ts +1896 -0
  130. package/src/browser-identities.ts +1168 -0
  131. package/src/browser-sessions.ts +2005 -0
  132. package/src/browser-state-artifacts.ts +59 -0
  133. package/src/capability-components.ts +166 -0
  134. package/src/capability-integrations.ts +1663 -0
  135. package/src/codemode-operations.ts +674 -0
  136. package/src/company-profile-schema.ts +143 -0
  137. package/src/company-profile.ts +968 -0
  138. package/src/computer-sessions.ts +1130 -0
  139. package/src/connection-token-resolver.ts +270 -25
  140. package/src/database.ts +434 -24
  141. package/src/editable-artifact-durable-export.ts +1201 -0
  142. package/src/editable-artifact-materialization.ts +893 -0
  143. package/src/editable-artifacts-schema.ts +879 -0
  144. package/src/editable-artifacts.ts +5404 -0
  145. package/src/environment-crypto.ts +22 -9
  146. package/src/generated-images.ts +740 -0
  147. package/src/index.ts +10117 -2730
  148. package/src/insights.ts +160 -13
  149. package/src/integration-bindings.ts +455 -0
  150. package/src/integration-features.ts +859 -0
  151. package/src/interaction-revisions.ts +40 -0
  152. package/src/interaction-schema.ts +1303 -0
  153. package/src/knowledge-source-sync-schema.ts +192 -0
  154. package/src/knowledge-source-sync.ts +1922 -0
  155. package/src/lossless-columns.ts +35 -0
  156. package/src/lossless-json.ts +322 -0
  157. package/src/memory-domain.ts +5 -44
  158. package/src/memory-slack-delivery.ts +801 -0
  159. package/src/pack-components.ts +954 -0
  160. package/src/pack-installations.ts +929 -0
  161. package/src/persistence-errors.ts +26 -34
  162. package/src/plugin-packages.ts +1186 -0
  163. package/src/provision-roles.ts +302 -1
  164. package/src/runtime-posture-cli.ts +9 -0
  165. package/src/runtime-posture.ts +514 -5
  166. package/src/schema.ts +5177 -2957
  167. package/src/scoped-knowledge-schema.ts +1 -1
  168. package/src/scoped-knowledge.ts +655 -3
  169. package/src/session-control.ts +133 -81
  170. package/src/session-queue-commands.ts +511 -247
  171. package/src/session-realtime-context.ts +20 -7
  172. package/src/session-realtime-ledger.ts +32 -10
  173. package/src/session-realtime-mirror.ts +4 -2
  174. package/src/session-realtime-terminal.ts +89 -21
  175. package/src/session-realtime.ts +10 -9
  176. package/src/session-tool-call-settlement.ts +38 -15
  177. package/src/slack-user-link-access.ts +702 -0
  178. package/src/transcription-recordings-schema.ts +5 -2
  179. package/src/transcription-recordings.ts +70 -40
  180. package/src/video-generation.ts +1452 -0
  181. package/src/workspace-learning-policy-schema.ts +139 -0
  182. package/src/workspace-learning-policy.ts +595 -0
  183. package/dist/chunk-DBS5HPEW.js.map +0 -1
  184. package/dist/chunk-HT5T62CC.js +0 -6803
  185. package/dist/chunk-HT5T62CC.js.map +0 -1
  186. package/dist/event-payload-sanitizer.d.ts +0 -32
  187. package/src/event-payload-sanitizer.ts +0 -377
package/src/database.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  runIdempotentPersistenceTransaction,
8
8
  type IdempotentPersistenceTransactionOptions,
9
9
  } from "./persistence-errors";
10
+ import { LOSSLESS_CONTENT_WRITER_APPLICATION_NAME } from "./lossless-json";
10
11
  import * as schema from "./schema";
11
12
 
12
13
  // §7.7 driver widening (Step I). `Database` is the structural, cross-driver
@@ -28,6 +29,17 @@ import * as schema from "./schema";
28
29
 
29
30
  export type Database = PgDatabase<any, typeof schema>;
30
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
+
31
43
  export type DbClient = {
32
44
  db: Database;
33
45
  close: () => Promise<void>;
@@ -38,6 +50,11 @@ export type RlsContext = {
38
50
  workspaceId?: string | null;
39
51
  };
40
52
 
53
+ type RlsContextSettings = {
54
+ accountId: string;
55
+ workspaceId: string;
56
+ };
57
+
41
58
  /**
42
59
  * RLS posture for the connection OpenGeni's query layer runs over (Step I, §7.7).
43
60
  *
@@ -143,6 +160,103 @@ export async function rawRows<T extends Record<string, unknown>>(
143
160
  throw new Error("Unsupported database execute() result shape");
144
161
  }
145
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
+
146
260
  export function createDb(databaseUrl: string, options: CreateDbOptions = {}): DbClient {
147
261
  // `prepare: false` is REQUIRED for Azure Database for PostgreSQL Flexible
148
262
  // Server's transaction-pooling PgBouncer: postgres-js's default named prepared
@@ -164,7 +278,7 @@ export function createDb(databaseUrl: string, options: CreateDbOptions = {}): Db
164
278
  // postgres-js IGNORES a URL `?search_path=`. Unset searchPath → omit it so the
165
279
  // server default (`public`) is unchanged for standalone.
166
280
  connection: {
167
- application_name: "opengeni",
281
+ application_name: LOSSLESS_CONTENT_WRITER_APPLICATION_NAME,
168
282
  ...(options.searchPath ? { search_path: options.searchPath } : {}),
169
283
  ...(options.isolationLevel ? { default_transaction_isolation: options.isolationLevel } : {}),
170
284
  },
@@ -210,17 +324,76 @@ export async function setRlsContext(db: Database, context: RlsContext): Promise<
210
324
  await db.execute(
211
325
  sql`select set_config('opengeni.workspace_id', ${context.workspaceId ?? ""}, true)`,
212
326
  );
327
+ // Transaction-local writer identity covers supported injected/embedded
328
+ // database handles whose connection-level application_name is host-owned.
329
+ // Old OpenGeni binaries do not set this GUC, so migration-installed update
330
+ // fences can distinguish their partial writes without inspecting content.
331
+ await db.execute(sql`select set_config('opengeni.lossless_content_writer', '1', true)`);
213
332
  await db.execute(sql`select set_config('opengeni.sandbox_recovery_protocol_v2', '1', true)`);
214
333
  }
215
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
+
216
387
  export async function withRlsContext<T>(
217
388
  db: Database,
218
389
  context: RlsContext,
219
390
  fn: (db: Database) => Promise<T>,
220
391
  transactionConfig?: PgTransactionConfig,
221
392
  ): Promise<T> {
393
+ const restoreParentScope = isTransactionHandle(db);
222
394
  return await db.transaction(async (tx) => {
223
395
  const scoped = tx as unknown as Database;
396
+ const parentScope = restoreParentScope ? await readRlsContextSettings(scoped) : null;
224
397
  await setRlsContext(scoped, context);
225
398
  // Defense-in-depth: read the LOCAL GUC back on THIS backend BEFORE running
226
399
  // the scoped query. The set_config and this read share one db.transaction,
@@ -231,28 +404,13 @@ export async function withRlsContext<T>(
231
404
  // manufacturing a phantom "no active subscription" from a credential that is
232
405
  // in fact active. Convert that silent false into a loud, root-cause-bearing
233
406
  // error so the caller can retry rather than permanently mis-decide.
234
- const applied = await tx.execute<{
235
- account_id: string | null;
236
- workspace_id: string | null;
237
- }>(
238
- sql`select
239
- current_setting('opengeni.account_id', true) as account_id,
240
- current_setting('opengeni.workspace_id', true) as workspace_id`,
241
- );
242
- const appliedAccountId = applied[0]?.account_id ?? "";
243
- const expectedWorkspaceId = context.workspaceId ?? "";
244
- const appliedWorkspaceId = applied[0]?.workspace_id ?? "";
245
- if (appliedAccountId !== context.accountId) {
246
- throw new Error(
247
- `RLS context not applied on the active backend: expected account ${context.accountId}, got "${appliedAccountId}"`,
248
- );
249
- }
250
- if (appliedWorkspaceId !== expectedWorkspaceId) {
251
- throw new Error(
252
- `RLS context not applied on the active backend: expected workspace "${expectedWorkspaceId}", got "${appliedWorkspaceId}"`,
253
- );
254
- }
255
- 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;
256
414
  }, transactionConfig);
257
415
  }
258
416
 
@@ -300,8 +458,238 @@ export async function withWorkspaceRls<T>(
300
458
  db: Database,
301
459
  workspaceId: string,
302
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>,
303
680
  ): Promise<T> {
304
- 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
+ });
305
693
  }
306
694
 
307
695
  export async function retryWorkspacePersistence<T>(
@@ -353,6 +741,28 @@ export async function withWorkspaceSubjectRls<T>(
353
741
  );
354
742
  }
355
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
+
356
766
  /** Apply and verify actor-private RLS on an already transaction-pinned handle. */
357
767
  export async function setSubjectRlsContext(db: Database, subjectId: string): Promise<void> {
358
768
  if (!subjectId.trim()) {