@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,836 @@
1
+ import crypto from 'node:crypto';
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { inspectMaintenanceSupportPayload } from './dataset-maintenance-support-validation.js';
5
+ import { CliError } from './errors.js';
6
+ export const MAINTENANCE_MUTABLE_TABLES = ['contacts', 'sources', 'flows', 'processes'];
7
+ export const MAINTENANCE_SUPPORT_TABLES = ['unitgroups', 'flowproperties'];
8
+ export const MAINTENANCE_SCAN_TABLES = [
9
+ ...MAINTENANCE_MUTABLE_TABLES,
10
+ 'lifecyclemodels',
11
+ ...MAINTENANCE_SUPPORT_TABLES,
12
+ ];
13
+ function token(value) {
14
+ if (typeof value !== 'string') {
15
+ return null;
16
+ }
17
+ const normalized = value.trim();
18
+ return normalized || null;
19
+ }
20
+ export function isJsonObject(value) {
21
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
22
+ }
23
+ export function stableJsonValue(value) {
24
+ if (Array.isArray(value)) {
25
+ return value.map(stableJsonValue);
26
+ }
27
+ if (isJsonObject(value)) {
28
+ return Object.fromEntries(Object.keys(value)
29
+ .sort()
30
+ .map((key) => [key, stableJsonValue(value[key])]));
31
+ }
32
+ return value;
33
+ }
34
+ export function stableJsonText(value) {
35
+ return JSON.stringify(stableJsonValue(value));
36
+ }
37
+ export function sha256Text(value) {
38
+ return crypto.createHash('sha256').update(value).digest('hex');
39
+ }
40
+ export function sha256Json(value) {
41
+ return sha256Text(stableJsonText(value));
42
+ }
43
+ export function snapshotRemoteRow(row) {
44
+ return {
45
+ ...row,
46
+ row_sha256: sha256Json(row),
47
+ payload_sha256: row.json_ordered ? sha256Json(row.json_ordered) : null,
48
+ };
49
+ }
50
+ export function maintenanceRowKey(row) {
51
+ return `${row.table}\u0000${row.id}\u0000${row.version}`;
52
+ }
53
+ function requireToken(value, label) {
54
+ const normalized = token(value);
55
+ if (!normalized) {
56
+ throw new CliError(`Maintenance scope ${label} must be a non-empty string.`, {
57
+ code: 'DATASET_MAINTENANCE_SCOPE_INVALID',
58
+ exitCode: 2,
59
+ details: { field: label },
60
+ });
61
+ }
62
+ return normalized;
63
+ }
64
+ function parseEntityRef(value, label) {
65
+ if (!isJsonObject(value)) {
66
+ throw new CliError(`Maintenance scope ${label} must be an object.`, {
67
+ code: 'DATASET_MAINTENANCE_SCOPE_INVALID',
68
+ exitCode: 2,
69
+ });
70
+ }
71
+ return {
72
+ id: requireToken(value.id, `${label}.id`),
73
+ version: requireToken(value.version, `${label}.version`),
74
+ };
75
+ }
76
+ function parseAliasBatch(value, index) {
77
+ const label = `alias_batches[${index}]`;
78
+ if (!isJsonObject(value) || !isJsonObject(value.source) || !isJsonObject(value.target)) {
79
+ throw new CliError(`Maintenance scope ${label} must include source and target objects.`, {
80
+ code: 'DATASET_MAINTENANCE_SCOPE_INVALID',
81
+ exitCode: 2,
82
+ });
83
+ }
84
+ const dimension = requireToken(value.dimension, `${label}.dimension`);
85
+ const factors = {
86
+ time: '0.00011415525114155251',
87
+ length_time: '1000',
88
+ };
89
+ if (!(dimension in factors)) {
90
+ throw new CliError(`Unsupported alias-rewrite dimension: ${dimension}`, {
91
+ code: 'DATASET_MAINTENANCE_ALIAS_DIMENSION_INVALID',
92
+ exitCode: 2,
93
+ });
94
+ }
95
+ const factor = requireToken(value.factor, `${label}.factor`);
96
+ if (factor !== factors[dimension]) {
97
+ throw new CliError(`Alias-rewrite factor does not match dimension ${dimension}.`, {
98
+ code: 'DATASET_MAINTENANCE_ALIAS_FACTOR_INVALID',
99
+ exitCode: 2,
100
+ });
101
+ }
102
+ return {
103
+ batch_id: requireToken(value.batch_id, `${label}.batch_id`),
104
+ dimension: dimension,
105
+ factor,
106
+ source: {
107
+ unitgroup: parseEntityRef(value.source.unitgroup, `${label}.source.unitgroup`),
108
+ flowproperty: parseEntityRef(value.source.flowproperty, `${label}.source.flowproperty`),
109
+ },
110
+ target: {
111
+ unitgroup: parseEntityRef(value.target.unitgroup, `${label}.target.unitgroup`),
112
+ flowproperty: parseEntityRef(value.target.flowproperty, `${label}.target.flowproperty`),
113
+ },
114
+ };
115
+ }
116
+ function parseExchangeInstance(value, actionIndex, exchangeIndex) {
117
+ const label = `actions[${actionIndex}].exchange_instances[${exchangeIndex}]`;
118
+ if (!isJsonObject(value) ||
119
+ typeof value.exchange_index !== 'number' ||
120
+ !Number.isInteger(value.exchange_index) ||
121
+ value.exchange_index < 0) {
122
+ throw new CliError(`Maintenance scope ${label}.exchange_index must be a non-negative integer.`, {
123
+ code: 'DATASET_MAINTENANCE_SCOPE_INVALID',
124
+ exitCode: 2,
125
+ });
126
+ }
127
+ const beforeExchangeSha256 = requireToken(value.before_exchange_sha256, `${label}.before_exchange_sha256`);
128
+ if (!/^[a-f0-9]{64}$/u.test(beforeExchangeSha256)) {
129
+ throw new CliError(`Maintenance scope ${label}.before_exchange_sha256 is invalid.`, {
130
+ code: 'DATASET_MAINTENANCE_SCOPE_INVALID',
131
+ exitCode: 2,
132
+ });
133
+ }
134
+ const direction = requireToken(value.direction, `${label}.direction`);
135
+ if (!['Input', 'Output'].includes(direction)) {
136
+ throw new CliError(`Maintenance scope ${label}.direction must be Input or Output.`, {
137
+ code: 'DATASET_MAINTENANCE_SCOPE_INVALID',
138
+ exitCode: 2,
139
+ });
140
+ }
141
+ return {
142
+ exchange_index: value.exchange_index,
143
+ data_set_internal_id: requireToken(value.data_set_internal_id, `${label}.data_set_internal_id`),
144
+ flow_id: requireToken(value.flow_id, `${label}.flow_id`),
145
+ flow_version: requireToken(value.flow_version, `${label}.flow_version`),
146
+ direction: direction,
147
+ before_exchange_sha256: beforeExchangeSha256,
148
+ before_mean_amount: requireToken(value.before_mean_amount, `${label}.before_mean_amount`),
149
+ before_resulting_amount: requireToken(value.before_resulting_amount, `${label}.before_resulting_amount`),
150
+ };
151
+ }
152
+ function parseAction(value, index, accountUserId, operation) {
153
+ if (!isJsonObject(value)) {
154
+ throw new CliError(`Maintenance scope action ${index} must be an object.`, {
155
+ code: 'DATASET_MAINTENANCE_SCOPE_INVALID',
156
+ exitCode: 2,
157
+ });
158
+ }
159
+ const action = requireToken(value.action, `actions[${index}].action`);
160
+ const table = requireToken(value.table, `actions[${index}].table`);
161
+ if (!['save_draft', 'delete', 'update_json_ordered'].includes(action)) {
162
+ throw new CliError(`Unsupported maintenance action: ${action}`, {
163
+ code: 'DATASET_MAINTENANCE_ACTION_UNSUPPORTED',
164
+ exitCode: 2,
165
+ });
166
+ }
167
+ const aliasAction = action === 'update_json_ordered';
168
+ const allowedTable = aliasAction
169
+ ? ['flowproperties', 'flows', 'processes'].includes(table)
170
+ : MAINTENANCE_MUTABLE_TABLES.includes(table);
171
+ if (!allowedTable) {
172
+ throw new CliError(`Maintenance cannot mutate protected or unsupported dataset table: ${table}`, {
173
+ code: 'DATASET_MAINTENANCE_TABLE_PROTECTED',
174
+ exitCode: 2,
175
+ });
176
+ }
177
+ if ((operation === 'merge-support-aliases' && !aliasAction) ||
178
+ (operation !== 'merge-support-aliases' && aliasAction)) {
179
+ throw new CliError(`Maintenance operation ${operation} cannot contain ${action} actions.`, {
180
+ code: 'DATASET_MAINTENANCE_OPERATION_ACTION_MISMATCH',
181
+ exitCode: 2,
182
+ });
183
+ }
184
+ if (value.expected_state_code !== 0) {
185
+ throw new CliError(`Maintenance action ${index} must require expected_state_code=0.`, {
186
+ code: 'DATASET_MAINTENANCE_NON_DRAFT_FORBIDDEN',
187
+ exitCode: 2,
188
+ });
189
+ }
190
+ const expectedUserId = requireToken(value.expected_user_id, `actions[${index}].expected_user_id`);
191
+ if (expectedUserId !== accountUserId) {
192
+ throw new CliError(`Maintenance action ${index} owner does not match scope account.`, {
193
+ code: 'DATASET_MAINTENANCE_SCOPE_OWNER_MISMATCH',
194
+ exitCode: 2,
195
+ });
196
+ }
197
+ if (!Array.isArray(value.evidence)) {
198
+ throw new CliError(`Maintenance action ${index} evidence must be an array.`, {
199
+ code: 'DATASET_MAINTENANCE_SCOPE_INVALID',
200
+ exitCode: 2,
201
+ });
202
+ }
203
+ const desiredPayloadPath = token(value.desired_payload_path);
204
+ if (action === 'save_draft' && !desiredPayloadPath) {
205
+ throw new CliError(`Maintenance save_draft action ${index} requires desired_payload_path.`, {
206
+ code: 'DATASET_MAINTENANCE_DESIRED_PAYLOAD_REQUIRED',
207
+ exitCode: 2,
208
+ });
209
+ }
210
+ if (action !== 'save_draft' && desiredPayloadPath) {
211
+ throw new CliError(`Maintenance ${action} action ${index} cannot include desired_payload_path.`, {
212
+ code: 'DATASET_MAINTENANCE_DESIRED_PAYLOAD_FORBIDDEN',
213
+ exitCode: 2,
214
+ });
215
+ }
216
+ const batchId = token(value.batch_id);
217
+ if (aliasAction !== Boolean(batchId)) {
218
+ throw new CliError(aliasAction
219
+ ? `Maintenance alias action ${index} requires batch_id.`
220
+ : `Maintenance non-alias action ${index} cannot include batch_id.`, {
221
+ code: 'DATASET_MAINTENANCE_ALIAS_BATCH_BINDING_INVALID',
222
+ exitCode: 2,
223
+ });
224
+ }
225
+ const exchangeInstances = Array.isArray(value.exchange_instances)
226
+ ? value.exchange_instances.map((entry, exchangeIndex) => parseExchangeInstance(entry, index, exchangeIndex))
227
+ : [];
228
+ if (aliasAction &&
229
+ ((table === 'processes' && exchangeInstances.length === 0) ||
230
+ (table !== 'processes' && exchangeInstances.length > 0))) {
231
+ throw new CliError(`Alias action ${index} exchange_instances must be non-empty only for processes.`, {
232
+ code: 'DATASET_MAINTENANCE_ALIAS_EXCHANGE_SCOPE_INVALID',
233
+ exitCode: 2,
234
+ });
235
+ }
236
+ if (!aliasAction && 'exchange_instances' in value) {
237
+ throw new CliError(`Maintenance non-alias action ${index} cannot include exchange_instances.`, {
238
+ code: 'DATASET_MAINTENANCE_ALIAS_EXCHANGE_SCOPE_INVALID',
239
+ exitCode: 2,
240
+ });
241
+ }
242
+ const exchangeKeys = new Set(exchangeInstances.map((entry) => `${entry.exchange_index}\u0000${entry.data_set_internal_id}\u0000${entry.flow_id}`));
243
+ if (exchangeKeys.size !== exchangeInstances.length) {
244
+ throw new CliError(`Alias action ${index} contains duplicate exchange instances.`, {
245
+ code: 'DATASET_MAINTENANCE_ALIAS_EXCHANGE_SCOPE_INVALID',
246
+ exitCode: 2,
247
+ });
248
+ }
249
+ const expectedBeforeSha256 = token(value.expected_before_sha256);
250
+ if (expectedBeforeSha256 && !/^[a-f0-9]{64}$/u.test(expectedBeforeSha256)) {
251
+ throw new CliError(`Maintenance action ${index} expected_before_sha256 is invalid.`, {
252
+ code: 'DATASET_MAINTENANCE_SCOPE_INVALID',
253
+ exitCode: 2,
254
+ });
255
+ }
256
+ return {
257
+ action_id: requireToken(value.action_id, `actions[${index}].action_id`),
258
+ action: action,
259
+ table: table,
260
+ id: requireToken(value.id, `actions[${index}].id`),
261
+ version: requireToken(value.version, `actions[${index}].version`),
262
+ expected_user_id: expectedUserId,
263
+ expected_state_code: 0,
264
+ reason_code: requireToken(value.reason_code, `actions[${index}].reason_code`),
265
+ reason: requireToken(value.reason, `actions[${index}].reason`),
266
+ evidence: value.evidence,
267
+ ...(batchId ? { batch_id: batchId } : {}),
268
+ ...(exchangeInstances.length ? { exchange_instances: exchangeInstances } : {}),
269
+ ...(desiredPayloadPath ? { desired_payload_path: desiredPayloadPath } : {}),
270
+ ...(expectedBeforeSha256 ? { expected_before_sha256: expectedBeforeSha256 } : {}),
271
+ };
272
+ }
273
+ export function parseMaintenanceScope(value, expectedOperation) {
274
+ if (!isJsonObject(value) || value.schema_version !== 1 || !isJsonObject(value.account)) {
275
+ throw new CliError('Maintenance scope must use schema_version=1 and include account.', {
276
+ code: 'DATASET_MAINTENANCE_SCOPE_INVALID',
277
+ exitCode: 2,
278
+ });
279
+ }
280
+ const operation = requireToken(value.operation, 'operation');
281
+ if (!['delete', 'retire', 'redo-import', 'repair-references', 'merge-support-aliases'].includes(operation)) {
282
+ throw new CliError(`Unsupported maintenance operation: ${operation}`, {
283
+ code: 'DATASET_MAINTENANCE_OPERATION_UNSUPPORTED',
284
+ exitCode: 2,
285
+ });
286
+ }
287
+ if (expectedOperation && operation !== expectedOperation) {
288
+ throw new CliError(`Scope operation ${operation} does not match requested operation ${expectedOperation}.`, {
289
+ code: 'DATASET_MAINTENANCE_OPERATION_MISMATCH',
290
+ exitCode: 2,
291
+ });
292
+ }
293
+ const userId = requireToken(value.account.user_id, 'account.user_id');
294
+ if (!Array.isArray(value.actions) || value.actions.length === 0) {
295
+ throw new CliError('Maintenance scope actions must be a non-empty array.', {
296
+ code: 'DATASET_MAINTENANCE_SCOPE_INVALID',
297
+ exitCode: 2,
298
+ });
299
+ }
300
+ const actions = value.actions.map((entry, index) => parseAction(entry, index, userId, operation));
301
+ const targetMode = token(value.target_mode);
302
+ if ((operation === 'merge-support-aliases' && targetMode !== 'owner_draft') ||
303
+ (operation !== 'merge-support-aliases' && targetMode !== null)) {
304
+ throw new CliError('merge-support-aliases requires target_mode=owner_draft; other operations forbid target_mode.', {
305
+ code: 'DATASET_MAINTENANCE_TARGET_MODE_INVALID',
306
+ exitCode: 2,
307
+ details: { operation, target_mode: targetMode },
308
+ });
309
+ }
310
+ const aliasBatches = Array.isArray(value.alias_batches)
311
+ ? value.alias_batches.map(parseAliasBatch)
312
+ : [];
313
+ if ((operation === 'merge-support-aliases' && aliasBatches.length !== 2) ||
314
+ (operation !== 'merge-support-aliases' && aliasBatches.length !== 0)) {
315
+ throw new CliError('merge-support-aliases requires exactly two alias_batches; other operations forbid them.', {
316
+ code: 'DATASET_MAINTENANCE_ALIAS_BATCH_SCOPE_INVALID',
317
+ exitCode: 2,
318
+ });
319
+ }
320
+ if (aliasBatches.length) {
321
+ const batchIds = new Set(aliasBatches.map((batch) => batch.batch_id));
322
+ const dimensions = new Set(aliasBatches.map((batch) => batch.dimension));
323
+ if (batchIds.size !== 2 ||
324
+ dimensions.size !== 2 ||
325
+ !dimensions.has('time') ||
326
+ !dimensions.has('length_time') ||
327
+ actions.some((action) => !action.batch_id || !batchIds.has(action.batch_id))) {
328
+ throw new CliError('Alias batches and action batch_id bindings are incomplete or duplicate.', {
329
+ code: 'DATASET_MAINTENANCE_ALIAS_BATCH_SCOPE_INVALID',
330
+ exitCode: 2,
331
+ });
332
+ }
333
+ }
334
+ const actionIds = new Set();
335
+ const rowKeys = new Set();
336
+ const payloadNames = new Set();
337
+ for (const action of actions) {
338
+ if (actionIds.has(action.action_id)) {
339
+ throw new CliError(`Duplicate maintenance action_id: ${action.action_id}`, {
340
+ code: 'DATASET_MAINTENANCE_ACTION_ID_DUPLICATE',
341
+ exitCode: 2,
342
+ });
343
+ }
344
+ actionIds.add(action.action_id);
345
+ const rowKey = maintenanceRowKey(action);
346
+ if (rowKeys.has(rowKey)) {
347
+ throw new CliError(`Multiple maintenance actions target the same row: ${action.id}`, {
348
+ code: 'DATASET_MAINTENANCE_TARGET_DUPLICATE',
349
+ exitCode: 2,
350
+ });
351
+ }
352
+ rowKeys.add(rowKey);
353
+ const payloadName = safeActionFileName(action.action_id);
354
+ if (payloadNames.has(payloadName)) {
355
+ throw new CliError(`Maintenance action ids collide as payload filenames: ${payloadName}`, {
356
+ code: 'DATASET_MAINTENANCE_ACTION_FILENAME_COLLISION',
357
+ exitCode: 2,
358
+ });
359
+ }
360
+ payloadNames.add(payloadName);
361
+ }
362
+ const email = token(value.account.email);
363
+ const sourceImportRunId = token(value.source_import_run_id);
364
+ return {
365
+ schema_version: 1,
366
+ task_id: requireToken(value.task_id, 'task_id'),
367
+ operation: operation,
368
+ account: {
369
+ user_id: userId,
370
+ ...(email ? { email } : {}),
371
+ },
372
+ ...(sourceImportRunId ? { source_import_run_id: sourceImportRunId } : {}),
373
+ ...('source_lineage' in value ? { source_lineage: value.source_lineage } : {}),
374
+ ...(targetMode === 'owner_draft' ? { target_mode: targetMode } : {}),
375
+ ...(aliasBatches.length ? { alias_batches: aliasBatches } : {}),
376
+ actions,
377
+ };
378
+ }
379
+ export function readJsonFile(filePath, label) {
380
+ const resolved = path.resolve(filePath);
381
+ if (!existsSync(resolved)) {
382
+ throw new CliError(`${label} not found: ${resolved}`, {
383
+ code: 'DATASET_MAINTENANCE_ARTIFACT_NOT_FOUND',
384
+ exitCode: 2,
385
+ });
386
+ }
387
+ try {
388
+ return JSON.parse(readFileSync(resolved, 'utf8'));
389
+ }
390
+ catch (error) {
391
+ throw new CliError(`${label} is not valid JSON: ${resolved}`, {
392
+ code: 'DATASET_MAINTENANCE_ARTIFACT_INVALID',
393
+ exitCode: 2,
394
+ details: String(error),
395
+ });
396
+ }
397
+ }
398
+ function writeImmutableText(filePath, text) {
399
+ const resolved = path.resolve(filePath);
400
+ if (existsSync(resolved)) {
401
+ if (readFileSync(resolved, 'utf8') === text) {
402
+ return resolved;
403
+ }
404
+ throw new CliError(`Refusing to overwrite immutable maintenance artifact: ${resolved}`, {
405
+ code: 'DATASET_MAINTENANCE_ARTIFACT_IMMUTABLE',
406
+ exitCode: 1,
407
+ });
408
+ }
409
+ mkdirSync(path.dirname(resolved), { recursive: true });
410
+ writeFileSync(resolved, text, { encoding: 'utf8', flag: 'wx' });
411
+ return resolved;
412
+ }
413
+ export function writeImmutableJson(filePath, value) {
414
+ return writeImmutableText(filePath, `${stableJsonText(value)}\n`);
415
+ }
416
+ export function writeImmutableJsonLines(filePath, values) {
417
+ const text = values.length ? `${values.map(stableJsonText).join('\n')}\n` : '';
418
+ return writeImmutableText(filePath, text);
419
+ }
420
+ export function appendStableJsonLine(filePath, value) {
421
+ const resolved = path.resolve(filePath);
422
+ mkdirSync(path.dirname(resolved), { recursive: true });
423
+ appendFileSync(resolved, `${stableJsonText(value)}\n`, 'utf8');
424
+ return resolved;
425
+ }
426
+ export function readJsonLinesIfPresent(filePath) {
427
+ const resolved = path.resolve(filePath);
428
+ if (!existsSync(resolved)) {
429
+ return [];
430
+ }
431
+ return readFileSync(resolved, 'utf8')
432
+ .split(/\r?\n/u)
433
+ .map((line) => line.trim())
434
+ .filter(Boolean)
435
+ .map((line, index) => {
436
+ try {
437
+ return JSON.parse(line);
438
+ }
439
+ catch (error) {
440
+ throw new CliError(`Invalid maintenance JSONL at ${resolved}:${index + 1}`, {
441
+ code: 'DATASET_MAINTENANCE_ARTIFACT_INVALID',
442
+ exitCode: 1,
443
+ details: String(error),
444
+ });
445
+ }
446
+ });
447
+ }
448
+ export function safeActionFileName(actionId) {
449
+ const safe = actionId.replace(/[^a-zA-Z0-9._-]+/gu, '_').replace(/^_+|_+$/gu, '');
450
+ return safe || sha256Text(actionId).slice(0, 16);
451
+ }
452
+ export function resolveMaintenancePlanArtifactPath(planDir, relativePath, label) {
453
+ const root = path.resolve(planDir);
454
+ if (!relativePath.trim() || path.isAbsolute(relativePath)) {
455
+ throw new CliError(`${label} must be a relative path inside the maintenance plan directory.`, {
456
+ code: 'DATASET_MAINTENANCE_PLAN_ARTIFACT_PATH_INVALID',
457
+ exitCode: 2,
458
+ details: relativePath,
459
+ });
460
+ }
461
+ const resolved = path.resolve(root, relativePath);
462
+ const withinRoot = path.relative(root, resolved);
463
+ if (!withinRoot || withinRoot === '..' || withinRoot.startsWith(`..${path.sep}`)) {
464
+ throw new CliError(`${label} must stay inside the maintenance plan directory.`, {
465
+ code: 'DATASET_MAINTENANCE_PLAN_ARTIFACT_PATH_INVALID',
466
+ exitCode: 2,
467
+ details: relativePath,
468
+ });
469
+ }
470
+ return resolved;
471
+ }
472
+ export function computePlanSha256(plan) {
473
+ const body = { ...plan, plan_sha256: '' };
474
+ return sha256Json(body);
475
+ }
476
+ export function parseMaintenancePlan(value) {
477
+ if (!isJsonObject(value) ||
478
+ value.schema_version !== 1 ||
479
+ !Array.isArray(value.actions) ||
480
+ !Array.isArray(value.protected_rows) ||
481
+ !Array.isArray(value.blockers) ||
482
+ !isJsonObject(value.account) ||
483
+ !isJsonObject(value.artifacts)) {
484
+ throw new CliError('Maintenance plan is not a valid schema_version=1 plan.', {
485
+ code: 'DATASET_MAINTENANCE_PLAN_INVALID',
486
+ exitCode: 2,
487
+ });
488
+ }
489
+ const plan = value;
490
+ const expected = computePlanSha256(plan);
491
+ if (plan.plan_sha256 !== expected) {
492
+ throw new CliError('Maintenance plan hash does not match its canonical contents.', {
493
+ code: 'DATASET_MAINTENANCE_PLAN_HASH_MISMATCH',
494
+ exitCode: 2,
495
+ details: { expected, received: plan.plan_sha256 },
496
+ });
497
+ }
498
+ const normalizedScope = parseMaintenanceScope({
499
+ schema_version: 1,
500
+ task_id: plan.task_id,
501
+ operation: plan.operation,
502
+ account: plan.account,
503
+ ...(plan.source_import_run_id ? { source_import_run_id: plan.source_import_run_id } : {}),
504
+ source_lineage: plan.source_lineage,
505
+ ...(plan.target_mode ? { target_mode: plan.target_mode } : {}),
506
+ ...(plan.alias_batches
507
+ ? {
508
+ alias_batches: plan.alias_batches.map((batch) => ({
509
+ batch_id: batch.batch_id,
510
+ dimension: batch.dimension,
511
+ factor: batch.factor,
512
+ source: batch.source,
513
+ target: batch.target,
514
+ })),
515
+ }
516
+ : {}),
517
+ actions: plan.actions,
518
+ }, plan.operation);
519
+ const actionIds = new Set();
520
+ const ordinals = new Set();
521
+ for (const [index, action] of plan.actions.entries()) {
522
+ const normalizedAction = normalizedScope.actions[index];
523
+ if (!normalizedAction ||
524
+ action.action_id !== normalizedAction.action_id ||
525
+ !Number.isInteger(action.ordinal) ||
526
+ action.ordinal < 0 ||
527
+ ordinals.has(action.ordinal) ||
528
+ !['ready', 'blocked'].includes(action.status) ||
529
+ !Array.isArray(action.blockers) ||
530
+ !isJsonObject(action.rollback)) {
531
+ throw new CliError('Maintenance plan contains an invalid action contract.', {
532
+ code: 'DATASET_MAINTENANCE_PLAN_INVALID',
533
+ exitCode: 2,
534
+ details: { index, action_id: action.action_id },
535
+ });
536
+ }
537
+ actionIds.add(action.action_id);
538
+ ordinals.add(action.ordinal);
539
+ if (action.status === 'ready') {
540
+ const before = action.before;
541
+ if (!isJsonObject(before) ||
542
+ before.table !== action.table ||
543
+ before.id !== action.id ||
544
+ before.version !== action.version ||
545
+ before.user_id !== action.expected_user_id ||
546
+ before.state_code !== 0 ||
547
+ !isJsonObject(before.json_ordered) ||
548
+ typeof before.row_sha256 !== 'string' ||
549
+ typeof before.payload_sha256 !== 'string' ||
550
+ action.blockers.length > 0) {
551
+ throw new CliError('Ready maintenance plan action has an invalid before snapshot.', {
552
+ code: 'DATASET_MAINTENANCE_PLAN_INVALID',
553
+ exitCode: 2,
554
+ details: { action_id: action.action_id },
555
+ });
556
+ }
557
+ if (action.action === 'update_json_ordered' && !before.modified_at) {
558
+ throw new CliError('Ready alias action requires a frozen modified_at value.', {
559
+ code: 'DATASET_MAINTENANCE_PLAN_INVALID',
560
+ exitCode: 2,
561
+ details: { action_id: action.action_id },
562
+ });
563
+ }
564
+ const remoteRow = {
565
+ table: before.table,
566
+ id: before.id,
567
+ version: before.version,
568
+ user_id: before.user_id,
569
+ state_code: before.state_code,
570
+ modified_at: before.modified_at,
571
+ json_ordered: before.json_ordered,
572
+ model_id: before.model_id,
573
+ rule_verification: before.rule_verification,
574
+ };
575
+ const expectedSnapshot = snapshotRemoteRow(remoteRow);
576
+ if (before.row_sha256 !== expectedSnapshot.row_sha256 ||
577
+ before.payload_sha256 !== expectedSnapshot.payload_sha256) {
578
+ throw new CliError('Ready maintenance plan action before snapshot hash is invalid.', {
579
+ code: 'DATASET_MAINTENANCE_PLAN_INVALID',
580
+ exitCode: 2,
581
+ details: { action_id: action.action_id },
582
+ });
583
+ }
584
+ const expectedRollbackStrategy = action.action === 'save_draft'
585
+ ? 'save_before_snapshot'
586
+ : action.action === 'delete'
587
+ ? 'restore_deleted_before_snapshot'
588
+ : 'restore_atomic_alias_before_snapshot';
589
+ if (action.rollback.strategy !== expectedRollbackStrategy ||
590
+ action.rollback.before_payload_sha256 !== before.payload_sha256 ||
591
+ !isJsonObject(action.rollback.before_payload) ||
592
+ sha256Json(action.rollback.before_payload) !== before.payload_sha256 ||
593
+ action.rollback.model_id !== before.model_id ||
594
+ action.rollback.rule_verification !== before.rule_verification) {
595
+ throw new CliError('Ready maintenance plan action rollback snapshot is invalid.', {
596
+ code: 'DATASET_MAINTENANCE_PLAN_INVALID',
597
+ exitCode: 2,
598
+ details: { action_id: action.action_id },
599
+ });
600
+ }
601
+ }
602
+ if ((['save_draft', 'update_json_ordered'].includes(action.action) &&
603
+ action.status === 'ready' &&
604
+ (!isJsonObject(action.desired_payload) ||
605
+ typeof action.desired_payload.path !== 'string' ||
606
+ !/^[a-f0-9]{64}$/u.test(String(action.desired_payload.sha256)))) ||
607
+ (!['save_draft', 'update_json_ordered'].includes(action.action) &&
608
+ action.desired_payload !== null)) {
609
+ throw new CliError('Maintenance plan action desired payload contract is invalid.', {
610
+ code: 'DATASET_MAINTENANCE_PLAN_INVALID',
611
+ exitCode: 2,
612
+ details: { action_id: action.action_id },
613
+ });
614
+ }
615
+ }
616
+ const flattenedBlockers = plan.actions.flatMap((action) => action.blockers);
617
+ const summaryMatches = isJsonObject(plan.summary) &&
618
+ plan.summary.actions === plan.actions.length &&
619
+ plan.summary.save_draft ===
620
+ plan.actions.filter((action) => action.action === 'save_draft').length &&
621
+ plan.summary.delete === plan.actions.filter((action) => action.action === 'delete').length &&
622
+ (plan.summary.update_json_ordered ?? 0) ===
623
+ plan.actions.filter((action) => action.action === 'update_json_ordered').length &&
624
+ (plan.summary.atomic_batches ?? 0) === (plan.alias_batches?.length ?? 0) &&
625
+ (plan.summary.scaled_exchanges ?? 0) ===
626
+ (plan.alias_batches?.reduce((sum, batch) => sum + batch.summary.exchanges, 0) ?? 0) &&
627
+ (plan.summary.scaled_amount_fields ?? 0) ===
628
+ (plan.alias_batches?.reduce((sum, batch) => sum + batch.summary.amount_fields, 0) ?? 0) &&
629
+ (plan.summary.unrelated_exchanges_preserved ?? 0) ===
630
+ (plan.alias_batches?.reduce((sum, batch) => sum + batch.summary.unrelated_exchanges, 0) ??
631
+ 0) &&
632
+ plan.summary.protected_rows === plan.protected_rows.length &&
633
+ plan.summary.blockers === plan.blockers.length &&
634
+ Number.isInteger(plan.summary.current_reference_impacts) &&
635
+ plan.summary.current_reference_impacts >= 0 &&
636
+ Number.isInteger(plan.summary.projected_reference_impacts) &&
637
+ plan.summary.projected_reference_impacts >= 0;
638
+ const ready = plan.status === 'ready' &&
639
+ plan.blockers.length === 0 &&
640
+ flattenedBlockers.length === 0 &&
641
+ plan.actions.every((action) => action.status === 'ready');
642
+ const blocked = plan.status === 'blocked' &&
643
+ plan.blockers.length > 0 &&
644
+ flattenedBlockers.length > 0 &&
645
+ plan.actions.some((action) => action.status === 'blocked');
646
+ if (actionIds.size !== plan.actions.length ||
647
+ !summaryMatches ||
648
+ (!ready && !blocked) ||
649
+ sha256Json(plan.blockers) !== sha256Json(flattenedBlockers)) {
650
+ throw new CliError('Maintenance plan status or blocker contract is inconsistent.', {
651
+ code: 'DATASET_MAINTENANCE_PLAN_INVALID',
652
+ exitCode: 2,
653
+ });
654
+ }
655
+ if (plan.operation === 'merge-support-aliases') {
656
+ const batches = plan.alias_batches;
657
+ const batchActionIds = new Set(batches.flatMap((batch) => batch.action_ids));
658
+ const expectedProfiles = {
659
+ time: {
660
+ factor: '0.00011415525114155251',
661
+ rows: 25,
662
+ flowproperties: 1,
663
+ flows: 10,
664
+ processes: 14,
665
+ exchanges: 20,
666
+ },
667
+ length_time: {
668
+ factor: '1000',
669
+ rows: 27,
670
+ flowproperties: 1,
671
+ flows: 13,
672
+ processes: 13,
673
+ exchanges: 39,
674
+ },
675
+ };
676
+ const validAliasPlan = plan.target_mode === 'owner_draft' &&
677
+ batches.length === 2 &&
678
+ plan.artifacts.exchange_rewrite_plan === 'exchange-rewrite-plan.jsonl' &&
679
+ batchActionIds.size === plan.actions.length &&
680
+ batches.reduce((sum, batch) => sum + batch.action_ids.length, 0) === plan.actions.length &&
681
+ batches.reduce((sum, batch) => sum + batch.summary.unrelated_exchanges, 0) === 309 &&
682
+ plan.actions.every((action) => action.action === 'update_json_ordered' &&
683
+ action.batch_id &&
684
+ isJsonObject(action.alias_mutation) &&
685
+ batches.some((batch) => batch.batch_id === action.batch_id && batch.action_ids.includes(action.action_id))) &&
686
+ batches.every((batch) => {
687
+ const profile = expectedProfiles[batch.dimension];
688
+ const actions = plan.actions.filter((action) => action.batch_id === batch.batch_id);
689
+ const counts = {
690
+ flowproperties: actions.filter((action) => action.table === 'flowproperties').length,
691
+ flows: actions.filter((action) => action.table === 'flows').length,
692
+ processes: actions.filter((action) => action.table === 'processes').length,
693
+ };
694
+ const targetSnapshots = [
695
+ batch.target_snapshots.unitgroup,
696
+ batch.target_snapshots.flowproperty,
697
+ ];
698
+ const sourceSnapshot = batch.target_snapshots.source_unitgroup;
699
+ const supportPayloadsValid = [
700
+ {
701
+ snapshot: batch.target_snapshots.unitgroup,
702
+ table: 'unitgroups',
703
+ id: batch.target.unitgroup.id,
704
+ version: batch.target.unitgroup.version,
705
+ },
706
+ {
707
+ snapshot: batch.target_snapshots.flowproperty,
708
+ table: 'flowproperties',
709
+ id: batch.target.flowproperty.id,
710
+ version: batch.target.flowproperty.version,
711
+ },
712
+ {
713
+ snapshot: sourceSnapshot,
714
+ table: 'unitgroups',
715
+ id: batch.source.unitgroup.id,
716
+ version: batch.source.unitgroup.version,
717
+ },
718
+ ].every((entry) => {
719
+ if (!entry.snapshot?.json_ordered)
720
+ return plan.status === 'blocked';
721
+ const inspection = inspectMaintenanceSupportPayload({
722
+ table: entry.table,
723
+ payload: entry.snapshot.json_ordered,
724
+ });
725
+ return (inspection.identity.id === entry.id && inspection.identity.version === entry.version);
726
+ });
727
+ const snapshotsValid = (snapshots) => snapshots.every((snapshot) => {
728
+ const remoteRow = {
729
+ table: snapshot.table,
730
+ id: snapshot.id,
731
+ version: snapshot.version,
732
+ user_id: snapshot.user_id,
733
+ state_code: snapshot.state_code,
734
+ modified_at: snapshot.modified_at,
735
+ json_ordered: snapshot.json_ordered,
736
+ model_id: snapshot.model_id,
737
+ rule_verification: snapshot.rule_verification,
738
+ };
739
+ const expectedSnapshot = snapshotRemoteRow(remoteRow);
740
+ return (snapshot.row_sha256 === expectedSnapshot.row_sha256 &&
741
+ snapshot.payload_sha256 === expectedSnapshot.payload_sha256);
742
+ });
743
+ const snapshotIdentityMatches = Boolean(batch.target_snapshots.unitgroup?.table === 'unitgroups' &&
744
+ batch.target_snapshots.unitgroup.id === batch.target.unitgroup.id &&
745
+ batch.target_snapshots.unitgroup.version === batch.target.unitgroup.version &&
746
+ batch.target_snapshots.flowproperty?.table === 'flowproperties' &&
747
+ batch.target_snapshots.flowproperty.id === batch.target.flowproperty.id &&
748
+ batch.target_snapshots.flowproperty.version === batch.target.flowproperty.version &&
749
+ sourceSnapshot?.table === 'unitgroups' &&
750
+ sourceSnapshot.id === batch.source.unitgroup.id &&
751
+ sourceSnapshot.version === batch.source.unitgroup.version);
752
+ const mutationsValid = actions.every((action) => {
753
+ const mutation = action.alias_mutation;
754
+ if (action.table === 'flowproperties') {
755
+ return (mutation.kind === 'flowproperty_unitgroup_reference' &&
756
+ action.id === batch.source.flowproperty.id &&
757
+ action.version === batch.source.flowproperty.version);
758
+ }
759
+ if (action.table === 'flows') {
760
+ return (mutation.kind === 'flow_flowproperty_reference' &&
761
+ mutation.flow_property_internal_id === '1' &&
762
+ mutation.source_flowproperty_id === batch.source.flowproperty.id &&
763
+ mutation.source_flowproperty_version === batch.source.flowproperty.version);
764
+ }
765
+ return (mutation.kind === 'process_exchange_amounts' &&
766
+ sha256Json(mutation.exchanges) ===
767
+ sha256Json(action.exchange_instances.map((instance) => ({
768
+ index: instance.exchange_index,
769
+ internal_id: instance.data_set_internal_id,
770
+ flow_id: instance.flow_id,
771
+ flow_version: instance.flow_version,
772
+ direction: instance.direction,
773
+ before_exchange_sha256: instance.before_exchange_sha256,
774
+ }))));
775
+ });
776
+ const exchangeRewritesValid = batch.exchange_rewrites.every((rewrite) => {
777
+ const action = actions.find((entry) => entry.action_id === rewrite.action_id);
778
+ const instance = action?.exchange_instances?.find((entry) => entry.exchange_index === rewrite.exchange_index &&
779
+ entry.data_set_internal_id === rewrite.data_set_internal_id &&
780
+ entry.flow_id === rewrite.flow_id &&
781
+ entry.flow_version === rewrite.flow_version);
782
+ return Boolean(action?.table === 'processes' &&
783
+ instance &&
784
+ rewrite.process_id === action.id &&
785
+ rewrite.process_version === action.version &&
786
+ rewrite.direction === instance.direction &&
787
+ rewrite.before_exchange_sha256 === instance.before_exchange_sha256 &&
788
+ /^[a-f0-9]{64}$/u.test(rewrite.after_exchange_sha256));
789
+ });
790
+ return (profile !== undefined &&
791
+ batch.factor === profile.factor &&
792
+ batch.summary.rows === profile.rows &&
793
+ counts.flowproperties === profile.flowproperties &&
794
+ counts.flows === profile.flows &&
795
+ counts.processes === profile.processes &&
796
+ batch.summary.flowproperties === counts.flowproperties &&
797
+ batch.summary.flows === counts.flows &&
798
+ batch.summary.processes === counts.processes &&
799
+ batch.summary.exchanges === profile.exchanges &&
800
+ batch.postconditions.source_unitgroup_incoming_refs === 0 &&
801
+ batch.postconditions.source_flowproperty_flow_refs === 0 &&
802
+ batch.postconditions.target_flow_refs === (batch.dimension === 'time' ? 106 : 32) &&
803
+ batch.postconditions.target_exchange_refs === (batch.dimension === 'time' ? 441 : 3216) &&
804
+ batch.summary.rows === batch.action_ids.length &&
805
+ batch.summary.amount_fields === batch.summary.exchanges * 2 &&
806
+ batch.exchange_rewrites.length === batch.summary.exchanges &&
807
+ snapshotIdentityMatches &&
808
+ supportPayloadsValid &&
809
+ mutationsValid &&
810
+ exchangeRewritesValid &&
811
+ (plan.status === 'blocked' ||
812
+ (targetSnapshots.every((snapshot) => snapshot?.state_code === 0 &&
813
+ snapshot.user_id === plan.account.user_id &&
814
+ Boolean(snapshot.modified_at)) &&
815
+ sourceSnapshot?.state_code === 0 &&
816
+ Boolean(sourceSnapshot.modified_at) &&
817
+ sourceSnapshot.user_id === plan.account.user_id &&
818
+ snapshotsValid([
819
+ ...targetSnapshots.filter(Boolean),
820
+ sourceSnapshot,
821
+ ]) &&
822
+ batch.conversion_evidence.source_unitgroup_payload_sha256 ===
823
+ sourceSnapshot.payload_sha256 &&
824
+ isJsonObject(batch.conversion_evidence.source_reference_unit) &&
825
+ isJsonObject(batch.conversion_evidence.target_conversion_unit))));
826
+ });
827
+ if (!validAliasPlan) {
828
+ throw new CliError('Maintenance alias plan contract is inconsistent.', {
829
+ code: 'DATASET_MAINTENANCE_PLAN_INVALID',
830
+ exitCode: 2,
831
+ });
832
+ }
833
+ }
834
+ return plan;
835
+ }
836
+ //# sourceMappingURL=dataset-maintenance-contract.js.map