@tenonhq/dovetail-core 0.0.105 → 0.0.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/appUtils.js CHANGED
@@ -38,30 +38,20 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.createAndAssignUpdateSet = exports.swapScope = exports.buildFiles = exports.summarizeRecord = exports.pushFiles = exports.getAppFileList = exports.groupAppFiles = exports.processMissingFiles = exports.refreshAllFiles = exports.findMissingFiles = exports.syncManifest = exports.processManifest = exports.normalizeManifestKeys = exports.toSafeFolderName = exports.stampMetadataContent = void 0;
40
40
  const path_1 = __importDefault(require("path"));
41
- const fs_1 = __importDefault(require("fs"));
42
41
  const progress_1 = __importDefault(require("progress"));
43
42
  const fUtils = __importStar(require("./FileUtils"));
44
43
  const ConfigManager = __importStar(require("./config"));
45
44
  const constants_1 = require("./constants");
46
45
  const PluginManager_1 = __importDefault(require("./PluginManager"));
47
46
  const FileLogger_1 = require("./FileLogger");
48
- const projectFiles_1 = require("./projectFiles");
47
+ const updateSetConfig_1 = require("./updateSetConfig");
49
48
  const snClient_1 = require("./snClient");
50
49
  const benchmark_1 = require("./benchmark");
51
50
  const Logger_1 = require("./Logger");
52
51
  const genericUtils_1 = require("./genericUtils");
53
- const getUpdateSetConfig = () => {
54
- const configPath = (0, projectFiles_1.getUpdateSetsConfigPath)();
55
- try {
56
- if (fs_1.default.existsSync(configPath)) {
57
- return JSON.parse(fs_1.default.readFileSync(configPath, "utf8"));
58
- }
59
- }
60
- catch (e) {
61
- Logger_1.logger.warn(`Failed to parse update set config at ${configPath}: ${e instanceof Error ? e.message : String(e)}`);
62
- }
63
- return {};
64
- };
52
+ // Delegate to the shared reader so the push hot-path and the
53
+ // createUpdateSet/switchUpdateSet writers stay on one parse contract.
54
+ const getUpdateSetConfig = () => (0, updateSetConfig_1.readUpdateSetConfig)();
65
55
  // Strip the ServiceNow instance host from an absolute _record_link, leaving the
66
56
  // instance-relative path. The CADSO metadata endpoint returns an absolute URL
67
57
  // (https://<instance>.service-now.com/<table>.do?sys_id=...); persisting that
@@ -762,9 +752,11 @@ const pushFiles = async (recs) => {
762
752
  const hasUpdateSets = Object.keys(updateSetConfig).length > 0;
763
753
  if (hasUpdateSets) {
764
754
  const activeScopes = Object.entries(updateSetConfig)
765
- .map(([scope, us]) => `${scope} -> ${us.name}`)
755
+ .map(([scope, us]) => `${scope} -> ${us.name} (${us.sys_id})`)
766
756
  .join(", ");
767
- Logger_1.logger.info(`Update set routing active: ${activeScopes}`);
757
+ // Name the source file and show the sys_id so the operator can see exactly
758
+ // where captures land — push routes by this file, not the active set (#182).
759
+ Logger_1.logger.info(`Update set routing (from .dove-update-sets.json): ${activeScopes}`);
768
760
  }
769
761
  // Pre-resolve read-only table sets for every scope present in this batch.
770
762
  // Cheaper than re-resolving per record. Tables flagged in _readOnlyTables
@@ -971,6 +963,20 @@ const createAndAssignUpdateSet = async (updateSetName = "", scope) => {
971
963
  else {
972
964
  await client.createCurrentUpdateSetUserPref(updateSetSysId, userSysId);
973
965
  }
966
+ // Route this scope's captures to the new set. pushFiles() routes by
967
+ // .dove-update-sets.json, not the active set, so without this a `dove push
968
+ // --updateSet` could create+activate a set yet capture into a stale one
969
+ // (TenonHQ/Dovetail#182). Only possible when the scope name is known.
970
+ if (scope) {
971
+ const routed = (0, updateSetConfig_1.writeUpdateSetRouting)({
972
+ scope,
973
+ sysId: updateSetSysId,
974
+ name: updateSetName,
975
+ });
976
+ if (routed) {
977
+ Logger_1.logger.info(`Push routing updated: ${scope} -> ${updateSetName} (${updateSetSysId})`);
978
+ }
979
+ }
974
980
  return {
975
981
  name: updateSetName,
976
982
  id: updateSetSysId,
package/dist/commander.js CHANGED
@@ -388,7 +388,7 @@ async function initCommands() {
388
388
  await (0, schemaCommand_1.schemaSnapshotsCommand)(args);
389
389
  }
390
390
  })
391
- .command("reconcile", "Make your personal instance match the checked-out branch (diff/report by default; --apply to apply UPDATE + DELETE)", (cmdArgs) => {
391
+ .command("reconcile", "Make your personal instance match the checked-out branch (diff/report by default; --apply to apply CREATE + UPDATE + DELETE)", (cmdArgs) => {
392
392
  cmdArgs.options({
393
393
  ...sharedOptions,
394
394
  scope: {
@@ -409,7 +409,7 @@ async function initCommands() {
409
409
  apply: {
410
410
  type: "boolean",
411
411
  default: false,
412
- describe: "Apply the safe subset: record UPDATE (branch -> instance) + tracked DELETE. Refuses on drift unless --force. CREATE is Phase 3.",
412
+ describe: "Apply records branch -> instance: CREATE + UPDATE + tracked DELETE. Refuses on drift unless --force.",
413
413
  },
414
414
  force: {
415
415
  type: "boolean",
@@ -12,13 +12,14 @@
12
12
  // since baseline; refresh to keep them or --force.
13
13
  // - Clean, or --force -> apply.
14
14
  //
15
- // What applies, in Phase 2:
15
+ // What applies:
16
+ // - CREATE every branch-only record (created on the instance with the
17
+ // branch's own sys_id — the server honors it via setNewGuidValue).
16
18
  // - UPDATE every diff update (branch content overwrites the instance).
17
19
  // - DELETE only "tracked" instance-only records (the branch deleted them).
18
20
  // "local-new" / "no-baseline" records are the dev's own creations —
19
21
  // NEVER deleted, not even with --force (force discards edits to the
20
22
  // branch's records, it does not reap records the branch never owned).
21
- // - CREATE deferred to Phase 3 — reported, never applied here.
22
23
  Object.defineProperty(exports, "__esModule", { value: true });
23
24
  exports.buildApplyPlan = buildApplyPlan;
24
25
  function buildApplyPlan(options) {
@@ -51,9 +52,9 @@ function buildApplyPlan(options) {
51
52
  return {
52
53
  refuse,
53
54
  refuseReason,
55
+ creates: diff.creates.slice(),
54
56
  updates: diff.updates.slice(),
55
57
  deletes,
56
58
  skippedDeletes,
57
- deferredCreates: diff.creates.slice(),
58
59
  };
59
60
  }
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ // FK-safe record operations without building a dependency graph. ServiceNow
3
+ // refuses to delete a record another still references, and refuses to create a
4
+ // record before the parent it references exists. Both orderings fall out of the
5
+ // same loop: attempt every pending record, keep whatever succeeded, retry the
6
+ // failures next pass, stop when a full pass makes no progress (a genuine,
7
+ // non-ordering failure — e.g. ACL) and report the stragglers.
8
+ // - DELETE: a parent blocked by a still-present child succeeds once the child
9
+ // is gone (children before parents).
10
+ // - CREATE: a child blocked by a missing parent succeeds once the parent
11
+ // exists (parents before children).
12
+ //
13
+ // Pure control flow: the operation is injected, so the loop is unit-tested with
14
+ // a fake executor that encodes a dependency ("B cannot proceed until A has").
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.runMultiPassOps = runMultiPassOps;
17
+ async function runMultiPassOps(changes, attempt) {
18
+ const succeeded = [];
19
+ let pending = changes.slice();
20
+ let lastError = new Map();
21
+ while (pending.length > 0) {
22
+ const stillPending = [];
23
+ let progressed = false;
24
+ lastError = new Map();
25
+ for (const change of pending) {
26
+ let result;
27
+ try {
28
+ result = await attempt(change);
29
+ }
30
+ catch (e) {
31
+ result = { ok: false, error: e instanceof Error ? e.message : String(e) };
32
+ }
33
+ if (result.ok) {
34
+ succeeded.push({ change, ok: true });
35
+ progressed = true;
36
+ }
37
+ else {
38
+ stillPending.push(change);
39
+ lastError.set(change.sys_id, result.error || "operation failed");
40
+ }
41
+ }
42
+ pending = stillPending;
43
+ if (!progressed) {
44
+ // A full pass changed nothing — the remaining failures are not ordering
45
+ // problems. Report them and stop.
46
+ break;
47
+ }
48
+ }
49
+ const failures = pending.map((change) => ({
50
+ change,
51
+ ok: false,
52
+ error: lastError.get(change.sys_id) || "operation failed",
53
+ }));
54
+ return succeeded.concat(failures);
55
+ }
@@ -9,10 +9,13 @@
9
9
  // report schema drift (report-only), print the report.
10
10
  // --write-baseline establish the per-instance merge-base from current live
11
11
  // state (no record changes). Run this once before --apply.
12
- // --apply [--force] apply the safe subset: record UPDATE (branch -> instance)
13
- // and tracked DELETE. Refuses if the instance drifted since
14
- // baseline (unless --force). CREATE is deferred to Phase 3;
15
- // schema stays report-only (a ServiceNow ceiling).
12
+ // --apply [--force] apply records branch -> instance: CREATE net-new (with
13
+ // the branch's own sys_id, which the server honors),
14
+ // UPDATE changed records, DELETE tracked instance-only
15
+ // records. Refuses if the instance drifted since baseline
16
+ // (unless --force). Schema stays report-only (a SN ceiling).
17
+ // Updates for a scope with no configured update set are
18
+ // skipped, not pushed — a raw PATCH would re-parent scope.
16
19
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
17
20
  if (k2 === undefined) k2 = k;
18
21
  var desc = Object.getOwnPropertyDescriptor(m, k);
@@ -53,7 +56,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
53
56
  exports.reconcileCommand = reconcileCommand;
54
57
  const path_1 = __importDefault(require("path"));
55
58
  const os_1 = __importDefault(require("os"));
56
- const fs_1 = require("fs");
59
+ const fs_1 = __importStar(require("fs"));
57
60
  const child_process_1 = require("child_process");
58
61
  const util_1 = require("util");
59
62
  const dovetail_schema_1 = require("@tenonhq/dovetail-schema");
@@ -63,13 +66,33 @@ const commands_1 = require("./commands");
63
66
  const ConfigManager = __importStar(require("./config"));
64
67
  const snClient_1 = require("./snClient");
65
68
  const appUtils_1 = require("./appUtils");
69
+ const projectFiles_1 = require("./projectFiles");
66
70
  const recordSource_1 = require("./reconcile/recordSource");
67
71
  const recordDiff_1 = require("./reconcile/recordDiff");
72
+ const fields_1 = require("./reconcile/fields");
68
73
  const baseline_1 = require("./reconcile/baseline");
69
74
  const report_1 = require("./reconcile/report");
70
75
  const applyPlan_1 = require("./reconcile/applyPlan");
71
- const deleteOrder_1 = require("./reconcile/deleteOrder");
76
+ const multiPass_1 = require("./reconcile/multiPass");
72
77
  const gitignore_1 = require("./reconcile/gitignore");
78
+ // Per-scope update-set routing, read from the same .dove-update-sets.json the
79
+ // push pipeline uses. Missing/parse errors degrade to {} (treated as "no update
80
+ // set configured"), never throw.
81
+ function getUpdateSetConfig() {
82
+ try {
83
+ const configPath = (0, projectFiles_1.getUpdateSetsConfigPath)();
84
+ if (fs_1.default.existsSync(configPath)) {
85
+ const parsed = JSON.parse(fs_1.default.readFileSync(configPath, "utf8"));
86
+ if (parsed && typeof parsed === "object") {
87
+ return parsed;
88
+ }
89
+ }
90
+ }
91
+ catch (e) {
92
+ FileLogger_1.fileLogger.debug("reconcile: could not read update-set config");
93
+ }
94
+ return {};
95
+ }
73
96
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
74
97
  function requireCreds() {
75
98
  const { SN_USER = "", SN_PASSWORD = "", SN_INSTANCE = "" } = process.env;
@@ -218,7 +241,7 @@ async function applyDeletes(scope, deletes) {
218
241
  return [];
219
242
  }
220
243
  const client = (0, snClient_1.defaultClient)();
221
- return (0, deleteOrder_1.runMultiPassDeletes)(deletes, async (change) => {
244
+ return (0, multiPass_1.runMultiPassOps)(deletes, async (change) => {
222
245
  try {
223
246
  const response = await client.deleteRecord({
224
247
  table: change.table,
@@ -239,6 +262,117 @@ async function applyDeletes(scope, deletes) {
239
262
  }
240
263
  });
241
264
  }
265
+ // Reconstruct the full field set for a CREATE from the record's on-disk
266
+ // representation: the metaData.json snapshot supplies every captured field
267
+ // value, then the canonical field files (script.js, etc.) override their own
268
+ // fields. Volatile bookkeeping and identity columns are excluded — sys_id is
269
+ // passed separately (and honored by the server), sys_scope comes from `scope`.
270
+ async function buildCreateFields(scope, change) {
271
+ const sourcePath = ConfigManager.getSourcePathForScope(scope);
272
+ const recordDir = path_1.default.join(sourcePath, change.table, change.name);
273
+ const fields = {};
274
+ // 1. metaData snapshot — short/non-file fields (active, order, name, ...).
275
+ try {
276
+ const metaRaw = await fs_1.promises.readFile(path_1.default.join(recordDir, "metaData.json"), "utf8");
277
+ const meta = JSON.parse(metaRaw);
278
+ for (const key of Object.keys(meta)) {
279
+ if (key.charAt(0) === "_" || key === "sys_id" || key === "sys_scope") {
280
+ continue;
281
+ }
282
+ if (key.indexOf("sys_") === 0) {
283
+ // sys_created_on/by, sys_updated_on/by, sys_mod_count, sys_class_name, ...
284
+ // Skipped: server-managed. (sys_class_name on a base table defaults fine.)
285
+ continue;
286
+ }
287
+ const value = meta[key];
288
+ if (typeof value === "string") {
289
+ fields[key] = value;
290
+ }
291
+ else if (typeof value === "object" &&
292
+ value !== null &&
293
+ typeof value.value === "string") {
294
+ fields[key] = value.value;
295
+ }
296
+ }
297
+ }
298
+ catch (e) {
299
+ FileLogger_1.fileLogger.debug("reconcile: no metaData for create " + recordDir);
300
+ }
301
+ // 2. Canonical field files override (the authoritative large-field content).
302
+ let entries = [];
303
+ try {
304
+ entries = await fs_1.promises.readdir(recordDir);
305
+ }
306
+ catch (e) {
307
+ FileLogger_1.fileLogger.debug("reconcile: create record dir missing " + recordDir);
308
+ }
309
+ for (const entry of entries) {
310
+ if (entry === "metaData.json") {
311
+ continue;
312
+ }
313
+ const dot = entry.lastIndexOf(".");
314
+ if (dot <= 0) {
315
+ continue;
316
+ }
317
+ const name = entry.slice(0, dot);
318
+ const type = entry.slice(dot + 1);
319
+ if (!(0, fields_1.isComparableField)({ name, type })) {
320
+ continue;
321
+ }
322
+ try {
323
+ const stat = await fs_1.promises.stat(path_1.default.join(recordDir, entry));
324
+ if (stat.isFile()) {
325
+ fields[name] = await fs_1.promises.readFile(path_1.default.join(recordDir, entry), "utf8");
326
+ }
327
+ }
328
+ catch (e) {
329
+ // ignore unreadable entry
330
+ }
331
+ }
332
+ // Ensure a name is present (some tables require it).
333
+ if (!fields.name && change.name) {
334
+ fields.name = change.name;
335
+ }
336
+ return fields;
337
+ }
338
+ async function applyCreates(scope, creates, updateSetSysId) {
339
+ if (creates.length === 0) {
340
+ return [];
341
+ }
342
+ const client = (0, snClient_1.defaultClient)();
343
+ return (0, multiPass_1.runMultiPassOps)(creates, async (change) => {
344
+ try {
345
+ const fields = await buildCreateFields(scope, change);
346
+ const response = await client.createRecord({
347
+ table: change.table,
348
+ fields,
349
+ sys_id: change.sys_id,
350
+ scope,
351
+ update_set_sys_id: updateSetSysId,
352
+ });
353
+ let data = response.data;
354
+ if (data && data.result) {
355
+ data = data.result;
356
+ }
357
+ if (data && data.error) {
358
+ return { ok: false, error: data.error };
359
+ }
360
+ if (data && data.sys_id && data.sys_id !== change.sys_id) {
361
+ // The server reassigned the sys_id — identity would diverge and the
362
+ // diff would never converge. Surface it loudly rather than silently
363
+ // creating a phantom.
364
+ return {
365
+ ok: false,
366
+ error: "server reassigned sys_id (" + data.sys_id + " != " + change.sys_id + ")",
367
+ };
368
+ }
369
+ return { ok: true };
370
+ }
371
+ catch (e) {
372
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
373
+ }
374
+ });
375
+ }
242
376
  // Capture current live state across every scope into a fresh baseline + ensure
243
377
  // the baseline file is gitignored in the consumer project.
244
378
  async function recaptureBaseline(options) {
@@ -364,23 +498,45 @@ async function applyMode(options) {
364
498
  }
365
499
  Logger_1.logger.info("");
366
500
  Logger_1.logger.info("Applying reconcile (branch -> instance)...");
501
+ const updateSetConfig = getUpdateSetConfig();
502
+ let totalCreated = 0;
367
503
  let totalUpdated = 0;
368
504
  let totalDeleted = 0;
369
505
  const failures = [];
370
506
  for (const entry of plans) {
371
507
  const { scope, plan } = entry;
372
- if (plan.deferredCreates.length > 0) {
373
- Logger_1.logger.info(" [" +
508
+ const updateSet = updateSetConfig[scope];
509
+ const updateSetSysId = updateSet ? updateSet.sys_id : undefined;
510
+ // CREATE — scope-correct via the `scope` param; routed into the update set
511
+ // when one is configured.
512
+ const createOutcomes = await applyCreates(scope, plan.creates, updateSetSysId);
513
+ for (const outcome of createOutcomes) {
514
+ if (outcome.ok) {
515
+ totalCreated++;
516
+ }
517
+ else {
518
+ failures.push("[" + scope + "] create " + outcome.change.table + "/" + outcome.change.name + ": " + (outcome.error || "failed"));
519
+ }
520
+ }
521
+ // UPDATE — guard against the re-parenting trap: pushFiles falls back to a
522
+ // raw PATCH (which adopts session scope) when no update set is configured.
523
+ // Refuse to push updates for such a scope rather than silently re-parent.
524
+ if (plan.updates.length > 0 && !updateSetSysId) {
525
+ failures.push("[" +
374
526
  scope +
375
527
  "] " +
376
- plan.deferredCreates.length +
377
- " create(s) deferred — CREATE ships in Phase 3.");
528
+ plan.updates.length +
529
+ " update(s) skipped — no update set configured for this scope. " +
530
+ "Set one (e.g. `dove switchUpdateSet`) before applying updates, to avoid re-parenting scope.");
378
531
  }
379
- const updateResult = await applyUpdates(scope, plan.updates);
380
- totalUpdated += updateResult.pushed;
381
- for (const failure of updateResult.failures) {
382
- failures.push("[" + scope + "] update: " + failure);
532
+ else {
533
+ const updateResult = await applyUpdates(scope, plan.updates);
534
+ totalUpdated += updateResult.pushed;
535
+ for (const failure of updateResult.failures) {
536
+ failures.push("[" + scope + "] update: " + failure);
537
+ }
383
538
  }
539
+ // DELETE — tracked instance-only records.
384
540
  const deleteOutcomes = await applyDeletes(scope, plan.deletes);
385
541
  for (const outcome of deleteOutcomes) {
386
542
  if (outcome.ok) {
@@ -398,7 +554,13 @@ async function applyMode(options) {
398
554
  scopes: options.scopes,
399
555
  });
400
556
  Logger_1.logger.info("");
401
- Logger_1.logger.success("Applied: " + totalUpdated + " updated, " + totalDeleted + " deleted.");
557
+ Logger_1.logger.success("Applied: " +
558
+ totalCreated +
559
+ " created, " +
560
+ totalUpdated +
561
+ " updated, " +
562
+ totalDeleted +
563
+ " deleted.");
402
564
  if (failures.length > 0) {
403
565
  Logger_1.logger.error(failures.length + " operation(s) failed:");
404
566
  for (const failure of failures) {
package/dist/snClient.js CHANGED
@@ -334,6 +334,15 @@ const snClient = (baseURL, username, password) => {
334
334
  },
335
335
  });
336
336
  };
337
+ const getScopeById = (scopeSysId) => {
338
+ const endpoint = "api/now/table/sys_scope";
339
+ return client.get(endpoint, {
340
+ params: {
341
+ sysparm_query: `sys_id=${scopeSysId}`,
342
+ sysparm_fields: "scope",
343
+ },
344
+ });
345
+ };
337
346
  const getUserSysId = (userName = process.env.SN_USER) => {
338
347
  const endpoint = "api/now/table/sys_user";
339
348
  return client.get(endpoint, {
@@ -504,6 +513,7 @@ const snClient = (baseURL, username, password) => {
504
513
  getAppList,
505
514
  updateRecord,
506
515
  getScopeId,
516
+ getScopeById,
507
517
  getUserSysId,
508
518
  getCurrentAppUserPrefSysId,
509
519
  updateCurrentAppUserPref,
@@ -54,7 +54,7 @@ describe("buildApplyPlan — refusal gating", () => {
54
54
  });
55
55
  });
56
56
  describe("buildApplyPlan — what applies", () => {
57
- it("includes updates and defers creates", () => {
57
+ it("includes updates and creates", () => {
58
58
  const plan = (0, applyPlan_1.buildApplyPlan)({
59
59
  diff: diff({
60
60
  updates: [change({ sys_id: "u", kind: "update" })],
@@ -65,7 +65,7 @@ describe("buildApplyPlan — what applies", () => {
65
65
  force: false,
66
66
  });
67
67
  expect(plan.updates.map((u) => u.sys_id)).toEqual(["u"]);
68
- expect(plan.deferredCreates.map((c) => c.sys_id)).toEqual(["c"]);
68
+ expect(plan.creates.map((c) => c.sys_id)).toEqual(["c"]);
69
69
  });
70
70
  it("applies only tracked deletes; keeps local-new and no-baseline", () => {
71
71
  const plan = (0, applyPlan_1.buildApplyPlan)({
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ // Tests for the generic multi-pass op loop: clean ops, emergent ordering (a
3
+ // record blocked until its dependency resolves), a genuine failure surviving a
4
+ // no-progress pass, and thrown-error handling. Exercised for both delete
5
+ // ordering (children before parents) and create ordering (parents before
6
+ // children).
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ const multiPass_1 = require("../reconcile/multiPass");
9
+ const rec = (sys_id, kind) => ({
10
+ kind,
11
+ table: "sys_script",
12
+ scope: "x_cadso_core",
13
+ sys_id,
14
+ name: sys_id,
15
+ fieldDeltas: [],
16
+ });
17
+ describe("runMultiPassOps", () => {
18
+ it("completes everything in a single clean pass", async () => {
19
+ const calls = [];
20
+ const exec = async (c) => {
21
+ calls.push(c.sys_id);
22
+ return { ok: true };
23
+ };
24
+ const out = await (0, multiPass_1.runMultiPassOps)([rec("a", "delete"), rec("b", "delete")], exec);
25
+ expect(out.every((o) => o.ok)).toBe(true);
26
+ expect(calls.sort()).toEqual(["a", "b"]);
27
+ });
28
+ it("resolves DELETE ordering: parent succeeds only after child is gone", async () => {
29
+ const deleted = new Set();
30
+ const exec = async (c) => {
31
+ if (c.sys_id === "parent" && !deleted.has("child")) {
32
+ return { ok: false, error: "still referenced by child" };
33
+ }
34
+ deleted.add(c.sys_id);
35
+ return { ok: true };
36
+ };
37
+ const out = await (0, multiPass_1.runMultiPassOps)([rec("parent", "delete"), rec("child", "delete")], exec);
38
+ expect(out.every((o) => o.ok)).toBe(true);
39
+ });
40
+ it("resolves CREATE ordering: child succeeds only after parent exists", async () => {
41
+ const created = new Set();
42
+ const exec = async (c) => {
43
+ if (c.sys_id === "child" && !created.has("parent")) {
44
+ return { ok: false, error: "parent does not exist yet" };
45
+ }
46
+ created.add(c.sys_id);
47
+ return { ok: true };
48
+ };
49
+ // child attempted first, fails, then succeeds on the pass after parent.
50
+ const out = await (0, multiPass_1.runMultiPassOps)([rec("child", "create"), rec("parent", "create")], exec);
51
+ expect(out.every((o) => o.ok)).toBe(true);
52
+ expect(created.has("child")).toBe(true);
53
+ });
54
+ it("reports a record that fails for a non-ordering reason", async () => {
55
+ const exec = async (c) => c.sys_id === "locked" ? { ok: false, error: "ACL blocked" } : { ok: true };
56
+ const out = await (0, multiPass_1.runMultiPassOps)([rec("ok", "delete"), rec("locked", "delete")], exec);
57
+ const failed = out.filter((o) => !o.ok);
58
+ expect(failed).toHaveLength(1);
59
+ expect(failed[0].change.sys_id).toBe("locked");
60
+ expect(failed[0].error).toBe("ACL blocked");
61
+ });
62
+ it("treats a thrown executor error as a failure", async () => {
63
+ const exec = async () => {
64
+ throw new Error("network down");
65
+ };
66
+ const out = await (0, multiPass_1.runMultiPassOps)([rec("a", "create")], exec);
67
+ expect(out[0].ok).toBe(false);
68
+ expect(out[0].error).toBe("network down");
69
+ });
70
+ it("returns [] for no changes", async () => {
71
+ const out = await (0, multiPass_1.runMultiPassOps)([], async () => ({ ok: true }));
72
+ expect(out).toEqual([]);
73
+ });
74
+ });