@aiwg/cli 2026.8.20 → 2026.8.26

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.
@@ -0,0 +1,468 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { loadFeaturePackage } from '../../features/runtime.js';
3
+ import { STORAGE_BACKEND_CONTRACT, } from '../backend-contract.js';
4
+ import { POSTGRES_SCHEMA_V1_SQL } from './postgres-schema.js';
5
+ const SCHEMA_VERSION = '1';
6
+ const DEFAULT_POOL_MAX = 10;
7
+ const DEFAULT_TIMEOUT_MS = 15_000;
8
+ const DEFAULT_LOCK_TIMEOUT_MS = 5_000;
9
+ const DEFAULT_IDLE_TRANSACTION_TIMEOUT_MS = 30_000;
10
+ export class PostgresBackendError extends Error {
11
+ code;
12
+ retryable;
13
+ constructor(code, message, retryable = false) {
14
+ super(message);
15
+ this.code = code;
16
+ this.retryable = retryable;
17
+ this.name = 'PostgresBackendError';
18
+ }
19
+ }
20
+ /**
21
+ * Advanced canonical backend for aiwg.storage-backend/v1.
22
+ *
23
+ * A checked-out client owns every transaction. Record effects, the durable
24
+ * idempotency receipt, and its high-water mark commit together. The class is
25
+ * asynchronous by design and does not implement the legacy synchronous
26
+ * GraphBackend interface.
27
+ */
28
+ export class PostgresStorageBackend {
29
+ options;
30
+ descriptor = {
31
+ contract: STORAGE_BACKEND_CONTRACT,
32
+ backend: 'postgres-direct',
33
+ implementationVersion: '1.0.0',
34
+ schemaVersion: SCHEMA_VERSION,
35
+ maturity: 'advanced',
36
+ capabilities: [
37
+ 'read', 'atomic-batch', 'consistent-snapshot', 'change-cursor',
38
+ 'tombstones', 'idempotency-keys', 'recursive-traversal', 'set-operations',
39
+ 'filtered-query', 'cursor-pagination', 'health', 'readiness', 'telemetry',
40
+ 'tenant-isolation', 'subsystem-isolation', 'tls',
41
+ ],
42
+ durability: 'replicated',
43
+ availability: 'remote-service',
44
+ isolation: 'serializable',
45
+ dataClass: 'canonical',
46
+ };
47
+ identity;
48
+ pool;
49
+ ownsPool;
50
+ statementTimeoutMs;
51
+ lockTimeoutMs;
52
+ idleTransactionTimeoutMs;
53
+ constructor(options) {
54
+ this.options = options;
55
+ validateIdentity(options.tenant, 'tenant');
56
+ validateIdentity(options.subsystem, 'subsystem');
57
+ this.pool = options.pool;
58
+ this.ownsPool = !options.pool;
59
+ this.statementTimeoutMs = bounded(options.statementTimeoutMs, DEFAULT_TIMEOUT_MS, 1, 600_000, 'statementTimeoutMs');
60
+ this.lockTimeoutMs = bounded(options.lockTimeoutMs, DEFAULT_LOCK_TIMEOUT_MS, 1, 600_000, 'lockTimeoutMs');
61
+ this.idleTransactionTimeoutMs = bounded(options.idleTransactionTimeoutMs, DEFAULT_IDLE_TRANSACTION_TIMEOUT_MS, 1, 600_000, 'idleTransactionTimeoutMs');
62
+ this.identity = {
63
+ backend: 'postgres-direct',
64
+ instance: options.instance ?? 'default',
65
+ tenant: options.tenant,
66
+ subsystem: options.subsystem,
67
+ schemaVersion: SCHEMA_VERSION,
68
+ };
69
+ }
70
+ async init() {
71
+ if (!this.pool)
72
+ this.pool = await createPool(this.options);
73
+ if (this.options.schemaMode !== 'migrate') {
74
+ const schema = await this.runQuery('SELECT schema_version FROM aiwg_storage_schema WHERE singleton = true');
75
+ if (Number(schema.rows[0]?.schema_version) !== 1) {
76
+ throw new PostgresBackendError('AIWG_POSTGRES_SCHEMA_UNAVAILABLE', 'PostgreSQL storage schema v1 is unavailable; initialize it with the separate migration role');
77
+ }
78
+ return;
79
+ }
80
+ await this.withTransaction(async (client) => {
81
+ await client.query(POSTGRES_SCHEMA_V1_SQL);
82
+ const schema = await client.query('SELECT schema_version FROM aiwg_storage_schema WHERE singleton = true');
83
+ if (Number(schema.rows[0]?.schema_version) !== 1) {
84
+ throw new PostgresBackendError('AIWG_POSTGRES_SCHEMA_UNSUPPORTED', 'PostgreSQL storage schema is not version 1');
85
+ }
86
+ });
87
+ }
88
+ async commitBatch(mutations) {
89
+ if (mutations.length === 0)
90
+ throw new PostgresBackendError('AIWG_POSTGRES_EMPTY_BATCH', 'batch must contain at least one mutation');
91
+ const batchId = postgresBatchId(mutations);
92
+ const payloadDigest = postgresPayloadDigest(mutations);
93
+ return this.withTransaction(async (client) => {
94
+ const prior = await client.query(`SELECT payload_digest, receipt FROM aiwg_storage_batch_receipts
95
+ WHERE tenant=$1 AND subsystem=$2 AND batch_id=$3`, [this.options.tenant, this.options.subsystem, batchId]);
96
+ if (prior.rows[0]) {
97
+ if (prior.rows[0].payload_digest !== payloadDigest) {
98
+ throw new PostgresBackendError('AIWG_POSTGRES_IDEMPOTENCY_CONFLICT', 'batch id was reused with different mutations');
99
+ }
100
+ return prior.rows[0].receipt;
101
+ }
102
+ const recordReceipts = [];
103
+ let highWaterMark = '0';
104
+ for (const mutation of mutations) {
105
+ this.assertMutationIdentity(mutation);
106
+ const tombstone = mutation.operation === 'delete' || Boolean(mutation.record.tombstone);
107
+ const result = await client.query(`INSERT INTO aiwg_storage_records
108
+ (tenant, subsystem, path, source_revision, digest, value, tombstone,
109
+ deleted_at, delete_reason, idempotency_key)
110
+ VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10)
111
+ ON CONFLICT (tenant, subsystem, path) DO UPDATE SET
112
+ source_revision=EXCLUDED.source_revision, digest=EXCLUDED.digest,
113
+ value=EXCLUDED.value, tombstone=EXCLUDED.tombstone,
114
+ deleted_at=EXCLUDED.deleted_at, delete_reason=EXCLUDED.delete_reason,
115
+ idempotency_key=EXCLUDED.idempotency_key,
116
+ change_seq=nextval(pg_get_serial_sequence('aiwg_storage_records','change_seq')),
117
+ updated_at=clock_timestamp()
118
+ WHERE $11::text IS NULL OR aiwg_storage_records.source_revision=$11
119
+ RETURNING tenant, subsystem, path, source_revision, digest, value,
120
+ tombstone, deleted_at, delete_reason, change_seq`, [
121
+ this.options.tenant, this.options.subsystem, mutation.record.identity.path,
122
+ mutation.record.sourceRevision, mutation.record.digest,
123
+ JSON.stringify(mutation.record.value ?? null), tombstone,
124
+ mutation.record.tombstone?.deletedAt ?? null,
125
+ mutation.record.tombstone?.reason ?? null, mutation.idempotencyKey,
126
+ mutation.expectedRevision ?? null,
127
+ ]);
128
+ if (result.rowCount !== 1 || !result.rows[0]) {
129
+ throw new PostgresBackendError('AIWG_POSTGRES_REVISION_CONFLICT', `revision conflict for ${mutation.record.identity.path}`, true);
130
+ }
131
+ highWaterMark = String(result.rows[0].change_seq);
132
+ recordReceipts.push({
133
+ identity: mutation.record.identity,
134
+ sourceRevision: mutation.record.sourceRevision,
135
+ digest: mutation.record.digest,
136
+ });
137
+ }
138
+ const receipt = { batchId, committed: true, highWaterMark, recordReceipts };
139
+ await client.query(`INSERT INTO aiwg_storage_batch_receipts
140
+ (tenant, subsystem, batch_id, payload_digest, high_water_mark, receipt)
141
+ VALUES ($1,$2,$3,$4,$5,$6::jsonb)`, [this.options.tenant, this.options.subsystem, batchId, payloadDigest, highWaterMark, JSON.stringify(receipt)]);
142
+ return receipt;
143
+ });
144
+ }
145
+ async get(path) {
146
+ const result = await this.runQuery(`SELECT tenant, subsystem, path, source_revision, digest, value,
147
+ tombstone, deleted_at, delete_reason, change_seq
148
+ FROM aiwg_storage_records WHERE tenant=$1 AND subsystem=$2 AND path=$3`, [this.options.tenant, this.options.subsystem, path]);
149
+ return result.rows[0] ? recordFromRow(result.rows[0]) : null;
150
+ }
151
+ async readAll() {
152
+ const result = await this.runQuery(`SELECT tenant, subsystem, path, source_revision, digest, value,
153
+ tombstone, deleted_at, delete_reason, change_seq
154
+ FROM aiwg_storage_records WHERE tenant=$1 AND subsystem=$2 ORDER BY path`, [this.options.tenant, this.options.subsystem]);
155
+ return result.rows.map((recordFromRow));
156
+ }
157
+ async query(filters, limit = 100, cursor) {
158
+ const boundedLimit = bounded(limit, 100, 1, 10_000, 'limit');
159
+ const result = await this.runQuery(`SELECT tenant, subsystem, path, source_revision, digest, value,
160
+ tombstone, deleted_at, delete_reason, change_seq
161
+ FROM aiwg_storage_records
162
+ WHERE tenant=$1 AND subsystem=$2 AND tombstone=false
163
+ AND value @> $3::jsonb AND ($4::text IS NULL OR path > $4)
164
+ ORDER BY path LIMIT $5`, [this.options.tenant, this.options.subsystem, JSON.stringify(filters), cursor ?? null, boundedLimit + 1]);
165
+ const hasMore = result.rows.length > boundedLimit;
166
+ const rows = result.rows.slice(0, boundedLimit);
167
+ return {
168
+ records: rows.map((recordFromRow)),
169
+ ...(hasMore ? { nextCursor: rows.at(-1)?.path } : {}),
170
+ };
171
+ }
172
+ async snapshot() {
173
+ const lease = await this.openSnapshotLease();
174
+ try {
175
+ const records = [];
176
+ let cursor;
177
+ do {
178
+ const page = await lease.readPage(cursor, 10_000);
179
+ records.push(...page.records);
180
+ cursor = page.nextCursor;
181
+ } while (cursor);
182
+ return { id: lease.id, highWaterMark: lease.highWaterMark, cursor: lease.cursor, records };
183
+ }
184
+ finally {
185
+ await lease.close();
186
+ }
187
+ }
188
+ /** Hold an exported PostgreSQL snapshot open while parallel consumers page it. */
189
+ async openSnapshotLease() {
190
+ const exporter = await this.requiredPool().connect();
191
+ let closed = false;
192
+ try {
193
+ await exporter.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
194
+ const snapshot = await exporter.query('SELECT pg_export_snapshot() AS snapshot');
195
+ const highWater = await exporter.query(`SELECT COALESCE(MAX(change_seq),0)::text AS high_water_mark FROM aiwg_storage_records
196
+ WHERE tenant=$1 AND subsystem=$2`, [this.options.tenant, this.options.subsystem]);
197
+ const id = snapshot.rows[0].snapshot;
198
+ const highWaterMark = highWater.rows[0].high_water_mark;
199
+ return {
200
+ id,
201
+ highWaterMark,
202
+ cursor: highWaterMark,
203
+ readPage: (cursor, limit) => this.readSnapshotPage(id, cursor, limit),
204
+ close: async () => {
205
+ if (closed)
206
+ return;
207
+ closed = true;
208
+ try {
209
+ await exporter.query('COMMIT');
210
+ }
211
+ finally {
212
+ exporter.release(false);
213
+ }
214
+ },
215
+ };
216
+ }
217
+ catch (error) {
218
+ try {
219
+ await exporter.query('ROLLBACK');
220
+ }
221
+ catch { /* preserve original error */ }
222
+ exporter.release(true);
223
+ throw classifyPostgresError(error);
224
+ }
225
+ }
226
+ async readSnapshotPage(snapshotId, cursor, limit = 1000) {
227
+ if (!/^[0-9A-Fa-f-]+$/.test(snapshotId)) {
228
+ throw new PostgresBackendError('AIWG_POSTGRES_SNAPSHOT_INVALID', 'exported snapshot identifier is malformed');
229
+ }
230
+ const boundedLimit = bounded(limit, 1000, 1, 10_000, 'limit');
231
+ const client = await this.requiredPool().connect();
232
+ let failed = false;
233
+ try {
234
+ await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
235
+ await client.query(`SET TRANSACTION SNAPSHOT '${snapshotId}'`);
236
+ const result = await client.query(`SELECT tenant, subsystem, path, source_revision, digest, value,
237
+ tombstone, deleted_at, delete_reason, change_seq
238
+ FROM aiwg_storage_records
239
+ WHERE tenant=$1 AND subsystem=$2 AND ($3::text IS NULL OR path > $3)
240
+ ORDER BY path LIMIT $4`, [this.options.tenant, this.options.subsystem, cursor ?? null, boundedLimit + 1]);
241
+ await client.query('COMMIT');
242
+ const hasMore = result.rows.length > boundedLimit;
243
+ const rows = result.rows.slice(0, boundedLimit);
244
+ return { records: rows.map((recordFromRow)), ...(hasMore ? { nextCursor: rows.at(-1)?.path } : {}), snapshot: snapshotId };
245
+ }
246
+ catch (error) {
247
+ failed = true;
248
+ try {
249
+ await client.query('ROLLBACK');
250
+ }
251
+ catch { /* preserve original error */ }
252
+ throw classifyPostgresError(error);
253
+ }
254
+ finally {
255
+ client.release(failed);
256
+ }
257
+ }
258
+ async changes(cursor) {
259
+ const after = parseCursor(cursor);
260
+ const result = await this.runQuery(`SELECT tenant, subsystem, path, source_revision, digest, value,
261
+ tombstone, deleted_at, delete_reason, change_seq
262
+ FROM aiwg_storage_records
263
+ WHERE tenant=$1 AND subsystem=$2 AND change_seq > $3
264
+ ORDER BY change_seq, path LIMIT 1001`, [this.options.tenant, this.options.subsystem, after]);
265
+ const page = result.rows.slice(0, 1000);
266
+ const highWaterMark = String(page.at(-1)?.change_seq ?? after);
267
+ return {
268
+ records: page.map((recordFromRow)),
269
+ ...(result.rows.length > 1000 ? { nextCursor: highWaterMark } : {}),
270
+ highWaterMark,
271
+ };
272
+ }
273
+ async replaceEdges(sourcePath, edges) {
274
+ await this.withTransaction(async (client) => {
275
+ await client.query('DELETE FROM aiwg_storage_edges WHERE tenant=$1 AND subsystem=$2 AND source_path=$3', [this.options.tenant, this.options.subsystem, sourcePath]);
276
+ for (const edge of [...edges].sort((a, b) => `${a.type}\0${a.targetPath}`.localeCompare(`${b.type}\0${b.targetPath}`))) {
277
+ await client.query(`INSERT INTO aiwg_storage_edges(tenant,subsystem,source_path,target_path,edge_type)
278
+ VALUES ($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`, [this.options.tenant, this.options.subsystem, sourcePath, edge.targetPath, edge.type]);
279
+ }
280
+ });
281
+ }
282
+ async traverse(path, direction, maxDepth, edgeType) {
283
+ const depth = bounded(maxDepth, 10, 1, 100, 'maxDepth');
284
+ const from = direction === 'out' ? 'source_path' : 'target_path';
285
+ const to = direction === 'out' ? 'target_path' : 'source_path';
286
+ const result = await this.runQuery(`
287
+ WITH RECURSIVE walk(path, depth, visited) AS (
288
+ SELECT $3::text, 0, ARRAY[$3::text]
289
+ UNION ALL
290
+ SELECT edge.${to}, walk.depth + 1, walk.visited || edge.${to}
291
+ FROM walk JOIN aiwg_storage_edges edge
292
+ ON edge.tenant=$1 AND edge.subsystem=$2 AND edge.${from}=walk.path
293
+ WHERE walk.depth < $4 AND ($5::text IS NULL OR edge.edge_type=$5)
294
+ AND NOT edge.${to}=ANY(walk.visited)
295
+ )
296
+ SELECT path, MIN(depth)::int AS depth FROM walk WHERE depth > 0
297
+ GROUP BY path ORDER BY depth, path`, [this.options.tenant, this.options.subsystem, path, depth, edgeType ?? null]);
298
+ return result.rows;
299
+ }
300
+ async setOperation(operation, left, right) {
301
+ const sqlOperation = operation === 'union' ? 'UNION' : operation === 'intersection' ? 'INTERSECT' : 'EXCEPT';
302
+ const result = await this.runQuery(`SELECT unnest($1::text[]) AS path ${sqlOperation} SELECT unnest($2::text[]) AS path ORDER BY path`, [left, right]);
303
+ return result.rows.map(row => row.path);
304
+ }
305
+ async health() {
306
+ const result = await this.runQuery(`
307
+ SELECT current_setting('server_version') AS server_version,
308
+ (SELECT schema_version FROM aiwg_storage_schema WHERE singleton=true) AS schema_version,
309
+ COALESCE((SELECT MAX(change_seq) FROM aiwg_storage_records WHERE tenant=$1 AND subsystem=$2),0)::text AS high_water_mark`, [this.options.tenant, this.options.subsystem]);
310
+ const row = result.rows[0];
311
+ return { healthy: true, ready: Number(row.schema_version) === 1, serverVersion: row.server_version, schemaVersion: String(row.schema_version), highWaterMark: row.high_water_mark };
312
+ }
313
+ metrics() {
314
+ const pool = this.requiredPool();
315
+ return { total: pool.totalCount ?? 0, idle: pool.idleCount ?? 0, waiting: pool.waitingCount ?? 0 };
316
+ }
317
+ async withMigrationLock(operation) {
318
+ return this.withTransaction(async (client) => {
319
+ const key = advisoryKey(this.options.tenant, this.options.subsystem);
320
+ await client.query('SELECT pg_advisory_xact_lock($1)', [key]);
321
+ return operation();
322
+ });
323
+ }
324
+ async close() {
325
+ if (this.ownsPool && this.pool)
326
+ await this.pool.end();
327
+ this.pool = undefined;
328
+ }
329
+ async withTransaction(operation, isolation = 'SERIALIZABLE') {
330
+ const client = await this.requiredPool().connect();
331
+ let failed = false;
332
+ try {
333
+ await client.query(`BEGIN ISOLATION LEVEL ${isolation}`);
334
+ await client.query(`SET LOCAL statement_timeout = '${this.statementTimeoutMs}ms'`);
335
+ await client.query(`SET LOCAL lock_timeout = '${this.lockTimeoutMs}ms'`);
336
+ await client.query(`SET LOCAL idle_in_transaction_session_timeout = '${this.idleTransactionTimeoutMs}ms'`);
337
+ const result = await operation(client);
338
+ await client.query('COMMIT');
339
+ return result;
340
+ }
341
+ catch (error) {
342
+ failed = true;
343
+ try {
344
+ await client.query('ROLLBACK');
345
+ }
346
+ catch { /* preserve original error */ }
347
+ throw classifyPostgresError(error);
348
+ }
349
+ finally {
350
+ client.release(failed);
351
+ }
352
+ }
353
+ assertMutationIdentity(mutation) {
354
+ const identity = mutation.record.identity;
355
+ if (identity.tenant !== this.options.tenant || identity.subsystem !== this.options.subsystem) {
356
+ throw new PostgresBackendError('AIWG_POSTGRES_IDENTITY_MISMATCH', 'mutation identity is outside this backend tenant/subsystem');
357
+ }
358
+ }
359
+ requiredPool() {
360
+ if (!this.pool)
361
+ throw new PostgresBackendError('AIWG_POSTGRES_NOT_INITIALIZED', 'call init() before using the PostgreSQL backend');
362
+ return this.pool;
363
+ }
364
+ async runQuery(text, values) {
365
+ try {
366
+ return await this.requiredPool().query(text, values);
367
+ }
368
+ catch (error) {
369
+ throw classifyPostgresError(error);
370
+ }
371
+ }
372
+ }
373
+ async function createPool(options) {
374
+ const envName = options.connectionStringEnv ?? 'AIWG_POSTGRES_URL';
375
+ const connectionString = process.env[envName];
376
+ if (!connectionString) {
377
+ throw new PostgresBackendError('AIWG_POSTGRES_CREDENTIAL_UNAVAILABLE', `PostgreSQL connection locator ${envName} is not set`);
378
+ }
379
+ if (options.ssl === undefined) {
380
+ throw new PostgresBackendError('AIWG_POSTGRES_TLS_REQUIRED', 'PostgreSQL remote connections require explicit ssl=require or ssl=verify-full');
381
+ }
382
+ if (options.ssl === 'disable' && !isLoopbackPostgresUrl(connectionString)) {
383
+ throw new PostgresBackendError('AIWG_POSTGRES_TLS_REQUIRED', 'ssl=disable is limited to explicit loopback development endpoints');
384
+ }
385
+ const pg = await loadFeaturePackage('pg');
386
+ const defaultExport = pg.default;
387
+ const Pool = (pg.Pool ?? defaultExport?.Pool);
388
+ if (!Pool)
389
+ throw new PostgresBackendError('AIWG_POSTGRES_DRIVER_INVALID', 'optional pg package does not export Pool');
390
+ return new Pool({
391
+ connectionString,
392
+ max: bounded(options.maxConnections, DEFAULT_POOL_MAX, 1, 100, 'maxConnections'),
393
+ connectionTimeoutMillis: bounded(options.connectionTimeoutMs, DEFAULT_TIMEOUT_MS, 1, 600_000, 'connectionTimeoutMs'),
394
+ statement_timeout: bounded(options.statementTimeoutMs, DEFAULT_TIMEOUT_MS, 1, 600_000, 'statementTimeoutMs'),
395
+ application_name: options.applicationName ?? 'aiwg',
396
+ ssl: options.ssl === 'disable' ? false : { rejectUnauthorized: options.ssl === 'verify-full' },
397
+ });
398
+ }
399
+ function isLoopbackPostgresUrl(connectionString) {
400
+ try {
401
+ const host = new URL(connectionString).hostname;
402
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1';
403
+ }
404
+ catch {
405
+ return false;
406
+ }
407
+ }
408
+ function recordFromRow(row) {
409
+ return {
410
+ identity: { tenant: row.tenant, subsystem: row.subsystem, path: row.path },
411
+ sourceRevision: row.source_revision,
412
+ digest: row.digest,
413
+ ...(row.tombstone
414
+ ? { tombstone: { deletedAt: new Date(row.deleted_at ?? 0).toISOString(), ...(row.delete_reason ? { reason: row.delete_reason } : {}) } }
415
+ : { value: row.value }),
416
+ };
417
+ }
418
+ export function postgresBatchId(mutations) {
419
+ const digest = createHash('sha256').update(mutations.map(item => item.idempotencyKey).join('\0')).digest('hex');
420
+ return `${digest.slice(0, 8)}-${digest.slice(8, 12)}-4${digest.slice(13, 16)}-8${digest.slice(17, 20)}-${digest.slice(20, 32)}`;
421
+ }
422
+ export function postgresPayloadDigest(value) {
423
+ return createHash('sha256').update(stableStringify(value)).digest('hex');
424
+ }
425
+ function stableStringify(value) {
426
+ if (Array.isArray(value))
427
+ return `[${value.map(stableStringify).join(',')}]`;
428
+ if (value && typeof value === 'object') {
429
+ return `{${Object.entries(value)
430
+ .filter(([, child]) => child !== undefined)
431
+ .sort(([left], [right]) => left.localeCompare(right))
432
+ .map(([key, child]) => `${JSON.stringify(key)}:${stableStringify(child)}`)
433
+ .join(',')}}`;
434
+ }
435
+ return JSON.stringify(value);
436
+ }
437
+ function advisoryKey(tenant, subsystem) {
438
+ return BigInt.asIntN(64, BigInt(`0x${createHash('sha256').update(`${tenant}\0${subsystem}`).digest('hex').slice(0, 16)}`)).toString();
439
+ }
440
+ function parseCursor(cursor) {
441
+ if (cursor === undefined)
442
+ return '0';
443
+ if (!/^\d+$/.test(cursor))
444
+ throw new PostgresBackendError('AIWG_POSTGRES_CURSOR_INVALID', 'change cursor must be an unsigned integer');
445
+ return cursor;
446
+ }
447
+ function validateIdentity(value, label) {
448
+ if (!value || value.length > 200 || /[\u0000-\u001f]/.test(value)) {
449
+ throw new PostgresBackendError('AIWG_POSTGRES_IDENTITY_INVALID', `${label} must be a non-empty printable identifier`);
450
+ }
451
+ }
452
+ function bounded(value, fallback, min, max, label) {
453
+ const resolved = value ?? fallback;
454
+ if (!Number.isInteger(resolved) || resolved < min || resolved > max) {
455
+ throw new PostgresBackendError('AIWG_POSTGRES_OPTION_INVALID', `${label} must be an integer from ${min} through ${max}`);
456
+ }
457
+ return resolved;
458
+ }
459
+ function classifyPostgresError(error) {
460
+ if (error instanceof PostgresBackendError)
461
+ return error;
462
+ const code = typeof error === 'object' && error !== null && 'code' in error ? String(error.code) : '';
463
+ if (['40001', '40P01', '55P03', '57014', '57P01', '57P02', '57P03', '08000', '08003', '08006', '08001'].includes(code)) {
464
+ return new PostgresBackendError('AIWG_POSTGRES_RETRYABLE', `retryable PostgreSQL failure (${code})`, true);
465
+ }
466
+ return error instanceof Error ? error : new Error(String(error));
467
+ }
468
+ //# sourceMappingURL=postgres.js.map
@@ -0,0 +1,192 @@
1
+ /** Versioned security-invoker RPC surface used by the PostgREST transport. */
2
+ export const POSTGREST_SCHEMA_V1_SQL = String.raw `
3
+ CREATE OR REPLACE FUNCTION aiwg_record_v1(r aiwg_storage_records)
4
+ RETURNS jsonb LANGUAGE sql STABLE AS $$
5
+ SELECT jsonb_strip_nulls(jsonb_build_object(
6
+ 'identity', jsonb_build_object('tenant', r.tenant, 'subsystem', r.subsystem, 'path', r.path),
7
+ 'sourceRevision', r.source_revision, 'digest', r.digest,
8
+ 'value', CASE WHEN r.tombstone THEN NULL ELSE r.value END,
9
+ 'tombstone', CASE WHEN r.tombstone THEN jsonb_strip_nulls(jsonb_build_object(
10
+ 'deletedAt', to_char(r.deleted_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
11
+ 'reason', r.delete_reason)) ELSE NULL END));
12
+ $$;
13
+
14
+ CREATE OR REPLACE FUNCTION aiwg_commit_batch_v1(
15
+ p_tenant text, p_subsystem text, p_batch_id uuid,
16
+ p_payload_digest text, p_mutations jsonb
17
+ ) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER AS $$
18
+ DECLARE
19
+ m jsonb; prior aiwg_storage_batch_receipts%ROWTYPE;
20
+ changed aiwg_storage_records%ROWTYPE; receipts jsonb := '[]'::jsonb;
21
+ high_water bigint := 0; result jsonb;
22
+ BEGIN
23
+ IF jsonb_typeof(p_mutations) <> 'array' OR jsonb_array_length(p_mutations)=0 THEN
24
+ RAISE EXCEPTION USING ERRCODE='22023', MESSAGE='AIWG_POSTGREST_EMPTY_BATCH';
25
+ END IF;
26
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant || chr(31) || p_subsystem || chr(31) || p_batch_id::text, 0));
27
+ SELECT * INTO prior FROM aiwg_storage_batch_receipts
28
+ WHERE tenant=p_tenant AND subsystem=p_subsystem AND batch_id=p_batch_id FOR UPDATE;
29
+ IF FOUND THEN
30
+ IF prior.payload_digest <> p_payload_digest THEN
31
+ RAISE EXCEPTION USING ERRCODE='23505', MESSAGE='AIWG_POSTGREST_IDEMPOTENCY_CONFLICT';
32
+ END IF;
33
+ RETURN prior.receipt;
34
+ END IF;
35
+ FOR m IN SELECT value FROM jsonb_array_elements(p_mutations) LOOP
36
+ IF m#>>'{record,identity,tenant}' <> p_tenant OR m#>>'{record,identity,subsystem}' <> p_subsystem THEN
37
+ RAISE EXCEPTION USING ERRCODE='42501', MESSAGE='AIWG_POSTGREST_IDENTITY_MISMATCH';
38
+ END IF;
39
+ INSERT INTO aiwg_storage_records
40
+ (tenant,subsystem,path,source_revision,digest,value,tombstone,deleted_at,delete_reason,idempotency_key)
41
+ VALUES (p_tenant,p_subsystem,m#>>'{record,identity,path}',m#>>'{record,sourceRevision}',
42
+ m#>>'{record,digest}',m#>'{record,value}',
43
+ (m->>'operation'='delete' OR m#>'{record,tombstone}' IS NOT NULL),
44
+ NULLIF(m#>>'{record,tombstone,deletedAt}','')::timestamptz,
45
+ m#>>'{record,tombstone,reason}',m->>'idempotencyKey')
46
+ ON CONFLICT (tenant,subsystem,path) DO UPDATE SET
47
+ source_revision=EXCLUDED.source_revision,digest=EXCLUDED.digest,value=EXCLUDED.value,
48
+ tombstone=EXCLUDED.tombstone,deleted_at=EXCLUDED.deleted_at,
49
+ delete_reason=EXCLUDED.delete_reason,idempotency_key=EXCLUDED.idempotency_key,
50
+ change_seq=nextval(pg_get_serial_sequence('aiwg_storage_records','change_seq')),
51
+ updated_at=clock_timestamp()
52
+ WHERE m->>'expectedRevision' IS NULL
53
+ OR aiwg_storage_records.source_revision=m->>'expectedRevision'
54
+ RETURNING * INTO changed;
55
+ IF NOT FOUND THEN
56
+ RAISE EXCEPTION USING ERRCODE='P0001', MESSAGE='AIWG_POSTGREST_REVISION_CONFLICT';
57
+ END IF;
58
+ high_water := changed.change_seq;
59
+ receipts := receipts || jsonb_build_array(jsonb_build_object(
60
+ 'identity',jsonb_build_object('tenant',changed.tenant,'subsystem',changed.subsystem,'path',changed.path),
61
+ 'sourceRevision',changed.source_revision,'digest',changed.digest));
62
+ END LOOP;
63
+ result := jsonb_build_object('batchId',p_batch_id::text,'committed',true,
64
+ 'highWaterMark',high_water::text,'recordReceipts',receipts);
65
+ INSERT INTO aiwg_storage_batch_receipts
66
+ (tenant,subsystem,batch_id,payload_digest,high_water_mark,receipt)
67
+ VALUES (p_tenant,p_subsystem,p_batch_id,p_payload_digest,high_water,result);
68
+ RETURN result;
69
+ END;
70
+ $$;
71
+
72
+ CREATE OR REPLACE FUNCTION aiwg_get_record_v1(p_tenant text,p_subsystem text,p_path text)
73
+ RETURNS jsonb LANGUAGE sql STABLE SECURITY INVOKER AS $$
74
+ SELECT aiwg_record_v1(r) FROM aiwg_storage_records r
75
+ WHERE r.tenant=p_tenant AND r.subsystem=p_subsystem AND r.path=p_path;
76
+ $$;
77
+
78
+ CREATE OR REPLACE FUNCTION aiwg_query_records_v1(
79
+ p_tenant text,p_subsystem text,p_filters jsonb DEFAULT '{}'::jsonb,
80
+ p_after_path text DEFAULT NULL,p_limit integer DEFAULT 1000,
81
+ p_include_tombstones boolean DEFAULT false
82
+ ) RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY INVOKER AS $$
83
+ DECLARE result jsonb;
84
+ BEGIN
85
+ IF p_limit<1 OR p_limit>10000 THEN RAISE EXCEPTION USING ERRCODE='22023',MESSAGE='AIWG_POSTGREST_LIMIT_INVALID'; END IF;
86
+ WITH page AS (SELECT r.* FROM aiwg_storage_records r
87
+ WHERE r.tenant=p_tenant AND r.subsystem=p_subsystem
88
+ AND (p_include_tombstones OR NOT r.tombstone)
89
+ AND (r.value @> p_filters OR (r.tombstone AND p_filters='{}'::jsonb))
90
+ AND (p_after_path IS NULL OR r.path>p_after_path)
91
+ ORDER BY r.path LIMIT p_limit+1),
92
+ kept AS (SELECT * FROM page ORDER BY path LIMIT p_limit)
93
+ SELECT jsonb_build_object('records',COALESCE(jsonb_agg(aiwg_record_v1(k) ORDER BY k.path),'[]'::jsonb),
94
+ 'nextCursor',CASE WHEN (SELECT count(*) FROM page)>p_limit THEN max(k.path) END)
95
+ INTO result FROM kept k;
96
+ RETURN jsonb_strip_nulls(result);
97
+ END;
98
+ $$;
99
+
100
+ CREATE OR REPLACE FUNCTION aiwg_snapshot_v1(p_tenant text,p_subsystem text,p_limit integer DEFAULT 1000)
101
+ RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY INVOKER AS $$
102
+ DECLARE record_count bigint; high_water bigint;
103
+ BEGIN
104
+ IF p_limit<1 OR p_limit>10000 THEN RAISE EXCEPTION USING ERRCODE='22023',MESSAGE='AIWG_POSTGREST_LIMIT_INVALID'; END IF;
105
+ SELECT count(*),COALESCE(max(change_seq),0) INTO record_count,high_water
106
+ FROM aiwg_storage_records WHERE tenant=p_tenant AND subsystem=p_subsystem;
107
+ IF record_count>p_limit THEN RAISE EXCEPTION USING ERRCODE='54000',MESSAGE='AIWG_POSTGREST_SNAPSHOT_CEILING'; END IF;
108
+ RETURN jsonb_build_object('snapshot_id',txid_current()::text,'high_water_mark',high_water::text,
109
+ 'records',COALESCE((SELECT jsonb_agg(aiwg_record_v1(r) ORDER BY r.path)
110
+ FROM aiwg_storage_records r WHERE r.tenant=p_tenant AND r.subsystem=p_subsystem),'[]'::jsonb));
111
+ END;
112
+ $$;
113
+
114
+ CREATE OR REPLACE FUNCTION aiwg_changes_v1(
115
+ p_tenant text,p_subsystem text,p_after_cursor bigint DEFAULT 0,p_limit integer DEFAULT 1000
116
+ ) RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY INVOKER AS $$
117
+ DECLARE result jsonb;
118
+ BEGIN
119
+ IF p_limit<1 OR p_limit>10000 THEN RAISE EXCEPTION USING ERRCODE='22023',MESSAGE='AIWG_POSTGREST_LIMIT_INVALID'; END IF;
120
+ WITH page AS (SELECT r.* FROM aiwg_storage_records r
121
+ WHERE r.tenant=p_tenant AND r.subsystem=p_subsystem AND r.change_seq>p_after_cursor
122
+ ORDER BY r.change_seq,r.path LIMIT p_limit+1),
123
+ kept AS (SELECT * FROM page ORDER BY change_seq,path LIMIT p_limit)
124
+ SELECT jsonb_build_object('records',COALESCE(jsonb_agg(aiwg_record_v1(k) ORDER BY k.change_seq,k.path),'[]'::jsonb),
125
+ 'high_water_mark',COALESCE(max(k.change_seq),p_after_cursor)::text,
126
+ 'next_cursor',CASE WHEN (SELECT count(*) FROM page)>p_limit THEN max(k.change_seq)::text END)
127
+ INTO result FROM kept k;
128
+ RETURN jsonb_strip_nulls(result);
129
+ END;
130
+ $$;
131
+
132
+ CREATE OR REPLACE FUNCTION aiwg_health_v1(p_tenant text,p_subsystem text)
133
+ RETURNS jsonb LANGUAGE sql STABLE SECURITY INVOKER AS $$
134
+ SELECT jsonb_build_object('healthy',true,'ready',s.schema_version=1,
135
+ 'schemaVersion',s.schema_version::text,
136
+ 'highWaterMark',COALESCE((SELECT max(change_seq) FROM aiwg_storage_records
137
+ WHERE tenant=p_tenant AND subsystem=p_subsystem),0)::text,
138
+ 'accessMode','postgrest','engine','postgres')
139
+ FROM aiwg_storage_schema s WHERE singleton=true;
140
+ $$;
141
+
142
+ CREATE OR REPLACE FUNCTION aiwg_reload_schema_v1()
143
+ RETURNS void LANGUAGE plpgsql VOLATILE SECURITY INVOKER AS $$
144
+ BEGIN PERFORM pg_notify('pgrst','reload schema'); END;
145
+ $$;
146
+ `;
147
+ /** Install with a migration-role connection; runtime requesters need only EXECUTE/table DML grants. */
148
+ export async function installPostgrestSchemaV1(client) {
149
+ await client.query('BEGIN');
150
+ try {
151
+ await client.query(POSTGREST_SCHEMA_V1_SQL);
152
+ await client.query('COMMIT');
153
+ await client.query("NOTIFY pgrst, 'reload schema'");
154
+ }
155
+ catch (error) {
156
+ try {
157
+ await client.query('ROLLBACK');
158
+ }
159
+ catch { /* preserve installation error */ }
160
+ throw error;
161
+ }
162
+ }
163
+ /** Generate the requester-role grants and JWT-claim RLS policies for v1. */
164
+ export function postgrestLeastPrivilegeSql(requesterRole) {
165
+ const role = quoteIdentifier(requesterRole);
166
+ const predicate = `(tenant=current_setting('request.jwt.claims',true)::jsonb->>'tenant' AND subsystem=current_setting('request.jwt.claims',true)::jsonb->>'subsystem')`;
167
+ return `REVOKE ALL ON aiwg_storage_records, aiwg_storage_batch_receipts, aiwg_storage_edges FROM PUBLIC;
168
+ REVOKE ALL ON ALL FUNCTIONS IN SCHEMA public FROM PUBLIC;
169
+ GRANT USAGE ON SCHEMA public TO ${role};
170
+ GRANT SELECT, INSERT, UPDATE, DELETE ON aiwg_storage_records, aiwg_storage_batch_receipts, aiwg_storage_edges TO ${role};
171
+ GRANT SELECT ON aiwg_storage_schema TO ${role};
172
+ GRANT USAGE, SELECT ON SEQUENCE aiwg_storage_records_change_seq_seq TO ${role};
173
+ GRANT EXECUTE ON FUNCTION aiwg_record_v1(aiwg_storage_records), aiwg_commit_batch_v1(text,text,uuid,text,jsonb), aiwg_get_record_v1(text,text,text), aiwg_query_records_v1(text,text,jsonb,text,integer,boolean), aiwg_snapshot_v1(text,text,integer), aiwg_changes_v1(text,text,bigint,integer), aiwg_health_v1(text,text), aiwg_reload_schema_v1() TO ${role};
174
+ ALTER TABLE aiwg_storage_records ENABLE ROW LEVEL SECURITY;
175
+ ALTER TABLE aiwg_storage_records FORCE ROW LEVEL SECURITY;
176
+ ALTER TABLE aiwg_storage_batch_receipts ENABLE ROW LEVEL SECURITY;
177
+ ALTER TABLE aiwg_storage_batch_receipts FORCE ROW LEVEL SECURITY;
178
+ ALTER TABLE aiwg_storage_edges ENABLE ROW LEVEL SECURITY;
179
+ ALTER TABLE aiwg_storage_edges FORCE ROW LEVEL SECURITY;
180
+ DROP POLICY IF EXISTS aiwg_records_tenant_v1 ON aiwg_storage_records;
181
+ CREATE POLICY aiwg_records_tenant_v1 ON aiwg_storage_records FOR ALL TO ${role} USING ${predicate} WITH CHECK ${predicate};
182
+ DROP POLICY IF EXISTS aiwg_receipts_tenant_v1 ON aiwg_storage_batch_receipts;
183
+ CREATE POLICY aiwg_receipts_tenant_v1 ON aiwg_storage_batch_receipts FOR ALL TO ${role} USING ${predicate} WITH CHECK ${predicate};
184
+ DROP POLICY IF EXISTS aiwg_edges_tenant_v1 ON aiwg_storage_edges;
185
+ CREATE POLICY aiwg_edges_tenant_v1 ON aiwg_storage_edges FOR ALL TO ${role} USING ${predicate} WITH CHECK ${predicate};`;
186
+ }
187
+ function quoteIdentifier(value) {
188
+ if (!value || value.length > 63 || /[\u0000-\u001f]/.test(value))
189
+ throw new Error('requester role must be a printable PostgreSQL identifier up to 63 characters');
190
+ return `"${value.replaceAll('"', '""')}"`;
191
+ }
192
+ //# sourceMappingURL=postgrest-schema.js.map