@opengeni/db 0.7.2 → 0.9.3

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 (59) hide show
  1. package/dist/{chunk-B22X3IEZ.js → chunk-4LG5NBTC.js} +512 -32
  2. package/dist/chunk-4LG5NBTC.js.map +1 -0
  3. package/dist/{chunk-YFQ7SGE4.js → chunk-BMFDXFPA.js} +23 -1
  4. package/dist/chunk-BMFDXFPA.js.map +1 -0
  5. package/dist/chunk-KW526IJA.js +127 -0
  6. package/dist/chunk-KW526IJA.js.map +1 -0
  7. package/dist/index.d.ts +3 -3
  8. package/dist/index.js +6092 -2080
  9. package/dist/index.js.map +1 -1
  10. package/dist/migrate.d.ts +29 -4
  11. package/dist/migrate.js +3 -1
  12. package/dist/provision-roles.d.ts +732 -47
  13. package/dist/provision-roles.js +1 -1
  14. package/dist/{schema-BN5mB9xZ.d.ts → schema-CdPGTHlD.d.ts} +2118 -119
  15. package/dist/schema.d.ts +1 -1
  16. package/dist/schema.js +17 -1
  17. package/drizzle/0064_rotation_strategy_sharded_backfill.sql +15 -0
  18. package/drizzle/0065_session_attempt_quiescence.sql +26 -0
  19. package/drizzle/0066_session_interruption_attempt_lookup.sql +4 -0
  20. package/drizzle/0067_session_event_payload_bounds.sql +209 -0
  21. package/drizzle/0068_workspace_control_event_bounds.sql +134 -0
  22. package/drizzle/0069_session_event_history_backfill.sql +32 -0
  23. package/drizzle/0070_session_event_type_sequence_lookup.sql +4 -0
  24. package/drizzle/0071_session_event_monitoring_tail.sql +10 -0
  25. package/drizzle/0072_sessions_workspace_created_id_idx.sql +4 -0
  26. package/drizzle/0073_sessions_workspace_updated_id_idx.sql +4 -0
  27. package/drizzle/0074_session_activity_revisions.sql +72 -0
  28. package/drizzle/0075_sessions_workspace_activity_revision_idx.sql +4 -0
  29. package/drizzle/0076_session_workflow_wake_acl.sql +13 -0
  30. package/drizzle/0077_session_attempt_latest_lookup.sql +4 -0
  31. package/drizzle/0094_quarantine_credential_bearing_catalog_urls.sql +51 -0
  32. package/drizzle/0095_github_existing_installations.sql +84 -0
  33. package/drizzle/0096_session_turn_initiators.sql +97 -0
  34. package/drizzle/0097_host_export_outbox.sql +1220 -0
  35. package/drizzle/0098_usage_events_workspace_session_idx.sql +4 -0
  36. package/drizzle/0099_session_human_input_attempt_owner_index.sql +6 -0
  37. package/drizzle/0100_session_human_input_requests.sql +89 -0
  38. package/drizzle/0101_session_mcp_connection_refs.sql +30 -0
  39. package/drizzle/0102_session_command_receipt_service_actor.sql +16 -0
  40. package/drizzle/0103_host_export_root_session.sql +166 -0
  41. package/drizzle/0104_host_export_root_session_backfill.sql +27 -0
  42. package/drizzle/0105_session_turn_instructions.sql +9 -0
  43. package/package.json +4 -4
  44. package/src/connection-token-resolver.ts +287 -1
  45. package/src/event-payload-sanitizer.ts +57 -19
  46. package/src/index.ts +5429 -1131
  47. package/src/memory-domain.ts +1 -1
  48. package/src/migrate.ts +86 -23
  49. package/src/persistence-errors.ts +252 -0
  50. package/src/provision-roles.ts +42 -0
  51. package/src/schema.ts +552 -34
  52. package/src/session-control.ts +518 -38
  53. package/src/session-queue-commands.ts +308 -57
  54. package/src/session-tool-call-settlement.ts +58 -7
  55. package/src/turn-initiator.ts +155 -0
  56. package/dist/chunk-7LDU7F5P.js +0 -80
  57. package/dist/chunk-7LDU7F5P.js.map +0 -1
  58. package/dist/chunk-B22X3IEZ.js.map +0 -1
  59. package/dist/chunk-YFQ7SGE4.js.map +0 -1
@@ -54,7 +54,7 @@ export const MEMORY_KIND_SECTION_TITLES: Record<KnowledgeMemoryKind, string> = {
54
54
  };
55
55
 
56
56
  // ---------------------------------------------------------------------------
57
- // Canonical prompt surface (dossier §10b) — the prompts ARE the product.
57
+ // Canonical prompt surface — the prompts ARE the product.
58
58
  // ---------------------------------------------------------------------------
59
59
 
60
60
  export const WORKSPACE_MEMORY_BLOCK_HEADER_POPULATED = `## Workspace memory
package/src/migrate.ts CHANGED
@@ -5,6 +5,24 @@ import postgres from "postgres";
5
5
 
6
6
  const DEFAULT_DATABASE_URL = "postgres://opengeni:opengeni@127.0.0.1:5432/opengeni";
7
7
  const concurrentIndexDirective = /^-- opengeni:concurrent-index lock-timeout=(\d+(?:ms|s|min))$/;
8
+ const concurrentIndexStatement =
9
+ /^CREATE\s+(?:UNIQUE\s+)?INDEX\s+CONCURRENTLY\s+(?:(IF\s+NOT\s+EXISTS)\s+)?(?:"((?:[^"]|"")+)"|([A-Za-z_][A-Za-z0-9_]*))\s+ON\b/is;
10
+ const governedLegacyConcurrentIndexMigrations = new Set([
11
+ "0066_session_interruption_attempt_lookup.sql",
12
+ "0070_session_event_type_sequence_lookup.sql",
13
+ "0071_session_event_monitoring_tail.sql",
14
+ "0072_sessions_workspace_created_id_idx.sql",
15
+ "0073_sessions_workspace_updated_id_idx.sql",
16
+ "0075_sessions_workspace_activity_revision_idx.sql",
17
+ "0077_session_attempt_latest_lookup.sql",
18
+ ]);
19
+
20
+ export interface ConcurrentIndexMigration {
21
+ indexName: string;
22
+ lockTimeout: string;
23
+ skipWhenValid: boolean;
24
+ statement: string;
25
+ }
8
26
 
9
27
  /** A bare Postgres identifier (schema/role name) safe to interpolate into DDL. */
10
28
  function assertIdentifier(name: string, value: string): string {
@@ -20,45 +38,90 @@ function assertIdentifier(name: string, value: string): string {
20
38
  * into one narrowly validated transactionless statement with:
21
39
  *
22
40
  * -- opengeni:concurrent-index lock-timeout=5s
23
- * CREATE [UNIQUE] INDEX CONCURRENTLY ...;
41
+ * CREATE [UNIQUE] INDEX CONCURRENTLY IF NOT EXISTS ...;
24
42
  *
25
43
  * The directive is deliberately not a generic "no transaction" escape hatch:
26
- * only one concurrent-index statement is accepted, and lock acquisition is
27
- * always bounded. This keeps additive large-table indexes online without making
28
- * arbitrary partially-applied migration scripts possible.
44
+ * only one idempotent concurrent-index statement is accepted, lock acquisition
45
+ * is always bounded, and an invalid artifact left by a failed concurrent build
46
+ * is removed before retry. Seven governed historical migrations predate the
47
+ * IF NOT EXISTS rule; only those exact filenames may use their immutable bare
48
+ * statements, and the runner makes their retries idempotent by skipping an
49
+ * already-valid index. This keeps additive large-table indexes online without
50
+ * rewriting shipped history or making arbitrary partially-applied migration
51
+ * scripts possible.
29
52
  */
30
- async function executeMigrationFile(
31
- sql: postgres.Sql,
53
+ export function parseConcurrentIndexMigration(
32
54
  file: string,
33
55
  sqlText: string,
34
- ): Promise<void> {
35
- const [firstLine = "", ...remainingLines] = sqlText.replaceAll("\r\n", "\n").split("\n");
36
- const directive = concurrentIndexDirective.exec(firstLine.trim());
56
+ ): ConcurrentIndexMigration | null {
57
+ const lines = sqlText.replaceAll("\r\n", "\n").split("\n");
58
+ const firstLine = lines[0]?.trim() ?? "";
59
+ const deploymentPrefixed = /^-- deployment-mode: (?:rolling|maintenance)$/.test(firstLine);
60
+ const directiveIndex = deploymentPrefixed ? 1 : 0;
61
+ const directiveLine = lines[directiveIndex]?.trim() ?? "";
62
+ const directive = concurrentIndexDirective.exec(directiveLine);
37
63
  if (!directive) {
38
- if (firstLine.trim().startsWith("-- opengeni:")) {
64
+ if (directiveLine.startsWith("-- opengeni:")) {
39
65
  throw new Error(`Unsupported OpenGeni migration directive in ${file}`);
40
66
  }
41
- await sql.unsafe(sqlText);
42
- return;
67
+ return null;
43
68
  }
44
69
 
45
70
  const lockTimeout = directive[1]!;
46
- const statement = remainingLines.join("\n").trim();
71
+ const statement = lines
72
+ .slice(directiveIndex + 1)
73
+ .join("\n")
74
+ .trim();
47
75
  const withoutTrailingSemicolon = statement.endsWith(";")
48
76
  ? statement.slice(0, -1).trimEnd()
49
77
  : statement;
50
- if (
51
- !/^CREATE\s+(?:UNIQUE\s+)?INDEX\s+CONCURRENTLY\b/is.test(withoutTrailingSemicolon) ||
52
- withoutTrailingSemicolon.includes(";")
53
- ) {
78
+ const parsedStatement = concurrentIndexStatement.exec(withoutTrailingSemicolon);
79
+ if (!parsedStatement || withoutTrailingSemicolon.includes(";")) {
54
80
  throw new Error(
55
- `${file}: opengeni:concurrent-index requires exactly one CREATE [UNIQUE] INDEX CONCURRENTLY statement`,
81
+ `${file}: opengeni:concurrent-index requires exactly one CREATE [UNIQUE] INDEX CONCURRENTLY IF NOT EXISTS statement with an unqualified index name`,
56
82
  );
57
83
  }
84
+ const idempotentInSql = parsedStatement[1] !== undefined;
85
+ if (!idempotentInSql && !governedLegacyConcurrentIndexMigrations.has(file)) {
86
+ throw new Error(
87
+ `${file}: opengeni:concurrent-index requires IF NOT EXISTS; bare statements are supported only for governed historical migrations`,
88
+ );
89
+ }
90
+ return {
91
+ indexName: (parsedStatement[2] ?? parsedStatement[3]!).replaceAll('""', '"'),
92
+ lockTimeout,
93
+ skipWhenValid: !idempotentInSql,
94
+ statement,
95
+ };
96
+ }
58
97
 
59
- await sql`select set_config('lock_timeout', ${lockTimeout}, false)`;
98
+ async function executeMigrationFile(
99
+ sql: postgres.Sql,
100
+ file: string,
101
+ sqlText: string,
102
+ ): Promise<void> {
103
+ const concurrentIndex = parseConcurrentIndexMigration(file, sqlText);
104
+ if (!concurrentIndex) {
105
+ await sql.unsafe(sqlText);
106
+ return;
107
+ }
108
+
109
+ await sql`select set_config('lock_timeout', ${concurrentIndex.lockTimeout}, false)`;
60
110
  try {
61
- await sql.unsafe(statement);
111
+ const [existing] = await sql<Array<{ valid: boolean; ready: boolean }>>`
112
+ select i.indisvalid as valid, i.indisready as ready
113
+ from pg_catalog.pg_class c
114
+ join pg_catalog.pg_namespace n on n.oid = c.relnamespace
115
+ join pg_catalog.pg_index i on i.indexrelid = c.oid
116
+ where n.nspname = current_schema() and c.relname = ${concurrentIndex.indexName}
117
+ `;
118
+ if (existing && (!existing.valid || !existing.ready)) {
119
+ const quotedIndexName = `"${concurrentIndex.indexName.replaceAll('"', '""')}"`;
120
+ await sql.unsafe(`DROP INDEX CONCURRENTLY ${quotedIndexName}`);
121
+ } else if (existing && concurrentIndex.skipWhenValid) {
122
+ return;
123
+ }
124
+ await sql.unsafe(concurrentIndex.statement);
62
125
  } finally {
63
126
  await sql`select set_config('lock_timeout', '0', false)`;
64
127
  }
@@ -73,18 +136,18 @@ async function executeMigrationFile(
73
136
  * byte-for-byte historical behavior — the migration test suite calls
74
137
  * `migrate(DB_URL)` and is unaffected.
75
138
  *
76
- * EMBEDDED (Step I, §7.8 runtime/SDK half): pass a `schema` (or set
139
+ * EMBEDDED SCHEMA MODE: pass a `schema` (or set
77
140
  * `OPENGENI_DB_SCHEMA`). The migrate session then `CREATE SCHEMA IF NOT EXISTS`
78
141
  * for both `<schema>` and `opengeni_private`, and sets
79
142
  * `search_path = "<schema>", "opengeni_private", "public"`, so EVERY unqualified
80
143
  * DDL statement lands in the dedicated schema with NO per-statement SQL rewrite
81
- * (the SPIKE-1 F1 result). Two things make this work and stay idempotent:
144
+ * (the schema-isolation contract). Two things make this work and stay idempotent:
82
145
  * 1. The policy-existence guards in the migration SQL use `current_schema()`
83
146
  * (not a hardcoded `'public'`) — so a re-run finds the policy it already
84
147
  * created in `<schema>` and DROP/CREATEs idempotently instead of failing
85
148
  * with "policy already exists". (This guard substitution is the migrate-
86
149
  * time enabler for the runtime search_path approach; without it the SDK
87
- * entry point silently fails on re-run — the Fork-6 hazard.)
150
+ * entry point silently fails on re-run — the migration replay hazard.)
88
151
  * 2. `public` stays LAST on the path so `gen_random_uuid()` (pgcrypto) and the
89
152
  * `vector` type — both installed into `public` by 0000 — still resolve. The
90
153
  * `opengeni_private.*` helpers are always called with an absolute prefix.
@@ -0,0 +1,252 @@
1
+ import { sanitizeEventString } from "./event-payload-sanitizer";
2
+
3
+ export type DatabaseFailureCode = "db_deadlock" | "db_serialization_failure" | "db_failure";
4
+
5
+ export type PersistenceRetryOutcome = "not_retryable" | "exhausted";
6
+
7
+ export type SafeDatabaseErrorFacts = {
8
+ severity?: string;
9
+ schema?: string;
10
+ table?: string;
11
+ column?: string;
12
+ dataType?: string;
13
+ constraint?: string;
14
+ routine?: string;
15
+ };
16
+
17
+ export type PersistenceFailureDetails = {
18
+ code: DatabaseFailureCode;
19
+ sqlState: string | null;
20
+ stage: string;
21
+ eventTypes: string[];
22
+ correlationId: string;
23
+ attempts: number;
24
+ retryOutcome: PersistenceRetryOutcome;
25
+ database: SafeDatabaseErrorFacts;
26
+ };
27
+
28
+ const SQLSTATE_KEYS = ["sqlState", "sqlstate", "code"] as const;
29
+ const NESTED_ERROR_KEYS = ["cause", "original", "driverError", "error", "errors"] as const;
30
+ const DATABASE_ERROR_NAMES = new Set(["DatabaseError", "DrizzleQueryError", "PostgresError"]);
31
+ const DATABASE_DIAGNOSTIC_KEYS = [
32
+ "severity",
33
+ "schema_name",
34
+ "table_name",
35
+ "column_name",
36
+ "data_type_name",
37
+ "constraint_name",
38
+ "routine",
39
+ ] as const;
40
+ const SAFE_FACT_KEYS = [
41
+ ["severity", "severity"],
42
+ ["schema_name", "schema"],
43
+ ["schema", "schema"],
44
+ ["table_name", "table"],
45
+ ["table", "table"],
46
+ ["column_name", "column"],
47
+ ["column", "column"],
48
+ ["data_type_name", "dataType"],
49
+ ["dataType", "dataType"],
50
+ ["constraint_name", "constraint"],
51
+ ["constraint", "constraint"],
52
+ ["routine", "routine"],
53
+ ] as const;
54
+
55
+ function isRecord(value: unknown): value is Record<string, unknown> {
56
+ return Boolean(value && typeof value === "object");
57
+ }
58
+
59
+ function safeFact(value: unknown): string | undefined {
60
+ if (typeof value !== "string" || value.length === 0) return undefined;
61
+ return sanitizeEventString(value).slice(0, 256);
62
+ }
63
+
64
+ /** Find the driver SQLSTATE even when Drizzle wrapped it under nested causes. */
65
+ export function nestedPostgresSqlState(error: unknown): string | null {
66
+ const queue: unknown[] = [error];
67
+ const seen = new Set<unknown>();
68
+ let fallback: string | null = null;
69
+ while (queue.length > 0 && seen.size < 64) {
70
+ const current = queue.shift();
71
+ if (!isRecord(current) || seen.has(current)) continue;
72
+ seen.add(current);
73
+ for (const key of SQLSTATE_KEYS) {
74
+ const value = current[key];
75
+ if (typeof value !== "string" || !/^[0-9A-Z]{5}$/i.test(value)) continue;
76
+ const normalized = value.toUpperCase();
77
+ if (normalized === "40P01" || normalized === "40001") return normalized;
78
+ fallback ??= normalized;
79
+ }
80
+ for (const key of NESTED_ERROR_KEYS) {
81
+ const nested = current[key];
82
+ if (Array.isArray(nested)) queue.push(...nested);
83
+ else if (nested !== undefined) queue.push(nested);
84
+ }
85
+ }
86
+ return fallback;
87
+ }
88
+
89
+ export function databaseFailureCode(sqlState: string | null): DatabaseFailureCode {
90
+ if (sqlState === "40P01") return "db_deadlock";
91
+ if (sqlState === "40001") return "db_serialization_failure";
92
+ return "db_failure";
93
+ }
94
+
95
+ export function isRetryablePersistenceSqlState(sqlState: string | null): boolean {
96
+ return sqlState === "40P01" || sqlState === "40001";
97
+ }
98
+
99
+ /**
100
+ * Distinguish database/ORM failures from expected domain exceptions when a
101
+ * driver omitted SQLSTATE. This checks shape only and never retains query text,
102
+ * bound parameters, or a raw driver cause.
103
+ */
104
+ export function isDatabasePersistenceFailure(error: unknown): boolean {
105
+ if (nestedPostgresSqlState(error) !== null) return true;
106
+
107
+ const queue: unknown[] = [error];
108
+ const seen = new Set<unknown>();
109
+ while (queue.length > 0 && seen.size < 64) {
110
+ const current = queue.shift();
111
+ if (!isRecord(current) || seen.has(current)) continue;
112
+ seen.add(current);
113
+
114
+ if (typeof current.name === "string" && DATABASE_ERROR_NAMES.has(current.name)) {
115
+ return true;
116
+ }
117
+ if (
118
+ typeof current.query === "string" &&
119
+ (Object.hasOwn(current, "params") || Object.hasOwn(current, "parameters"))
120
+ ) {
121
+ return true;
122
+ }
123
+ if (DATABASE_DIAGNOSTIC_KEYS.some((key) => Object.hasOwn(current, key))) {
124
+ return true;
125
+ }
126
+
127
+ for (const key of NESTED_ERROR_KEYS) {
128
+ const nested = current[key];
129
+ if (Array.isArray(nested)) queue.push(...nested);
130
+ else if (nested !== undefined) queue.push(nested);
131
+ }
132
+ }
133
+ return false;
134
+ }
135
+
136
+ /** Extract only PostgreSQL diagnostic identifiers; never query text/parameters. */
137
+ export function safeDatabaseErrorFacts(error: unknown): SafeDatabaseErrorFacts {
138
+ const queue: unknown[] = [error];
139
+ const seen = new Set<unknown>();
140
+ const facts: SafeDatabaseErrorFacts = {};
141
+ while (queue.length > 0 && seen.size < 64) {
142
+ const current = queue.shift();
143
+ if (!isRecord(current) || seen.has(current)) continue;
144
+ seen.add(current);
145
+ for (const [source, destination] of SAFE_FACT_KEYS) {
146
+ if (facts[destination] !== undefined) continue;
147
+ const value = safeFact(current[source]);
148
+ if (value !== undefined) facts[destination] = value;
149
+ }
150
+ for (const key of NESTED_ERROR_KEYS) {
151
+ const nested = current[key];
152
+ if (Array.isArray(nested)) queue.push(...nested);
153
+ else if (nested !== undefined) queue.push(nested);
154
+ }
155
+ }
156
+ return facts;
157
+ }
158
+
159
+ /** Public-safe cause that preserves database classification without driver data. */
160
+ export class SanitizedDatabasePersistenceCause extends Error {
161
+ readonly name = "SanitizedDatabasePersistenceCause";
162
+
163
+ constructor(
164
+ readonly sqlState: string | null,
165
+ readonly database: SafeDatabaseErrorFacts,
166
+ ) {
167
+ super(sqlState === null ? "Database driver failure" : `PostgreSQL failure ${sqlState}`);
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Public-safe replacement for a raw Drizzle/postgres-js failure. Its `cause`
173
+ * is a newly constructed sanitized projection; the original driver cause can
174
+ * contain full SQL and bound parameters and is never retained.
175
+ */
176
+ export class SessionEventPersistenceError extends Error {
177
+ readonly name = "SessionEventPersistenceError";
178
+ readonly cause: SanitizedDatabasePersistenceCause;
179
+
180
+ constructor(readonly details: PersistenceFailureDetails) {
181
+ const label =
182
+ details.code === "db_deadlock"
183
+ ? "Database deadlock"
184
+ : details.code === "db_serialization_failure"
185
+ ? "Database serialization failure"
186
+ : "Database failure";
187
+ super(`${label} while persisting ${details.eventTypes.join(", ") || "session events"}`);
188
+ this.cause = new SanitizedDatabasePersistenceCause(details.sqlState, details.database);
189
+ }
190
+
191
+ get code(): DatabaseFailureCode {
192
+ return this.details.code;
193
+ }
194
+ }
195
+
196
+ export function isSessionEventPersistenceError(
197
+ error: unknown,
198
+ ): error is SessionEventPersistenceError {
199
+ return error instanceof SessionEventPersistenceError;
200
+ }
201
+
202
+ export type IdempotentPersistenceTransactionOptions = {
203
+ stage: string;
204
+ eventTypes?: string[];
205
+ maxAttempts?: number;
206
+ correlationId?: string;
207
+ onRetry?: (input: { attempt: number; sqlState: "40P01" | "40001" }) => void | Promise<void>;
208
+ };
209
+
210
+ /**
211
+ * Retry only the supplied idempotent database transaction/savepoint. Provider
212
+ * inference, tools, NATS, and all other external effects must remain outside
213
+ * this function. A single correlation ID follows every persistence attempt.
214
+ */
215
+ export async function runIdempotentPersistenceTransaction<T>(
216
+ options: IdempotentPersistenceTransactionOptions,
217
+ transaction: (attempt: number) => Promise<T>,
218
+ ): Promise<T> {
219
+ const maxAttempts = options.maxAttempts ?? 3;
220
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
221
+ throw new Error("Persistence maxAttempts must be a positive integer");
222
+ }
223
+ const correlationId = options.correlationId ?? crypto.randomUUID();
224
+ const eventTypes = [...new Set(options.eventTypes ?? [])].sort();
225
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
226
+ try {
227
+ return await transaction(attempt);
228
+ } catch (error) {
229
+ const sqlState = nestedPostgresSqlState(error);
230
+ const retryable = isRetryablePersistenceSqlState(sqlState);
231
+ if (retryable && attempt < maxAttempts) {
232
+ await options.onRetry?.({
233
+ attempt,
234
+ sqlState: sqlState as "40P01" | "40001",
235
+ });
236
+ continue;
237
+ }
238
+ if (!isDatabasePersistenceFailure(error)) throw error;
239
+ throw new SessionEventPersistenceError({
240
+ code: databaseFailureCode(sqlState),
241
+ sqlState,
242
+ stage: options.stage,
243
+ eventTypes,
244
+ correlationId,
245
+ attempts: attempt,
246
+ retryOutcome: retryable ? "exhausted" : "not_retryable",
247
+ database: safeDatabaseErrorFacts(error),
248
+ });
249
+ }
250
+ }
251
+ throw new Error("Unreachable persistence retry state");
252
+ }
@@ -3,6 +3,7 @@ import type { RlsStrategy } from "./index";
3
3
 
4
4
  export type ProvisionResult = {
5
5
  appRole: string | null;
6
+ hostExportRole: string | null;
6
7
  temporalRole: string | null;
7
8
  temporalDatabases: string[];
8
9
  schema: string;
@@ -27,6 +28,16 @@ export type ProvisionRolesOptions = {
27
28
  rlsStrategy?: RlsStrategy;
28
29
  appRole?: string;
29
30
  appPassword?: string;
31
+ /**
32
+ * Optional cross-workspace projection role. It receives schema USAGE and
33
+ * EXECUTE only on the host-export API; it receives no table privileges.
34
+ * Provision it after the first migration run so the schema exists. The
35
+ * provisioner also registers same-owner default privileges for future
36
+ * host-export functions; shipped migrations preserve existing exporter ACLs
37
+ * when a migration-only upgrade adds a function.
38
+ */
39
+ hostExportRole?: string;
40
+ hostExportPassword?: string;
30
41
  temporalRole?: string;
31
42
  temporalPassword?: string;
32
43
  temporalDatabases?: string[];
@@ -59,6 +70,13 @@ export async function provisionRoles(
59
70
  options.appRole ?? (process.env.OPENGENI_APP_DATABASE_USER?.trim() || "opengeni_app"),
60
71
  );
61
72
  const appPassword = options.appPassword ?? process.env.OPENGENI_APP_DATABASE_PASSWORD;
73
+ const hostExportRole = validateIdentifier(
74
+ "hostExportRole",
75
+ options.hostExportRole ??
76
+ (process.env.OPENGENI_HOST_EXPORT_DATABASE_USER?.trim() || "opengeni_host_exporter"),
77
+ );
78
+ const hostExportPassword =
79
+ options.hostExportPassword ?? process.env.OPENGENI_HOST_EXPORT_DATABASE_PASSWORD;
62
80
  const temporalRole = validateIdentifier(
63
81
  "temporalRole",
64
82
  options.temporalRole ??
@@ -95,12 +113,18 @@ export async function provisionRoles(
95
113
  }
96
114
  }
97
115
 
116
+ if (hostExportPassword) {
117
+ await ensureLoginRole(sql, hostExportRole, hostExportPassword);
118
+ await grantHostExportRoleIfSchemaExists(sql, hostExportRole);
119
+ }
120
+
98
121
  if (rlsStrategy === "force") {
99
122
  await grantAppRoleIfSchemaExists(sql, appRole, schema);
100
123
  }
101
124
 
102
125
  return {
103
126
  appRole: provisionedAppRole,
127
+ hostExportRole: hostExportPassword ? hostExportRole : null,
104
128
  temporalRole: temporalPassword ? temporalRole : null,
105
129
  temporalDatabases: temporalPassword ? temporalDatabases : [],
106
130
  schema,
@@ -111,6 +135,24 @@ export async function provisionRoles(
111
135
  }
112
136
  }
113
137
 
138
+ /**
139
+ * The exporter is intentionally separate from `opengeni_app`: its functions
140
+ * project every workspace into a host-owned sink and therefore cannot be made
141
+ * available to the tenant-scoped application role.
142
+ */
143
+ async function grantHostExportRoleIfSchemaExists(sql: postgres.Sql, role: string): Promise<void> {
144
+ await sql.unsafe(`
145
+ DO $$
146
+ BEGIN
147
+ IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'opengeni_host_export') THEN
148
+ EXECUTE format('GRANT USAGE ON SCHEMA opengeni_host_export TO %I', ${literal(role)});
149
+ EXECUTE format('GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA opengeni_host_export TO %I', ${literal(role)});
150
+ EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA opengeni_host_export GRANT EXECUTE ON FUNCTIONS TO %I', ${literal(role)});
151
+ END IF;
152
+ END $$;
153
+ `);
154
+ }
155
+
114
156
  async function ensureLoginRole(sql: postgres.Sql, role: string, password: string): Promise<void> {
115
157
  await sql.unsafe(`
116
158
  DO $$