@aiwg/cli 2026.8.25 → 2026.8.27

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.
@@ -37,10 +37,28 @@ export class StorageMigrationCoordinator {
37
37
  this.rollbackWindowMs = boundedInteger(options.rollbackWindowMs, 86_400_000, 1, 31_536_000_000, 'rollbackWindowMs');
38
38
  this.now = options.now ?? (() => new Date());
39
39
  this.random = options.random ?? Math.random;
40
+ if (options.mode === 'online' && !options.compareSourceRevisions) {
41
+ throw new MigrationProtocolError('AIWG_MIGRATION_REVISION_ORDER_REQUIRED', 'online migration requires a source-revision comparator');
42
+ }
40
43
  }
41
44
  async preview() {
42
45
  this.throwIfAborted();
43
- const snapshot = await this.source.snapshot(this.options.signal);
46
+ const boundary = await this.options.safety.prepare(this.options.mode, this.source.identity, this.options.signal);
47
+ try {
48
+ assertBoundaryState(boundary, this.options.mode === 'offline' ? 'quiesced' : 'tracking', 'initial');
49
+ }
50
+ catch (error) {
51
+ await this.options.safety.release(boundary, 'failed');
52
+ throw error;
53
+ }
54
+ let snapshot;
55
+ try {
56
+ snapshot = await this.source.snapshot(this.options.signal);
57
+ }
58
+ catch (error) {
59
+ await this.options.safety.release(boundary, 'failed');
60
+ throw error;
61
+ }
44
62
  const createdAt = this.now().toISOString();
45
63
  const sourceDigest = digestRecords(snapshot.records);
46
64
  const manifest = {
@@ -67,57 +85,69 @@ export class StorageMigrationCoordinator {
67
85
  },
68
86
  digests: { source: sourceDigest },
69
87
  records: [],
88
+ boundaries: { initial: boundary },
70
89
  };
71
- await this.store.save(manifest);
90
+ try {
91
+ await this.store.save(manifest);
92
+ }
93
+ catch (error) {
94
+ await this.options.safety.release(boundary, 'failed');
95
+ throw error;
96
+ }
72
97
  return manifest;
73
98
  }
74
99
  async apply(manifest) {
75
100
  validateManifest(manifest, this.source.identity, this.destination.identity);
76
- const snapshot = await this.source.snapshot(this.options.signal);
77
- if (snapshot.id !== manifest.snapshot.id || digestRecords(snapshot.records) !== manifest.digests.source) {
78
- throw new MigrationProtocolError('AIWG_MIGRATION_SNAPSHOT_CHANGED', 'source snapshot no longer matches the preview; create a new preview');
79
- }
80
- manifest.state = 'copying';
81
- await this.persist(manifest);
82
- await this.copyRecords(snapshot.records, manifest);
83
- if (manifest.mode === 'online') {
84
- if (!this.source.changes) {
85
- throw new MigrationProtocolError('AIWG_MIGRATION_CURSOR_UNAVAILABLE', 'online migration requires a source change cursor');
101
+ try {
102
+ const snapshot = await this.source.snapshot(this.options.signal);
103
+ if (digestRecords(snapshot.records) !== manifest.digests.source) {
104
+ throw new MigrationProtocolError('AIWG_MIGRATION_SNAPSHOT_CHANGED', 'source snapshot no longer matches the preview; create a new preview');
86
105
  }
87
- manifest.state = 'replaying';
106
+ manifest.state = 'copying';
88
107
  await this.persist(manifest);
89
- let cursor = manifest.snapshot.cursor;
90
- for (;;) {
91
- this.throwIfAborted();
92
- const page = await this.source.changes(cursor, this.options.signal);
93
- await this.copyRecords(page.records, manifest);
94
- cursor = page.nextCursor;
95
- manifest.checkpoint = {
96
- ...manifest.checkpoint,
97
- ...(cursor === undefined ? {} : { cursor }),
98
- highWaterMark: page.highWaterMark,
99
- drained: page.records.length === 0 || cursor === undefined,
100
- };
108
+ await this.copyRecords(snapshot.records, manifest);
109
+ if (manifest.mode === 'online') {
110
+ if (!this.source.changes) {
111
+ throw new MigrationProtocolError('AIWG_MIGRATION_CURSOR_UNAVAILABLE', 'online migration requires a source change cursor');
112
+ }
113
+ manifest.state = 'replaying';
101
114
  await this.persist(manifest);
102
- if (page.records.length === 0 || cursor === undefined)
103
- break;
115
+ await this.replayChanges(manifest);
104
116
  }
117
+ const finalBoundary = await this.options.safety.freeze(manifest, this.options.signal);
118
+ manifest.boundaries.final = finalBoundary;
119
+ assertBoundaryState(finalBoundary, 'quiesced', 'final');
120
+ await this.persist(manifest);
121
+ if (manifest.mode === 'online')
122
+ await this.replayChanges(manifest);
123
+ manifest.state = 'verifying';
124
+ await this.persist(manifest);
125
+ const verification = await this.verify(manifest);
126
+ if (!verification.valid) {
127
+ throw new MigrationProtocolError('AIWG_MIGRATION_PARITY_FAILED', summarizeVerificationFailure(verification));
128
+ }
129
+ manifest.digests.destination = verification.destinationDigest;
130
+ manifest.verification = verification;
131
+ manifest.digests.verification = sha256(stableStringify(verification));
132
+ manifest.state = 'awaiting-approval';
133
+ manifest.updatedAt = this.now().toISOString();
134
+ manifest.digests.approval = approvalDigest(manifest);
135
+ await this.save(manifest);
136
+ return manifest;
105
137
  }
106
- manifest.state = 'verifying';
107
- await this.persist(manifest);
108
- const verification = await this.verify(manifest);
109
- if (!verification.valid) {
138
+ catch (error) {
139
+ const stage = manifest.state;
110
140
  manifest.state = 'failed';
111
- manifest.failure = { stage: 'verify', message: summarizeVerificationFailure(verification) };
112
- await this.persist(manifest);
113
- throw new MigrationProtocolError('AIWG_MIGRATION_PARITY_FAILED', manifest.failure.message);
141
+ manifest.failure = { stage, message: errorMessage(error) };
142
+ try {
143
+ await this.persist(manifest);
144
+ }
145
+ catch {
146
+ // Preserve the triggering failure; the manifest store error is already observable at its source.
147
+ }
148
+ await this.options.safety.release(manifest.boundaries.final ?? manifest.boundaries.initial, 'failed');
149
+ throw error;
114
150
  }
115
- manifest.digests.destination = verification.destinationDigest;
116
- manifest.state = 'awaiting-approval';
117
- manifest.updatedAt = this.now().toISOString();
118
- manifest.digests.approval = approvalDigest(manifest);
119
- await this.save(manifest);
120
- return manifest;
121
151
  }
122
152
  async verify(manifest) {
123
153
  const [sourceRecords, destinationRecords] = await Promise.all([
@@ -131,16 +161,35 @@ export class StorageMigrationCoordinator {
131
161
  const corrupt = [...sourceMap.entries()]
132
162
  .filter(([key, record]) => {
133
163
  const destination = destinationMap.get(key);
134
- return destination !== undefined && (destination.digest !== record.digest ||
164
+ return destination !== undefined && (destination.sourceRevision !== record.sourceRevision ||
165
+ destination.digest !== record.digest ||
135
166
  Boolean(destination.tombstone) !== Boolean(record.tombstone));
136
167
  })
137
168
  .map(([key]) => key)
138
169
  .sort();
139
- const lag = manifest.mode === 'online' ? Number(!manifest.checkpoint.drained) : 0;
170
+ const boundaryReady = manifest.boundaries.final?.state === 'quiesced';
171
+ const lag = Number(!boundaryReady || (manifest.mode === 'online' && !manifest.checkpoint.drained));
140
172
  const sourceDigest = digestRecords(sourceRecords);
141
173
  const destinationDigest = digestRecords(destinationRecords);
174
+ const sourceChunks = digestRecordChunks(sourceRecords, this.batchSize);
175
+ const destinationChunks = digestRecordChunks(destinationRecords, this.batchSize);
176
+ const chunks = {
177
+ valid: stableStringify(sourceChunks) === stableStringify(destinationChunks),
178
+ source: sourceChunks,
179
+ destination: destinationChunks,
180
+ };
181
+ const semantic = await this.options.verifier.verify({
182
+ manifest,
183
+ source: this.source,
184
+ destination: this.destination,
185
+ sourceRecords,
186
+ destinationRecords,
187
+ signal: this.options.signal,
188
+ });
189
+ validateSemanticVerification(semantic);
142
190
  return {
143
- valid: missing.length === 0 && unexpected.length === 0 && corrupt.length === 0 && lag === 0,
191
+ valid: missing.length === 0 && unexpected.length === 0 && corrupt.length === 0 && lag === 0 &&
192
+ sourceDigest === destinationDigest && chunks.valid && semanticIsValid(semantic),
144
193
  sourceCount: sourceRecords.length,
145
194
  destinationCount: destinationRecords.length,
146
195
  sourceDigest,
@@ -149,6 +198,8 @@ export class StorageMigrationCoordinator {
149
198
  unexpected,
150
199
  corrupt,
151
200
  lag,
201
+ chunks,
202
+ semantic,
152
203
  };
153
204
  }
154
205
  async cutover(manifest, approval) {
@@ -164,14 +215,22 @@ export class StorageMigrationCoordinator {
164
215
  if (!verification.valid) {
165
216
  throw new MigrationProtocolError('AIWG_MIGRATION_PARITY_FAILED', summarizeVerificationFailure(verification));
166
217
  }
218
+ if (!manifest.digests.verification || sha256(stableStringify(verification)) !== manifest.digests.verification) {
219
+ throw new MigrationProtocolError('AIWG_MIGRATION_VERIFICATION_CHANGED', 'migration verification changed after approval; verify and approve a new manifest');
220
+ }
221
+ const finalBoundary = manifest.boundaries.final;
222
+ if (!finalBoundary) {
223
+ throw new MigrationProtocolError('AIWG_MIGRATION_BOUNDARY_MISSING', 'migration has no final quiesced source boundary');
224
+ }
167
225
  manifest.state = 'cutover';
168
226
  await this.persist(manifest);
169
227
  try {
170
- await this.routing.switchReads(manifest);
171
- await this.routing.switchWrites(manifest);
228
+ manifest.routingSwitch = await this.routing.switchAtomically(manifest);
229
+ await this.options.safety.release(finalBoundary, 'cutover');
172
230
  }
173
231
  catch (error) {
174
232
  await this.routing.restoreSource(manifest);
233
+ await this.options.safety.release(finalBoundary, 'failed');
175
234
  manifest.state = 'failed';
176
235
  manifest.failure = { stage: 'cutover', message: errorMessage(error) };
177
236
  await this.persist(manifest);
@@ -195,13 +254,47 @@ export class StorageMigrationCoordinator {
195
254
  throw new MigrationProtocolError('AIWG_MIGRATION_NOT_ROLLBACKABLE', `cannot roll back state ${manifest.state}`);
196
255
  }
197
256
  await this.routing.restoreSource(manifest);
257
+ await this.options.safety.release(manifest.boundaries.final ?? manifest.boundaries.initial, 'rollback');
198
258
  manifest.state = 'rolled-back';
199
259
  await this.persist(manifest);
200
260
  return manifest;
201
261
  }
202
262
  async copyRecords(records, manifest) {
203
263
  const completed = new Set(manifest.records.map(receiptKey));
204
- const pending = records.filter(record => !completed.has(recordKey(record)));
264
+ const latest = new Map();
265
+ for (const receipt of manifest.records)
266
+ latest.set(identityKey(receipt.identity), receipt);
267
+ const selected = new Map();
268
+ for (const record of records) {
269
+ if (completed.has(recordKey(record)))
270
+ continue;
271
+ const key = identityKey(record.identity);
272
+ const receipt = latest.get(key);
273
+ if (receipt && this.options.compareSourceRevisions) {
274
+ const order = compareRevisions(this.options.compareSourceRevisions, record.sourceRevision, receipt.sourceRevision);
275
+ if (order < 0)
276
+ continue;
277
+ if (order === 0) {
278
+ if (record.digest !== receipt.contentDigest)
279
+ throw revisionConflict(key, record.sourceRevision);
280
+ continue;
281
+ }
282
+ }
283
+ const prior = selected.get(key);
284
+ if (!prior) {
285
+ selected.set(key, record);
286
+ continue;
287
+ }
288
+ if (!this.options.compareSourceRevisions) {
289
+ throw new MigrationProtocolError('AIWG_MIGRATION_DUPLICATE_IDENTITY', `snapshot contains multiple records for ${key}`);
290
+ }
291
+ const order = compareRevisions(this.options.compareSourceRevisions, record.sourceRevision, prior.sourceRevision);
292
+ if (order > 0)
293
+ selected.set(key, record);
294
+ else if (order === 0 && record.digest !== prior.digest)
295
+ throw revisionConflict(key, record.sourceRevision);
296
+ }
297
+ const pending = [...selected.values()].sort((left, right) => identityKey(left.identity).localeCompare(identityKey(right.identity)));
205
298
  const batches = chunk(pending, this.batchSize);
206
299
  let next = 0;
207
300
  const workers = Array.from({ length: Math.min(this.concurrency, batches.length) }, async () => {
@@ -210,7 +303,7 @@ export class StorageMigrationCoordinator {
210
303
  if (index >= batches.length)
211
304
  return;
212
305
  const batch = batches[index];
213
- const mutations = batch.map(record => mutationFor(manifest.migrationId, record));
306
+ const mutations = batch.map(record => mutationFor(manifest.migrationId, record, latest.get(identityKey(record.identity))?.destinationRevision));
214
307
  const receipt = await this.retry(() => this.destination.commitBatch(mutations, this.options.signal), `batch ${index}`);
215
308
  if (!receipt.committed || receipt.recordReceipts.length !== batch.length) {
216
309
  throw new MigrationProtocolError('AIWG_MIGRATION_INVALID_RECEIPT', `destination returned an invalid receipt for batch ${index}`);
@@ -242,6 +335,27 @@ export class StorageMigrationCoordinator {
242
335
  });
243
336
  await Promise.all(workers);
244
337
  }
338
+ async replayChanges(manifest) {
339
+ if (!this.source.changes) {
340
+ throw new MigrationProtocolError('AIWG_MIGRATION_CURSOR_UNAVAILABLE', 'online migration requires a source change cursor');
341
+ }
342
+ let cursor = manifest.checkpoint.cursor ?? manifest.snapshot.cursor;
343
+ for (;;) {
344
+ this.throwIfAborted();
345
+ const page = await this.source.changes(cursor, this.options.signal);
346
+ await this.copyRecords(page.records, manifest);
347
+ cursor = page.nextCursor ?? page.highWaterMark;
348
+ manifest.checkpoint = {
349
+ ...manifest.checkpoint,
350
+ cursor,
351
+ highWaterMark: page.highWaterMark,
352
+ drained: page.records.length === 0 || page.nextCursor === undefined,
353
+ };
354
+ await this.persist(manifest);
355
+ if (manifest.checkpoint.drained)
356
+ break;
357
+ }
358
+ }
245
359
  async retry(operation, label) {
246
360
  let attempt = 0;
247
361
  for (;;) {
@@ -250,7 +364,7 @@ export class StorageMigrationCoordinator {
250
364
  return await operation();
251
365
  }
252
366
  catch (error) {
253
- const retryable = error instanceof MigrationProtocolError && error.retryable;
367
+ const retryable = isRetryableError(error);
254
368
  if (!retryable)
255
369
  throw error;
256
370
  if (attempt >= this.maxRetries) {
@@ -290,6 +404,10 @@ export function digestRecords(records) {
290
404
  .sort((left, right) => identityKey(left.identity).localeCompare(identityKey(right.identity)));
291
405
  return sha256(stableStringify(normalized));
292
406
  }
407
+ export function digestRecordChunks(records, chunkSize) {
408
+ const sorted = [...records].sort((left, right) => identityKey(left.identity).localeCompare(identityKey(right.identity)));
409
+ return chunk(sorted, chunkSize).map(recordsInChunk => digestRecords(recordsInChunk));
410
+ }
293
411
  export function approvalDigest(manifest) {
294
412
  const copy = structuredClone(manifest);
295
413
  delete copy.digests.approval;
@@ -302,6 +420,10 @@ export function validateManifest(manifest, source, destination) {
302
420
  if (!manifest.migrationId || !manifest.snapshot?.id || !manifest.digests?.source) {
303
421
  throw new MigrationProtocolError('AIWG_MIGRATION_MANIFEST_CORRUPT', 'migration manifest is missing required identity or digest fields');
304
422
  }
423
+ if (!manifest.boundaries?.initial) {
424
+ throw new MigrationProtocolError('AIWG_MIGRATION_BOUNDARY_MISSING', 'migration manifest has no durable source boundary receipt');
425
+ }
426
+ assertBoundaryState(manifest.boundaries.initial, manifest.mode === 'offline' ? 'quiesced' : 'tracking', 'initial');
305
427
  if (source && stableStringify(manifest.source) !== stableStringify(source)) {
306
428
  throw new MigrationProtocolError('AIWG_MIGRATION_SOURCE_MISMATCH', 'manifest source does not match the active source');
307
429
  }
@@ -309,13 +431,29 @@ export function validateManifest(manifest, source, destination) {
309
431
  throw new MigrationProtocolError('AIWG_MIGRATION_DESTINATION_MISMATCH', 'manifest destination does not match the active destination');
310
432
  }
311
433
  }
312
- function mutationFor(migrationId, record) {
434
+ function assertBoundaryState(boundary, expected, stage) {
435
+ if (!boundary.boundaryId || !boundary.establishedAt || boundary.state !== expected) {
436
+ throw new MigrationProtocolError('AIWG_MIGRATION_BOUNDARY_INVALID', `${stage} source boundary must provide a durable ${expected} receipt`);
437
+ }
438
+ }
439
+ function mutationFor(migrationId, record, expectedRevision) {
313
440
  return {
314
441
  operation: record.tombstone ? 'delete' : 'upsert',
315
442
  record,
316
443
  idempotencyKey: sha256(`${migrationId}\0${recordKey(record)}`),
444
+ ...(expectedRevision === undefined ? {} : { expectedRevision }),
317
445
  };
318
446
  }
447
+ function revisionConflict(identity, revision) {
448
+ return new MigrationProtocolError('AIWG_MIGRATION_REVISION_CONFLICT', `source revision ${revision} has conflicting content for ${identity}`);
449
+ }
450
+ function compareRevisions(comparator, left, right) {
451
+ const result = comparator(left, right);
452
+ if (!Number.isFinite(result)) {
453
+ throw new MigrationProtocolError('AIWG_MIGRATION_REVISION_ORDER_INVALID', 'source-revision comparator returned a non-finite result');
454
+ }
455
+ return Math.sign(result);
456
+ }
319
457
  function recordMap(records) {
320
458
  return new Map(records.map(record => [identityKey(record.identity), record]));
321
459
  }
@@ -357,11 +495,51 @@ function boundedInteger(raw, fallback, min, max, label) {
357
495
  return value;
358
496
  }
359
497
  function summarizeVerificationFailure(verification) {
360
- return `migration parity failed: missing=${verification.missing.length} unexpected=${verification.unexpected.length} corrupt=${verification.corrupt.length} lag=${verification.lag}`;
498
+ const semanticFailures = Object.values(verification.semantic).filter(check => !check.valid).length;
499
+ return `migration parity failed: missing=${verification.missing.length} unexpected=${verification.unexpected.length} corrupt=${verification.corrupt.length} chunks=${verification.chunks.valid ? 'valid' : 'invalid'} semantic=${semanticFailures} lag=${verification.lag}`;
500
+ }
501
+ function validateSemanticVerification(verification) {
502
+ for (const dimension of [
503
+ 'schema', 'constraints', 'countsByType', 'edgeIntegrity', 'queryParity', 'traversalParity',
504
+ ]) {
505
+ const check = verification?.[dimension];
506
+ if (!check || typeof check.valid !== 'boolean' || !Number.isInteger(check.declared) || check.declared < 0 ||
507
+ !Number.isInteger(check.checked) || check.checked < 0 || check.checked !== check.declared || !Array.isArray(check.failures)) {
508
+ throw new MigrationProtocolError('AIWG_MIGRATION_SEMANTIC_VERIFICATION_INVALID', `semantic verification is incomplete for ${dimension}`);
509
+ }
510
+ if (check.valid !== (check.failures.length === 0)) {
511
+ throw new MigrationProtocolError('AIWG_MIGRATION_SEMANTIC_VERIFICATION_INVALID', `semantic verification validity disagrees with failures for ${dimension}`);
512
+ }
513
+ if (check.evidenceDigest !== undefined && !/^[0-9A-Za-z._:-]+$/.test(check.evidenceDigest)) {
514
+ throw new MigrationProtocolError('AIWG_MIGRATION_SEMANTIC_VERIFICATION_INVALID', `semantic verification evidence digest is invalid for ${dimension}`);
515
+ }
516
+ if (['schema', 'constraints', 'queryParity'].includes(dimension) && check.declared < 1) {
517
+ throw new MigrationProtocolError('AIWG_MIGRATION_SEMANTIC_VERIFICATION_INVALID', `semantic verification must exercise at least one ${dimension} check`);
518
+ }
519
+ if (dimension === 'countsByType') {
520
+ if (!isCountMap(check.sourceCounts) || !isCountMap(check.destinationCounts)) {
521
+ throw new MigrationProtocolError('AIWG_MIGRATION_SEMANTIC_VERIFICATION_INVALID', 'countsByType must include non-negative integer source and destination maps');
522
+ }
523
+ const declaredTypes = new Set([...Object.keys(check.sourceCounts), ...Object.keys(check.destinationCounts)]).size;
524
+ if (check.declared !== declaredTypes || (check.valid && stableStringify(check.sourceCounts) !== stableStringify(check.destinationCounts))) {
525
+ throw new MigrationProtocolError('AIWG_MIGRATION_SEMANTIC_VERIFICATION_INVALID', 'countsByType scope or validity does not match its declared maps');
526
+ }
527
+ }
528
+ }
529
+ }
530
+ function semanticIsValid(verification) {
531
+ return Object.values(verification).every(check => check.valid);
532
+ }
533
+ function isCountMap(value) {
534
+ return typeof value === 'object' && value !== null && !Array.isArray(value) &&
535
+ Object.values(value).every(count => Number.isInteger(count) && count >= 0);
361
536
  }
362
537
  function errorMessage(error) {
363
538
  return error instanceof Error ? error.message : String(error);
364
539
  }
540
+ function isRetryableError(error) {
541
+ return typeof error === 'object' && error !== null && 'retryable' in error && error.retryable === true;
542
+ }
365
543
  async function abortableDelay(ms, signal) {
366
544
  if (!signal) {
367
545
  await new Promise(resolve => setTimeout(resolve, ms));
@@ -0,0 +1,171 @@
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 readerCount = bounded(options.scope.readers, 1, 1, 32, 'readers');
13
+ if (options.scope.operations !== options.records.length + readerCount) {
14
+ throw new Error('declared operation scope must equal record writes plus concurrent readers');
15
+ }
16
+ const now = options.now ?? (() => new Date());
17
+ const startedAt = now().toISOString();
18
+ const cpuBefore = process.cpuUsage();
19
+ const rssBefore = process.memoryUsage().rss;
20
+ const start = performance.now();
21
+ const latencies = [];
22
+ const sideEffects = [];
23
+ let errors = 0;
24
+ let retries = 0;
25
+ const maxRetries = bounded(options.maxRetries, 5, 0, 20, 'maxRetries');
26
+ const baseBackoffMs = bounded(options.baseBackoffMs, 2, 0, 60_000, 'baseBackoffMs');
27
+ const batches = distribute(options.records, options.scope.writers);
28
+ const writes = Promise.all(batches.map(async (records, writer) => {
29
+ for (const record of records) {
30
+ const mutation = mutationFor(record, `qualification:${writer}:${record.identity.path}:${record.sourceRevision}`);
31
+ const operationStart = performance.now();
32
+ let receipt;
33
+ let replayed = false;
34
+ for (let attempt = 0;; attempt += 1) {
35
+ try {
36
+ receipt = await endpoint.commitBatch([mutation]);
37
+ break;
38
+ }
39
+ catch (error) {
40
+ errors += 1;
41
+ if (!isRetryable(error) || attempt >= maxRetries) {
42
+ sideEffects.push({ operation: `write:${record.identity.path}`, outcome: 'failed' });
43
+ throw error;
44
+ }
45
+ retries += 1;
46
+ replayed = true;
47
+ if (baseBackoffMs > 0)
48
+ await new Promise(resolve => setTimeout(resolve, baseBackoffMs * 2 ** attempt));
49
+ }
50
+ }
51
+ latencies.push(performance.now() - operationStart);
52
+ sideEffects.push({ operation: `write:${record.identity.path}`, outcome: replayed ? 'replayed' : 'committed', batchId: receipt.batchId });
53
+ }
54
+ }));
55
+ const reads = Promise.all(Array.from({ length: readerCount }, async (_, reader) => {
56
+ const operationStart = performance.now();
57
+ try {
58
+ await endpoint.readAll();
59
+ latencies.push(performance.now() - operationStart);
60
+ sideEffects.push({ operation: `read:${reader}`, outcome: 'committed' });
61
+ }
62
+ catch (error) {
63
+ errors += 1;
64
+ sideEffects.push({ operation: `read:${reader}`, outcome: 'failed' });
65
+ throw error;
66
+ }
67
+ }));
68
+ await Promise.all([writes, reads]);
69
+ const expectedDigest = digestRecords(options.records);
70
+ const observed = [...await endpoint.readAll()];
71
+ const verification = verifyExactRecords(options.records, observed);
72
+ const durationMs = Math.max(performance.now() - start, 0.001);
73
+ const cpu = process.cpuUsage(cpuBefore);
74
+ const supplied = await options.resourceObservation?.() ?? {};
75
+ const operations = options.records.length + readerCount;
76
+ const report = {
77
+ schemaVersion: STORAGE_QUALIFICATION_REPORT,
78
+ runId: createHash('sha256').update(`${options.scope.commit}\0${options.scope.backend}\0${startedAt}`).digest('hex'),
79
+ startedAt,
80
+ completedAt: now().toISOString(),
81
+ scope: { ...options.scope, observedRecords: observed.length, observedOperations: sideEffects.length },
82
+ verification: { ...verification, expectedDigest, observedDigest: digestRecords(observed) },
83
+ latencyMs: percentiles(latencies),
84
+ throughputPerSecond: operations / (durationMs / 1000),
85
+ errors,
86
+ retries,
87
+ errorRate: errors / Math.max(operations, 1),
88
+ retryRate: retries / Math.max(operations, 1),
89
+ resources: {
90
+ cpuUserMicros: cpu.user, cpuSystemMicros: cpu.system,
91
+ rssBytes: Math.max(process.memoryUsage().rss, rssBefore),
92
+ databaseBytes: resourceMetric(supplied.databaseBytes, 'databaseBytes'),
93
+ writeAmplification: resourceMetric(supplied.writeAmplification, 'writeAmplification'),
94
+ walBytes: resourceMetric(supplied.walBytes, 'walBytes'),
95
+ lockWaits: resourceMetric(supplied.lockWaits, 'lockWaits'),
96
+ poolSaturation: resourceMetric(supplied.poolSaturation, 'poolSaturation'),
97
+ migrationMs: resourceMetric(supplied.migrationMs, 'migrationMs'),
98
+ recoveryMs: resourceMetric(supplied.recoveryMs, 'recoveryMs'),
99
+ transportOverheadMs: resourceMetric(supplied.transportOverheadMs, 'transportOverheadMs'),
100
+ },
101
+ sideEffects: sideEffects.sort((a, b) => a.operation.localeCompare(b.operation)),
102
+ };
103
+ if (!report.verification.valid)
104
+ throw new StorageQualificationError('AIWG_STORAGE_QUALIFICATION_PARITY_FAILED', report);
105
+ return report;
106
+ }
107
+ export class StorageQualificationError extends Error {
108
+ code;
109
+ report;
110
+ constructor(code, report) {
111
+ super(`${code}: correctness parity failed; benchmark evidence is invalid`);
112
+ this.code = code;
113
+ this.report = report;
114
+ this.name = 'StorageQualificationError';
115
+ }
116
+ }
117
+ export function verifyExactRecords(expected, observed) {
118
+ const key = (record) => `${record.identity.tenant}\0${record.identity.subsystem}\0${record.identity.path}`;
119
+ const expectedByKey = new Map(expected.map(record => [key(record), record]));
120
+ const observedByKey = new Map(observed.map(record => [key(record), record]));
121
+ const missing = [...expectedByKey.keys()].filter(item => !observedByKey.has(item)).sort();
122
+ const unexpected = [...observedByKey.keys()].filter(item => !expectedByKey.has(item)).sort();
123
+ const corrupt = [...expectedByKey].filter(([item, record]) => {
124
+ const candidate = observedByKey.get(item);
125
+ return candidate !== undefined && digestRecords([record]) !== digestRecords([candidate]);
126
+ }).map(([item]) => item).sort();
127
+ return { valid: missing.length === 0 && unexpected.length === 0 && corrupt.length === 0, missing, unexpected, corrupt };
128
+ }
129
+ export function assertCurrentStorageEvidence(report, commit) {
130
+ if (report.schemaVersion !== STORAGE_QUALIFICATION_REPORT || !report.verification.valid) {
131
+ throw new Error('storage performance claim lacks a valid qualification record');
132
+ }
133
+ if (report.scope.commit !== commit)
134
+ throw new Error('storage qualification record is stale for the requested commit');
135
+ if (report.scope.declaredRecords !== report.scope.observedRecords)
136
+ throw new Error('storage qualification scope is incomplete');
137
+ if (report.scope.operations !== report.scope.observedOperations)
138
+ throw new Error('storage qualification operation scope is incomplete');
139
+ }
140
+ function mutationFor(record, idempotencyKey) {
141
+ return { operation: record.tombstone ? 'delete' : 'upsert', record, idempotencyKey };
142
+ }
143
+ function distribute(records, workers) {
144
+ if (!Number.isInteger(workers) || workers < 1 || workers > 32)
145
+ throw new Error('writers must be an integer from 1 through 32');
146
+ const buckets = Array.from({ length: workers }, () => []);
147
+ records.forEach((record, index) => buckets[index % workers].push(record));
148
+ return buckets;
149
+ }
150
+ function percentiles(values) {
151
+ const sorted = [...values].sort((a, b) => a - b);
152
+ const at = (percentile) => sorted[Math.min(Math.ceil(percentile * sorted.length) - 1, sorted.length - 1)] ?? 0;
153
+ return { p50: at(0.5), p95: at(0.95), p99: at(0.99) };
154
+ }
155
+ function isRetryable(error) {
156
+ return typeof error === 'object' && error !== null && 'retryable' in error && error.retryable === true;
157
+ }
158
+ function bounded(value, fallback, min, max, label) {
159
+ const resolved = value ?? fallback;
160
+ if (!Number.isInteger(resolved) || resolved < min || resolved > max)
161
+ throw new Error(`${label} must be an integer from ${min} through ${max}`);
162
+ return resolved;
163
+ }
164
+ function resourceMetric(value, label) {
165
+ if (value === undefined || value === null)
166
+ return null;
167
+ if (!Number.isFinite(value) || value < 0)
168
+ throw new Error(`${label} must be a finite non-negative number`);
169
+ return value;
170
+ }
171
+ //# sourceMappingURL=qualification.js.map