@aiwg/cli 2026.8.25 → 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,346 @@
1
+ import { STORAGE_BACKEND_CONTRACT } from '../backend-contract.js';
2
+ import { postgresBatchId, postgresPayloadDigest } from './postgres.js';
3
+ const DEFAULT_TIMEOUT_MS = 15_000;
4
+ const DEFAULT_MAX_PAYLOAD_BYTES = 1_048_576;
5
+ const DEFAULT_PAGE_SIZE = 1000;
6
+ export class PostgrestBackendError extends Error {
7
+ code;
8
+ retryable;
9
+ retryAfterMs;
10
+ constructor(code, message, retryable = false, retryAfterMs) {
11
+ super(message);
12
+ this.code = code;
13
+ this.retryable = retryable;
14
+ this.retryAfterMs = retryAfterMs;
15
+ this.name = 'PostgrestBackendError';
16
+ }
17
+ }
18
+ /** PostgreSQL accessMode=postgrest; not a separate storage engine. */
19
+ export class PostgrestStorageBackend {
20
+ options;
21
+ descriptor = {
22
+ contract: STORAGE_BACKEND_CONTRACT,
23
+ backend: 'postgres-postgrest',
24
+ implementationVersion: '1.0.0',
25
+ schemaVersion: '1',
26
+ maturity: 'advanced',
27
+ capabilities: [
28
+ 'read', 'atomic-batch', 'consistent-snapshot', 'change-cursor',
29
+ 'tombstones', 'idempotency-keys', 'filtered-query', 'cursor-pagination',
30
+ 'health', 'readiness', 'tenant-isolation', 'subsystem-isolation', 'tls',
31
+ ],
32
+ durability: 'replicated',
33
+ availability: 'remote-service',
34
+ isolation: 'serializable',
35
+ dataClass: 'canonical',
36
+ };
37
+ identity;
38
+ baseUrl;
39
+ fetchImpl;
40
+ timeoutMs;
41
+ maxPayloadBytes;
42
+ maxResponseBytes;
43
+ maxBatchSize;
44
+ maxPageSize;
45
+ constructor(options) {
46
+ this.options = options;
47
+ this.baseUrl = normalizeBaseUrl(options.baseUrl);
48
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
49
+ if (!this.fetchImpl)
50
+ throw new PostgrestBackendError('AIWG_POSTGREST_FETCH_UNAVAILABLE', 'fetch is unavailable');
51
+ this.timeoutMs = bounded(options.timeoutMs, DEFAULT_TIMEOUT_MS, 1, 600_000, 'timeoutMs');
52
+ this.maxPayloadBytes = bounded(options.maxPayloadBytes, DEFAULT_MAX_PAYLOAD_BYTES, 1024, 16_777_216, 'maxPayloadBytes');
53
+ this.maxResponseBytes = bounded(options.maxResponseBytes, DEFAULT_MAX_PAYLOAD_BYTES, 1024, 16_777_216, 'maxResponseBytes');
54
+ this.maxBatchSize = bounded(options.maxBatchSize, DEFAULT_PAGE_SIZE, 1, 10_000, 'maxBatchSize');
55
+ this.maxPageSize = bounded(options.maxPageSize, DEFAULT_PAGE_SIZE, 1, 10_000, 'maxPageSize');
56
+ this.identity = {
57
+ backend: 'postgres-postgrest', instance: options.instance ?? this.baseUrl,
58
+ tenant: options.tenant, subsystem: options.subsystem, schemaVersion: '1',
59
+ };
60
+ }
61
+ async init() {
62
+ const health = await this.health();
63
+ if (!health.ready || health.schemaVersion !== '1') {
64
+ throw new PostgrestBackendError('AIWG_POSTGREST_SCHEMA_UNAVAILABLE', 'PostgREST schema/RPC contract v1 is unavailable');
65
+ }
66
+ }
67
+ async commitBatch(mutations) {
68
+ if (mutations.length === 0)
69
+ throw new PostgrestBackendError('AIWG_POSTGREST_EMPTY_BATCH', 'batch must contain at least one mutation');
70
+ if (mutations.length > this.maxBatchSize)
71
+ throw new PostgrestBackendError('AIWG_POSTGREST_BATCH_TOO_LARGE', `batch exceeds ${this.maxBatchSize} mutation ceiling`);
72
+ for (const mutation of mutations)
73
+ this.assertMutationIdentity(mutation);
74
+ return this.rpc('aiwg_commit_batch_v1', {
75
+ p_tenant: this.options.tenant,
76
+ p_subsystem: this.options.subsystem,
77
+ p_batch_id: postgresBatchId(mutations),
78
+ p_payload_digest: postgresPayloadDigest(mutations),
79
+ p_mutations: mutations,
80
+ }, 'return=representation');
81
+ }
82
+ async get(path) {
83
+ return this.rpc('aiwg_get_record_v1', {
84
+ p_tenant: this.options.tenant,
85
+ p_subsystem: this.options.subsystem,
86
+ p_path: path,
87
+ });
88
+ }
89
+ async readAll() {
90
+ const records = [];
91
+ let cursor;
92
+ do {
93
+ const page = await this.query({}, this.maxPageSize, cursor, true);
94
+ records.push(...page.records);
95
+ cursor = page.nextCursor;
96
+ } while (cursor);
97
+ return records;
98
+ }
99
+ async query(filters, limit = this.maxPageSize, cursor, includeTombstones = false) {
100
+ const boundedLimit = bounded(limit, this.maxPageSize, 1, this.maxPageSize, 'limit');
101
+ return this.rpc('aiwg_query_records_v1', {
102
+ p_tenant: this.options.tenant,
103
+ p_subsystem: this.options.subsystem,
104
+ p_filters: filters,
105
+ p_after_path: cursor ?? null,
106
+ p_limit: boundedLimit,
107
+ p_include_tombstones: includeTombstones,
108
+ });
109
+ }
110
+ async snapshot() {
111
+ const response = await this.rpc('aiwg_snapshot_v1', {
112
+ p_tenant: this.options.tenant,
113
+ p_subsystem: this.options.subsystem,
114
+ p_limit: this.maxPageSize,
115
+ });
116
+ return {
117
+ id: response.snapshot_id,
118
+ highWaterMark: response.high_water_mark,
119
+ cursor: response.high_water_mark,
120
+ records: response.records,
121
+ };
122
+ }
123
+ async changes(cursor) {
124
+ if (cursor !== undefined && !/^\d+$/.test(cursor)) {
125
+ throw new PostgrestBackendError('AIWG_POSTGREST_CURSOR_INVALID', 'change cursor must be an unsigned integer');
126
+ }
127
+ const response = await this.rpc('aiwg_changes_v1', {
128
+ p_tenant: this.options.tenant,
129
+ p_subsystem: this.options.subsystem,
130
+ p_after_cursor: cursor ?? '0',
131
+ p_limit: this.maxPageSize,
132
+ });
133
+ return {
134
+ records: response.records,
135
+ highWaterMark: response.high_water_mark,
136
+ ...(response.next_cursor ? { nextCursor: response.next_cursor } : {}),
137
+ };
138
+ }
139
+ async reloadSchemaCache() {
140
+ await this.rpc('aiwg_reload_schema_v1', {});
141
+ }
142
+ async health() {
143
+ return this.rpc('aiwg_health_v1', {
144
+ p_tenant: this.options.tenant,
145
+ p_subsystem: this.options.subsystem,
146
+ });
147
+ }
148
+ /** Receipt-less native JSON bulk upsert for controlled bootstrap only. */
149
+ async bulkBootstrapJson(rows) {
150
+ this.assertBootstrapCount(rows.length);
151
+ for (const row of rows)
152
+ this.assertBootstrapIdentity(row.tenant, row.subsystem);
153
+ return this.request('/aiwg_storage_records?on_conflict=tenant%2Csubsystem%2Cpath', {
154
+ method: 'POST', body: rows, prefer: 'resolution=merge-duplicates,return=representation',
155
+ });
156
+ }
157
+ /** Receipt-less native CSV bulk upsert for controlled bootstrap only. */
158
+ async bulkBootstrapCsv(csv) {
159
+ const parsed = parseCsv(csv);
160
+ const required = ['tenant', 'subsystem', 'path', 'source_revision', 'digest', 'value', 'tombstone', 'idempotency_key'];
161
+ for (const column of required) {
162
+ if (!parsed.header.includes(column))
163
+ throw new PostgrestBackendError('AIWG_POSTGREST_CSV_INVALID', `CSV header is missing ${column}`);
164
+ }
165
+ this.assertBootstrapCount(parsed.rows.length);
166
+ const tenantIndex = parsed.header.indexOf('tenant');
167
+ const subsystemIndex = parsed.header.indexOf('subsystem');
168
+ for (const row of parsed.rows)
169
+ this.assertBootstrapIdentity(row[tenantIndex] ?? '', row[subsystemIndex] ?? '');
170
+ return this.request('/aiwg_storage_records?on_conflict=tenant%2Csubsystem%2Cpath', {
171
+ method: 'POST', rawBody: csv, contentType: 'text/csv',
172
+ prefer: 'resolution=merge-duplicates,return=representation',
173
+ });
174
+ }
175
+ async rpc(name, body, prefer) {
176
+ return this.request(`/rpc/${name}`, { method: 'POST', body, ...(prefer ? { prefer } : {}) });
177
+ }
178
+ async request(pathname, options = {}) {
179
+ const body = options.rawBody ?? (options.body === undefined ? undefined : JSON.stringify(options.body));
180
+ if (body && Buffer.byteLength(body) > this.maxPayloadBytes) {
181
+ throw new PostgrestBackendError('AIWG_POSTGREST_PAYLOAD_TOO_LARGE', `request exceeds ${this.maxPayloadBytes} byte ceiling`);
182
+ }
183
+ const headers = { Accept: 'application/json' };
184
+ if (body)
185
+ headers['Content-Type'] = options.contentType ?? 'application/json';
186
+ if (options.prefer)
187
+ headers.Prefer = options.prefer;
188
+ const authorization = this.authorization();
189
+ if (authorization)
190
+ headers.Authorization = authorization;
191
+ let response;
192
+ try {
193
+ response = await this.fetchImpl(`${this.baseUrl}${pathname}`, {
194
+ method: options.method ?? 'GET', headers, body,
195
+ signal: AbortSignal.timeout(this.timeoutMs),
196
+ });
197
+ }
198
+ catch (error) {
199
+ throw new PostgrestBackendError('AIWG_POSTGREST_TRANSPORT_FAILED', error instanceof Error ? error.message : String(error), true);
200
+ }
201
+ if (response.status === 204)
202
+ return undefined;
203
+ const declaredLength = Number(response.headers.get('content-length') ?? '0');
204
+ if (declaredLength > this.maxResponseBytes) {
205
+ throw new PostgrestBackendError('AIWG_POSTGREST_RESPONSE_TOO_LARGE', `response exceeds ${this.maxResponseBytes} byte ceiling`);
206
+ }
207
+ const responseBody = await response.text();
208
+ if (Buffer.byteLength(responseBody) > this.maxResponseBytes) {
209
+ throw new PostgrestBackendError('AIWG_POSTGREST_RESPONSE_TOO_LARGE', `response exceeds ${this.maxResponseBytes} byte ceiling`);
210
+ }
211
+ if (!response.ok) {
212
+ const databaseError = parseDatabaseError(responseBody);
213
+ const retryable = response.status === 408 || response.status === 425 || response.status === 429 ||
214
+ response.status >= 500 || isRetryableDatabaseCode(databaseError.databaseCode);
215
+ const exposedCode = databaseError.message?.startsWith('AIWG_')
216
+ ? databaseError.message
217
+ : retryable ? 'AIWG_POSTGREST_RETRYABLE' : 'AIWG_POSTGREST_REQUEST_FAILED';
218
+ throw new PostgrestBackendError(exposedCode, `PostgREST request failed with HTTP ${response.status}${databaseError.databaseCode ? ` (database ${databaseError.databaseCode})` : ''}`, retryable, parseRetryAfter(response.headers.get('retry-after')));
219
+ }
220
+ try {
221
+ return JSON.parse(responseBody);
222
+ }
223
+ catch {
224
+ throw new PostgrestBackendError('AIWG_POSTGREST_RESPONSE_INVALID', 'PostgREST returned invalid JSON');
225
+ }
226
+ }
227
+ authorization() {
228
+ if (!this.options.authorizationEnv)
229
+ return undefined;
230
+ const value = process.env[this.options.authorizationEnv];
231
+ if (!value)
232
+ throw new PostgrestBackendError('AIWG_POSTGREST_CREDENTIAL_UNAVAILABLE', `authorization locator ${this.options.authorizationEnv} is not set`);
233
+ return value;
234
+ }
235
+ assertMutationIdentity(mutation) {
236
+ if (mutation.record.identity.tenant !== this.options.tenant || mutation.record.identity.subsystem !== this.options.subsystem) {
237
+ throw new PostgrestBackendError('AIWG_POSTGREST_IDENTITY_MISMATCH', 'mutation identity is outside this backend tenant/subsystem');
238
+ }
239
+ }
240
+ assertBootstrapCount(count) {
241
+ if (count < 1)
242
+ throw new PostgrestBackendError('AIWG_POSTGREST_EMPTY_BATCH', 'bootstrap batch must contain at least one row');
243
+ if (count > this.maxBatchSize)
244
+ throw new PostgrestBackendError('AIWG_POSTGREST_BATCH_TOO_LARGE', `batch exceeds ${this.maxBatchSize} row ceiling`);
245
+ }
246
+ assertBootstrapIdentity(tenant, subsystem) {
247
+ if (tenant !== this.options.tenant || subsystem !== this.options.subsystem) {
248
+ throw new PostgrestBackendError('AIWG_POSTGREST_IDENTITY_MISMATCH', 'bootstrap row is outside this backend tenant/subsystem');
249
+ }
250
+ }
251
+ }
252
+ function parseCsv(input) {
253
+ const output = [];
254
+ let row = [];
255
+ let field = '';
256
+ let quoted = false;
257
+ for (let index = 0; index < input.length; index += 1) {
258
+ const character = input[index];
259
+ if (character === '"') {
260
+ if (quoted && input[index + 1] === '"') {
261
+ field += '"';
262
+ index += 1;
263
+ }
264
+ else
265
+ quoted = !quoted;
266
+ }
267
+ else if (character === ',' && !quoted) {
268
+ row.push(field);
269
+ field = '';
270
+ }
271
+ else if ((character === '\n' || character === '\r') && !quoted) {
272
+ if (character === '\r' && input[index + 1] === '\n')
273
+ index += 1;
274
+ row.push(field);
275
+ field = '';
276
+ if (row.some(value => value.length > 0))
277
+ output.push(row);
278
+ row = [];
279
+ }
280
+ else
281
+ field += character;
282
+ }
283
+ if (quoted)
284
+ throw new PostgrestBackendError('AIWG_POSTGREST_CSV_INVALID', 'CSV contains an unterminated quoted field');
285
+ row.push(field);
286
+ if (row.some(value => value.length > 0))
287
+ output.push(row);
288
+ const header = output.shift();
289
+ if (!header)
290
+ throw new PostgrestBackendError('AIWG_POSTGREST_CSV_INVALID', 'CSV must contain a header');
291
+ for (const candidate of output) {
292
+ if (candidate.length !== header.length)
293
+ throw new PostgrestBackendError('AIWG_POSTGREST_CSV_INVALID', 'CSV row width does not match header');
294
+ }
295
+ return { header, rows: output };
296
+ }
297
+ function parseDatabaseError(body) {
298
+ try {
299
+ const parsed = JSON.parse(body);
300
+ return {
301
+ ...(typeof parsed.code === 'string' ? { databaseCode: parsed.code } : {}),
302
+ ...(typeof parsed.message === 'string' ? { message: parsed.message } : {}),
303
+ };
304
+ }
305
+ catch {
306
+ return {};
307
+ }
308
+ }
309
+ function isRetryableDatabaseCode(code) {
310
+ return Boolean(code && (code === '40001' || code === '40P01' || code === '55P03' || code === '57014' || code.startsWith('08')));
311
+ }
312
+ function normalizeBaseUrl(raw) {
313
+ let url;
314
+ try {
315
+ url = new URL(raw);
316
+ }
317
+ catch {
318
+ throw new PostgrestBackendError('AIWG_POSTGREST_URL_INVALID', 'baseUrl must be an absolute URL');
319
+ }
320
+ const loopback = ['localhost', '127.0.0.1', '::1'].includes(url.hostname);
321
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
322
+ throw new PostgrestBackendError('AIWG_POSTGREST_TLS_REQUIRED', 'PostgREST requires HTTPS except on explicit loopback development endpoints');
323
+ }
324
+ if (url.username || url.password || url.search || url.hash) {
325
+ throw new PostgrestBackendError('AIWG_POSTGREST_URL_SECRET_FORBIDDEN', 'baseUrl cannot contain credentials, query parameters, or fragments');
326
+ }
327
+ return url.toString().replace(/\/$/, '');
328
+ }
329
+ function parseRetryAfter(value) {
330
+ if (!value)
331
+ return undefined;
332
+ if (/^\d+$/.test(value))
333
+ return Math.min(Number(value) * 1000, 60_000);
334
+ const timestamp = Date.parse(value);
335
+ if (!Number.isFinite(timestamp))
336
+ return undefined;
337
+ return Math.max(0, Math.min(timestamp - Date.now(), 60_000));
338
+ }
339
+ function bounded(value, fallback, min, max, label) {
340
+ const resolved = value ?? fallback;
341
+ if (!Number.isInteger(resolved) || resolved < min || resolved > max) {
342
+ throw new PostgrestBackendError('AIWG_POSTGREST_OPTION_INVALID', `${label} must be an integer from ${min} through ${max}`);
343
+ }
344
+ return resolved;
345
+ }
346
+ //# sourceMappingURL=postgrest.js.map
@@ -23,6 +23,11 @@ export { LogseqAdapter } from './backends/logseq.js';
23
23
  export { FortemiAdapter } from './backends/fortemi.js';
24
24
  export { STORAGE_BACKEND_CONTRACT, STORAGE_BACKEND_MATRIX, StorageCapabilityError, negotiateStorageCapabilities, } from './backend-contract.js';
25
25
  export { STORAGE_MIGRATION_PROTOCOL, MigrationProtocolError, StorageMigrationCoordinator, approvalDigest, digestRecords, validateManifest, } from './migration-protocol.js';
26
+ export { PostgresStorageBackend, PostgresBackendError, } from './backends/postgres.js';
27
+ export { POSTGRES_SCHEMA_VERSION, POSTGRES_SCHEMA_V1_SQL, inspectPostgresSchema, upgradePostgresSchemaV1, rollbackPostgresSchemaV1, postgresLeastPrivilegeSql, } from './backends/postgres-schema.js';
28
+ export { PostgrestStorageBackend, PostgrestBackendError, } from './backends/postgrest.js';
29
+ export { POSTGREST_SCHEMA_V1_SQL, installPostgrestSchemaV1, postgrestLeastPrivilegeSql } from './backends/postgrest-schema.js';
30
+ export { STORAGE_QUALIFICATION_REPORT, StorageQualificationError, qualifyStorageBackend, verifyExactRecords, assertCurrentStorageEvidence, } from './qualification.js';
26
31
  let state = null;
27
32
  /**
28
33
  * Initialize or reset the registry for a given project root. Most
@@ -0,0 +1,137 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { digestRecords } from './migration-protocol.js';
3
+ export const STORAGE_QUALIFICATION_REPORT = 'aiwg.storage-qualification/v1';
4
+ /**
5
+ * Evidence-producing common gate. Correctness is evaluated before latency is
6
+ * reported, so an incomplete or corrupt run can never become a benchmark.
7
+ */
8
+ export async function qualifyStorageBackend(endpoint, options) {
9
+ if (options.records.length !== options.scope.declaredRecords) {
10
+ throw new Error('declared record scope does not match the qualification corpus');
11
+ }
12
+ const now = options.now ?? (() => new Date());
13
+ const startedAt = now().toISOString();
14
+ const cpuBefore = process.cpuUsage();
15
+ const rssBefore = process.memoryUsage().rss;
16
+ const start = performance.now();
17
+ const latencies = [];
18
+ const sideEffects = [];
19
+ let errors = 0;
20
+ let retries = 0;
21
+ const maxRetries = bounded(options.maxRetries, 5, 0, 20, 'maxRetries');
22
+ const baseBackoffMs = bounded(options.baseBackoffMs, 2, 0, 60_000, 'baseBackoffMs');
23
+ const batches = distribute(options.records, options.scope.writers);
24
+ await Promise.all(batches.map(async (records, writer) => {
25
+ for (const record of records) {
26
+ const mutation = mutationFor(record, `qualification:${writer}:${record.identity.path}:${record.sourceRevision}`);
27
+ const operationStart = performance.now();
28
+ let receipt;
29
+ let replayed = false;
30
+ for (let attempt = 0;; attempt += 1) {
31
+ try {
32
+ receipt = await endpoint.commitBatch([mutation]);
33
+ break;
34
+ }
35
+ catch (error) {
36
+ errors += 1;
37
+ if (!isRetryable(error) || attempt >= maxRetries) {
38
+ sideEffects.push({ operation: `write:${record.identity.path}`, outcome: 'failed' });
39
+ throw error;
40
+ }
41
+ retries += 1;
42
+ replayed = true;
43
+ if (baseBackoffMs > 0)
44
+ await new Promise(resolve => setTimeout(resolve, baseBackoffMs * 2 ** attempt));
45
+ }
46
+ }
47
+ latencies.push(performance.now() - operationStart);
48
+ sideEffects.push({ operation: `write:${record.identity.path}`, outcome: replayed ? 'replayed' : 'committed', batchId: receipt.batchId });
49
+ }
50
+ }));
51
+ const expectedDigest = digestRecords(options.records);
52
+ const observed = [...await endpoint.readAll()];
53
+ const verification = verifyExactRecords(options.records, observed);
54
+ const durationMs = Math.max(performance.now() - start, 0.001);
55
+ const cpu = process.cpuUsage(cpuBefore);
56
+ const supplied = options.resourceObservation?.() ?? {};
57
+ const operations = options.records.length + options.scope.readers;
58
+ const report = {
59
+ schemaVersion: STORAGE_QUALIFICATION_REPORT,
60
+ runId: createHash('sha256').update(`${options.scope.commit}\0${options.scope.backend}\0${startedAt}`).digest('hex'),
61
+ startedAt,
62
+ completedAt: now().toISOString(),
63
+ scope: { ...options.scope, observedRecords: observed.length },
64
+ verification: { ...verification, expectedDigest, observedDigest: digestRecords(observed) },
65
+ latencyMs: percentiles(latencies),
66
+ throughputPerSecond: operations / (durationMs / 1000),
67
+ errors,
68
+ retries,
69
+ errorRate: errors / Math.max(operations, 1),
70
+ retryRate: retries / Math.max(operations, 1),
71
+ resources: {
72
+ cpuUserMicros: cpu.user, cpuSystemMicros: cpu.system,
73
+ rssBytes: Math.max(process.memoryUsage().rss, rssBefore),
74
+ ...supplied,
75
+ },
76
+ sideEffects: sideEffects.sort((a, b) => a.operation.localeCompare(b.operation)),
77
+ };
78
+ if (!report.verification.valid)
79
+ throw new StorageQualificationError('AIWG_STORAGE_QUALIFICATION_PARITY_FAILED', report);
80
+ return report;
81
+ }
82
+ export class StorageQualificationError extends Error {
83
+ code;
84
+ report;
85
+ constructor(code, report) {
86
+ super(`${code}: correctness parity failed; benchmark evidence is invalid`);
87
+ this.code = code;
88
+ this.report = report;
89
+ this.name = 'StorageQualificationError';
90
+ }
91
+ }
92
+ export function verifyExactRecords(expected, observed) {
93
+ const key = (record) => `${record.identity.tenant}\0${record.identity.subsystem}\0${record.identity.path}`;
94
+ const expectedByKey = new Map(expected.map(record => [key(record), record]));
95
+ const observedByKey = new Map(observed.map(record => [key(record), record]));
96
+ const missing = [...expectedByKey.keys()].filter(item => !observedByKey.has(item)).sort();
97
+ const unexpected = [...observedByKey.keys()].filter(item => !expectedByKey.has(item)).sort();
98
+ const corrupt = [...expectedByKey].filter(([item, record]) => {
99
+ const candidate = observedByKey.get(item);
100
+ return candidate !== undefined && digestRecords([record]) !== digestRecords([candidate]);
101
+ }).map(([item]) => item).sort();
102
+ return { valid: missing.length === 0 && unexpected.length === 0 && corrupt.length === 0, missing, unexpected, corrupt };
103
+ }
104
+ export function assertCurrentStorageEvidence(report, commit) {
105
+ if (report.schemaVersion !== STORAGE_QUALIFICATION_REPORT || !report.verification.valid) {
106
+ throw new Error('storage performance claim lacks a valid qualification record');
107
+ }
108
+ if (report.scope.commit !== commit)
109
+ throw new Error('storage qualification record is stale for the requested commit');
110
+ if (report.scope.declaredRecords !== report.scope.observedRecords)
111
+ throw new Error('storage qualification scope is incomplete');
112
+ }
113
+ function mutationFor(record, idempotencyKey) {
114
+ return { operation: record.tombstone ? 'delete' : 'upsert', record, idempotencyKey };
115
+ }
116
+ function distribute(records, workers) {
117
+ if (!Number.isInteger(workers) || workers < 1 || workers > 32)
118
+ throw new Error('writers must be an integer from 1 through 32');
119
+ const buckets = Array.from({ length: workers }, () => []);
120
+ records.forEach((record, index) => buckets[index % workers].push(record));
121
+ return buckets;
122
+ }
123
+ function percentiles(values) {
124
+ const sorted = [...values].sort((a, b) => a - b);
125
+ const at = (percentile) => sorted[Math.min(Math.ceil(percentile * sorted.length) - 1, sorted.length - 1)] ?? 0;
126
+ return { p50: at(0.5), p95: at(0.95), p99: at(0.99) };
127
+ }
128
+ function isRetryable(error) {
129
+ return typeof error === 'object' && error !== null && 'retryable' in error && error.retryable === true;
130
+ }
131
+ function bounded(value, fallback, min, max, label) {
132
+ const resolved = value ?? fallback;
133
+ if (!Number.isInteger(resolved) || resolved < min || resolved > max)
134
+ throw new Error(`${label} must be an integer from ${min} through ${max}`);
135
+ return resolved;
136
+ }
137
+ //# sourceMappingURL=qualification.js.map
@@ -41,6 +41,7 @@ const CACHE_FILE = path.join(CACHE_DIR, 'update-notifier.json');
41
41
  * `lastCheckAt` timestamp in the cache file.
42
42
  */
43
43
  const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
44
+ const NOTICE_INTERVAL_MS = 24 * 60 * 60 * 1000;
44
45
 
45
46
  /**
46
47
  * Hard timeout for the npm registry HTTPS request. Keeps the spawned
@@ -51,8 +52,8 @@ const NETWORK_TIMEOUT_MS = 10_000;
51
52
  /**
52
53
  * Read an env var and return true iff it's set to a truthy value.
53
54
  */
54
- function envFlag(name) {
55
- const v = process.env[name];
55
+ function envFlag(name, env = process.env) {
56
+ const v = env[name];
56
57
  if (!v) return false;
57
58
  return v !== '0' && v.toLowerCase() !== 'false';
58
59
  }
@@ -60,17 +61,21 @@ function envFlag(name) {
60
61
  /**
61
62
  * True iff update checking is currently disabled by env / CI conventions.
62
63
  */
63
- function isDisabled() {
64
- if (envFlag('NO_UPDATE_NOTIFIER')) return true;
65
- if (envFlag('AIWG_NO_UPDATE_CHECK')) return true;
66
- if (envFlag('CI')) return true;
67
- if (envFlag('GITHUB_ACTIONS')) return true;
68
- if (envFlag('GITLAB_CI')) return true;
64
+ export function notificationDisabled(env = process.env, isTTY = process.stdout.isTTY) {
65
+ if (envFlag('NO_UPDATE_NOTIFIER', env)) return true;
66
+ if (envFlag('AIWG_NO_UPDATE_CHECK', env)) return true;
67
+ if (envFlag('CI', env)) return true;
68
+ if (envFlag('GITHUB_ACTIONS', env)) return true;
69
+ if (envFlag('GITLAB_CI', env)) return true;
69
70
  // Non-interactive terminals shouldn't get a notice they can't act on.
70
- if (!process.stdout.isTTY) return true;
71
+ if (!isTTY) return true;
71
72
  return false;
72
73
  }
73
74
 
75
+ function isDisabled() {
76
+ return notificationDisabled();
77
+ }
78
+
74
79
  /**
75
80
  * Read the update-notifier cache. Returns null if absent or malformed.
76
81
  */
@@ -168,6 +173,35 @@ export function cacheMatchesPackage(cache, packageRoot) {
168
173
  return !!(currentVersion && cache?.current === currentVersion);
169
174
  }
170
175
 
176
+ export function formatUpdateNotice(current, latest) {
177
+ return (
178
+ `aiwg: update available ${current} → ${latest}\n` +
179
+ 'Update: aiwg update\n' +
180
+ 'Then rerun your command.\n'
181
+ );
182
+ }
183
+
184
+ export function noticeIsRateLimited(cache, intervalMs, now = Date.now()) {
185
+ if (!cache?.lastNotifiedAt) return false;
186
+ const age = now - new Date(cache.lastNotifiedAt).getTime();
187
+ return Number.isFinite(age) && age >= 0 && age < intervalMs;
188
+ }
189
+
190
+ /** Resolve the package that the canonical installation record says is active. */
191
+ export function resolveActivePackageRoot(launcherPackageRoot) {
192
+ try {
193
+ const installation = loadInstallationIdentity({
194
+ actualRoot: launcherPackageRoot,
195
+ createIfMissing: false,
196
+ });
197
+ return installation?.root ?? launcherPackageRoot;
198
+ } catch {
199
+ // A broken installation identity is diagnosed by the installation
200
+ // preflight. The best-effort notifier must never mask that recovery path.
201
+ return launcherPackageRoot;
202
+ }
203
+ }
204
+
171
205
  /**
172
206
  * Run the actual update check and write the cache file. Invoked by the
173
207
  * spawned background child via `node src/update/notifier.mjs --check`.
@@ -192,7 +226,12 @@ async function runCheck(packageRoot) {
192
226
  */
193
227
  export function scheduleBackgroundCheck(packageRoot) {
194
228
  if (isDisabled()) return;
195
- const installation = loadInstallationIdentity({ actualRoot: packageRoot, createIfMissing: false });
229
+ let installation;
230
+ try {
231
+ installation = loadInstallationIdentity({ actualRoot: packageRoot, createIfMissing: false });
232
+ } catch {
233
+ return;
234
+ }
196
235
  if (installation?.checkOnStartup === false) return;
197
236
 
198
237
  const cache = readCache();
@@ -228,14 +267,20 @@ export function scheduleBackgroundCheck(packageRoot) {
228
267
  */
229
268
  export function maybePrintNotice(packageRoot) {
230
269
  if (isDisabled()) return;
231
- const installation = loadInstallationIdentity({ actualRoot: packageRoot, createIfMissing: false });
270
+ let installation;
271
+ try {
272
+ installation = loadInstallationIdentity({ actualRoot: packageRoot, createIfMissing: false });
273
+ } catch {
274
+ return;
275
+ }
232
276
  if (installation?.checkOnStartup === false) return;
233
277
  const cache = readCache();
234
278
  if (!cacheMatchesPackage(cache, packageRoot)) return;
235
279
  if (!cache?.hasUpdate || !cache.current || !cache.latest) return;
236
- // One-line, non-intrusive. Users who want to update run `aiwg update`.
237
- const msg = `aiwg: update available ${cache.current} ${cache.latest} (run: aiwg update)`;
238
- process.stderr.write(`\n${msg}\n`);
280
+ const noticeInterval = installation?.updateCheckInterval ?? NOTICE_INTERVAL_MS;
281
+ if (noticeIsRateLimited(cache, noticeInterval)) return;
282
+ process.stderr.write(formatUpdateNotice(cache.current, cache.latest));
283
+ writeCache({ ...cache, lastNotifiedAt: new Date().toISOString() });
239
284
  }
240
285
 
241
286
  // ── Child-process entry ────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwg/cli",
3
- "version": "2026.8.25",
3
+ "version": "2026.8.26",
4
4
  "description": "Lightweight AIWG CLI for signed, versioned web-backed resources.",
5
5
  "type": "module",
6
6
  "license": "MIT",