@opengeni/db 0.28.9 → 0.36.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 (225) 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 +299 -0
  4. package/dist/browser-downloads.d.ts +51 -0
  5. package/dist/browser-identities.d.ts +137 -0
  6. package/dist/browser-sessions.d.ts +354 -0
  7. package/dist/browser-state-artifacts.d.ts +91 -0
  8. package/dist/canonical-human-identities.d.ts +48 -0
  9. package/dist/canonical-human-identities.js +28 -0
  10. package/dist/canonical-human-identities.js.map +1 -0
  11. package/dist/capability-components.d.ts +37 -0
  12. package/dist/capability-integrations.d.ts +194 -0
  13. package/dist/chunk-5KTIEDX7.js +13200 -0
  14. package/dist/chunk-5KTIEDX7.js.map +1 -0
  15. package/dist/chunk-CQZ4QSP5.js +995 -0
  16. package/dist/chunk-CQZ4QSP5.js.map +1 -0
  17. package/dist/chunk-F32YT7MP.js +117 -0
  18. package/dist/chunk-F32YT7MP.js.map +1 -0
  19. package/dist/chunk-JC5QPLUM.js +666 -0
  20. package/dist/chunk-JC5QPLUM.js.map +1 -0
  21. package/dist/chunk-P3RE4D2B.js +3535 -0
  22. package/dist/chunk-P3RE4D2B.js.map +1 -0
  23. package/dist/chunk-TAUTWOS5.js +259 -0
  24. package/dist/chunk-TAUTWOS5.js.map +1 -0
  25. package/dist/chunk-VKLB2ZGI.js +2389 -0
  26. package/dist/chunk-VKLB2ZGI.js.map +1 -0
  27. package/dist/codemode-operations.d.ts +117 -0
  28. package/dist/codex-token-resolver.d.ts +2 -2
  29. package/dist/company-profile-schema.d.ts +798 -0
  30. package/dist/company-profile.d.ts +268 -0
  31. package/dist/computer-sessions.d.ts +109 -0
  32. package/dist/connection-token-resolver.d.ts +9 -4
  33. package/dist/database.d.ts +57 -3
  34. package/dist/editable-artifact-durable-export.d.ts +153 -0
  35. package/dist/editable-artifact-durable-export.js +788 -0
  36. package/dist/editable-artifact-durable-export.js.map +1 -0
  37. package/dist/editable-artifact-materialization.d.ts +123 -0
  38. package/dist/editable-artifacts-schema.d.ts +4152 -0
  39. package/dist/editable-artifacts.d.ts +610 -0
  40. package/dist/editable-artifacts.js +26 -0
  41. package/dist/editable-artifacts.js.map +1 -0
  42. package/dist/generated-images.d.ts +139 -0
  43. package/dist/index.d.ts +1122 -126
  44. package/dist/index.js +42329 -18407
  45. package/dist/index.js.map +1 -1
  46. package/dist/insights.d.ts +73 -10
  47. package/dist/integration-bindings.d.ts +70 -0
  48. package/dist/integration-facets.d.ts +99 -0
  49. package/dist/interaction-revisions.d.ts +14 -0
  50. package/dist/interaction-schema.d.ts +6036 -0
  51. package/dist/knowledge-source-sync-schema.d.ts +1679 -0
  52. package/dist/knowledge-source-sync.d.ts +274 -0
  53. package/dist/memory-governance.d.ts +2 -2
  54. package/dist/memory-slack-delivery.d.ts +101 -0
  55. package/dist/new-session-drafts.d.ts +2 -2
  56. package/dist/pack-components.d.ts +76 -0
  57. package/dist/pack-installations.d.ts +86 -0
  58. package/dist/persistence-errors.d.ts +3 -3
  59. package/dist/plugin-packages.d.ts +126 -0
  60. package/dist/preference-registry.d.ts +1 -1
  61. package/dist/provision-roles.d.ts +9 -1
  62. package/dist/provision-roles.js +1 -1
  63. package/dist/runtime-posture.d.ts +19 -6
  64. package/dist/schema.d.ts +22654 -9359
  65. package/dist/schema.js +209 -3
  66. package/dist/scoped-knowledge.d.ts +97 -1
  67. package/dist/session-control.d.ts +33 -7
  68. package/dist/session-queue-commands.d.ts +19 -11
  69. package/dist/session-realtime-context.d.ts +2 -2
  70. package/dist/session-realtime-ledger.d.ts +4 -4
  71. package/dist/session-realtime-mirror.d.ts +2 -2
  72. package/dist/session-realtime-state.d.ts +1 -1
  73. package/dist/session-realtime-terminal.d.ts +1 -1
  74. package/dist/session-realtime.d.ts +7 -7
  75. package/dist/session-tenancy.d.ts +44 -0
  76. package/dist/session-tenancy.js +16 -0
  77. package/dist/session-tenancy.js.map +1 -0
  78. package/dist/session-tool-call-settlement.d.ts +1 -1
  79. package/dist/slack-task-policy-schema.d.ts +847 -0
  80. package/dist/slack-task-policy.d.ts +65 -0
  81. package/dist/slack-user-link-access.d.ts +54 -0
  82. package/dist/task-notes-schema.d.ts +924 -0
  83. package/dist/task-notes.d.ts +85 -0
  84. package/dist/transcription-recordings.d.ts +2 -2
  85. package/dist/turn-initiator.d.ts +2 -2
  86. package/dist/video-generation.d.ts +225 -0
  87. package/dist/video-generation.js +68 -0
  88. package/dist/video-generation.js.map +1 -0
  89. package/dist/workspace-artifacts.d.ts +1 -1
  90. package/dist/workspace-instruction-policies.d.ts +1 -1
  91. package/dist/workspace-learning-policy-schema.d.ts +932 -0
  92. package/dist/workspace-learning-policy.d.ts +161 -0
  93. package/dist/xai-subscription.d.ts +326 -0
  94. package/drizzle/0183_model_call_provider_cost_estimates.sql +39 -0
  95. package/drizzle/0184_sandbox_drain_teardown_fence.sql +682 -0
  96. package/drizzle/0185_temporal_schedule_cleanup_outbox.sql +168 -0
  97. package/drizzle/0186_sandbox_capture_provider_contract.sql +400 -0
  98. package/drizzle/0187_generated_image_artifacts.sql +173 -0
  99. package/drizzle/0188_image_generation_retention_failure.sql +25 -0
  100. package/drizzle/0189_retained_session_image_formats.sql +13 -0
  101. package/drizzle/0190_timeline_annotations.sql +31 -0
  102. package/drizzle/0191_editable_artifact_engine.sql +3853 -0
  103. package/drizzle/0192_editable_artifact_live_tickets.sql +338 -0
  104. package/drizzle/0193_editable_artifact_authorization.sql +190 -0
  105. package/drizzle/0194_editable_artifact_durable_exports.sql +160 -0
  106. package/drizzle/0195_editable_artifact_import_authorization.sql +203 -0
  107. package/drizzle/0196_rig_provider_images.sql +152 -0
  108. package/drizzle/0197_knowledge_source_sync_schedules.sql +523 -0
  109. package/drizzle/0198_memory_slack_publication_delivery.sql +393 -0
  110. package/drizzle/0199_workspace_learning_policy.sql +980 -0
  111. package/drizzle/0201_company_profile_authority.sql +849 -0
  112. package/drizzle/0202_document_index_checkpoints.sql +85 -0
  113. package/drizzle/0203_durable_video_generation.sql +353 -0
  114. package/drizzle/0204_video_generation_funding.sql +44 -0
  115. package/drizzle/0205_attempt_tool_catalogs.sql +230 -0
  116. package/drizzle/0206_browser_sessions.sql +646 -0
  117. package/drizzle/0207_browser_identities.sql +379 -0
  118. package/drizzle/0208_attached_browser_devices.sql +165 -0
  119. package/drizzle/0209_computer_sessions.sql +575 -0
  120. package/drizzle/0210_browser_auth_network_interventions.sql +441 -0
  121. package/drizzle/0211_editable_artifact_session_links.sql +58 -0
  122. package/drizzle/0212_browser_state_transfer_hardening.sql +560 -0
  123. package/drizzle/0212_slack_installation_bindings.sql +348 -0
  124. package/drizzle/0213_browser_interaction_authority.sql +67 -0
  125. package/drizzle/0213_slack_user_link_access_requests.sql +177 -0
  126. package/drizzle/0214_browser_download_saves.sql +34 -0
  127. package/drizzle/0214_session_activity_commit_gate.sql +262 -0
  128. package/drizzle/0215_browser_controller_host.sql +385 -0
  129. package/drizzle/0215_capabilities_platform.sql +980 -0
  130. package/drizzle/0216_browser_auth_health_evidence.sql +137 -0
  131. package/drizzle/0216_pack_component_ownership.sql +235 -0
  132. package/drizzle/0217_capability_definition_delete_authority.sql +32 -0
  133. package/drizzle/0217_external_browser_auth_operations.sql +22 -0
  134. package/drizzle/0218_organization_tenancy_foundation.sql +299 -0
  135. package/drizzle/0219_organization_tenancy_managed_human_provisioning.sql +317 -0
  136. package/drizzle/0219_site_auth_maintenance_sessions.sql +234 -0
  137. package/drizzle/0220_memory_slack_append_only_cascade.sql +38 -0
  138. package/drizzle/0220_session_channels.sql +54 -0
  139. package/drizzle/0221_sessions_channel_index.sql +5 -0
  140. package/drizzle/0222_session_visibility_authority_epochs.sql +158 -0
  141. package/drizzle/0222_sessions_channel_fk.sql +89 -0
  142. package/drizzle/0223_pending_tool_event_output.sql +55 -0
  143. package/drizzle/0223_sessions_channel_fk_validate.sql +118 -0
  144. package/drizzle/0224_slack_post_outcome_reconciliation.sql +123 -0
  145. package/drizzle/0225_session_visibility_fork_activation.sql +1363 -0
  146. package/drizzle/0226_personal_codex_authority_foundation.sql +365 -0
  147. package/drizzle/0227_slack_native_actions.sql +145 -0
  148. package/drizzle/0228_interaction_controller_data_plane.sql +14 -0
  149. package/drizzle/0228_slack_task_policy.sql +400 -0
  150. package/drizzle/0229_slack_inbox_file_fact.sql +10 -0
  151. package/drizzle/0230_user_scoped_variable_sets_rigs.sql +84 -0
  152. package/drizzle/0231_integration_definition_identity_cutover.sql +271 -0
  153. package/drizzle/0232_integration_facet_authority_cutover.sql +610 -0
  154. package/drizzle/0233_skill_and_integration_authority_cutover.sql +1162 -0
  155. package/drizzle/0234_xai_subscription_authority.sql +1139 -0
  156. package/drizzle/0235_canonical_human_login_bindings.sql +920 -0
  157. package/drizzle/0236_browser_identity_lifecycle.sql +39 -0
  158. package/drizzle/0236_session_visibility_slack_policy.sql +72 -0
  159. package/drizzle/0237_interaction_transition_reaper.sql +263 -0
  160. package/drizzle/0238_goal_persistence_policy.sql +471 -0
  161. package/drizzle/0238_supergrok_realtime_model.sql +29 -0
  162. package/drizzle/0239_supergrok_video_funding.sql +50 -0
  163. package/drizzle/0239_task_tree_notes.sql +990 -0
  164. package/package.json +26 -5
  165. package/src/attached-browser-devices.ts +371 -0
  166. package/src/attempt-tool-catalogs.ts +131 -0
  167. package/src/browser-auth.ts +4373 -0
  168. package/src/browser-downloads.ts +517 -0
  169. package/src/browser-identities.ts +1385 -0
  170. package/src/browser-sessions.ts +2294 -0
  171. package/src/browser-state-artifacts.ts +449 -0
  172. package/src/canonical-human-identities.ts +317 -0
  173. package/src/capability-components.ts +166 -0
  174. package/src/capability-integrations.ts +1571 -0
  175. package/src/codemode-operations.ts +674 -0
  176. package/src/company-profile-schema.ts +143 -0
  177. package/src/company-profile.ts +968 -0
  178. package/src/computer-sessions.ts +1130 -0
  179. package/src/connection-token-resolver.ts +270 -25
  180. package/src/database.ts +468 -24
  181. package/src/editable-artifact-durable-export.ts +1201 -0
  182. package/src/editable-artifact-materialization.ts +893 -0
  183. package/src/editable-artifacts-schema.ts +879 -0
  184. package/src/editable-artifacts.ts +5404 -0
  185. package/src/generated-images.ts +740 -0
  186. package/src/index.ts +9963 -1465
  187. package/src/insights.ts +251 -23
  188. package/src/integration-bindings.ts +455 -0
  189. package/src/integration-facets.ts +862 -0
  190. package/src/interaction-revisions.ts +70 -0
  191. package/src/interaction-schema.ts +1606 -0
  192. package/src/knowledge-source-sync-schema.ts +192 -0
  193. package/src/knowledge-source-sync.ts +1922 -0
  194. package/src/memory-slack-delivery.ts +801 -0
  195. package/src/pack-components.ts +954 -0
  196. package/src/pack-installations.ts +886 -0
  197. package/src/persistence-errors.ts +4 -7
  198. package/src/plugin-packages.ts +1186 -0
  199. package/src/provision-roles.ts +534 -1
  200. package/src/runtime-posture-cli.ts +9 -0
  201. package/src/runtime-posture.ts +721 -5
  202. package/src/schema.ts +6270 -3159
  203. package/src/scoped-knowledge-schema.ts +1 -1
  204. package/src/scoped-knowledge.ts +655 -3
  205. package/src/session-control.ts +130 -42
  206. package/src/session-queue-commands.ts +265 -15
  207. package/src/session-realtime-context.ts +2 -2
  208. package/src/session-realtime-ledger.ts +3 -3
  209. package/src/session-realtime.ts +7 -7
  210. package/src/session-tenancy.ts +188 -0
  211. package/src/session-tool-call-settlement.ts +18 -3
  212. package/src/slack-task-policy-schema.ts +130 -0
  213. package/src/slack-task-policy.ts +341 -0
  214. package/src/slack-user-link-access.ts +702 -0
  215. package/src/task-notes-schema.ts +135 -0
  216. package/src/task-notes.ts +178 -0
  217. package/src/video-generation.ts +1530 -0
  218. package/src/workspace-artifacts.ts +1 -1
  219. package/src/workspace-learning-policy-schema.ts +139 -0
  220. package/src/workspace-learning-policy.ts +595 -0
  221. package/src/xai-subscription.ts +1887 -0
  222. package/dist/chunk-P6CUHXQZ.js +0 -7248
  223. package/dist/chunk-P6CUHXQZ.js.map +0 -1
  224. package/dist/chunk-VHBFHMPC.js +0 -1219
  225. package/dist/chunk-VHBFHMPC.js.map +0 -1
package/src/database.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { eq, sql, type SQL } from "drizzle-orm";
2
3
  import type { PgDatabase, PgTransactionConfig } from "drizzle-orm/pg-core";
3
4
  import { drizzle } from "drizzle-orm/postgres-js";
@@ -29,6 +30,17 @@ import * as schema from "./schema";
29
30
 
30
31
  export type Database = PgDatabase<any, typeof schema>;
31
32
 
33
+ declare const sessionActivityDatabaseBrand: unique symbol;
34
+
35
+ /**
36
+ * A workspace-scoped transaction whose session activity commit gate is open.
37
+ * Session-row writers accept this narrower handle so new call sites cannot
38
+ * accidentally bypass the once-per-transaction revision finalizer.
39
+ */
40
+ export type SessionActivityDatabase = Database & {
41
+ readonly [sessionActivityDatabaseBrand]: true;
42
+ };
43
+
32
44
  export type DbClient = {
33
45
  db: Database;
34
46
  close: () => Promise<void>;
@@ -39,6 +51,37 @@ export type RlsContext = {
39
51
  workspaceId?: string | null;
40
52
  };
41
53
 
54
+ export type SessionRlsActorContext = {
55
+ subjectId: string;
56
+ initiatingHumanSubjectId?: string | null;
57
+ };
58
+
59
+ const sessionRlsActorContext = new AsyncLocalStorage<SessionRlsActorContext>();
60
+
61
+ export async function withSessionRlsActorContext<T>(
62
+ actor: SessionRlsActorContext,
63
+ fn: () => Promise<T>,
64
+ ): Promise<T> {
65
+ if (!actor.subjectId.trim()) {
66
+ throw new Error("withSessionRlsActorContext: a non-empty subjectId is required");
67
+ }
68
+ if (
69
+ actor.initiatingHumanSubjectId !== undefined &&
70
+ actor.initiatingHumanSubjectId !== null &&
71
+ !actor.initiatingHumanSubjectId.trim()
72
+ ) {
73
+ throw new Error(
74
+ "withSessionRlsActorContext: initiatingHumanSubjectId must be null or non-empty",
75
+ );
76
+ }
77
+ return await sessionRlsActorContext.run(actor, fn);
78
+ }
79
+
80
+ type RlsContextSettings = {
81
+ accountId: string;
82
+ workspaceId: string;
83
+ };
84
+
42
85
  /**
43
86
  * RLS posture for the connection OpenGeni's query layer runs over (Step I, §7.7).
44
87
  *
@@ -144,6 +187,103 @@ export async function rawRows<T extends Record<string, unknown>>(
144
187
  throw new Error("Unsupported database execute() result shape");
145
188
  }
146
189
 
190
+ export type SandboxProviderReadLockIdentity = {
191
+ workspaceId: string;
192
+ sandboxGroupId: string;
193
+ leaseEpoch: number;
194
+ instanceId: string;
195
+ };
196
+
197
+ export class SandboxProviderReadLockUnavailableError extends Error {
198
+ constructor() {
199
+ super("Sandbox provider reads are temporarily busy. Retry the request.");
200
+ this.name = "SandboxProviderReadLockUnavailableError";
201
+ }
202
+ }
203
+
204
+ const SANDBOX_PROVIDER_READ_LOCK_WAIT_MS = 15_000;
205
+ const SANDBOX_PROVIDER_READ_LOCK_POLL_MAX_MS = 200;
206
+
207
+ function sandboxProviderReadLockKey(identity: SandboxProviderReadLockIdentity): string {
208
+ return [
209
+ "sandbox-provider-read",
210
+ identity.workspaceId,
211
+ identity.sandboxGroupId,
212
+ identity.leaseEpoch,
213
+ identity.instanceId,
214
+ ].join(":");
215
+ }
216
+
217
+ async function waitForSandboxProviderReadLockPoll(
218
+ delayMs: number,
219
+ signal?: AbortSignal | undefined,
220
+ ): Promise<void> {
221
+ if (signal?.aborted) {
222
+ throw signal.reason ?? new DOMException("The operation was aborted", "AbortError");
223
+ }
224
+ await new Promise<void>((resolve, reject) => {
225
+ let settled = false;
226
+ let timer: ReturnType<typeof setTimeout>;
227
+ const cleanup = () => signal?.removeEventListener("abort", abort);
228
+ const finish = () => {
229
+ if (settled) return;
230
+ settled = true;
231
+ cleanup();
232
+ resolve();
233
+ };
234
+ const abort = () => {
235
+ if (settled) return;
236
+ settled = true;
237
+ clearTimeout(timer);
238
+ cleanup();
239
+ reject(signal?.reason ?? new DOMException("The operation was aborted", "AbortError"));
240
+ };
241
+ timer = setTimeout(finish, delayMs);
242
+ signal?.addEventListener("abort", abort, { once: true });
243
+ if (signal?.aborted) abort();
244
+ });
245
+ }
246
+
247
+ /**
248
+ * Cross-process exclusive boundary for one exact live provider instance's
249
+ * API-direct read request. Modal accepts concurrent commands from one attached
250
+ * handle, but independent API replicas reconstruct different handles; issuing
251
+ * overlapping command groups through those handles can make an otherwise valid
252
+ * read fail transiently. Keep concurrency inside one batched request while
253
+ * serializing only separate requests that target the same lease identity.
254
+ *
255
+ * The callback intentionally runs while one transaction-pinned DB connection
256
+ * holds the advisory lock. The direct lease holder separately prevents drain,
257
+ * and the abort-aware bounded poll prevents a disconnected HTTP request from
258
+ * occupying the connection indefinitely.
259
+ */
260
+ export async function withSandboxProviderReadLock<T>(
261
+ db: Database,
262
+ identity: SandboxProviderReadLockIdentity,
263
+ signal: AbortSignal | undefined,
264
+ fn: () => Promise<T>,
265
+ ): Promise<T> {
266
+ const key = sandboxProviderReadLockKey(identity);
267
+ const deadline = Date.now() + SANDBOX_PROVIDER_READ_LOCK_WAIT_MS;
268
+ return await db.transaction(async (lockedDb) => {
269
+ let pollMs = 20;
270
+ while (true) {
271
+ if (signal?.aborted) {
272
+ throw signal.reason ?? new DOMException("The operation was aborted", "AbortError");
273
+ }
274
+ const [row] = await rawRows<{ acquired: boolean }>(
275
+ lockedDb,
276
+ sql`select pg_try_advisory_xact_lock(hashtextextended(${key}, 0)) as acquired`,
277
+ );
278
+ if (row?.acquired === true) return await fn();
279
+ const remainingMs = deadline - Date.now();
280
+ if (remainingMs <= 0) throw new SandboxProviderReadLockUnavailableError();
281
+ await waitForSandboxProviderReadLockPoll(Math.min(pollMs, remainingMs), signal);
282
+ pollMs = Math.min(pollMs * 2, SANDBOX_PROVIDER_READ_LOCK_POLL_MAX_MS);
283
+ }
284
+ });
285
+ }
286
+
147
287
  export function createDb(databaseUrl: string, options: CreateDbOptions = {}): DbClient {
148
288
  // `prepare: false` is REQUIRED for Azure Database for PostgreSQL Flexible
149
289
  // Server's transaction-pooling PgBouncer: postgres-js's default named prepared
@@ -216,7 +356,72 @@ export async function setRlsContext(db: Database, context: RlsContext): Promise<
216
356
  // Old OpenGeni binaries do not set this GUC, so migration-installed update
217
357
  // fences can distinguish their partial writes without inspecting content.
218
358
  await db.execute(sql`select set_config('opengeni.lossless_content_writer', '1', true)`);
219
- await db.execute(sql`select set_config('opengeni.sandbox_recovery_protocol_v2', '1', true)`);
359
+ await db.execute(sql`select
360
+ set_config('opengeni.sandbox_recovery_protocol_v2', '1', true),
361
+ set_config('opengeni.pending_tool_event_output_v1', '1', true)`);
362
+ const sessionActor = sessionRlsActorContext.getStore();
363
+ if (sessionActor) {
364
+ await setSubjectRlsContext(db, sessionActor.subjectId);
365
+ await db.execute(
366
+ sql`select set_config(
367
+ 'opengeni.initiating_human_subject_id',
368
+ ${sessionActor.initiatingHumanSubjectId ?? ""},
369
+ true
370
+ )`,
371
+ );
372
+ }
373
+ }
374
+
375
+ async function readRlsContextSettings(db: Database): Promise<RlsContextSettings> {
376
+ const [settings] = await rawRows<{
377
+ account_id: string | null;
378
+ workspace_id: string | null;
379
+ }>(
380
+ db,
381
+ sql`select
382
+ current_setting('opengeni.account_id', true) as account_id,
383
+ current_setting('opengeni.workspace_id', true) as workspace_id`,
384
+ );
385
+ return {
386
+ accountId: settings?.account_id ?? "",
387
+ workspaceId: settings?.workspace_id ?? "",
388
+ };
389
+ }
390
+
391
+ async function assertRlsContextApplied(db: Database, context: RlsContext): Promise<void> {
392
+ const applied = await readRlsContextSettings(db);
393
+ const expectedWorkspaceId = context.workspaceId ?? "";
394
+ if (applied.accountId !== context.accountId) {
395
+ throw new Error(
396
+ `RLS context not applied on the active backend: expected account ${context.accountId}, got "${applied.accountId}"`,
397
+ );
398
+ }
399
+ if (applied.workspaceId !== expectedWorkspaceId) {
400
+ throw new Error(
401
+ `RLS context not applied on the active backend: expected workspace "${expectedWorkspaceId}", got "${applied.workspaceId}"`,
402
+ );
403
+ }
404
+ }
405
+
406
+ async function restoreRlsContextSettings(
407
+ db: Database,
408
+ settings: RlsContextSettings,
409
+ ): Promise<void> {
410
+ const [restored] = await rawRows<{
411
+ account_id: string;
412
+ workspace_id: string;
413
+ }>(
414
+ db,
415
+ sql`select
416
+ set_config('opengeni.account_id', ${settings.accountId}, true) as account_id,
417
+ set_config('opengeni.workspace_id', ${settings.workspaceId}, true) as workspace_id`,
418
+ );
419
+ if (
420
+ restored?.account_id !== settings.accountId ||
421
+ restored.workspace_id !== settings.workspaceId
422
+ ) {
423
+ throw new Error("RLS context could not be restored after a nested scope");
424
+ }
220
425
  }
221
426
 
222
427
  export async function withRlsContext<T>(
@@ -225,8 +430,10 @@ export async function withRlsContext<T>(
225
430
  fn: (db: Database) => Promise<T>,
226
431
  transactionConfig?: PgTransactionConfig,
227
432
  ): Promise<T> {
433
+ const restoreParentScope = isTransactionHandle(db);
228
434
  return await db.transaction(async (tx) => {
229
435
  const scoped = tx as unknown as Database;
436
+ const parentScope = restoreParentScope ? await readRlsContextSettings(scoped) : null;
230
437
  await setRlsContext(scoped, context);
231
438
  // Defense-in-depth: read the LOCAL GUC back on THIS backend BEFORE running
232
439
  // the scoped query. The set_config and this read share one db.transaction,
@@ -237,28 +444,13 @@ export async function withRlsContext<T>(
237
444
  // manufacturing a phantom "no active subscription" from a credential that is
238
445
  // in fact active. Convert that silent false into a loud, root-cause-bearing
239
446
  // 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);
447
+ await assertRlsContextApplied(scoped, context);
448
+ const value = await fn(scoped);
449
+ // A nested transaction is a savepoint, and SET LOCAL survives successful
450
+ // savepoint release. Restore only the tenant scope the nested helper owns;
451
+ // writer/protocol capabilities intentionally remain transaction-wide.
452
+ if (parentScope) await restoreRlsContextSettings(scoped, parentScope);
453
+ return value;
262
454
  }, transactionConfig);
263
455
  }
264
456
 
@@ -306,8 +498,238 @@ export async function withWorkspaceRls<T>(
306
498
  db: Database,
307
499
  workspaceId: string,
308
500
  fn: (db: Database) => Promise<T>,
501
+ transactionConfig?: PgTransactionConfig,
309
502
  ): Promise<T> {
310
- return await withRlsContext(db, await rlsContextForWorkspace(db, workspaceId), fn);
503
+ return await withRlsContext(
504
+ db,
505
+ await rlsContextForWorkspace(db, workspaceId),
506
+ fn,
507
+ transactionConfig,
508
+ );
509
+ }
510
+
511
+ type SessionActivityGate = {
512
+ db: SessionActivityDatabase;
513
+ owner: boolean;
514
+ };
515
+
516
+ function isTransactionHandle(db: Database): boolean {
517
+ return typeof (db as Database & { rollback?: unknown }).rollback === "function";
518
+ }
519
+
520
+ async function assertSessionActivityGateEntry(db: Database): Promise<void> {
521
+ if (!isTransactionHandle(db)) return;
522
+ const [gate] = await rawRows<{ state: string | null }>(
523
+ db,
524
+ sql`
525
+ select nullif(
526
+ current_setting('opengeni.session_activity_gate_state', true),
527
+ ''
528
+ ) as state
529
+ `,
530
+ );
531
+ if (gate?.state === null || gate === undefined) {
532
+ throw new Error("Session activity commit gate must start at the outer transaction boundary");
533
+ }
534
+ }
535
+
536
+ async function beginSessionActivityGate(
537
+ db: Database,
538
+ workspaceId: string,
539
+ ): Promise<SessionActivityGate> {
540
+ const [gate] = await rawRows<{
541
+ prior_state: string | null;
542
+ prior_workspace_id: string | null;
543
+ }>(
544
+ db,
545
+ sql`
546
+ with current_gate as materialized (
547
+ select
548
+ nullif(current_setting('opengeni.session_activity_gate_state', true), '') as prior_state,
549
+ nullif(current_setting('opengeni.session_activity_gate_workspace_id', true), '') as prior_workspace_id
550
+ )
551
+ select
552
+ prior_state,
553
+ prior_workspace_id,
554
+ case
555
+ when prior_state is null
556
+ then set_config('opengeni.session_activity_gate_state', 'open', true)
557
+ else prior_state
558
+ end as effective_state,
559
+ case
560
+ when prior_state is null
561
+ then set_config('opengeni.session_activity_gate_workspace_id', ${workspaceId}, true)
562
+ else prior_workspace_id
563
+ end as effective_workspace_id
564
+ from current_gate
565
+ `,
566
+ );
567
+ if (!gate) {
568
+ throw new Error("Session activity commit gate could not be opened");
569
+ }
570
+ if (gate.prior_state === null) {
571
+ return { db: db as SessionActivityDatabase, owner: true };
572
+ }
573
+ if (gate.prior_state === "open" && gate.prior_workspace_id === workspaceId) {
574
+ return { db: db as SessionActivityDatabase, owner: false };
575
+ }
576
+ throw new Error("Session activity commit gate is already owned by another transaction scope");
577
+ }
578
+
579
+ async function finalizeSessionActivityGate(
580
+ db: SessionActivityDatabase,
581
+ workspaceId: string,
582
+ ): Promise<void> {
583
+ // Resolve every deferred FK/constraint before taking the shared workspace
584
+ // counter. The only row writes after that point stamp session rows already
585
+ // modified and therefore already owned by this transaction.
586
+ await db.execute(
587
+ sql`select set_config('opengeni.session_activity_gate_state', 'preparing', true)`,
588
+ );
589
+ await db.execute(sql`set constraints all immediate`);
590
+ // The first flush proves every pre-existing deferred obligation. Re-defer
591
+ // only our commit guard so the marker-clearing writes below queue a fresh
592
+ // proof against the final row state, then force that proof before returning.
593
+ await db.execute(
594
+ sql`set constraints sessions_activity_insert_commit_guard, sessions_activity_update_commit_guard deferred`,
595
+ );
596
+ const [result] = await rawRows<{
597
+ had_pending: boolean;
598
+ pending_count: number;
599
+ revision: string | null;
600
+ stamped_count: number;
601
+ }>(
602
+ db,
603
+ sql`
604
+ with gate as materialized (
605
+ select set_config('opengeni.session_activity_gate_state', 'finalizing', true) as state
606
+ ),
607
+ pending as materialized (
608
+ select ${schema.sessions.id}
609
+ from ${schema.sessions}
610
+ cross join gate
611
+ where ${schema.sessions.workspaceId} = ${workspaceId}
612
+ and ${schema.sessions.activityRevisionPendingXid}
613
+ = pg_current_xact_id()::text::bigint
614
+ ),
615
+ advanced as (
616
+ update ${schema.workspaceSessionActivityRevisions} as counter
617
+ set revision = counter.revision + 1
618
+ where counter.workspace_id = ${workspaceId}
619
+ and exists (select 1 from pending)
620
+ returning counter.revision
621
+ ),
622
+ stamped as (
623
+ update ${schema.sessions} as session
624
+ set
625
+ activity_revision = advanced.revision,
626
+ activity_revision_pending_xid = null
627
+ from advanced
628
+ where session.workspace_id = ${workspaceId}
629
+ and session.activity_revision_pending_xid
630
+ = pg_current_xact_id()::text::bigint
631
+ returning session.id
632
+ ),
633
+ stats as materialized (
634
+ select
635
+ exists (select 1 from pending) as had_pending,
636
+ (select count(*)::integer from pending) as pending_count,
637
+ (select revision::text from advanced) as revision,
638
+ (select count(*)::integer from stamped) as stamped_count
639
+ ),
640
+ finalized as materialized (
641
+ select
642
+ had_pending,
643
+ pending_count,
644
+ revision,
645
+ stamped_count,
646
+ set_config('opengeni.session_activity_gate_state', 'finalized', true) as state
647
+ from stats
648
+ )
649
+ select had_pending, pending_count, revision, stamped_count
650
+ from finalized
651
+ `,
652
+ );
653
+ if (!result) {
654
+ throw new Error("Session activity commit gate did not return a finalization result");
655
+ }
656
+ if (result.had_pending && result.revision === null) {
657
+ throw new Error("Workspace session activity counter is unavailable");
658
+ }
659
+ if (result.stamped_count !== result.pending_count) {
660
+ throw new Error("Session activity commit gate did not stamp every pending row");
661
+ }
662
+ await db.execute(
663
+ sql`set constraints sessions_activity_insert_commit_guard, sessions_activity_update_commit_guard immediate`,
664
+ );
665
+ }
666
+
667
+ /**
668
+ * Run one workspace transaction with explicit once-per-transaction session
669
+ * activity finalization. Nested callers reuse the outer gate; only its owner
670
+ * finalizes immediately before the enclosing RLS transaction commits.
671
+ */
672
+ export async function withSessionActivityRlsContext<T>(
673
+ db: Database,
674
+ context: RlsContext & { workspaceId: string },
675
+ fn: (db: SessionActivityDatabase) => Promise<T>,
676
+ transactionConfig?: PgTransactionConfig,
677
+ ): Promise<T> {
678
+ await assertSessionActivityGateEntry(db);
679
+ return await withRlsContext(
680
+ db,
681
+ context,
682
+ async (scopedDb) => {
683
+ const gate = await beginSessionActivityGate(scopedDb, context.workspaceId);
684
+ const value = await fn(gate.db);
685
+ // The finalizer must never trust tenant GUCs that arbitrary callback code
686
+ // could have changed. Nested RLS helpers restore their parent scope; this
687
+ // assertion is the fail-closed commit-boundary proof for every other path.
688
+ await assertRlsContextApplied(gate.db, context);
689
+ if (gate.owner) {
690
+ await finalizeSessionActivityGate(gate.db, context.workspaceId);
691
+ }
692
+ return value;
693
+ },
694
+ transactionConfig,
695
+ );
696
+ }
697
+
698
+ export async function withWorkspaceSessionActivityRls<T>(
699
+ db: Database,
700
+ workspaceId: string,
701
+ fn: (db: SessionActivityDatabase) => Promise<T>,
702
+ transactionConfig?: PgTransactionConfig,
703
+ ): Promise<T> {
704
+ return await withSessionActivityRlsContext(
705
+ db,
706
+ { ...(await rlsContextForWorkspace(db, workspaceId)), workspaceId },
707
+ fn,
708
+ transactionConfig,
709
+ );
710
+ }
711
+
712
+ /**
713
+ * Open one nested savepoint without losing the gate brand. Drizzle correctly
714
+ * preserves the backend-local gate GUCs, but its structural transaction type
715
+ * cannot express that provenance; keep the single trusted rebrand here.
716
+ */
717
+ export async function withSessionActivitySavepoint<T>(
718
+ db: SessionActivityDatabase,
719
+ fn: (db: SessionActivityDatabase) => Promise<T>,
720
+ ): Promise<T> {
721
+ return await db.transaction(async (tx) => await fn(tx as unknown as SessionActivityDatabase));
722
+ }
723
+
724
+ export async function retrySessionActivityRls<T>(
725
+ db: Database,
726
+ workspaceId: string,
727
+ options: IdempotentPersistenceTransactionOptions,
728
+ fn: (db: SessionActivityDatabase) => Promise<T>,
729
+ ): Promise<T> {
730
+ return await runIdempotentPersistenceTransaction(options, async () => {
731
+ return await withWorkspaceSessionActivityRls(db, workspaceId, fn);
732
+ });
311
733
  }
312
734
 
313
735
  export async function retryWorkspacePersistence<T>(
@@ -359,6 +781,28 @@ export async function withWorkspaceSubjectRls<T>(
359
781
  );
360
782
  }
361
783
 
784
+ export async function withWorkspaceSubjectSessionActivityRls<T>(
785
+ db: Database,
786
+ workspaceId: string,
787
+ subjectId: string,
788
+ fn: (db: SessionActivityDatabase) => Promise<T>,
789
+ transactionConfig?: PgTransactionConfig,
790
+ ): Promise<T> {
791
+ if (!subjectId.trim()) {
792
+ throw new Error("withWorkspaceSubjectSessionActivityRls: a non-empty subjectId is required");
793
+ }
794
+ const context = await rlsContextForWorkspace(db, workspaceId);
795
+ return await withSessionActivityRlsContext(
796
+ db,
797
+ { ...context, workspaceId },
798
+ async (scopedDb) => {
799
+ await setSubjectRlsContext(scopedDb, subjectId);
800
+ return await fn(scopedDb);
801
+ },
802
+ transactionConfig,
803
+ );
804
+ }
805
+
362
806
  /** Apply and verify actor-private RLS on an already transaction-pinned handle. */
363
807
  export async function setSubjectRlsContext(db: Database, subjectId: string): Promise<void> {
364
808
  if (!subjectId.trim()) {