@tenonhq/dovetail-core 0.0.105 → 0.0.106

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/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) {
@@ -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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenonhq/dovetail-core",
3
- "version": "0.0.105",
3
+ "version": "0.0.106",
4
4
  "description": "Next-gen file syncer",
5
5
  "license": "GPL-3.0",
6
6
  "main": "./dist/index.js",
@@ -1,52 +0,0 @@
1
- "use strict";
2
- // FK-safe deletion without building a dependency graph. ServiceNow refuses to
3
- // delete a record another record still references; the safe order is therefore
4
- // children before parents. Rather than model the foreign-key graph, delete in
5
- // passes: attempt every pending record, keep whatever succeeded, and retry the
6
- // failures on the next pass. A record blocked by a still-present dependent
7
- // succeeds once that dependent is gone. Stop when a full pass makes no progress
8
- // (a genuine, non-ordering failure — e.g. ACL) and report the stragglers.
9
- //
10
- // Pure control flow: the actual delete is injected, so the loop is unit-tested
11
- // with a fake executor that encodes "B cannot delete until A is gone."
12
- Object.defineProperty(exports, "__esModule", { value: true });
13
- exports.runMultiPassDeletes = runMultiPassDeletes;
14
- async function runMultiPassDeletes(changes, attemptDelete) {
15
- const succeeded = [];
16
- let pending = changes.slice();
17
- let lastError = new Map();
18
- while (pending.length > 0) {
19
- const stillPending = [];
20
- let progressed = false;
21
- lastError = new Map();
22
- for (const change of pending) {
23
- let result;
24
- try {
25
- result = await attemptDelete(change);
26
- }
27
- catch (e) {
28
- result = { ok: false, error: e instanceof Error ? e.message : String(e) };
29
- }
30
- if (result.ok) {
31
- succeeded.push({ change, ok: true });
32
- progressed = true;
33
- }
34
- else {
35
- stillPending.push(change);
36
- lastError.set(change.sys_id, result.error || "delete failed");
37
- }
38
- }
39
- pending = stillPending;
40
- if (!progressed) {
41
- // A full pass deleted nothing — the remaining failures are not ordering
42
- // problems. Report them and stop.
43
- break;
44
- }
45
- }
46
- const failures = pending.map((change) => ({
47
- change,
48
- ok: false,
49
- error: lastError.get(change.sys_id) || "delete failed",
50
- }));
51
- return succeeded.concat(failures);
52
- }
@@ -1,70 +0,0 @@
1
- "use strict";
2
- // Tests for the multi-pass delete loop: clean deletes, emergent FK ordering
3
- // (a record blocked until its dependent is gone), and a genuine failure that
4
- // survives a no-progress pass and is reported.
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- const deleteOrder_1 = require("../reconcile/deleteOrder");
7
- const del = (sys_id) => ({
8
- kind: "delete",
9
- table: "sys_script",
10
- scope: "x_cadso_core",
11
- sys_id,
12
- name: sys_id,
13
- fieldDeltas: [],
14
- deleteDisposition: "tracked",
15
- });
16
- describe("runMultiPassDeletes", () => {
17
- it("deletes everything in a single clean pass", async () => {
18
- const calls = [];
19
- const exec = async (c) => {
20
- calls.push(c.sys_id);
21
- return { ok: true };
22
- };
23
- const out = await (0, deleteOrder_1.runMultiPassDeletes)([del("a"), del("b")], exec);
24
- expect(out.every((o) => o.ok)).toBe(true);
25
- expect(calls.sort()).toEqual(["a", "b"]);
26
- });
27
- it("resolves FK ordering: parent succeeds only after the child is gone", async () => {
28
- // "parent" cannot delete while "child" still exists.
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
- // parent is attempted first, fails, then succeeds on the pass after child.
38
- const out = await (0, deleteOrder_1.runMultiPassDeletes)([del("parent"), del("child")], exec);
39
- expect(out.filter((o) => o.ok).map((o) => o.change.sys_id).sort()).toEqual([
40
- "child",
41
- "parent",
42
- ]);
43
- expect(out.every((o) => o.ok)).toBe(true);
44
- });
45
- it("reports a record that fails for a non-ordering reason", async () => {
46
- const exec = async (c) => {
47
- if (c.sys_id === "locked") {
48
- return { ok: false, error: "ACL blocked" };
49
- }
50
- return { ok: true };
51
- };
52
- const out = await (0, deleteOrder_1.runMultiPassDeletes)([del("ok"), del("locked")], exec);
53
- const failed = out.filter((o) => !o.ok);
54
- expect(failed).toHaveLength(1);
55
- expect(failed[0].change.sys_id).toBe("locked");
56
- expect(failed[0].error).toBe("ACL blocked");
57
- });
58
- it("treats a thrown executor error as a failure", async () => {
59
- const exec = async () => {
60
- throw new Error("network down");
61
- };
62
- const out = await (0, deleteOrder_1.runMultiPassDeletes)([del("a")], exec);
63
- expect(out[0].ok).toBe(false);
64
- expect(out[0].error).toBe("network down");
65
- });
66
- it("returns [] for no changes", async () => {
67
- const out = await (0, deleteOrder_1.runMultiPassDeletes)([], async () => ({ ok: true }));
68
- expect(out).toEqual([]);
69
- });
70
- });