@tenonhq/dovetail-core 0.0.104 → 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
@@ -12,6 +12,7 @@ const schemaCommand_1 = require("./schemaCommand");
12
12
  const claudeCommand_1 = require("./claudeCommand");
13
13
  const createRecordCommand_1 = require("./createRecordCommand");
14
14
  const deleteRecordCommand_1 = require("./deleteRecordCommand");
15
+ const reconcileCommand_1 = require("./reconcileCommand");
15
16
  const migrateCommand_1 = require("./migrateCommand");
16
17
  const clickupCommands_1 = require("./clickupCommands");
17
18
  const loginCommand_1 = require("./loginCommand");
@@ -386,6 +387,39 @@ async function initCommands() {
386
387
  else if (args.subcommand === "snapshots") {
387
388
  await (0, schemaCommand_1.schemaSnapshotsCommand)(args);
388
389
  }
390
+ })
391
+ .command("reconcile", "Make your personal instance match the checked-out branch (diff/report by default; --apply to apply CREATE + UPDATE + DELETE)", (cmdArgs) => {
392
+ cmdArgs.options({
393
+ ...sharedOptions,
394
+ scope: {
395
+ alias: "s",
396
+ type: "string",
397
+ describe: "Single scope (default: all scopes from dove.config.js)",
398
+ },
399
+ schema: {
400
+ type: "boolean",
401
+ default: true,
402
+ describe: "Include schema drift (report-only). --no-schema to skip. Requires a `dove schema pull --snapshot`.",
403
+ },
404
+ "write-baseline": {
405
+ type: "boolean",
406
+ default: false,
407
+ describe: "Establish the per-instance baseline (merge-base) from current live state. Run once before --apply.",
408
+ },
409
+ apply: {
410
+ type: "boolean",
411
+ default: false,
412
+ describe: "Apply records branch -> instance: CREATE + UPDATE + tracked DELETE. Refuses on drift unless --force.",
413
+ },
414
+ force: {
415
+ type: "boolean",
416
+ default: false,
417
+ describe: "With --apply: discard instance drift (edits to tracked records since baseline). Never deletes the dev's own local records.",
418
+ },
419
+ });
420
+ return cmdArgs;
421
+ }, async (args) => {
422
+ await (0, reconcileCommand_1.reconcileCommand)(args);
389
423
  })
390
424
  .command("init-claude", "Install Dovetail Claude Code skills to .claude/commands/", (cmdArgs) => {
391
425
  cmdArgs.options({
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ // Pure decision layer for the reconcile apply phase: given a classified diff,
3
+ // the dirty set, and the flags, decide whether to refuse and exactly which
4
+ // changes are eligible to apply. No I/O — the command layer executes the plan.
5
+ //
6
+ // Safety model (the git-checkout analogy):
7
+ // - No baseline -> refuse. You must establish the merge-base first
8
+ // (`dove reconcile --write-baseline`); without it
9
+ // there is no way to tell the dev's edits from the
10
+ // branch's, so an apply could clobber local work.
11
+ // - Drift, no --force -> refuse. Tracked records changed on the instance
12
+ // since baseline; refresh to keep them or --force.
13
+ // - Clean, or --force -> apply.
14
+ //
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).
18
+ // - UPDATE every diff update (branch content overwrites the instance).
19
+ // - DELETE only "tracked" instance-only records (the branch deleted them).
20
+ // "local-new" / "no-baseline" records are the dev's own creations —
21
+ // NEVER deleted, not even with --force (force discards edits to the
22
+ // branch's records, it does not reap records the branch never owned).
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.buildApplyPlan = buildApplyPlan;
25
+ function buildApplyPlan(options) {
26
+ const { diff, dirty, hasBaseline, force } = options;
27
+ const deletes = [];
28
+ const skippedDeletes = [];
29
+ for (const change of diff.deletes) {
30
+ if (change.deleteDisposition === "tracked") {
31
+ deletes.push(change);
32
+ }
33
+ else {
34
+ skippedDeletes.push(change);
35
+ }
36
+ }
37
+ let refuse = false;
38
+ let refuseReason = "";
39
+ if (!hasBaseline) {
40
+ refuse = true;
41
+ refuseReason =
42
+ "no baseline for this instance — establish the merge-base first with " +
43
+ "`dove reconcile --write-baseline`, then re-run apply.";
44
+ }
45
+ else if (dirty.length > 0 && !force) {
46
+ refuse = true;
47
+ refuseReason =
48
+ dirty.length +
49
+ " record(s) changed on the instance since baseline. Keep them with " +
50
+ "`dove refresh`, or discard them with --force.";
51
+ }
52
+ return {
53
+ refuse,
54
+ refuseReason,
55
+ creates: diff.creates.slice(),
56
+ updates: diff.updates.slice(),
57
+ deletes,
58
+ skippedDeletes,
59
+ };
60
+ }
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ // The reconcile baseline — the "merge-base / index" in the git analogy. It
3
+ // records the {sys_id -> sys_updated_on} the instance was known to hold at the
4
+ // last reconcile (or refresh), namespaced by instance host. It MUST live
5
+ // outside the git tree: after a `git checkout`, the on-disk
6
+ // `metaData._lastUpdatedOn` is the *committer's* refresh time, not this
7
+ // developer's instance state, so the branch files cannot answer "did the dev
8
+ // change the instance since we last synced?". The baseline can.
9
+ //
10
+ // Phase 1 only READS the baseline (to disambiguate deletes and surface drift).
11
+ // `writeBaseline` / `baselineFromLive` are the apply-phase write path and the
12
+ // pure dirty-check is shared by both.
13
+ var __importDefault = (this && this.__importDefault) || function (mod) {
14
+ return (mod && mod.__esModule) ? mod : { "default": mod };
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.BASELINE_FILENAME = void 0;
18
+ exports.baselinePath = baselinePath;
19
+ exports.readBaseline = readBaseline;
20
+ exports.writeBaseline = writeBaseline;
21
+ exports.baselineFromLive = baselineFromLive;
22
+ exports.baselineSysIds = baselineSysIds;
23
+ exports.computeDirty = computeDirty;
24
+ const fs_1 = __importDefault(require("fs"));
25
+ const path_1 = __importDefault(require("path"));
26
+ exports.BASELINE_FILENAME = ".dove-reconcile-baseline.json";
27
+ function baselinePath(rootDir) {
28
+ return path_1.default.join(rootDir, exports.BASELINE_FILENAME);
29
+ }
30
+ function isStringRecordMap(value) {
31
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
32
+ return false;
33
+ }
34
+ for (const key of Object.keys(value)) {
35
+ if (typeof value[key] !== "string") {
36
+ return false;
37
+ }
38
+ }
39
+ return true;
40
+ }
41
+ /**
42
+ * Read the baseline for `instance`. Returns null when the file is absent,
43
+ * unparseable, malformed, or was captured against a different instance (a
44
+ * mismatched baseline is useless and must not silently disambiguate the wrong
45
+ * instance's records). All failure modes degrade to "no baseline", never throw.
46
+ */
47
+ function readBaseline(rootDir, instance) {
48
+ const filePath = baselinePath(rootDir);
49
+ let raw;
50
+ try {
51
+ if (!fs_1.default.existsSync(filePath)) {
52
+ return null;
53
+ }
54
+ raw = fs_1.default.readFileSync(filePath, "utf8");
55
+ }
56
+ catch (e) {
57
+ return null;
58
+ }
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(raw);
62
+ }
63
+ catch (e) {
64
+ return null;
65
+ }
66
+ if (typeof parsed !== "object" || parsed === null) {
67
+ return null;
68
+ }
69
+ const candidate = parsed;
70
+ if (candidate.version !== 1 || !isStringRecordMap(candidate.records)) {
71
+ return null;
72
+ }
73
+ if (instance && candidate.instance && candidate.instance !== instance) {
74
+ return null;
75
+ }
76
+ return {
77
+ version: 1,
78
+ instance: candidate.instance || instance,
79
+ records: candidate.records,
80
+ };
81
+ }
82
+ function writeBaseline(rootDir, baseline) {
83
+ fs_1.default.writeFileSync(baselinePath(rootDir), JSON.stringify(baseline, null, 2) + "\n", "utf8");
84
+ }
85
+ /** Build a fresh baseline snapshot from the current live record set. */
86
+ function baselineFromLive(instance, live) {
87
+ const records = {};
88
+ for (const record of live) {
89
+ records[record.sys_id] = record.updatedOn || "";
90
+ }
91
+ return { version: 1, instance, records };
92
+ }
93
+ function baselineSysIds(baseline) {
94
+ if (!baseline) {
95
+ return null;
96
+ }
97
+ return new Set(Object.keys(baseline.records));
98
+ }
99
+ /**
100
+ * The dev's own uncommitted instance work: records present in the baseline
101
+ * whose live `sys_updated_on` has moved since. This is the refuse-if-dirty
102
+ * signal — Phase 1 reports it, Phase 2 blocks apply on it (unless `--force`).
103
+ * Pure. With no baseline there is nothing to compare against, so it returns [].
104
+ */
105
+ function computeDirty(options) {
106
+ const { baseline, live } = options;
107
+ if (!baseline) {
108
+ return [];
109
+ }
110
+ const dirty = [];
111
+ for (const record of live) {
112
+ const baselineStamp = baseline.records[record.sys_id];
113
+ if (baselineStamp !== undefined &&
114
+ baselineStamp !== "" &&
115
+ record.updatedOn !== "" &&
116
+ record.updatedOn !== baselineStamp) {
117
+ dirty.push({
118
+ sys_id: record.sys_id,
119
+ table: record.table,
120
+ name: record.name,
121
+ reason: "changed-since-baseline",
122
+ });
123
+ }
124
+ }
125
+ return dirty;
126
+ }
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ // Field-comparison rules for reconcile. A record's on-disk representation is a
3
+ // directory of per-field files plus a `metaData.json` snapshot. metaData is
4
+ // Dovetail bookkeeping — it carries `_lastUpdatedOn`, a host-stripped
5
+ // `_record_link`, and a full field dump, and it is re-stamped on every touch —
6
+ // so including it in a content comparison would make every record read as
7
+ // modified. Excluding it leaves the genuine, content-bearing field files
8
+ // (script.js, etc.), which is exactly what `dove refresh` writes verbatim from
9
+ // the instance. Comparing those raw is therefore consistent with the Phase 1
10
+ // acceptance test: "a reconcile dry-run matches a hand-diff of two refresh
11
+ // snapshots."
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.fieldKey = fieldKey;
14
+ exports.isComparableField = isComparableField;
15
+ /** Stable "<field>.<type>" key used on both the branch and live sides. */
16
+ function fieldKey(file) {
17
+ return file.name + "." + file.type;
18
+ }
19
+ /**
20
+ * True when a record file is a content-bearing field that should participate in
21
+ * the diff. Excludes the `metaData.json` bookkeeping file.
22
+ */
23
+ function isComparableField(file) {
24
+ if (file.name === "metaData" && file.type === "json") {
25
+ return false;
26
+ }
27
+ return true;
28
+ }
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ // Ensure the per-instance baseline is gitignored in the consumer project. The
3
+ // baseline is per-developer, per-instance local state (the "merge-base/index")
4
+ // and must never be committed. The compute step is pure and unit-tested; the
5
+ // wrapper does the file I/O and never throws (a gitignore it cannot write is a
6
+ // warning, not a failed reconcile).
7
+ var __importDefault = (this && this.__importDefault) || function (mod) {
8
+ return (mod && mod.__esModule) ? mod : { "default": mod };
9
+ };
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.hasGitignoreEntry = hasGitignoreEntry;
12
+ exports.ensureEntryContent = ensureEntryContent;
13
+ exports.ensureGitignored = ensureGitignored;
14
+ const fs_1 = __importDefault(require("fs"));
15
+ const path_1 = __importDefault(require("path"));
16
+ function lines(content) {
17
+ return content.split(/\r?\n/);
18
+ }
19
+ /**
20
+ * Returns whether `entry` is already an active (non-comment) line in the
21
+ * gitignore content. Trailing whitespace and blank lines are ignored.
22
+ */
23
+ function hasGitignoreEntry(content, entry) {
24
+ const target = entry.trim();
25
+ for (const line of lines(content)) {
26
+ const trimmed = line.trim();
27
+ if (trimmed === target) {
28
+ return true;
29
+ }
30
+ }
31
+ return false;
32
+ }
33
+ /**
34
+ * Pure: compute the gitignore content that includes `entry`. Appends a tidy
35
+ * block (with a trailing newline) when missing; returns unchanged when present.
36
+ * `content` is null when the file does not yet exist.
37
+ */
38
+ function ensureEntryContent(content, entry) {
39
+ if (content !== null && hasGitignoreEntry(content, entry)) {
40
+ return { changed: false, content };
41
+ }
42
+ const base = content === null ? "" : content;
43
+ const needsNewline = base.length > 0 && !base.endsWith("\n");
44
+ const next = base + (needsNewline ? "\n" : "") + entry + "\n";
45
+ return { changed: true, content: next };
46
+ }
47
+ function ensureGitignored(rootDir, entry) {
48
+ const filePath = path_1.default.join(rootDir, ".gitignore");
49
+ let existing = null;
50
+ try {
51
+ if (fs_1.default.existsSync(filePath)) {
52
+ existing = fs_1.default.readFileSync(filePath, "utf8");
53
+ }
54
+ }
55
+ catch (e) {
56
+ existing = null;
57
+ }
58
+ const update = ensureEntryContent(existing, entry);
59
+ if (update.changed) {
60
+ try {
61
+ fs_1.default.writeFileSync(filePath, update.content, "utf8");
62
+ }
63
+ catch (e) {
64
+ return { changed: false, content: existing || "" };
65
+ }
66
+ }
67
+ return update;
68
+ }
@@ -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
+ }
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ // The pure record-diff engine: classify every tracked record into create /
3
+ // update / delete / unchanged by joining the branch and live sides on sys_id.
4
+ // No I/O — callers supply already-normalized ReconcileRecord[] (see
5
+ // recordSource.ts for the on-disk + instance adapters). Fully unit-testable.
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.diffRecords = diffRecords;
8
+ function indexBySysId(records) {
9
+ const map = new Map();
10
+ for (const record of records) {
11
+ map.set(record.sys_id, record);
12
+ }
13
+ return map;
14
+ }
15
+ function toChange(kind, record, fieldDeltas) {
16
+ return {
17
+ kind,
18
+ table: record.table,
19
+ scope: record.scope,
20
+ sys_id: record.sys_id,
21
+ name: record.name,
22
+ fieldDeltas,
23
+ };
24
+ }
25
+ // Compare two sides of the same record. Emits one FieldDelta per field that is
26
+ // added, removed, or changed; identical fields produce nothing.
27
+ function computeFieldDeltas(branch, live) {
28
+ const keys = new Set();
29
+ for (const key of Object.keys(branch.fields)) {
30
+ keys.add(key);
31
+ }
32
+ for (const key of Object.keys(live.fields)) {
33
+ keys.add(key);
34
+ }
35
+ const deltas = [];
36
+ for (const key of Array.from(keys).sort()) {
37
+ const onBranch = Object.prototype.hasOwnProperty.call(branch.fields, key);
38
+ const onLive = Object.prototype.hasOwnProperty.call(live.fields, key);
39
+ const changed = onBranch && onLive && branch.fields[key] !== live.fields[key];
40
+ if (!onBranch || !onLive || changed) {
41
+ deltas.push({ field: key, onBranch, onLive, changed });
42
+ }
43
+ }
44
+ return deltas;
45
+ }
46
+ function byTableThenName(a, b) {
47
+ if (a.table !== b.table) {
48
+ return a.table < b.table ? -1 : 1;
49
+ }
50
+ if (a.name !== b.name) {
51
+ return a.name < b.name ? -1 : 1;
52
+ }
53
+ return a.sys_id < b.sys_id ? -1 : a.sys_id > b.sys_id ? 1 : 0;
54
+ }
55
+ function diffRecords(options) {
56
+ const { branch, live } = options;
57
+ const branchIndex = indexBySysId(branch);
58
+ const liveIndex = indexBySysId(live);
59
+ const hasBaseline = options.baselineSysIds !== null;
60
+ const baseline = options.baselineSysIds || new Set();
61
+ const creates = [];
62
+ const updates = [];
63
+ const deletes = [];
64
+ let unchangedCount = 0;
65
+ // CREATE + UPDATE/unchanged — walk the branch side.
66
+ for (const branchRecord of branch) {
67
+ const liveRecord = liveIndex.get(branchRecord.sys_id);
68
+ if (!liveRecord) {
69
+ creates.push(toChange("create", branchRecord, []));
70
+ continue;
71
+ }
72
+ const deltas = computeFieldDeltas(branchRecord, liveRecord);
73
+ if (deltas.length === 0) {
74
+ unchangedCount++;
75
+ }
76
+ else {
77
+ updates.push(toChange("update", branchRecord, deltas));
78
+ }
79
+ }
80
+ // DELETE — live records the branch no longer carries.
81
+ for (const liveRecord of live) {
82
+ if (branchIndex.has(liveRecord.sys_id)) {
83
+ continue;
84
+ }
85
+ let disposition;
86
+ if (!hasBaseline) {
87
+ disposition = "no-baseline";
88
+ }
89
+ else if (baseline.has(liveRecord.sys_id)) {
90
+ disposition = "tracked";
91
+ }
92
+ else {
93
+ disposition = "local-new";
94
+ }
95
+ const change = toChange("delete", liveRecord, []);
96
+ change.deleteDisposition = disposition;
97
+ deletes.push(change);
98
+ }
99
+ creates.sort(byTableThenName);
100
+ updates.sort(byTableThenName);
101
+ deletes.sort(byTableThenName);
102
+ return { creates, updates, deletes, unchangedCount };
103
+ }