@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.
- package/bin/aiwg.mjs +25 -16
- package/dist/src/api/index.d.ts +1 -0
- package/dist/src/api/index.js +1 -0
- package/dist/src/cli/handlers/artifact-verify.js +3 -0
- package/dist/src/cli/handlers/refresh.js +37 -2
- package/dist/src/cli/handlers/subcommands.js +26 -20
- package/dist/src/cli/handlers/utilities.js +3 -0
- package/dist/src/cli/router.js +27 -0
- package/dist/src/features/catalog.js +11 -0
- package/dist/src/installation/manager-command.mjs +10 -1
- package/dist/src/storage/backend-contract.js +11 -2
- package/dist/src/storage/backends/postgres-schema.js +144 -0
- package/dist/src/storage/backends/postgres.js +468 -0
- package/dist/src/storage/backends/postgrest-schema.js +192 -0
- package/dist/src/storage/backends/postgrest.js +346 -0
- package/dist/src/storage/index.js +5 -0
- package/dist/src/storage/qualification.js +137 -0
- package/dist/src/update/notifier.mjs +59 -14
- package/dist/src/update/service.mjs +2 -5
- package/package.json +1 -1
package/bin/aiwg.mjs
CHANGED
|
@@ -203,13 +203,6 @@ function maybeHandleFastHelp(args) {
|
|
|
203
203
|
return true;
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
-
if (maybeHandleFastVersion(process.argv.slice(2))) {
|
|
207
|
-
process.exit(0);
|
|
208
|
-
}
|
|
209
|
-
if (maybeHandleFastHelp(process.argv.slice(2))) {
|
|
210
|
-
process.exit(0);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
206
|
// Preflight: verify dist/ is built before any of the dynamic imports below
|
|
214
207
|
// try to resolve files that don't exist. Without this, a missing/incomplete
|
|
215
208
|
// dist/ surfaces as a raw `Cannot find module '.../dist/src/.../*.mjs'` from
|
|
@@ -240,6 +233,31 @@ function maybeWarnUnbuiltDist() {
|
|
|
240
233
|
}
|
|
241
234
|
maybeWarnUnbuiltDist();
|
|
242
235
|
|
|
236
|
+
// Display a cached notice and schedule its refresh before every eligible CLI
|
|
237
|
+
// path, including fast help/version, channel recovery, and later preflight
|
|
238
|
+
// failures. This local-only bootstrap never waits on the registry and failures
|
|
239
|
+
// are deliberately ignored so update advice cannot change command behavior.
|
|
240
|
+
async function runUpdateNotifierBootstrap() {
|
|
241
|
+
try {
|
|
242
|
+
const notifierPath = path.join(packageRoot, 'dist', 'src', 'update', 'notifier.mjs');
|
|
243
|
+
const notifier = await import(pathToFileURL(notifierPath).href);
|
|
244
|
+
const activePackageRoot = notifier.resolveActivePackageRoot(packageRoot);
|
|
245
|
+
notifier.maybePrintNotice(activePackageRoot);
|
|
246
|
+
notifier.scheduleBackgroundCheck(activePackageRoot);
|
|
247
|
+
} catch {
|
|
248
|
+
// Best effort: installation/router diagnostics retain authority.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
await runUpdateNotifierBootstrap();
|
|
253
|
+
|
|
254
|
+
if (maybeHandleFastVersion(process.argv.slice(2))) {
|
|
255
|
+
process.exit(0);
|
|
256
|
+
}
|
|
257
|
+
if (maybeHandleFastHelp(process.argv.slice(2))) {
|
|
258
|
+
process.exit(0);
|
|
259
|
+
}
|
|
260
|
+
|
|
243
261
|
// Mint or inherit an invocation ID before anything else loads. Child processes
|
|
244
262
|
// spawned by handlers (detached update-notifier, aiwg exec, etc.) inherit the
|
|
245
263
|
// parent's ID via `AIWG_INVOCATION_ID` so their JSONL log records correlate
|
|
@@ -398,15 +416,6 @@ async function main() {
|
|
|
398
416
|
// tag every record with it.
|
|
399
417
|
await applyVerbosityFromArgs(args, routerPath);
|
|
400
418
|
|
|
401
|
-
// Update notifier: print any pending notice from the previous run's
|
|
402
|
-
// background check, then schedule the next background check. Both are
|
|
403
|
-
// non-blocking — the current command never waits on the network.
|
|
404
|
-
// Honors NO_UPDATE_NOTIFIER, CI=*, and non-TTY stderr.
|
|
405
|
-
const notifierPath = path.join(activePackageRoot, 'dist', 'src', 'update', 'notifier.mjs');
|
|
406
|
-
const { scheduleBackgroundCheck, maybePrintNotice } = await import(pathToFileURL(notifierPath).href);
|
|
407
|
-
maybePrintNotice(activePackageRoot);
|
|
408
|
-
scheduleBackgroundCheck(activePackageRoot);
|
|
409
|
-
|
|
410
419
|
// Top-level cancellation controller. SIGINT / SIGTERM flip it, long-running
|
|
411
420
|
// handlers plumb ctx.signal through fetches and loops so Ctrl-C cancels
|
|
412
421
|
// in-flight work cleanly instead of leaving orphaned sockets and children.
|
package/dist/src/api/index.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export { run } from '../cli/router.js';
|
|
|
9
9
|
export * from '../resources/index.js';
|
|
10
10
|
export * from '../sessions/index.js';
|
|
11
11
|
export * from '../memory/index.js';
|
|
12
|
+
export * from '../storage/index.js';
|
|
12
13
|
export * from '../security/threat-assessment-config.js';
|
|
13
14
|
export * from '../security/artifact-verifier.js';
|
|
14
15
|
export * from '../security/artifact-attestation.js';
|
package/dist/src/api/index.js
CHANGED
|
@@ -9,6 +9,7 @@ export { run } from '../cli/router.js';
|
|
|
9
9
|
export * from '../resources/index.js';
|
|
10
10
|
export * from '../sessions/index.js';
|
|
11
11
|
export * from '../memory/index.js';
|
|
12
|
+
export * from '../storage/index.js';
|
|
12
13
|
export * from '../security/threat-assessment-config.js';
|
|
13
14
|
export * from '../security/artifact-verifier.js';
|
|
14
15
|
export * from '../security/artifact-attestation.js';
|
|
@@ -102,6 +102,9 @@ export const artifactVerifyHandler = {
|
|
|
102
102
|
description: 'Verify cross-asset DSSE provenance and manage trust roots',
|
|
103
103
|
category: 'utility',
|
|
104
104
|
aliases: [],
|
|
105
|
+
async help() {
|
|
106
|
+
return { exitCode: 0, message: usage() };
|
|
107
|
+
},
|
|
105
108
|
async execute(ctx) {
|
|
106
109
|
if (ctx.args.includes('--help') || ctx.args.includes('-h') || ctx.args.length === 0)
|
|
107
110
|
return { exitCode: 0, message: usage() };
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* @implements @agentic/code/frameworks/sdlc-complete/rules/self-maintenance.md
|
|
12
12
|
* @source @src/cli/router.ts
|
|
13
|
-
* @issue #482, #557, #694
|
|
13
|
+
* @issue #173, #174, #482, #557, #694
|
|
14
14
|
*/
|
|
15
15
|
import { promises as fs } from 'fs';
|
|
16
16
|
import path from 'path';
|
|
@@ -151,6 +151,30 @@ export function collectModelDeployArgs(args) {
|
|
|
151
151
|
}
|
|
152
152
|
return forwarded;
|
|
153
153
|
}
|
|
154
|
+
const REFRESH_HELP = `Usage: aiwg refresh [options]
|
|
155
|
+
|
|
156
|
+
Update AIWG, re-deploy installed frameworks, and run health verification.
|
|
157
|
+
|
|
158
|
+
Options:
|
|
159
|
+
--dry-run Preview changes without updating or deploying
|
|
160
|
+
--quiet Suppress progress output
|
|
161
|
+
--skip-update Skip the installation update
|
|
162
|
+
--packages-only Refresh remote packages only
|
|
163
|
+
--provider <name> Override provider auto-detection
|
|
164
|
+
--channel <name> Select the update channel (stable or main)
|
|
165
|
+
--frameworks <list> Re-deploy a comma-separated installed subset
|
|
166
|
+
--model <name> Override all deployed agent model tiers
|
|
167
|
+
--reasoning-model <name> Override the reasoning model tier
|
|
168
|
+
--coding-model <name> Override the coding model tier
|
|
169
|
+
--efficiency-model <name> Override the efficiency model tier
|
|
170
|
+
--filter <pattern> Limit model deployment by agent name
|
|
171
|
+
--filter-role <role> Limit model deployment by role
|
|
172
|
+
--model-tier <tier> Limit model deployment by tier
|
|
173
|
+
--save Save model overrides to the project
|
|
174
|
+
--save-user Save model overrides to user configuration
|
|
175
|
+
-h, --help Show this help without running refresh
|
|
176
|
+
|
|
177
|
+
Alias: aiwg sync (deprecated)`;
|
|
154
178
|
/**
|
|
155
179
|
* Refresh command handler (formerly sync)
|
|
156
180
|
*/
|
|
@@ -160,6 +184,9 @@ export const refreshHandler = {
|
|
|
160
184
|
description: 'Refresh AIWG to latest version and re-deploy installed frameworks',
|
|
161
185
|
category: 'maintenance',
|
|
162
186
|
aliases: ['--refresh', 'sync', '--sync'],
|
|
187
|
+
async help() {
|
|
188
|
+
return { exitCode: 0, message: REFRESH_HELP, rawOutput: true };
|
|
189
|
+
},
|
|
163
190
|
async execute(ctx) {
|
|
164
191
|
const dryRun = hasFlag(ctx.args, '--dry-run');
|
|
165
192
|
const quiet = hasFlag(ctx.args, '--quiet');
|
|
@@ -451,6 +478,10 @@ export const refreshHandler = {
|
|
|
451
478
|
if (dryRun) {
|
|
452
479
|
ui.info('Dry run complete — no changes made');
|
|
453
480
|
}
|
|
481
|
+
else if (updateFailure) {
|
|
482
|
+
ui.warn(`Refresh completed with installation update failure (exit ${updateFailure.exitCode}); ` +
|
|
483
|
+
're-deployment continued, but AIWG may still be on the previous version.');
|
|
484
|
+
}
|
|
454
485
|
else {
|
|
455
486
|
ui.success('Refresh complete');
|
|
456
487
|
}
|
|
@@ -459,7 +490,11 @@ export const refreshHandler = {
|
|
|
459
490
|
// Quiet mode: JSON output
|
|
460
491
|
if (quiet) {
|
|
461
492
|
const output = JSON.stringify({
|
|
462
|
-
status: dryRun
|
|
493
|
+
status: dryRun
|
|
494
|
+
? 'dry-run'
|
|
495
|
+
: updateFailure
|
|
496
|
+
? 'refreshed-with-update-failure'
|
|
497
|
+
: 'refreshed',
|
|
463
498
|
provider: detectedProvider,
|
|
464
499
|
frameworks,
|
|
465
500
|
skipUpdate,
|
|
@@ -1139,34 +1139,40 @@ export const pluginStatusHandler = {
|
|
|
1139
1139
|
*
|
|
1140
1140
|
* Delegates to tools/plugin/package-plugins.mjs
|
|
1141
1141
|
*/
|
|
1142
|
+
function packagePluginHelp() {
|
|
1143
|
+
return {
|
|
1144
|
+
exitCode: 0,
|
|
1145
|
+
message: [
|
|
1146
|
+
"aiwg package-plugin — package a project-local or built-in marketplace wrapper",
|
|
1147
|
+
"",
|
|
1148
|
+
"Usage:",
|
|
1149
|
+
" aiwg package-plugin <name> [--source <path>] [--output <path>] [--provider <name>] [--clean] [--dry-run]",
|
|
1150
|
+
" aiwg package-plugin --plugin <name> [options] # compatibility form",
|
|
1151
|
+
"",
|
|
1152
|
+
"Options:",
|
|
1153
|
+
" --source <path> explicit project-local wrapper source (must stay inside the project)",
|
|
1154
|
+
" --output <path> standalone archive output (default: dist/plugins)",
|
|
1155
|
+
" --provider <name> claude, codex, or all for standalone wrappers; built-ins retain all formats",
|
|
1156
|
+
" --clean clean generated plugin output before packaging",
|
|
1157
|
+
" --dry-run, -n preview without writing",
|
|
1158
|
+
" --help, -h show this help",
|
|
1159
|
+
"",
|
|
1160
|
+
"Project-local wrappers are discovered under .aiwg/plugins and packaged as deterministic archives.",
|
|
1161
|
+
].join("\n"),
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1142
1164
|
export const packagePluginHandler = {
|
|
1143
1165
|
id: "package-plugin",
|
|
1144
1166
|
name: "Package Plugin",
|
|
1145
1167
|
description: "Package a plugin for distribution",
|
|
1146
1168
|
category: "plugin",
|
|
1147
1169
|
aliases: ["-package-plugin", "--package-plugin"],
|
|
1170
|
+
async help() {
|
|
1171
|
+
return packagePluginHelp();
|
|
1172
|
+
},
|
|
1148
1173
|
async execute(ctx) {
|
|
1149
1174
|
if (ctx.args.includes("--help") || ctx.args.includes("-h")) {
|
|
1150
|
-
return
|
|
1151
|
-
exitCode: 0,
|
|
1152
|
-
message: [
|
|
1153
|
-
"aiwg package-plugin — package a project-local or built-in marketplace wrapper",
|
|
1154
|
-
"",
|
|
1155
|
-
"Usage:",
|
|
1156
|
-
" aiwg package-plugin <name> [--source <path>] [--output <path>] [--provider <name>] [--clean] [--dry-run]",
|
|
1157
|
-
" aiwg package-plugin --plugin <name> [options] # compatibility form",
|
|
1158
|
-
"",
|
|
1159
|
-
"Options:",
|
|
1160
|
-
" --source <path> explicit project-local wrapper source (must stay inside the project)",
|
|
1161
|
-
" --output <path> standalone archive output (default: dist/plugins)",
|
|
1162
|
-
" --provider <name> claude, codex, or all for standalone wrappers; built-ins retain all formats",
|
|
1163
|
-
" --clean clean generated plugin output before packaging",
|
|
1164
|
-
" --dry-run, -n preview without writing",
|
|
1165
|
-
" --help, -h show this help",
|
|
1166
|
-
"",
|
|
1167
|
-
"Project-local wrappers are discovered under .aiwg/plugins and packaged as deterministic archives.",
|
|
1168
|
-
].join("\n"),
|
|
1169
|
-
};
|
|
1175
|
+
return packagePluginHelp();
|
|
1170
1176
|
}
|
|
1171
1177
|
const hasExplicitPlugin = ctx.args.includes("--plugin") || ctx.args.includes("-p");
|
|
1172
1178
|
const positional = ctx.args[0] && !ctx.args[0].startsWith("-")
|
|
@@ -466,6 +466,9 @@ export const doctorHandler = {
|
|
|
466
466
|
description: 'Run health diagnostics',
|
|
467
467
|
category: 'maintenance',
|
|
468
468
|
aliases: ['-doctor', '--doctor'],
|
|
469
|
+
async help() {
|
|
470
|
+
return { exitCode: 0, message: DOCTOR_HELP, rawOutput: true };
|
|
471
|
+
},
|
|
469
472
|
async execute(ctx) {
|
|
470
473
|
if (ctx.args.includes('--help') || ctx.args.includes('-h')) {
|
|
471
474
|
return { exitCode: 0, message: DOCTOR_HELP, rawOutput: true };
|
package/dist/src/cli/router.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* @tests @test/unit/cli/router.test.ts
|
|
11
11
|
* @issue #33
|
|
12
12
|
* @issue #58
|
|
13
|
+
* @issue #174
|
|
13
14
|
*/
|
|
14
15
|
import { loadRegistry } from '../extensions/loader.js';
|
|
15
16
|
import { getFrameworkRoot } from '../channel/manager.mjs';
|
|
@@ -134,6 +135,32 @@ export async function run(args, options = {}) {
|
|
|
134
135
|
ui.error(`No handler found for command: ${commandId}`);
|
|
135
136
|
process.exit(1);
|
|
136
137
|
}
|
|
138
|
+
// Help must be intercepted before hooks and the normal handler path. Some
|
|
139
|
+
// commands mutate project or installation state, so passing an unrecognised
|
|
140
|
+
// help flag through to execute() is unsafe (#174).
|
|
141
|
+
if (commandArgs.includes('--help') || commandArgs.includes('-h')) {
|
|
142
|
+
const ctx = await buildContext(commandArgs, args, options);
|
|
143
|
+
if (handler.help) {
|
|
144
|
+
const result = await handler.help(ctx);
|
|
145
|
+
if (result.message) {
|
|
146
|
+
if (result.rawOutput) {
|
|
147
|
+
process.stdout.write(result.message.endsWith('\n') ? result.message : `${result.message}\n`);
|
|
148
|
+
}
|
|
149
|
+
else if (result.exitCode !== 0) {
|
|
150
|
+
ui.error(result.message);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
ui.info(result.message);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (result.exitCode !== 0)
|
|
157
|
+
process.exit(result.exitCode);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
ui.info(`No detailed help for \`aiwg ${commandId}\`. Run \`aiwg help\` for the command overview.`);
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
137
164
|
// Build context for handler and hooks
|
|
138
165
|
const ctx = await buildContext(commandArgs, args, options);
|
|
139
166
|
// Build hook context
|
|
@@ -42,6 +42,17 @@ export const FEATURE_CATALOG = [
|
|
|
42
42
|
],
|
|
43
43
|
cost: '~5 MB — native compile via node-gyp',
|
|
44
44
|
},
|
|
45
|
+
{
|
|
46
|
+
name: 'postgres',
|
|
47
|
+
description: 'Advanced direct PostgreSQL canonical-storage backend',
|
|
48
|
+
packages: ['pg'],
|
|
49
|
+
packageSpecs: { pg: '8.23.0' },
|
|
50
|
+
enables: [
|
|
51
|
+
'aiwg.storage-backend/v1 direct PostgreSQL persistence',
|
|
52
|
+
'transactional migration batches, snapshots, cursors, and tombstones',
|
|
53
|
+
],
|
|
54
|
+
cost: '~1 MB — pure JavaScript PostgreSQL client',
|
|
55
|
+
},
|
|
45
56
|
{
|
|
46
57
|
name: 'pty',
|
|
47
58
|
description: 'PTY bridge for daemon — pass-through interactive TUIs (Claude Code, Codex, etc.)',
|
|
@@ -21,11 +21,20 @@ export function resolveManagerCommand(file, args, options = {}) {
|
|
|
21
21
|
return {
|
|
22
22
|
file: commandInterpreter,
|
|
23
23
|
args: ['/d', '/s', '/c', command],
|
|
24
|
+
// The payload above already contains cmd.exe-native quoting. Instruct
|
|
25
|
+
// Node not to apply MSVCRT escaping to those embedded quotes (#173).
|
|
26
|
+
windowsVerbatimArguments: true,
|
|
24
27
|
};
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
export function executeManagerCommand(file, args, options = {}) {
|
|
28
31
|
const invocation = resolveManagerCommand(file, args, options);
|
|
29
32
|
const execute = options.execute ?? execFileSync;
|
|
30
|
-
|
|
33
|
+
const execOptions = options.execOptions
|
|
34
|
+
? { ...options.execOptions }
|
|
35
|
+
: { stdio: 'inherit' };
|
|
36
|
+
if (invocation.windowsVerbatimArguments === true) {
|
|
37
|
+
execOptions.windowsVerbatimArguments = true;
|
|
38
|
+
}
|
|
39
|
+
return execute(invocation.file, invocation.args, execOptions);
|
|
31
40
|
}
|
|
@@ -43,8 +43,17 @@ export const STORAGE_BACKEND_MATRIX = {
|
|
|
43
43
|
sqlite: descriptor('sqlite', 'supported', [...BASELINE, 'atomic-batch', 'consistent-snapshot', 'tombstones', 'idempotency-keys', 'filtered-query', 'cursor-pagination', 'backup', 'restore'], 'wal', 'single-host', 'serializable', 'regenerable-index'),
|
|
44
44
|
'fortemi-core-static': descriptor('fortemi-core-static', 'supported', ['read', 'filtered-query', 'recursive-traversal', 'set-operations'], 'filesystem', 'single-host', 'snapshot', 'static-cache'),
|
|
45
45
|
'fortemi-server': descriptor('fortemi-server', 'alpha', [...BASELINE, 'filtered-query', 'health', 'tls', 'tenant-isolation'], 'replicated', 'remote-service', 'none', 'remote-persistence'),
|
|
46
|
-
'postgres-direct': descriptor('postgres-direct', 'advanced', [
|
|
47
|
-
|
|
46
|
+
'postgres-direct': descriptor('postgres-direct', 'advanced', [
|
|
47
|
+
...BASELINE, 'atomic-batch', 'consistent-snapshot', 'change-cursor',
|
|
48
|
+
'tombstones', 'idempotency-keys', 'recursive-traversal', 'set-operations',
|
|
49
|
+
'filtered-query', 'cursor-pagination', 'health', 'readiness', 'telemetry',
|
|
50
|
+
'tenant-isolation', 'tls',
|
|
51
|
+
], 'replicated', 'remote-service', 'serializable', 'canonical'),
|
|
52
|
+
'postgres-postgrest': descriptor('postgres-postgrest', 'advanced', [
|
|
53
|
+
...BASELINE, 'atomic-batch', 'consistent-snapshot', 'change-cursor',
|
|
54
|
+
'tombstones', 'idempotency-keys', 'filtered-query', 'cursor-pagination',
|
|
55
|
+
'health', 'readiness', 'tenant-isolation', 'tls',
|
|
56
|
+
], 'replicated', 'remote-service', 'serializable', 'canonical'),
|
|
48
57
|
mysql: descriptor('mysql', 'deferred', [], 'replicated', 'remote-service', 'none', 'canonical'),
|
|
49
58
|
};
|
|
50
59
|
function descriptor(backend, maturity, capabilities, durability, availability, isolation, dataClass) {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
export const POSTGRES_SCHEMA_VERSION = 1;
|
|
2
|
+
export const POSTGRES_SCHEMA_V1_SQL = `
|
|
3
|
+
CREATE TABLE IF NOT EXISTS aiwg_storage_schema (
|
|
4
|
+
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
|
5
|
+
schema_version integer NOT NULL
|
|
6
|
+
);
|
|
7
|
+
INSERT INTO aiwg_storage_schema(singleton, schema_version)
|
|
8
|
+
VALUES (true, 1) ON CONFLICT (singleton) DO NOTHING;
|
|
9
|
+
CREATE TABLE IF NOT EXISTS aiwg_storage_records (
|
|
10
|
+
tenant text NOT NULL,
|
|
11
|
+
subsystem text NOT NULL,
|
|
12
|
+
path text NOT NULL,
|
|
13
|
+
source_revision text NOT NULL,
|
|
14
|
+
digest text NOT NULL,
|
|
15
|
+
value jsonb,
|
|
16
|
+
tombstone boolean NOT NULL DEFAULT false,
|
|
17
|
+
deleted_at timestamptz,
|
|
18
|
+
delete_reason text,
|
|
19
|
+
idempotency_key text NOT NULL,
|
|
20
|
+
change_seq bigserial NOT NULL,
|
|
21
|
+
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
|
22
|
+
PRIMARY KEY (tenant, subsystem, path),
|
|
23
|
+
UNIQUE (tenant, subsystem, idempotency_key)
|
|
24
|
+
);
|
|
25
|
+
CREATE INDEX IF NOT EXISTS aiwg_storage_records_change
|
|
26
|
+
ON aiwg_storage_records(tenant, subsystem, change_seq, path);
|
|
27
|
+
CREATE TABLE IF NOT EXISTS aiwg_storage_batch_receipts (
|
|
28
|
+
tenant text NOT NULL,
|
|
29
|
+
subsystem text NOT NULL,
|
|
30
|
+
batch_id uuid NOT NULL,
|
|
31
|
+
payload_digest text NOT NULL,
|
|
32
|
+
high_water_mark bigint NOT NULL,
|
|
33
|
+
receipt jsonb NOT NULL,
|
|
34
|
+
committed_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
|
35
|
+
PRIMARY KEY (tenant, subsystem, batch_id)
|
|
36
|
+
);
|
|
37
|
+
CREATE TABLE IF NOT EXISTS aiwg_storage_edges (
|
|
38
|
+
tenant text NOT NULL,
|
|
39
|
+
subsystem text NOT NULL,
|
|
40
|
+
source_path text NOT NULL,
|
|
41
|
+
target_path text NOT NULL,
|
|
42
|
+
edge_type text NOT NULL,
|
|
43
|
+
PRIMARY KEY (tenant, subsystem, source_path, target_path, edge_type)
|
|
44
|
+
);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS aiwg_storage_edges_target
|
|
46
|
+
ON aiwg_storage_edges(tenant, subsystem, target_path, edge_type);
|
|
47
|
+
`;
|
|
48
|
+
const POSTGREST_FUNCTIONS = [
|
|
49
|
+
'aiwg_reload_schema_v1()',
|
|
50
|
+
'aiwg_health_v1(text,text)',
|
|
51
|
+
'aiwg_changes_v1(text,text,bigint,integer)',
|
|
52
|
+
'aiwg_snapshot_v1(text,text,integer)',
|
|
53
|
+
'aiwg_query_records_v1(text,text,jsonb,text,integer,boolean)',
|
|
54
|
+
'aiwg_get_record_v1(text,text,text)',
|
|
55
|
+
'aiwg_commit_batch_v1(text,text,uuid,text,jsonb)',
|
|
56
|
+
'aiwg_record_v1(aiwg_storage_records)',
|
|
57
|
+
];
|
|
58
|
+
export async function inspectPostgresSchema(client) {
|
|
59
|
+
const exists = await client.query("SELECT to_regclass('public.aiwg_storage_schema') IS NOT NULL AS exists");
|
|
60
|
+
if (!exists.rows[0]?.exists)
|
|
61
|
+
return { version: 0, records: 0, receipts: 0, edges: 0 };
|
|
62
|
+
const result = await client.query(`
|
|
63
|
+
SELECT schema_version,
|
|
64
|
+
(SELECT count(*) FROM aiwg_storage_records) AS records,
|
|
65
|
+
(SELECT count(*) FROM aiwg_storage_batch_receipts) AS receipts,
|
|
66
|
+
(SELECT count(*) FROM aiwg_storage_edges) AS edges
|
|
67
|
+
FROM aiwg_storage_schema WHERE singleton=true`);
|
|
68
|
+
const row = result.rows[0];
|
|
69
|
+
if (Number(row?.schema_version) !== 1)
|
|
70
|
+
throw new Error(`unsupported PostgreSQL storage schema ${String(row?.schema_version)}`);
|
|
71
|
+
return { version: 1, records: Number(row.records), receipts: Number(row.receipts), edges: Number(row.edges) };
|
|
72
|
+
}
|
|
73
|
+
/** Upgrade an empty database to v1 under a dedicated migration-role connection. */
|
|
74
|
+
export async function upgradePostgresSchemaV1(client) {
|
|
75
|
+
await client.query('BEGIN');
|
|
76
|
+
try {
|
|
77
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtextextended('aiwg-storage-schema',0))");
|
|
78
|
+
const before = await inspectPostgresSchema(client);
|
|
79
|
+
if (before.version === 1) {
|
|
80
|
+
await client.query('COMMIT');
|
|
81
|
+
return before;
|
|
82
|
+
}
|
|
83
|
+
await client.query(POSTGRES_SCHEMA_V1_SQL);
|
|
84
|
+
const after = await inspectPostgresSchema(client);
|
|
85
|
+
if (after.version !== 1)
|
|
86
|
+
throw new Error('PostgreSQL schema upgrade did not reach v1');
|
|
87
|
+
await client.query('COMMIT');
|
|
88
|
+
return after;
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
try {
|
|
92
|
+
await client.query('ROLLBACK');
|
|
93
|
+
}
|
|
94
|
+
catch { /* preserve upgrade error */ }
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Roll v1 back to an empty schema. This is intentionally impossible without
|
|
100
|
+
* exact version and observed-count acknowledgements from a prior inspection.
|
|
101
|
+
*/
|
|
102
|
+
export async function rollbackPostgresSchemaV1(client, approval) {
|
|
103
|
+
if (approval.expectedVersion !== 1 || approval.allowDataLoss !== true)
|
|
104
|
+
throw new Error('explicit v1 data-loss approval is required');
|
|
105
|
+
await client.query('BEGIN');
|
|
106
|
+
try {
|
|
107
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtextextended('aiwg-storage-schema',0))");
|
|
108
|
+
const before = await inspectPostgresSchema(client);
|
|
109
|
+
const observed = { records: before.records, receipts: before.receipts, edges: before.edges };
|
|
110
|
+
if (before.version !== 1 || JSON.stringify(observed) !== JSON.stringify(approval.expectedCounts)) {
|
|
111
|
+
throw new Error('schema rollback approval does not match current version/counts');
|
|
112
|
+
}
|
|
113
|
+
for (const signature of POSTGREST_FUNCTIONS)
|
|
114
|
+
await client.query(`DROP FUNCTION IF EXISTS ${signature}`);
|
|
115
|
+
await client.query('DROP TABLE aiwg_storage_edges, aiwg_storage_batch_receipts, aiwg_storage_records, aiwg_storage_schema');
|
|
116
|
+
const after = await inspectPostgresSchema(client);
|
|
117
|
+
if (after.version !== 0)
|
|
118
|
+
throw new Error('PostgreSQL schema rollback did not reach v0');
|
|
119
|
+
await client.query('COMMIT');
|
|
120
|
+
return after;
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
try {
|
|
124
|
+
await client.query('ROLLBACK');
|
|
125
|
+
}
|
|
126
|
+
catch { /* preserve rollback error */ }
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export function postgresLeastPrivilegeSql(runtimeRole) {
|
|
131
|
+
const role = quoteIdentifier(runtimeRole);
|
|
132
|
+
return `REVOKE ALL ON aiwg_storage_records, aiwg_storage_batch_receipts, aiwg_storage_edges FROM PUBLIC;
|
|
133
|
+
REVOKE ALL ON aiwg_storage_schema FROM PUBLIC;
|
|
134
|
+
GRANT USAGE ON SCHEMA public TO ${role};
|
|
135
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON aiwg_storage_records, aiwg_storage_batch_receipts, aiwg_storage_edges TO ${role};
|
|
136
|
+
GRANT SELECT ON aiwg_storage_schema TO ${role};
|
|
137
|
+
GRANT USAGE, SELECT ON SEQUENCE aiwg_storage_records_change_seq_seq TO ${role};`;
|
|
138
|
+
}
|
|
139
|
+
function quoteIdentifier(value) {
|
|
140
|
+
if (!value || value.length > 63 || /[\u0000-\u001f]/.test(value))
|
|
141
|
+
throw new Error('runtime role must be a printable PostgreSQL identifier up to 63 characters');
|
|
142
|
+
return `"${value.replaceAll('"', '""')}"`;
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=postgres-schema.js.map
|