@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.
- package/dist/{chunk-B22X3IEZ.js → chunk-4LG5NBTC.js} +512 -32
- package/dist/chunk-4LG5NBTC.js.map +1 -0
- package/dist/{chunk-YFQ7SGE4.js → chunk-BMFDXFPA.js} +23 -1
- package/dist/chunk-BMFDXFPA.js.map +1 -0
- package/dist/chunk-KW526IJA.js +127 -0
- package/dist/chunk-KW526IJA.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +6092 -2080
- package/dist/index.js.map +1 -1
- package/dist/migrate.d.ts +29 -4
- package/dist/migrate.js +3 -1
- package/dist/provision-roles.d.ts +732 -47
- package/dist/provision-roles.js +1 -1
- package/dist/{schema-BN5mB9xZ.d.ts → schema-CdPGTHlD.d.ts} +2118 -119
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +17 -1
- package/drizzle/0064_rotation_strategy_sharded_backfill.sql +15 -0
- package/drizzle/0065_session_attempt_quiescence.sql +26 -0
- package/drizzle/0066_session_interruption_attempt_lookup.sql +4 -0
- package/drizzle/0067_session_event_payload_bounds.sql +209 -0
- package/drizzle/0068_workspace_control_event_bounds.sql +134 -0
- package/drizzle/0069_session_event_history_backfill.sql +32 -0
- package/drizzle/0070_session_event_type_sequence_lookup.sql +4 -0
- package/drizzle/0071_session_event_monitoring_tail.sql +10 -0
- package/drizzle/0072_sessions_workspace_created_id_idx.sql +4 -0
- package/drizzle/0073_sessions_workspace_updated_id_idx.sql +4 -0
- package/drizzle/0074_session_activity_revisions.sql +72 -0
- package/drizzle/0075_sessions_workspace_activity_revision_idx.sql +4 -0
- package/drizzle/0076_session_workflow_wake_acl.sql +13 -0
- package/drizzle/0077_session_attempt_latest_lookup.sql +4 -0
- package/drizzle/0094_quarantine_credential_bearing_catalog_urls.sql +51 -0
- package/drizzle/0095_github_existing_installations.sql +84 -0
- package/drizzle/0096_session_turn_initiators.sql +97 -0
- package/drizzle/0097_host_export_outbox.sql +1220 -0
- package/drizzle/0098_usage_events_workspace_session_idx.sql +4 -0
- package/drizzle/0099_session_human_input_attempt_owner_index.sql +6 -0
- package/drizzle/0100_session_human_input_requests.sql +89 -0
- package/drizzle/0101_session_mcp_connection_refs.sql +30 -0
- package/drizzle/0102_session_command_receipt_service_actor.sql +16 -0
- package/drizzle/0103_host_export_root_session.sql +166 -0
- package/drizzle/0104_host_export_root_session_backfill.sql +27 -0
- package/drizzle/0105_session_turn_instructions.sql +9 -0
- package/package.json +4 -4
- package/src/connection-token-resolver.ts +287 -1
- package/src/event-payload-sanitizer.ts +57 -19
- package/src/index.ts +5429 -1131
- package/src/memory-domain.ts +1 -1
- package/src/migrate.ts +86 -23
- package/src/persistence-errors.ts +252 -0
- package/src/provision-roles.ts +42 -0
- package/src/schema.ts +552 -34
- package/src/session-control.ts +518 -38
- package/src/session-queue-commands.ts +308 -57
- package/src/session-tool-call-settlement.ts +58 -7
- package/src/turn-initiator.ts +155 -0
- package/dist/chunk-7LDU7F5P.js +0 -80
- package/dist/chunk-7LDU7F5P.js.map +0 -1
- package/dist/chunk-B22X3IEZ.js.map +0 -1
- package/dist/chunk-YFQ7SGE4.js.map +0 -1
package/src/memory-domain.ts
CHANGED
|
@@ -54,7 +54,7 @@ export const MEMORY_KIND_SECTION_TITLES: Record<KnowledgeMemoryKind, string> = {
|
|
|
54
54
|
};
|
|
55
55
|
|
|
56
56
|
// ---------------------------------------------------------------------------
|
|
57
|
-
// Canonical prompt surface
|
|
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,
|
|
27
|
-
* always bounded
|
|
28
|
-
*
|
|
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
|
-
|
|
31
|
-
sql: postgres.Sql,
|
|
53
|
+
export function parseConcurrentIndexMigration(
|
|
32
54
|
file: string,
|
|
33
55
|
sqlText: string,
|
|
34
|
-
):
|
|
35
|
-
const
|
|
36
|
-
const
|
|
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 (
|
|
64
|
+
if (directiveLine.startsWith("-- opengeni:")) {
|
|
39
65
|
throw new Error(`Unsupported OpenGeni migration directive in ${file}`);
|
|
40
66
|
}
|
|
41
|
-
|
|
42
|
-
return;
|
|
67
|
+
return null;
|
|
43
68
|
}
|
|
44
69
|
|
|
45
70
|
const lockTimeout = directive[1]!;
|
|
46
|
-
const statement =
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/provision-roles.ts
CHANGED
|
@@ -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 $$
|