@tiangong-lca/cli 0.0.21 → 0.0.22

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,690 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { writeJsonArtifact } from './artifacts.js';
4
+ import { collectRemoteReferences } from './dataset-remote-verify.js';
5
+ import { CliError } from './errors.js';
6
+ import { isJsonObject, maintenanceRowKey, parseMaintenancePlan, readJsonFile, readJsonLinesIfPresent, resolveMaintenancePlanArtifactPath, sha256Json, snapshotRemoteRow, } from './dataset-maintenance-contract.js';
7
+ import { maintenanceProjectedReferenceFingerprint } from './dataset-maintenance-plan.js';
8
+ import { fetchMaintenanceAccountRows, fetchMaintenanceExactRows, normalizeMaintenancePageSize, resolveMaintenanceRemoteContext, } from './dataset-maintenance-remote.js';
9
+ const POSITIVE_INTEGER_TEXT = /^[1-9]\d*$/u;
10
+ function issue(code, message, action, details) {
11
+ return {
12
+ code,
13
+ message,
14
+ ...(action
15
+ ? {
16
+ action_id: action.action_id,
17
+ table: action.table,
18
+ id: action.id,
19
+ version: action.version,
20
+ }
21
+ : {}),
22
+ ...(details === undefined ? {} : { details }),
23
+ };
24
+ }
25
+ function desiredPayload(planDir, action) {
26
+ if (!action.desired_payload) {
27
+ return null;
28
+ }
29
+ const raw = readJsonFile(resolveMaintenancePlanArtifactPath(planDir, action.desired_payload.path, 'Maintenance desired payload path'), 'Maintenance desired payload');
30
+ return isJsonObject(raw) && sha256Json(raw) === action.desired_payload.sha256 ? raw : null;
31
+ }
32
+ function deletedTargetReferences(options) {
33
+ const payloadRows = options.rows
34
+ .filter((row) => row.json_ordered)
35
+ .map((row) => ({ ...row, json_ordered: row.json_ordered }));
36
+ const references = collectRemoteReferences(payloadRows).filter((reference) => reference.role === 'reference');
37
+ return references.flatMap((reference) => {
38
+ const source = payloadRows[reference.row_index];
39
+ if (!reference.table || !reference.id) {
40
+ return [];
41
+ }
42
+ return options.deletes
43
+ .filter((target) => target.table === reference.table &&
44
+ target.id === reference.id &&
45
+ (!reference.version || target.version === reference.version))
46
+ .map((target) => ({
47
+ target_action_id: target.action_id,
48
+ source_key: maintenanceRowKey(source),
49
+ path: reference.path,
50
+ }));
51
+ });
52
+ }
53
+ export async function runDatasetMaintenanceVerify(options) {
54
+ const planPath = path.resolve(options.planPath);
55
+ const planDir = path.dirname(planPath);
56
+ const outDir = path.resolve(options.outDir ?? path.join(planDir, 'verify'));
57
+ const reportPath = path.join(outDir, 'readback-verify-report.json');
58
+ const approvalRecordPath = path.join(planDir, 'approval-record.json');
59
+ const progressPath = path.join(planDir, 'apply-progress.jsonl');
60
+ const commitReportPath = path.join(planDir, 'commit-report.json');
61
+ const aliasPlanProgressPath = path.join(planDir, 'alias-plan-progress.jsonl');
62
+ const aliasBatchProgressPath = path.join(planDir, 'alias-batch-progress.jsonl');
63
+ const aliasExchangeProgressPath = path.join(planDir, 'alias-exchange-progress.jsonl');
64
+ const pageSize = normalizeMaintenancePageSize(options.pageSize);
65
+ const plan = parseMaintenancePlan(readJsonFile(planPath, 'Maintenance plan'));
66
+ const context = await resolveMaintenanceRemoteContext({
67
+ env: options.env,
68
+ fetchImpl: options.fetchImpl,
69
+ timeoutMs: options.timeoutMs,
70
+ now: options.now,
71
+ });
72
+ if (context.account.user_id !== plan.account.user_id ||
73
+ context.account.email !== plan.account.email) {
74
+ throw new CliError('Current authenticated account does not match the maintenance plan.', {
75
+ code: 'DATASET_MAINTENANCE_ACCOUNT_MISMATCH',
76
+ exitCode: 1,
77
+ });
78
+ }
79
+ const problems = [];
80
+ const current = await fetchMaintenanceAccountRows({
81
+ context,
82
+ userId: plan.account.user_id,
83
+ pageSize,
84
+ });
85
+ const currentByKey = new Map(current.rows.map((row) => [maintenanceRowKey(row), row]));
86
+ const actionChecks = [];
87
+ for (const action of plan.actions) {
88
+ const exact = await fetchMaintenanceExactRows({
89
+ context,
90
+ table: action.table,
91
+ id: action.id,
92
+ version: action.version,
93
+ });
94
+ if (action.action === 'delete') {
95
+ const passed = exact.rows.length === 0;
96
+ if (!passed) {
97
+ problems.push(issue('DELETE_TARGET_STILL_VISIBLE', 'Deleted target is still visible.', action));
98
+ }
99
+ actionChecks.push({
100
+ action_id: action.action_id,
101
+ status: passed ? 'passed' : 'failed',
102
+ observed: passed ? 'absent' : 'mismatch',
103
+ });
104
+ continue;
105
+ }
106
+ const payload = desiredPayload(planDir, action);
107
+ const row = exact.rows.length === 1 ? exact.rows[0] : null;
108
+ const snapshot = row ? snapshotRemoteRow(row) : null;
109
+ const passed = Boolean(row &&
110
+ payload &&
111
+ snapshot?.payload_sha256 === action.desired_payload?.sha256 &&
112
+ row.user_id === action.expected_user_id &&
113
+ row.state_code === 0 &&
114
+ row.model_id === action.before?.model_id &&
115
+ row.rule_verification === action.before?.rule_verification);
116
+ if (!passed) {
117
+ problems.push(issue('SAVE_DRAFT_READBACK_MISMATCH', 'Saved draft payload, owner, state, model_id, or rule_verification did not match the plan.', action));
118
+ }
119
+ actionChecks.push({
120
+ action_id: action.action_id,
121
+ status: passed ? 'passed' : 'failed',
122
+ observed: passed ? 'desired_payload' : 'mismatch',
123
+ });
124
+ }
125
+ const progress = readJsonLinesIfPresent(progressPath);
126
+ const actionsById = new Map(plan.actions.map((action) => [action.action_id, action]));
127
+ let aliasBatchSuccesses = 0;
128
+ let aliasExchangeLogs = 0;
129
+ let aliasPlanProofs = 0;
130
+ let aliasPlanProof = null;
131
+ const aliasBatchProofs = new Map();
132
+ if (plan.operation === 'merge-support-aliases') {
133
+ const orderedBatches = [
134
+ plan.alias_batches.find((batch) => batch.dimension === 'time'),
135
+ plan.alias_batches.find((batch) => batch.dimension === 'length_time'),
136
+ ];
137
+ let successfulPlanSeen = false;
138
+ for (const [index, entry] of readJsonLinesIfPresent(aliasPlanProgressPath).entries()) {
139
+ const batchProofs = isJsonObject(entry) && Array.isArray(entry.batches) ? entry.batches : [];
140
+ const validSuccessBatches = batchProofs.length === 2 &&
141
+ batchProofs.every((proof, batchIndex) => {
142
+ const batch = orderedBatches[batchIndex];
143
+ return Boolean(isJsonObject(proof) &&
144
+ proof.batch_id === batch.batch_id &&
145
+ proof.dimension === batch.dimension &&
146
+ typeof proof.batch_request_sha256 === 'string' &&
147
+ /^[a-f0-9]{64}$/u.test(proof.batch_request_sha256) &&
148
+ typeof proof.summary_audit_id === 'string' &&
149
+ POSITIVE_INTEGER_TEXT.test(proof.summary_audit_id));
150
+ });
151
+ const batchRequestHashes = batchProofs
152
+ .filter(isJsonObject)
153
+ .map((proof) => proof.batch_request_sha256);
154
+ const batchSummaryAuditIds = batchProofs
155
+ .filter(isJsonObject)
156
+ .map((proof) => proof.summary_audit_id);
157
+ const valid = Boolean(isJsonObject(entry) &&
158
+ entry.schema_version === 1 &&
159
+ entry.plan_sha256 === plan.plan_sha256 &&
160
+ entry.operation_id === plan.operation_id &&
161
+ entry.target_mode === 'owner_draft' &&
162
+ isJsonObject(entry.actor) &&
163
+ entry.actor.user_id === plan.account.user_id &&
164
+ entry.actor.email === plan.account.email &&
165
+ typeof entry.started_at_utc === 'string' &&
166
+ typeof entry.ended_at_utc === 'string' &&
167
+ entry.batch_count === 2 &&
168
+ entry.row_count === 52 &&
169
+ entry.exchange_count === 59 &&
170
+ (entry.result === 'success' || entry.result === 'failed') &&
171
+ (entry.result === 'success'
172
+ ? !successfulPlanSeen &&
173
+ typeof entry.plan_request_sha256 === 'string' &&
174
+ /^[a-f0-9]{64}$/u.test(entry.plan_request_sha256) &&
175
+ typeof entry.idempotent_replay === 'boolean' &&
176
+ typeof entry.summary_audit_id === 'string' &&
177
+ POSITIVE_INTEGER_TEXT.test(entry.summary_audit_id) &&
178
+ validSuccessBatches &&
179
+ new Set(batchRequestHashes).size === 2 &&
180
+ new Set(batchSummaryAuditIds).size === 2 &&
181
+ !batchSummaryAuditIds.includes(entry.summary_audit_id) &&
182
+ entry.error === null
183
+ : entry.plan_request_sha256 === null &&
184
+ entry.idempotent_replay === null &&
185
+ entry.summary_audit_id === null &&
186
+ batchProofs.length === 0 &&
187
+ typeof entry.error === 'string'));
188
+ if (!valid) {
189
+ problems.push({
190
+ code: 'ALIAS_PLAN_PROGRESS_INVALID',
191
+ message: 'alias-plan-progress.jsonl contains an invalid or foreign entry.',
192
+ details: { line: index + 1 },
193
+ });
194
+ }
195
+ else if (isJsonObject(entry) && entry.result === 'success') {
196
+ successfulPlanSeen = true;
197
+ aliasPlanProof = {
198
+ plan_request_sha256: entry.plan_request_sha256,
199
+ summary_audit_id: entry.summary_audit_id,
200
+ batches: new Map(batchProofs.map((proof) => {
201
+ const value = proof;
202
+ return [
203
+ value.batch_id,
204
+ {
205
+ batch_request_sha256: value.batch_request_sha256,
206
+ summary_audit_id: value.summary_audit_id,
207
+ },
208
+ ];
209
+ })),
210
+ };
211
+ }
212
+ }
213
+ if (!aliasPlanProof) {
214
+ problems.push({
215
+ code: 'ALIAS_PLAN_SUCCESS_LOG_MISSING',
216
+ message: 'No successful whole-plan progress proof exists.',
217
+ });
218
+ }
219
+ else {
220
+ aliasPlanProofs = 1;
221
+ }
222
+ for (const batch of plan.alias_batches) {
223
+ for (const snapshot of [
224
+ batch.target_snapshots.unitgroup,
225
+ batch.target_snapshots.flowproperty,
226
+ batch.target_snapshots.source_unitgroup,
227
+ ]) {
228
+ const exact = await fetchMaintenanceExactRows({
229
+ context,
230
+ table: snapshot.table,
231
+ id: snapshot.id,
232
+ version: snapshot.version,
233
+ });
234
+ const row = exact.rows.length === 1 ? exact.rows[0] : null;
235
+ if (!row ||
236
+ row.user_id !== plan.account.user_id ||
237
+ row.state_code !== 0 ||
238
+ snapshotRemoteRow(row).row_sha256 !== snapshot.row_sha256) {
239
+ problems.push({
240
+ code: 'ALIAS_SUPPORT_READBACK_MISMATCH',
241
+ message: 'Alias target/source support snapshot changed after planning.',
242
+ table: snapshot.table,
243
+ id: snapshot.id,
244
+ version: snapshot.version,
245
+ details: { batch_id: batch.batch_id },
246
+ });
247
+ }
248
+ }
249
+ }
250
+ const batchesById = new Map(plan.alias_batches.map((batch) => [batch.batch_id, batch]));
251
+ const successfulBatches = new Set();
252
+ for (const [index, entry] of readJsonLinesIfPresent(aliasBatchProgressPath).entries()) {
253
+ const batch = isJsonObject(entry) && typeof entry.batch_id === 'string'
254
+ ? batchesById.get(entry.batch_id)
255
+ : null;
256
+ const planBatchProof = batch ? aliasPlanProof?.batches.get(batch.batch_id) : null;
257
+ const valid = Boolean(isJsonObject(entry) &&
258
+ entry.schema_version === 1 &&
259
+ entry.plan_sha256 === plan.plan_sha256 &&
260
+ entry.operation_id === plan.operation_id &&
261
+ entry.target_mode === 'owner_draft' &&
262
+ batch &&
263
+ entry.dimension === batch.dimension &&
264
+ entry.factor === batch.factor &&
265
+ entry.row_count === batch.summary.rows &&
266
+ entry.exchange_count === batch.summary.exchanges &&
267
+ isJsonObject(entry.actor) &&
268
+ entry.actor.user_id === plan.account.user_id &&
269
+ entry.actor.email === plan.account.email &&
270
+ typeof entry.started_at_utc === 'string' &&
271
+ typeof entry.ended_at_utc === 'string' &&
272
+ entry.result === 'success' &&
273
+ !successfulBatches.has(batch.batch_id) &&
274
+ planBatchProof &&
275
+ typeof entry.batch_request_sha256 === 'string' &&
276
+ /^[a-f0-9]{64}$/u.test(entry.batch_request_sha256) &&
277
+ entry.batch_request_sha256 === planBatchProof.batch_request_sha256 &&
278
+ typeof entry.idempotent_replay === 'boolean' &&
279
+ typeof entry.summary_audit_id === 'string' &&
280
+ POSITIVE_INTEGER_TEXT.test(entry.summary_audit_id) &&
281
+ entry.summary_audit_id === planBatchProof.summary_audit_id &&
282
+ entry.plan_request_sha256 === aliasPlanProof?.plan_request_sha256 &&
283
+ entry.plan_summary_audit_id === aliasPlanProof?.summary_audit_id &&
284
+ entry.error === null);
285
+ if (!valid) {
286
+ problems.push({
287
+ code: 'ALIAS_BATCH_PROGRESS_INVALID',
288
+ message: 'alias-batch-progress.jsonl contains an invalid or foreign entry.',
289
+ details: { line: index + 1 },
290
+ });
291
+ }
292
+ else if (isJsonObject(entry) && entry.result === 'success' && batch) {
293
+ successfulBatches.add(batch.batch_id);
294
+ aliasBatchProofs.set(batch.batch_id, {
295
+ batch_request_sha256: entry.batch_request_sha256,
296
+ summary_audit_id: entry.summary_audit_id,
297
+ plan_request_sha256: entry.plan_request_sha256,
298
+ plan_summary_audit_id: entry.plan_summary_audit_id,
299
+ });
300
+ }
301
+ }
302
+ for (const batch of plan.alias_batches) {
303
+ if (!successfulBatches.has(batch.batch_id)) {
304
+ problems.push({
305
+ code: 'ALIAS_BATCH_SUCCESS_LOG_MISSING',
306
+ message: 'No successful atomic batch progress entry exists.',
307
+ details: { batch_id: batch.batch_id },
308
+ });
309
+ }
310
+ }
311
+ aliasBatchSuccesses = successfulBatches.size;
312
+ const expectedRewrites = new Map(plan.alias_batches.flatMap((batch) => batch.exchange_rewrites.map((rewrite) => [
313
+ `${batch.batch_id}\u0000${rewrite.action_id}\u0000${rewrite.exchange_index}\u0000${rewrite.data_set_internal_id}`,
314
+ { batch, rewrite },
315
+ ])));
316
+ const loggedKeys = new Set();
317
+ for (const [index, entry] of readJsonLinesIfPresent(aliasExchangeProgressPath).entries()) {
318
+ const key = isJsonObject(entry) &&
319
+ typeof entry.batch_id === 'string' &&
320
+ typeof entry.action_id === 'string' &&
321
+ typeof entry.exchange_index === 'number' &&
322
+ typeof entry.data_set_internal_id === 'string'
323
+ ? `${entry.batch_id}\u0000${entry.action_id}\u0000${entry.exchange_index}\u0000${entry.data_set_internal_id}`
324
+ : '';
325
+ const expected = expectedRewrites.get(key);
326
+ const batchProof = expected ? aliasBatchProofs.get(expected.batch.batch_id) : null;
327
+ const action = expected ? actionsById.get(expected.rewrite.action_id) : null;
328
+ const rowProofCandidates = expected
329
+ ? progress.filter((candidate) => isJsonObject(candidate) &&
330
+ candidate.schema_version === 1 &&
331
+ candidate.plan_sha256 === plan.plan_sha256 &&
332
+ candidate.operation_id === plan.operation_id &&
333
+ candidate.action_id === expected.rewrite.action_id &&
334
+ candidate.action === 'update_json_ordered' &&
335
+ candidate.table === action?.table &&
336
+ candidate.id === action?.id &&
337
+ candidate.version === action?.version &&
338
+ candidate.batch_id === expected.batch.batch_id &&
339
+ candidate.batch_request_sha256 === batchProof?.batch_request_sha256 &&
340
+ candidate.summary_audit_id === batchProof?.summary_audit_id &&
341
+ typeof candidate.database_audit_id === 'string' &&
342
+ POSITIVE_INTEGER_TEXT.test(candidate.database_audit_id) &&
343
+ candidate.result === 'success')
344
+ : [];
345
+ const rowProof = rowProofCandidates.length === 1 ? rowProofCandidates[0] : null;
346
+ const rowAuditId = isJsonObject(rowProof) && typeof rowProof.database_audit_id === 'string'
347
+ ? rowProof.database_audit_id
348
+ : null;
349
+ const valid = Boolean(isJsonObject(entry) &&
350
+ expected &&
351
+ batchProof &&
352
+ rowProof &&
353
+ entry.schema_version === 1 &&
354
+ entry.plan_sha256 === plan.plan_sha256 &&
355
+ entry.operation_id === plan.operation_id &&
356
+ entry.target_mode === 'owner_draft' &&
357
+ entry.factor === expected.batch.factor &&
358
+ entry.result === 'success' &&
359
+ typeof entry.batch_request_sha256 === 'string' &&
360
+ /^[a-f0-9]{64}$/u.test(entry.batch_request_sha256) &&
361
+ entry.batch_request_sha256 === batchProof.batch_request_sha256 &&
362
+ typeof entry.database_audit_id === 'string' &&
363
+ POSITIVE_INTEGER_TEXT.test(entry.database_audit_id) &&
364
+ entry.database_audit_id === rowAuditId &&
365
+ entry.summary_audit_id === batchProof.summary_audit_id &&
366
+ entry.plan_request_sha256 === batchProof.plan_request_sha256 &&
367
+ entry.plan_request_sha256 === aliasPlanProof?.plan_request_sha256 &&
368
+ entry.plan_summary_audit_id === batchProof.plan_summary_audit_id &&
369
+ entry.plan_summary_audit_id === aliasPlanProof?.summary_audit_id &&
370
+ isJsonObject(entry.actor) &&
371
+ entry.actor.user_id === plan.account.user_id &&
372
+ entry.actor.email === plan.account.email &&
373
+ typeof entry.logged_at_utc === 'string' &&
374
+ sha256Json({
375
+ action_id: entry.action_id,
376
+ process_id: entry.process_id,
377
+ process_version: entry.process_version,
378
+ exchange_index: entry.exchange_index,
379
+ data_set_internal_id: entry.data_set_internal_id,
380
+ flow_id: entry.flow_id,
381
+ flow_version: entry.flow_version,
382
+ direction: entry.direction,
383
+ before_exchange_sha256: entry.before_exchange_sha256,
384
+ before_mean_amount: entry.before_mean_amount,
385
+ before_resulting_amount: entry.before_resulting_amount,
386
+ after_mean_amount: entry.after_mean_amount,
387
+ after_resulting_amount: entry.after_resulting_amount,
388
+ after_exchange_sha256: entry.after_exchange_sha256,
389
+ }) === sha256Json(expected.rewrite) &&
390
+ !loggedKeys.has(key));
391
+ if (!valid) {
392
+ problems.push({
393
+ code: 'ALIAS_EXCHANGE_PROGRESS_INVALID',
394
+ message: 'alias-exchange-progress.jsonl contains an invalid, duplicate, or foreign entry.',
395
+ details: { line: index + 1 },
396
+ });
397
+ }
398
+ else {
399
+ loggedKeys.add(key);
400
+ }
401
+ }
402
+ for (const key of expectedRewrites.keys()) {
403
+ if (!loggedKeys.has(key)) {
404
+ problems.push({
405
+ code: 'ALIAS_EXCHANGE_SUCCESS_LOG_MISSING',
406
+ message: 'An approved exchange rewrite lacks a durable success entry.',
407
+ details: { key },
408
+ });
409
+ }
410
+ }
411
+ aliasExchangeLogs = loggedKeys.size;
412
+ }
413
+ let protectedPassed = 0;
414
+ for (const protectedRow of plan.protected_rows) {
415
+ const row = currentByKey.get(maintenanceRowKey(protectedRow));
416
+ if (row && snapshotRemoteRow(row).row_sha256 === protectedRow.row_sha256) {
417
+ protectedPassed += 1;
418
+ }
419
+ else {
420
+ problems.push({
421
+ code: 'PROTECTED_ROW_CHANGED',
422
+ message: 'Protected row changed or disappeared after maintenance.',
423
+ table: protectedRow.table,
424
+ id: protectedRow.id,
425
+ version: protectedRow.version,
426
+ });
427
+ }
428
+ }
429
+ const expectedFinalKeys = new Set([
430
+ ...plan.protected_rows.map(maintenanceRowKey),
431
+ ...plan.actions.filter((action) => action.action !== 'delete').map(maintenanceRowKey),
432
+ ]);
433
+ for (const row of current.rows) {
434
+ if (!expectedFinalKeys.has(maintenanceRowKey(row))) {
435
+ problems.push({
436
+ code: 'UNEXPECTED_ACCOUNT_ROW',
437
+ message: 'Unexpected current-account row exists after maintenance.',
438
+ table: row.table,
439
+ id: row.id,
440
+ version: row.version,
441
+ });
442
+ }
443
+ }
444
+ const referenceSha256 = sha256Json(maintenanceProjectedReferenceFingerprint(current.rows));
445
+ if (referenceSha256 !== plan.projected_reference_sha256) {
446
+ problems.push({
447
+ code: 'PROJECTED_REFERENCE_CLOSURE_MISMATCH',
448
+ message: 'Readback reference closure differs from the approved plan.',
449
+ details: { expected: plan.projected_reference_sha256, actual: referenceSha256 },
450
+ });
451
+ }
452
+ const danglingReferences = deletedTargetReferences({
453
+ rows: current.rows,
454
+ deletes: plan.actions.filter((action) => action.action === 'delete'),
455
+ });
456
+ if (danglingReferences.length) {
457
+ problems.push({
458
+ code: 'DELETED_TARGET_REFERENCED',
459
+ message: 'Readback contains references to a deleted target.',
460
+ details: danglingReferences,
461
+ });
462
+ }
463
+ if (!existsSync(approvalRecordPath)) {
464
+ problems.push({
465
+ code: 'APPROVAL_RECORD_MISSING',
466
+ message: 'approval-record.json is missing.',
467
+ });
468
+ }
469
+ else {
470
+ const approvalRecord = readJsonFile(approvalRecordPath, 'Maintenance approval record');
471
+ if (!isJsonObject(approvalRecord) ||
472
+ approvalRecord.schema_version !== 1 ||
473
+ approvalRecord.plan_sha256 !== plan.plan_sha256 ||
474
+ approvalRecord.task_id !== plan.task_id ||
475
+ approvalRecord.operation !== plan.operation ||
476
+ approvalRecord.operation_id !== plan.operation_id ||
477
+ approvalRecord.target_mode !== plan.target_mode ||
478
+ !isJsonObject(approvalRecord.account) ||
479
+ approvalRecord.account.user_id !== plan.account.user_id ||
480
+ approvalRecord.account.email !== plan.account.email ||
481
+ approvalRecord.confirmed_email !== plan.account.email ||
482
+ !isJsonObject(approvalRecord.row_counts) ||
483
+ sha256Json(approvalRecord.row_counts) !== sha256Json(plan.summary)) {
484
+ problems.push({
485
+ code: 'APPROVAL_RECORD_INVALID',
486
+ message: 'approval-record.json does not match the immutable plan and actor.',
487
+ });
488
+ }
489
+ }
490
+ const successfulActionIds = new Set();
491
+ for (const [index, entry] of progress.entries()) {
492
+ const action = isJsonObject(entry) && typeof entry.action_id === 'string'
493
+ ? actionsById.get(entry.action_id)
494
+ : null;
495
+ const aliasProof = action?.batch_id ? aliasBatchProofs.get(action.batch_id) : null;
496
+ const valid = Boolean(isJsonObject(entry) &&
497
+ entry.schema_version === 1 &&
498
+ action &&
499
+ entry.plan_sha256 === plan.plan_sha256 &&
500
+ entry.operation_id === plan.operation_id &&
501
+ entry.action === action.action &&
502
+ entry.table === action.table &&
503
+ entry.id === action.id &&
504
+ entry.version === action.version &&
505
+ entry.reason_code === action.reason_code &&
506
+ entry.before_sha256 === action.before?.row_sha256 &&
507
+ typeof entry.started_at_utc === 'string' &&
508
+ typeof entry.ended_at_utc === 'string' &&
509
+ isJsonObject(entry.actor) &&
510
+ entry.actor.user_id === plan.account.user_id &&
511
+ entry.actor.email === plan.account.email &&
512
+ isJsonObject(entry.audit_context) &&
513
+ entry.audit_context.plan_sha256 === plan.plan_sha256 &&
514
+ entry.audit_context.operation_id === plan.operation_id &&
515
+ entry.audit_context.action_id === action.action_id &&
516
+ entry.audit_context.reason_code === action.reason_code &&
517
+ entry.audit_context.source === 'tiangong-lca dataset maintenance apply' &&
518
+ (action.action !== 'update_json_ordered' ||
519
+ (aliasProof &&
520
+ entry.target_mode === 'owner_draft' &&
521
+ entry.audit_context.target_mode === 'owner_draft' &&
522
+ entry.batch_id === action.batch_id &&
523
+ typeof entry.batch_request_sha256 === 'string' &&
524
+ /^[a-f0-9]{64}$/u.test(entry.batch_request_sha256) &&
525
+ entry.batch_request_sha256 === aliasProof.batch_request_sha256 &&
526
+ typeof entry.database_audit_id === 'string' &&
527
+ POSITIVE_INTEGER_TEXT.test(entry.database_audit_id) &&
528
+ entry.summary_audit_id === aliasProof.summary_audit_id &&
529
+ entry.plan_request_sha256 === aliasProof.plan_request_sha256 &&
530
+ entry.plan_request_sha256 === aliasPlanProof?.plan_request_sha256 &&
531
+ entry.plan_summary_audit_id === aliasProof.plan_summary_audit_id &&
532
+ entry.plan_summary_audit_id === aliasPlanProof?.summary_audit_id)) &&
533
+ (action.action === 'update_json_ordered' ||
534
+ (!('target_mode' in entry) &&
535
+ !('target_mode' in entry.audit_context) &&
536
+ !('batch_id' in entry) &&
537
+ !('batch_request_sha256' in entry) &&
538
+ !('database_audit_id' in entry) &&
539
+ !('summary_audit_id' in entry) &&
540
+ !('plan_request_sha256' in entry) &&
541
+ !('plan_summary_audit_id' in entry))) &&
542
+ isJsonObject(entry.rollback) &&
543
+ sha256Json(entry.rollback) === sha256Json(action.rollback) &&
544
+ (entry.result === 'success' || entry.result === 'failed') &&
545
+ (entry.result === 'success'
546
+ ? typeof entry.remote_result_sha256 === 'string' &&
547
+ entry.error === null &&
548
+ (action.action === 'delete'
549
+ ? entry.after_sha256 === null
550
+ : typeof entry.after_sha256 === 'string')
551
+ : entry.remote_result_sha256 === null &&
552
+ entry.after_sha256 === null &&
553
+ typeof entry.error === 'string'));
554
+ if (!valid) {
555
+ problems.push({
556
+ code: 'APPLY_PROGRESS_ENTRY_INVALID',
557
+ message: 'apply-progress.jsonl contains an invalid or foreign entry.',
558
+ details: { line: index + 1, action_id: action?.action_id ?? null },
559
+ });
560
+ continue;
561
+ }
562
+ if (isJsonObject(entry) && entry.result === 'success' && action) {
563
+ if (successfulActionIds.has(action.action_id)) {
564
+ problems.push({
565
+ code: 'APPLY_PROGRESS_SUCCESS_DUPLICATE',
566
+ message: 'apply-progress.jsonl contains more than one success proof for an action.',
567
+ details: { line: index + 1, action_id: action.action_id },
568
+ });
569
+ continue;
570
+ }
571
+ successfulActionIds.add(action.action_id);
572
+ }
573
+ }
574
+ for (const action of plan.actions) {
575
+ if (!successfulActionIds.has(action.action_id)) {
576
+ problems.push(issue('ACTION_SUCCESS_LOG_MISSING', 'No successful apply-progress entry exists.', action));
577
+ }
578
+ }
579
+ if (!existsSync(commitReportPath)) {
580
+ problems.push({ code: 'COMMIT_REPORT_MISSING', message: 'commit-report.json is missing.' });
581
+ }
582
+ else {
583
+ const commitReport = readJsonFile(commitReportPath, 'Maintenance commit report');
584
+ const commitActions = isJsonObject(commitReport) && Array.isArray(commitReport.actions) ? commitReport.actions : [];
585
+ const commitActionsById = new Map(commitActions
586
+ .filter((entry) => isJsonObject(entry) && typeof entry.action_id === 'string')
587
+ .map((entry) => [entry.action_id, entry]));
588
+ const commitActionsMatch = commitActions.length === plan.actions.length &&
589
+ commitActionsById.size === plan.actions.length &&
590
+ plan.actions.every((action) => {
591
+ const entry = commitActionsById.get(action.action_id);
592
+ return Boolean(entry &&
593
+ entry.action === action.action &&
594
+ entry.table === action.table &&
595
+ entry.id === action.id &&
596
+ entry.version === action.version &&
597
+ entry.status === 'success' &&
598
+ entry.error === null);
599
+ });
600
+ const aliasCommitProofMatches = plan.operation !== 'merge-support-aliases' ||
601
+ Boolean(isJsonObject(commitReport) &&
602
+ isJsonObject(commitReport.alias_plan_proof) &&
603
+ aliasPlanProof &&
604
+ commitReport.alias_plan_proof.plan_request_sha256 === aliasPlanProof.plan_request_sha256 &&
605
+ commitReport.alias_plan_proof.summary_audit_id === aliasPlanProof.summary_audit_id &&
606
+ commitReport.alias_plan_proof.batch_count === 2 &&
607
+ commitReport.alias_plan_proof.row_count === 52 &&
608
+ commitReport.alias_plan_proof.exchange_count === 59 &&
609
+ typeof commitReport.alias_plan_proof.idempotent_replay === 'boolean' &&
610
+ isJsonObject(commitReport.artifacts) &&
611
+ commitReport.artifacts.alias_plan_progress === aliasPlanProgressPath &&
612
+ commitReport.artifacts.alias_batch_progress === aliasBatchProgressPath &&
613
+ commitReport.artifacts.alias_exchange_progress === aliasExchangeProgressPath);
614
+ if (!isJsonObject(commitReport) ||
615
+ commitReport.schema_version !== 1 ||
616
+ commitReport.plan_sha256 !== plan.plan_sha256 ||
617
+ commitReport.task_id !== plan.task_id ||
618
+ commitReport.operation !== plan.operation ||
619
+ commitReport.operation_id !== plan.operation_id ||
620
+ commitReport.target_mode !== plan.target_mode ||
621
+ commitReport.status !== 'completed' ||
622
+ !isJsonObject(commitReport.actor) ||
623
+ commitReport.actor.user_id !== plan.account.user_id ||
624
+ commitReport.actor.email !== plan.account.email ||
625
+ !isJsonObject(commitReport.summary) ||
626
+ commitReport.summary.actions !== plan.actions.length ||
627
+ commitReport.summary.success !== plan.actions.length ||
628
+ commitReport.summary.failed !== 0 ||
629
+ commitReport.summary.pending !== 0 ||
630
+ !commitActionsMatch ||
631
+ !aliasCommitProofMatches) {
632
+ problems.push({
633
+ code: 'COMMIT_REPORT_INCOMPLETE',
634
+ message: 'commit-report.json does not prove full successful completion for this plan.',
635
+ });
636
+ }
637
+ }
638
+ const report = {
639
+ schema_version: 1,
640
+ generated_at_utc: (options.now ?? new Date()).toISOString(),
641
+ status: problems.length ? 'failed' : 'passed',
642
+ task_id: plan.task_id,
643
+ operation: plan.operation,
644
+ operation_id: plan.operation_id,
645
+ target_mode: plan.target_mode,
646
+ plan_sha256: plan.plan_sha256,
647
+ actor: { user_id: context.account.user_id, email: context.account.email },
648
+ summary: {
649
+ actions: plan.actions.length,
650
+ action_checks_passed: actionChecks.filter((check) => check.status === 'passed').length,
651
+ protected_rows: plan.protected_rows.length,
652
+ protected_checks_passed: protectedPassed,
653
+ progress_successes: successfulActionIds.size,
654
+ ...(plan.operation === 'merge-support-aliases'
655
+ ? {
656
+ atomic_plan_proofs: aliasPlanProofs,
657
+ atomic_batches: plan.alias_batches.length,
658
+ atomic_batch_successes: aliasBatchSuccesses,
659
+ exchange_rewrite_logs: aliasExchangeLogs,
660
+ }
661
+ : {}),
662
+ dangling_deleted_target_references: danglingReferences.length,
663
+ issues: problems.length,
664
+ },
665
+ action_checks: actionChecks,
666
+ issues: problems,
667
+ artifacts: {
668
+ plan: planPath,
669
+ approval_record: approvalRecordPath,
670
+ apply_progress: progressPath,
671
+ commit_report: commitReportPath,
672
+ ...(plan.operation === 'merge-support-aliases'
673
+ ? {
674
+ alias_plan_progress: aliasPlanProgressPath,
675
+ alias_batch_progress: aliasBatchProgressPath,
676
+ alias_exchange_progress: aliasExchangeProgressPath,
677
+ }
678
+ : {}),
679
+ report: reportPath,
680
+ },
681
+ };
682
+ writeJsonArtifact(reportPath, report);
683
+ return report;
684
+ }
685
+ export const __testInternals = {
686
+ deletedTargetReferences,
687
+ desiredPayload,
688
+ issue,
689
+ };
690
+ //# sourceMappingURL=dataset-maintenance-verify.js.map