@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.
- package/dist/attached-browser-devices.d.ts +42 -0
- package/dist/attempt-tool-catalogs.d.ts +16 -0
- package/dist/browser-auth.d.ts +91 -0
- package/dist/browser-identities.d.ts +128 -0
- package/dist/browser-sessions.d.ts +272 -0
- package/dist/browser-state-artifacts.d.ts +17 -0
- package/dist/capability-components.d.ts +37 -0
- package/dist/capability-integrations.d.ts +192 -0
- package/dist/chunk-F5FT7BEZ.js +3535 -0
- package/dist/chunk-F5FT7BEZ.js.map +1 -0
- package/dist/chunk-ISI2TIUG.js +639 -0
- package/dist/chunk-ISI2TIUG.js.map +1 -0
- package/dist/chunk-MWDVXQOL.js +11639 -0
- package/dist/chunk-MWDVXQOL.js.map +1 -0
- package/dist/chunk-NBE6CHRI.js +955 -0
- package/dist/chunk-NBE6CHRI.js.map +1 -0
- package/dist/{chunk-VHBFHMPC.js → chunk-YHZXQ7HP.js} +754 -11
- package/dist/chunk-YHZXQ7HP.js.map +1 -0
- package/dist/codemode-operations.d.ts +117 -0
- package/dist/codex-token-resolver.d.ts +2 -2
- package/dist/company-profile-schema.d.ts +798 -0
- package/dist/company-profile.d.ts +268 -0
- package/dist/computer-sessions.d.ts +109 -0
- package/dist/connection-token-resolver.d.ts +9 -4
- package/dist/database.d.ts +52 -3
- package/dist/editable-artifact-durable-export.d.ts +153 -0
- package/dist/editable-artifact-durable-export.js +788 -0
- package/dist/editable-artifact-durable-export.js.map +1 -0
- package/dist/editable-artifact-materialization.d.ts +123 -0
- package/dist/editable-artifacts-schema.d.ts +4152 -0
- package/dist/editable-artifacts.d.ts +610 -0
- package/dist/editable-artifacts.js +26 -0
- package/dist/editable-artifacts.js.map +1 -0
- package/dist/generated-images.d.ts +139 -0
- package/dist/index.d.ts +698 -111
- package/dist/index.js +27573 -10322
- package/dist/index.js.map +1 -1
- package/dist/insights.d.ts +51 -4
- package/dist/integration-bindings.d.ts +70 -0
- package/dist/integration-features.d.ts +99 -0
- package/dist/interaction-revisions.d.ts +3 -0
- package/dist/interaction-schema.d.ts +5372 -0
- package/dist/knowledge-source-sync-schema.d.ts +1679 -0
- package/dist/knowledge-source-sync.d.ts +274 -0
- package/dist/memory-governance.d.ts +2 -2
- package/dist/memory-slack-delivery.d.ts +101 -0
- package/dist/new-session-drafts.d.ts +2 -2
- package/dist/pack-components.d.ts +76 -0
- package/dist/pack-installations.d.ts +86 -0
- package/dist/persistence-errors.d.ts +3 -3
- package/dist/plugin-packages.d.ts +126 -0
- package/dist/preference-registry.d.ts +1 -1
- package/dist/provision-roles.d.ts +9 -1
- package/dist/provision-roles.js +1 -1
- package/dist/runtime-posture.d.ts +18 -6
- package/dist/schema.d.ts +18342 -9099
- package/dist/schema.js +165 -1
- package/dist/scoped-knowledge.d.ts +97 -1
- package/dist/session-control.d.ts +4 -4
- package/dist/session-queue-commands.d.ts +19 -11
- package/dist/session-realtime-context.d.ts +2 -2
- package/dist/session-realtime-ledger.d.ts +4 -4
- package/dist/session-realtime-mirror.d.ts +2 -2
- package/dist/session-realtime-state.d.ts +1 -1
- package/dist/session-realtime-terminal.d.ts +1 -1
- package/dist/session-realtime.d.ts +7 -7
- package/dist/session-tool-call-settlement.d.ts +1 -1
- package/dist/slack-user-link-access.d.ts +54 -0
- package/dist/transcription-recordings.d.ts +2 -2
- package/dist/turn-initiator.d.ts +2 -2
- package/dist/video-generation.d.ts +216 -0
- package/dist/video-generation.js +66 -0
- package/dist/video-generation.js.map +1 -0
- package/dist/workspace-artifacts.d.ts +1 -1
- package/dist/workspace-instruction-policies.d.ts +1 -1
- package/dist/workspace-learning-policy-schema.d.ts +932 -0
- package/dist/workspace-learning-policy.d.ts +161 -0
- package/drizzle/0183_model_call_provider_cost_estimates.sql +39 -0
- package/drizzle/0184_sandbox_drain_teardown_fence.sql +682 -0
- package/drizzle/0185_temporal_schedule_cleanup_outbox.sql +168 -0
- package/drizzle/0186_sandbox_capture_provider_contract.sql +400 -0
- package/drizzle/0187_generated_image_artifacts.sql +173 -0
- package/drizzle/0188_image_generation_retention_failure.sql +25 -0
- package/drizzle/0189_retained_session_image_formats.sql +13 -0
- package/drizzle/0190_timeline_annotations.sql +31 -0
- package/drizzle/0191_editable_artifact_engine.sql +3853 -0
- package/drizzle/0192_editable_artifact_live_tickets.sql +338 -0
- package/drizzle/0193_editable_artifact_authorization.sql +190 -0
- package/drizzle/0194_editable_artifact_durable_exports.sql +160 -0
- package/drizzle/0195_editable_artifact_import_authorization.sql +203 -0
- package/drizzle/0196_rig_provider_images.sql +152 -0
- package/drizzle/0197_knowledge_source_sync_schedules.sql +523 -0
- package/drizzle/0198_memory_slack_publication_delivery.sql +393 -0
- package/drizzle/0199_workspace_learning_policy.sql +980 -0
- package/drizzle/0201_company_profile_authority.sql +849 -0
- package/drizzle/0202_document_index_checkpoints.sql +85 -0
- package/drizzle/0203_durable_video_generation.sql +353 -0
- package/drizzle/0204_video_generation_funding.sql +44 -0
- package/drizzle/0205_attempt_tool_catalogs.sql +230 -0
- package/drizzle/0206_browser_sessions.sql +646 -0
- package/drizzle/0207_browser_identities.sql +379 -0
- package/drizzle/0208_attached_browser_devices.sql +165 -0
- package/drizzle/0209_computer_sessions.sql +575 -0
- package/drizzle/0210_browser_auth_network_interventions.sql +441 -0
- package/drizzle/0211_editable_artifact_session_links.sql +58 -0
- package/drizzle/0212_slack_installation_bindings.sql +348 -0
- package/drizzle/0213_slack_user_link_access_requests.sql +177 -0
- package/drizzle/0214_session_activity_commit_gate.sql +262 -0
- package/drizzle/0215_capabilities_platform.sql +980 -0
- package/drizzle/0216_pack_component_ownership.sql +235 -0
- package/drizzle/0217_capability_definition_delete_authority.sql +32 -0
- package/package.json +18 -5
- package/src/attached-browser-devices.ts +371 -0
- package/src/attempt-tool-catalogs.ts +131 -0
- package/src/browser-auth.ts +1896 -0
- package/src/browser-identities.ts +1168 -0
- package/src/browser-sessions.ts +2005 -0
- package/src/browser-state-artifacts.ts +59 -0
- package/src/capability-components.ts +166 -0
- package/src/capability-integrations.ts +1663 -0
- package/src/codemode-operations.ts +674 -0
- package/src/company-profile-schema.ts +143 -0
- package/src/company-profile.ts +968 -0
- package/src/computer-sessions.ts +1130 -0
- package/src/connection-token-resolver.ts +270 -25
- package/src/database.ts +427 -23
- package/src/editable-artifact-durable-export.ts +1201 -0
- package/src/editable-artifact-materialization.ts +893 -0
- package/src/editable-artifacts-schema.ts +879 -0
- package/src/editable-artifacts.ts +5404 -0
- package/src/generated-images.ts +740 -0
- package/src/index.ts +5917 -1182
- package/src/insights.ts +160 -13
- package/src/integration-bindings.ts +455 -0
- package/src/integration-features.ts +859 -0
- package/src/interaction-revisions.ts +40 -0
- package/src/interaction-schema.ts +1303 -0
- package/src/knowledge-source-sync-schema.ts +192 -0
- package/src/knowledge-source-sync.ts +1922 -0
- package/src/memory-slack-delivery.ts +801 -0
- package/src/pack-components.ts +954 -0
- package/src/pack-installations.ts +929 -0
- package/src/persistence-errors.ts +4 -7
- package/src/plugin-packages.ts +1186 -0
- package/src/provision-roles.ts +302 -1
- package/src/runtime-posture-cli.ts +9 -0
- package/src/runtime-posture.ts +508 -5
- package/src/schema.ts +5117 -3119
- package/src/scoped-knowledge-schema.ts +1 -1
- package/src/scoped-knowledge.ts +655 -3
- package/src/session-control.ts +8 -5
- package/src/session-queue-commands.ts +187 -14
- package/src/session-realtime-context.ts +2 -2
- package/src/session-realtime-ledger.ts +3 -3
- package/src/session-realtime.ts +7 -7
- package/src/session-tool-call-settlement.ts +9 -0
- package/src/slack-user-link-access.ts +702 -0
- package/src/video-generation.ts +1452 -0
- package/src/workspace-learning-policy-schema.ts +139 -0
- package/src/workspace-learning-policy.ts +595 -0
- package/dist/chunk-P6CUHXQZ.js +0 -7248
- package/dist/chunk-P6CUHXQZ.js.map +0 -1
- 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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
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()) {
|