@remnic/core 9.34.0 → 9.35.1

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.
Files changed (81) hide show
  1. package/dist/access-admin-ops-surface.js +10 -10
  2. package/dist/access-authorization-probe.js +11 -11
  3. package/dist/access-boundary.js +10 -10
  4. package/dist/access-cli.js +26 -26
  5. package/dist/access-extraction-force-flush.js +10 -10
  6. package/dist/access-http.js +17 -17
  7. package/dist/access-identity-continuity-surface.js +10 -10
  8. package/dist/access-lcm-surface.js +10 -10
  9. package/dist/access-mcp.js +15 -15
  10. package/dist/access-namespace-preflight.js +10 -10
  11. package/dist/access-observe-write-surface.js +10 -10
  12. package/dist/access-operations-batch.js +12 -12
  13. package/dist/access-operations.js +14 -14
  14. package/dist/access-recall-concurrency.js +10 -10
  15. package/dist/access-recall-response.js +10 -10
  16. package/dist/access-recall-surface.js +10 -10
  17. package/dist/access-schema.js +3 -3
  18. package/dist/access-service.js +10 -10
  19. package/dist/{capsule-crypto-GWVG7LGC.js → capsule-crypto-7FJQINUR.js} +2 -2
  20. package/dist/{chunk-OWHERGF2.js → chunk-2NLLXCJG.js} +2 -2
  21. package/dist/{chunk-RSFWAI4D.js → chunk-2NUC473W.js} +8 -8
  22. package/dist/{chunk-KJT3PZAR.js → chunk-2SHIWGY7.js} +2 -2
  23. package/dist/{chunk-FYO2GAZJ.js → chunk-3F2LKUMU.js} +2 -2
  24. package/dist/{chunk-FL3AM5KE.js → chunk-5FHE4JI7.js} +3 -3
  25. package/dist/{chunk-DG2R7M7K.js → chunk-5HTAJCTO.js} +5 -5
  26. package/dist/{chunk-7CCVK67W.js → chunk-65ZPVYTA.js} +5 -5
  27. package/dist/{chunk-HULSU7PS.js → chunk-AHZYNPLX.js} +2 -2
  28. package/dist/{chunk-6HXQ2YO5.js → chunk-EH5U2YCX.js} +2 -2
  29. package/dist/{chunk-QEPNZBBC.js → chunk-GHR7WHZX.js} +3 -3
  30. package/dist/{chunk-LRYAQGDR.js → chunk-HEVWWPTL.js} +18 -18
  31. package/dist/{chunk-SUZI6BHQ.js → chunk-IOQFPVGY.js} +2 -2
  32. package/dist/{chunk-5W7CTZ2E.js → chunk-KQAFEZQX.js} +4 -4
  33. package/dist/{chunk-PYHOAXTI.js → chunk-LRGSLV7M.js} +4 -4
  34. package/dist/{chunk-WFYWGDM6.js → chunk-SA3WJJF2.js} +2 -2
  35. package/dist/{chunk-RLOVRJZN.js → chunk-SLPLPGJQ.js} +6 -6
  36. package/dist/{chunk-OXBPHIKH.js → chunk-T4HH5Q3U.js} +5 -5
  37. package/dist/{chunk-RQBF54FC.js → chunk-TJLLYV2X.js} +3 -3
  38. package/dist/{chunk-Y6XRTQMV.js → chunk-UZQCNOPS.js} +4 -4
  39. package/dist/{chunk-UVYI6VIX.js → chunk-X7Y7WX73.js} +1 -1
  40. package/dist/cli.js +25 -25
  41. package/dist/contradiction/index.js +4 -4
  42. package/dist/corpus-watermark.js +6 -6
  43. package/dist/index.js +31 -31
  44. package/dist/namespaces/migrate.js +5 -5
  45. package/dist/namespaces/search.js +4 -4
  46. package/dist/operator-doctor-corpus.js +7 -7
  47. package/dist/operator-toolkit.js +8 -8
  48. package/dist/orchestrator.js +26 -26
  49. package/dist/reconcile/plan.d.ts +175 -0
  50. package/dist/reconcile/plan.js +420 -0
  51. package/dist/reconcile/plan.js.map +1 -0
  52. package/dist/search/factory.js +3 -3
  53. package/dist/search/index.js +7 -7
  54. package/dist/transfer/backup.js +2 -2
  55. package/dist/transfer/capsule-export.js +2 -2
  56. package/dist/transfer/capsule-import.js +2 -2
  57. package/dist/transfer/import-sqlite.js +2 -2
  58. package/package.json +12 -2
  59. package/src/reconcile/plan.test.ts +1013 -0
  60. package/src/reconcile/plan.ts +813 -0
  61. /package/dist/{capsule-crypto-GWVG7LGC.js.map → capsule-crypto-7FJQINUR.js.map} +0 -0
  62. /package/dist/{chunk-OWHERGF2.js.map → chunk-2NLLXCJG.js.map} +0 -0
  63. /package/dist/{chunk-RSFWAI4D.js.map → chunk-2NUC473W.js.map} +0 -0
  64. /package/dist/{chunk-KJT3PZAR.js.map → chunk-2SHIWGY7.js.map} +0 -0
  65. /package/dist/{chunk-FYO2GAZJ.js.map → chunk-3F2LKUMU.js.map} +0 -0
  66. /package/dist/{chunk-FL3AM5KE.js.map → chunk-5FHE4JI7.js.map} +0 -0
  67. /package/dist/{chunk-DG2R7M7K.js.map → chunk-5HTAJCTO.js.map} +0 -0
  68. /package/dist/{chunk-7CCVK67W.js.map → chunk-65ZPVYTA.js.map} +0 -0
  69. /package/dist/{chunk-HULSU7PS.js.map → chunk-AHZYNPLX.js.map} +0 -0
  70. /package/dist/{chunk-6HXQ2YO5.js.map → chunk-EH5U2YCX.js.map} +0 -0
  71. /package/dist/{chunk-QEPNZBBC.js.map → chunk-GHR7WHZX.js.map} +0 -0
  72. /package/dist/{chunk-LRYAQGDR.js.map → chunk-HEVWWPTL.js.map} +0 -0
  73. /package/dist/{chunk-SUZI6BHQ.js.map → chunk-IOQFPVGY.js.map} +0 -0
  74. /package/dist/{chunk-5W7CTZ2E.js.map → chunk-KQAFEZQX.js.map} +0 -0
  75. /package/dist/{chunk-PYHOAXTI.js.map → chunk-LRGSLV7M.js.map} +0 -0
  76. /package/dist/{chunk-WFYWGDM6.js.map → chunk-SA3WJJF2.js.map} +0 -0
  77. /package/dist/{chunk-RLOVRJZN.js.map → chunk-SLPLPGJQ.js.map} +0 -0
  78. /package/dist/{chunk-OXBPHIKH.js.map → chunk-T4HH5Q3U.js.map} +0 -0
  79. /package/dist/{chunk-RQBF54FC.js.map → chunk-TJLLYV2X.js.map} +0 -0
  80. /package/dist/{chunk-Y6XRTQMV.js.map → chunk-UZQCNOPS.js.map} +0 -0
  81. /package/dist/{chunk-UVYI6VIX.js.map → chunk-X7Y7WX73.js.map} +0 -0
@@ -0,0 +1,1013 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import test from "node:test";
4
+
5
+ import {
6
+ ReconcilePlanInputError,
7
+ planNamespaceReconciliation,
8
+ planReconciliation,
9
+ summarizeReconcilePlan,
10
+ type ReconcileFileState,
11
+ type ReconcilePlanEntry,
12
+ } from "./plan.js";
13
+
14
+ /** Readable labels stay in the tests; the planner sees real sha256 hex. */
15
+ function digest(label: string): string {
16
+ return createHash("sha256").update(label).digest("hex");
17
+ }
18
+
19
+ function file(path: string, label: string, mtimeMs?: number): ReconcileFileState {
20
+ const sha256 = digest(label);
21
+ return mtimeMs === undefined ? { path, sha256 } : { path, sha256, mtimeMs };
22
+ }
23
+
24
+ function entryFor(entries: readonly ReconcilePlanEntry[], path: string): ReconcilePlanEntry {
25
+ const found = entries.find((e) => e.path === path);
26
+ assert.ok(found, `no plan entry for ${path}`);
27
+ return found;
28
+ }
29
+
30
+ test("converged corpora produce no work and report converged", () => {
31
+ const files = [file("facts/2026-03-01/a.md", "aaa"), file("facts/2026-03-01/b.md", "bbb")];
32
+ const plan = planReconciliation([{ namespace: "default", local: files, peer: files }]);
33
+ assert.equal(plan.converged, true);
34
+ assert.deepEqual(
35
+ plan.entries.map((e) => e.action),
36
+ ["identical", "identical"],
37
+ );
38
+ assert.deepEqual(plan.byNamespace, [
39
+ { namespace: "default", pull: 0, push: 0, identical: 2, conflict: 0, suppress: 0, unresolved: 0 },
40
+ ]);
41
+ });
42
+
43
+ test("a bootstrap merge moves unique data both ways and deletes nothing", () => {
44
+ // The #2150 shape: no common base, both sides hold months the other never saw.
45
+ const plan = planReconciliation([
46
+ {
47
+ namespace: "default",
48
+ local: [file("facts/2026-01-01/local-only.md", "l1"), file("facts/2026-05-01/shared.md", "s1")],
49
+ peer: [file("facts/2026-04-01/peer-only.md", "p1"), file("facts/2026-05-01/shared.md", "s1")],
50
+ },
51
+ ]);
52
+ assert.equal(plan.converged, false);
53
+ assert.equal(entryFor(plan.entries, "facts/2026-01-01/local-only.md").action, "push");
54
+ assert.equal(entryFor(plan.entries, "facts/2026-04-01/peer-only.md").action, "pull");
55
+ assert.equal(entryFor(plan.entries, "facts/2026-05-01/shared.md").action, "identical");
56
+ assert.equal(
57
+ plan.entries.filter((e) => e.action === "conflict").length,
58
+ 0,
59
+ "absent on one side without a base means never seen, not deleted",
60
+ );
61
+ });
62
+
63
+ test("manual is the default policy: a both-modified path is reported, never auto-picked", () => {
64
+ const entries = planNamespaceReconciliation({
65
+ namespace: "default",
66
+ local: [file("facts/a.md", "local", 2000)],
67
+ peer: [file("facts/a.md", "peer", 1000)],
68
+ });
69
+ const conflict = entryFor(entries, "facts/a.md");
70
+ assert.equal(conflict.action, "conflict");
71
+ assert.equal(conflict.reason, "both_modified");
72
+ assert.equal(conflict.resolution, "unresolved", "equally authoritative sides must not be settled by default");
73
+ });
74
+
75
+ test("newest-wins picks the later side by mtime", () => {
76
+ const entries = planNamespaceReconciliation(
77
+ {
78
+ namespace: "default",
79
+ local: [file("facts/a.md", "local", 2000), file("facts/b.md", "local", 1000)],
80
+ peer: [file("facts/a.md", "peer", 1000), file("facts/b.md", "peer", 2000)],
81
+ },
82
+ { conflictPolicy: "newest-wins" },
83
+ );
84
+ assert.equal(entryFor(entries, "facts/a.md").resolution, "local-wins");
85
+ assert.equal(entryFor(entries, "facts/b.md").resolution, "peer-wins");
86
+ });
87
+
88
+ test("newest-wins degrades to keeping both when the order is not decidable", () => {
89
+ // No timestamp on one side, and an exact tie. Either way "newest" is a coin
90
+ // flip, and a coin flip here discards a fact the other corpus may be alone in
91
+ // holding — the whole failure #2150 exists to prevent.
92
+ const entries = planNamespaceReconciliation(
93
+ {
94
+ namespace: "default",
95
+ local: [file("facts/untimed.md", "local"), file("facts/tied.md", "local", 5000)],
96
+ peer: [file("facts/untimed.md", "peer", 1000), file("facts/tied.md", "peer", 5000)],
97
+ },
98
+ { conflictPolicy: "newest-wins" },
99
+ );
100
+ assert.equal(entryFor(entries, "facts/untimed.md").resolution, "supersede-link");
101
+ assert.equal(entryFor(entries, "facts/tied.md").resolution, "supersede-link");
102
+ });
103
+
104
+ test("keep-both never designates a loser", () => {
105
+ const entries = planNamespaceReconciliation(
106
+ {
107
+ namespace: "default",
108
+ local: [file("facts/a.md", "local", 2000)],
109
+ peer: [file("facts/a.md", "peer", 1000)],
110
+ },
111
+ { conflictPolicy: "keep-both" },
112
+ );
113
+ assert.equal(entryFor(entries, "facts/a.md").resolution, "supersede-link");
114
+ });
115
+
116
+ test("a base turns a one-sided change back into an ordinary transfer", () => {
117
+ const base = [file("facts/a.md", "v1"), file("facts/b.md", "v1")];
118
+ const entries = planNamespaceReconciliation({
119
+ namespace: "default",
120
+ local: [file("facts/a.md", "v1"), file("facts/b.md", "v2-local")],
121
+ peer: [file("facts/a.md", "v2-peer"), file("facts/b.md", "v1")],
122
+ base,
123
+ });
124
+ // Only the peer moved on a.md, only we moved on b.md — neither is a conflict.
125
+ assert.equal(entryFor(entries, "facts/a.md").action, "pull");
126
+ assert.equal(entryFor(entries, "facts/b.md").action, "push");
127
+ // Both censuses still hold the path, so these are NOT the bootstrap
128
+ // "one side has never seen it" reasons.
129
+ assert.equal(entryFor(entries, "facts/a.md").reason, "peer_changed");
130
+ assert.equal(entryFor(entries, "facts/b.md").reason, "local_changed");
131
+ assert.equal(entries.filter((e) => e.action === "conflict").length, 0);
132
+ });
133
+
134
+ test("a base proves a deletion, and a deletion against a live edit needs an operator", () => {
135
+ const entries = planNamespaceReconciliation({
136
+ namespace: "default",
137
+ local: [file("facts/kept.md", "v1")],
138
+ peer: [file("facts/dropped.md", "v1")],
139
+ base: [file("facts/kept.md", "v1"), file("facts/dropped.md", "v1")],
140
+ });
141
+ // The peer no longer has kept.md and the base says it had exactly our copy:
142
+ // that is a real deletion, not a gap. Re-pushing would resurrect it.
143
+ const kept = entryFor(entries, "facts/kept.md");
144
+ assert.equal(kept.action, "conflict");
145
+ assert.equal(kept.reason, "peer_deleted");
146
+ assert.equal(kept.resolution, "unresolved");
147
+ const dropped = entryFor(entries, "facts/dropped.md");
148
+ assert.equal(dropped.action, "conflict");
149
+ assert.equal(dropped.reason, "local_deleted");
150
+ assert.equal(dropped.resolution, "unresolved");
151
+ });
152
+
153
+ test("a locally tombstoned fact is suppressed on the peer, not pulled back", () => {
154
+ const entries = planNamespaceReconciliation({
155
+ namespace: "default",
156
+ local: [],
157
+ peer: [file("facts/retracted.md", "retracted-hash"), file("facts/fresh.md", "fresh-hash")],
158
+ tombstonedFileSha256: [digest("retracted-hash")],
159
+ });
160
+ const retracted = entryFor(entries, "facts/retracted.md");
161
+ assert.equal(retracted.action, "suppress", "a retraction must survive every future reconcile");
162
+ assert.equal(retracted.reason, "tombstoned");
163
+ assert.equal(entryFor(entries, "facts/fresh.md").action, "pull");
164
+ });
165
+
166
+ test("a peer still serving a retracted fact is NOT converged", () => {
167
+ // Marking it `identical` would let transport skip the whole run on
168
+ // plan.converged, so the peer would keep serving the retracted fact forever.
169
+ const plan = planReconciliation([
170
+ {
171
+ namespace: "default",
172
+ local: [],
173
+ peer: [file("facts/retracted.md", "retracted-hash")],
174
+ tombstonedFileSha256: [digest("retracted-hash")],
175
+ },
176
+ ]);
177
+ assert.equal(plan.converged, false, "propagating the retraction is work, not agreement");
178
+ assert.deepEqual(plan.byNamespace, [
179
+ { namespace: "default", pull: 0, push: 0, identical: 0, conflict: 0, suppress: 1, unresolved: 0 },
180
+ ]);
181
+ });
182
+
183
+ test("delete-versus-modify stays a conflict in both directions", () => {
184
+ // The surviving side edited since the base, so its hash no longer equals the
185
+ // base. Keying on equality would emit a plain push/pull and silently
186
+ // resurrect a deliberate deletion.
187
+ const entries = planNamespaceReconciliation({
188
+ namespace: "default",
189
+ local: [file("facts/we-edited.md", "local-v2")],
190
+ peer: [file("facts/peer-edited.md", "peer-v2")],
191
+ base: [file("facts/we-edited.md", "v1"), file("facts/peer-edited.md", "v1")],
192
+ });
193
+ const weEdited = entryFor(entries, "facts/we-edited.md");
194
+ assert.equal(weEdited.action, "conflict");
195
+ assert.equal(weEdited.reason, "local_modified_peer_deleted");
196
+ assert.equal(weEdited.resolution, "unresolved");
197
+ const peerEdited = entryFor(entries, "facts/peer-edited.md");
198
+ assert.equal(peerEdited.action, "conflict");
199
+ assert.equal(peerEdited.reason, "local_deleted_peer_modified");
200
+ assert.equal(peerEdited.resolution, "unresolved");
201
+ });
202
+
203
+ test("namespaces are planned independently and reported separately", () => {
204
+ const plan = planReconciliation([
205
+ { namespace: "alpha", local: [file("facts/a.md", "x")], peer: [] },
206
+ { namespace: "beta", local: [], peer: [file("facts/a.md", "y")] },
207
+ ]);
208
+ // Same path in two namespaces must not collapse into one decision.
209
+ assert.equal(plan.entries.length, 2);
210
+ assert.equal(plan.entries.filter((e) => e.namespace === "alpha")[0]?.action, "push");
211
+ assert.equal(plan.entries.filter((e) => e.namespace === "beta")[0]?.action, "pull");
212
+ assert.deepEqual(plan.byNamespace.map((r) => r.namespace), ["alpha", "beta"]);
213
+ });
214
+
215
+ test("the plan is byte-stable across runs regardless of input order", () => {
216
+ const local = [file("facts/c.md", "1"), file("facts/a.md", "2"), file("facts/b.md", "3")];
217
+ const peer = [file("facts/b.md", "3"), file("facts/z.md", "9")];
218
+ const first = planReconciliation([
219
+ { namespace: "beta", local, peer },
220
+ { namespace: "alpha", local, peer },
221
+ ]);
222
+ const second = planReconciliation([
223
+ { namespace: "alpha", local: [...local].reverse(), peer: [...peer].reverse() },
224
+ { namespace: "beta", local: [...local].reverse(), peer: [...peer].reverse() },
225
+ ]);
226
+ // A convergence report an operator diffs between runs is only useful if the
227
+ // ordering is total (§12) — no incidental readdir order may leak in.
228
+ assert.deepEqual(first.entries, second.entries);
229
+ assert.deepEqual(
230
+ first.entries.map((e) => `${e.namespace}:${e.path}`),
231
+ ["alpha:facts/a.md", "alpha:facts/b.md", "alpha:facts/c.md", "alpha:facts/z.md",
232
+ "beta:facts/a.md", "beta:facts/b.md", "beta:facts/c.md", "beta:facts/z.md"],
233
+ );
234
+ });
235
+
236
+ test("the report counts every action and isolates the unresolved ones", () => {
237
+ const entries = planNamespaceReconciliation({
238
+ namespace: "default",
239
+ local: [file("facts/push.md", "l"), file("facts/same.md", "s"), file("facts/clash.md", "l")],
240
+ peer: [file("facts/pull.md", "p"), file("facts/same.md", "s"), file("facts/clash.md", "p")],
241
+ });
242
+ assert.deepEqual(summarizeReconcilePlan(entries), [
243
+ { namespace: "default", pull: 1, push: 1, identical: 1, conflict: 1, suppress: 0, unresolved: 1 },
244
+ ]);
245
+ });
246
+
247
+ test("planning an empty pair is converged, not a crash", () => {
248
+ const plan = planReconciliation([{ namespace: "default", local: [], peer: [] }]);
249
+ assert.equal(plan.converged, true);
250
+ assert.deepEqual(plan.entries, []);
251
+ assert.deepEqual(plan.byNamespace, []);
252
+ });
253
+
254
+ test("entries carry the hashes a transport needs to verify what it moved", () => {
255
+ const entries = planNamespaceReconciliation({
256
+ namespace: "default",
257
+ local: [file("facts/a.md", "local-hash")],
258
+ peer: [file("facts/a.md", "peer-hash"), file("facts/b.md", "peer-b")],
259
+ base: [file("facts/a.md", "base-hash")],
260
+ });
261
+ const a = entryFor(entries, "facts/a.md");
262
+ assert.equal(a.localSha256, digest("local-hash"));
263
+ assert.equal(a.peerSha256, digest("peer-hash"));
264
+ assert.equal(a.baseSha256, digest("base-hash"));
265
+ const b = entryFor(entries, "facts/b.md");
266
+ assert.equal(b.peerSha256, digest("peer-b"));
267
+ assert.equal(b.localSha256, undefined);
268
+ });
269
+
270
+ test("a duplicate path in the streamed local census does not double-count", () => {
271
+ // The local side is consumed as a stream, so a repeated path must not emit
272
+ // two entries or reappear as peer-only after the index entry was consumed.
273
+ const entries = planNamespaceReconciliation({
274
+ namespace: "default",
275
+ local: [file("facts/a.md", "same"), file("facts/a.md", "same")],
276
+ peer: [file("facts/a.md", "same")],
277
+ });
278
+ assert.equal(entries.filter((e) => e.path === "facts/a.md").length, 1);
279
+ assert.equal(entries[0]?.action, "identical");
280
+ });
281
+
282
+ test("streaming the local side still reports every peer-only path exactly once", () => {
283
+ const entries = planNamespaceReconciliation({
284
+ namespace: "default",
285
+ local: [file("facts/shared.md", "s")],
286
+ peer: [file("facts/shared.md", "s"), file("facts/p1.md", "1"), file("facts/p2.md", "2")],
287
+ });
288
+ assert.deepEqual(
289
+ entries.filter((e) => e.action === "pull").map((e) => e.path),
290
+ ["facts/p1.md", "facts/p2.md"],
291
+ );
292
+ });
293
+
294
+ test("a retracted digest present on BOTH sides is suppressed, not called identical", () => {
295
+ // Same path, same digest, retracted here. Calling it identical converges the
296
+ // plan and the peer keeps serving the retracted fact.
297
+ const plan = planReconciliation([
298
+ {
299
+ namespace: "default",
300
+ local: [file("facts/retracted.md", "gone")],
301
+ peer: [file("facts/retracted.md", "gone")],
302
+ tombstonedFileSha256: [digest("gone")],
303
+ },
304
+ ]);
305
+ assert.equal(plan.entries[0]?.action, "suppress");
306
+ assert.equal(plan.converged, false);
307
+ });
308
+
309
+ test("a retracted peer revision cannot win a conflict", () => {
310
+ // Same path, different content, peer's copy is the retracted one and is
311
+ // NEWER. Reaching the conflict ladder at all would let newest-wins resurrect
312
+ // precisely what was retracted.
313
+ const entries = planNamespaceReconciliation(
314
+ {
315
+ namespace: "default",
316
+ local: [file("facts/a.md", "live", 1000)],
317
+ peer: [file("facts/a.md", "retracted", 9000)],
318
+ tombstonedFileSha256: [digest("retracted")],
319
+ },
320
+ { conflictPolicy: "newest-wins" },
321
+ );
322
+ const entry = entryFor(entries, "facts/a.md");
323
+ assert.equal(entry.action, "suppress");
324
+ assert.equal(entry.resolution, undefined, "a retraction is not a conflict to be won");
325
+ });
326
+
327
+ test("a malformed census record fails the plan instead of vanishing from it", () => {
328
+ for (const side of ["local", "peer"] as const) {
329
+ assert.throws(
330
+ () =>
331
+ planNamespaceReconciliation({
332
+ namespace: "default",
333
+ local: side === "local" ? [{ path: "", sha256: digest("x") }] : [],
334
+ peer: side === "peer" ? [{ path: "", sha256: digest("x") }] : [],
335
+ }),
336
+ ReconcilePlanInputError,
337
+ `${side} census with an empty path must reject`,
338
+ );
339
+ }
340
+ });
341
+
342
+ test("a path listed twice with different digests is rejected on every census", () => {
343
+ const dupe = [file("facts/a.md", "one"), file("facts/a.md", "two")];
344
+ for (const side of ["local", "peer", "base"] as const) {
345
+ assert.throws(
346
+ () =>
347
+ planNamespaceReconciliation({
348
+ namespace: "default",
349
+ local: side === "local" ? dupe : [],
350
+ peer: side === "peer" ? dupe : [],
351
+ base: side === "base" ? dupe : undefined,
352
+ }),
353
+ /lists facts\/a\.md twice with different digests/,
354
+ `${side} census must not silently pick a winner by arrival order`,
355
+ );
356
+ }
357
+ // An exact repeat is unambiguous and stays acceptable.
358
+ assert.doesNotThrow(() =>
359
+ planNamespaceReconciliation({
360
+ namespace: "default",
361
+ local: [file("facts/a.md", "one"), file("facts/a.md", "one")],
362
+ peer: [],
363
+ }),
364
+ );
365
+ });
366
+
367
+ test("an unknown conflictPolicy is rejected rather than silently treated as manual", () => {
368
+ assert.throws(
369
+ () =>
370
+ planNamespaceReconciliation(
371
+ { namespace: "default", local: [file("facts/a.md", "l")], peer: [file("facts/a.md", "p")] },
372
+ // Deserialized config or an untyped JS caller can produce this.
373
+ { conflictPolicy: "newest_wins" as unknown as "newest-wins" },
374
+ ),
375
+ ReconcilePlanInputError,
376
+ );
377
+ });
378
+
379
+ test("a retracted LOCAL revision is suppressed, never pushed", () => {
380
+ // Without this the suppression undoes itself: transport drops the peer copy,
381
+ // and the next run pushes our surviving retracted copy straight back.
382
+ const entries = planNamespaceReconciliation({
383
+ namespace: "default",
384
+ local: [file("facts/retracted.md", "gone")],
385
+ peer: [],
386
+ tombstonedFileSha256: [digest("gone")],
387
+ });
388
+ const entry = entryFor(entries, "facts/retracted.md");
389
+ assert.equal(entry.action, "suppress");
390
+ assert.equal(entry.peerSha256, undefined);
391
+ });
392
+
393
+ test("a retracted local revision does not win a conflict either", () => {
394
+ const entries = planNamespaceReconciliation(
395
+ {
396
+ namespace: "default",
397
+ local: [file("facts/a.md", "retracted", 9000)],
398
+ peer: [file("facts/a.md", "live", 1000)],
399
+ tombstonedFileSha256: [digest("retracted")],
400
+ },
401
+ { conflictPolicy: "newest-wins" },
402
+ );
403
+ assert.equal(entryFor(entries, "facts/a.md").action, "suppress");
404
+ });
405
+
406
+ test("a census record without a usable digest is rejected", () => {
407
+ // Two records that both omit sha256 compare equal and would plan `identical`,
408
+ // converging a corpus that was never actually read.
409
+ for (const bad of [undefined, "", "not-a-digest", 42] as const) {
410
+ assert.throws(
411
+ () =>
412
+ planNamespaceReconciliation({
413
+ namespace: "default",
414
+ local: [{ path: "facts/a.md", sha256: bad as unknown as string }],
415
+ peer: [],
416
+ }),
417
+ /64-character sha256 hex digest/,
418
+ `digest ${JSON.stringify(bad)} must be rejected`,
419
+ );
420
+ }
421
+ });
422
+
423
+ test("paths differing only by case are rejected across censuses", () => {
424
+ // One file on a case-insensitive peer; planning a push AND a pull would let
425
+ // application order pick the survivor.
426
+ assert.throws(
427
+ () =>
428
+ planNamespaceReconciliation({
429
+ namespace: "default",
430
+ local: [file("Facts/A.md", "one")],
431
+ peer: [file("facts/a.md", "two")],
432
+ }),
433
+ /aliasing paths/,
434
+ );
435
+ });
436
+
437
+ test("a missing namespace or a non-iterable census is rejected", () => {
438
+ assert.throws(
439
+ () => planNamespaceReconciliation({ namespace: "", local: [], peer: [] }),
440
+ ReconcilePlanInputError,
441
+ );
442
+ for (const side of ["local", "peer"] as const) {
443
+ assert.throws(
444
+ () =>
445
+ planNamespaceReconciliation({
446
+ namespace: "default",
447
+ local: side === "local" ? (undefined as unknown as ReconcileFileState[]) : [],
448
+ peer: side === "peer" ? (undefined as unknown as ReconcileFileState[]) : [],
449
+ }),
450
+ /must be iterable/,
451
+ `${side} census`,
452
+ );
453
+ }
454
+ });
455
+
456
+ test("digest casing is canonicalized, not compared verbatim", () => {
457
+ const upper = digest("same").toUpperCase();
458
+ const plan = planReconciliation([
459
+ {
460
+ namespace: "default",
461
+ local: [{ path: "facts/a.md", sha256: upper }],
462
+ peer: [file("facts/a.md", "same")],
463
+ },
464
+ ]);
465
+ assert.equal(plan.entries[0]?.action, "identical", "one digest in two spellings is one digest");
466
+ assert.equal(plan.entries[0]?.localSha256, digest("same"));
467
+ assert.equal(plan.converged, true);
468
+ });
469
+
470
+ test("tombstone membership survives digest casing", () => {
471
+ const entries = planNamespaceReconciliation({
472
+ namespace: "default",
473
+ local: [],
474
+ peer: [file("facts/r.md", "gone")],
475
+ tombstonedFileSha256: [digest("gone").toUpperCase()],
476
+ });
477
+ assert.equal(entryFor(entries, "facts/r.md").action, "suppress");
478
+ });
479
+
480
+ test("suppress names the side holding the retracted revision", () => {
481
+ const cases = [
482
+ { tomb: "local-rev", expect: "local" },
483
+ { tomb: "peer-rev", expect: "peer" },
484
+ ] as const;
485
+ for (const { tomb, expect } of cases) {
486
+ const entries = planNamespaceReconciliation({
487
+ namespace: "default",
488
+ local: [file("facts/a.md", "local-rev")],
489
+ peer: [file("facts/a.md", "peer-rev")],
490
+ tombstonedFileSha256: [digest(tomb)],
491
+ });
492
+ // Without this, transport cannot tell which copy to remove and may delete
493
+ // the live revision instead of the retracted one.
494
+ assert.equal(entryFor(entries, "facts/a.md").suppressSide, expect);
495
+ }
496
+ const both = planNamespaceReconciliation({
497
+ namespace: "default",
498
+ local: [file("facts/a.md", "gone")],
499
+ peer: [file("facts/a.md", "gone")],
500
+ tombstonedFileSha256: [digest("gone")],
501
+ });
502
+ assert.equal(entryFor(both, "facts/a.md").suppressSide, "both");
503
+ });
504
+
505
+ test("a single digest string is rejected instead of being split into characters", () => {
506
+ assert.throws(
507
+ () =>
508
+ planNamespaceReconciliation({
509
+ namespace: "default",
510
+ local: [],
511
+ peer: [file("facts/r.md", "gone")],
512
+ // Satisfies Iterable<string>; new Set() would make 64 one-char members.
513
+ tombstonedFileSha256: digest("gone") as unknown as string[],
514
+ }),
515
+ /must be a collection of digests, not a single string/,
516
+ );
517
+ assert.throws(
518
+ () =>
519
+ planNamespaceReconciliation({
520
+ namespace: "default",
521
+ local: [],
522
+ peer: [file("facts/r.md", "gone")],
523
+ tombstonedFileSha256: ["not-a-digest"],
524
+ }),
525
+ /64-character sha256 hex digest/,
526
+ );
527
+ });
528
+
529
+ test("an unsafe census path is rejected before it becomes transfer work", () => {
530
+ for (const bad of ["/outside.md", "../outside.md", "facts\\win.md"]) {
531
+ assert.throws(
532
+ () =>
533
+ planNamespaceReconciliation({
534
+ namespace: "default",
535
+ local: [],
536
+ peer: [{ path: bad, sha256: digest("x") }],
537
+ }),
538
+ ReconcilePlanInputError,
539
+ `peer census path ${bad} must be rejected`,
540
+ );
541
+ }
542
+ });
543
+
544
+ test("a duplicate record disagreeing only on mtime is rejected", () => {
545
+ // newest-wins reads that timestamp, so first-arrival-wins would let input
546
+ // order pick the winner.
547
+ assert.throws(
548
+ () =>
549
+ planNamespaceReconciliation({
550
+ namespace: "default",
551
+ local: [],
552
+ peer: [file("facts/a.md", "same", 1000), file("facts/a.md", "same", 2000)],
553
+ }),
554
+ /lists facts\/a\.md twice with different mtimeMs/,
555
+ );
556
+ });
557
+
558
+ test("the base census participates in case-collision detection", () => {
559
+ assert.throws(
560
+ () =>
561
+ planNamespaceReconciliation({
562
+ namespace: "default",
563
+ local: [file("facts/a.md", "one")],
564
+ peer: [],
565
+ base: [file("Facts/A.md", "one")],
566
+ }),
567
+ /aliasing paths/,
568
+ );
569
+ });
570
+
571
+ test("the streamed local census rejects an mtime-only duplicate like the indexed ones", () => {
572
+ assert.throws(
573
+ () =>
574
+ planNamespaceReconciliation({
575
+ namespace: "default",
576
+ local: [file("facts/a.md", "same", 1000), file("facts/a.md", "same", 2000)],
577
+ peer: [],
578
+ }),
579
+ /local census for namespace default lists facts\/a\.md twice with different mtimeMs/,
580
+ );
581
+ });
582
+
583
+ test("newest-wins cannot be flipped by local census ordering", () => {
584
+ // The duplicate is rejected outright, so neither arrival order can supply
585
+ // the timestamp that decides the winner.
586
+ for (const order of [
587
+ [file("facts/a.md", "local", 1000), file("facts/a.md", "local", 9000)],
588
+ [file("facts/a.md", "local", 9000), file("facts/a.md", "local", 1000)],
589
+ ]) {
590
+ assert.throws(
591
+ () =>
592
+ planNamespaceReconciliation(
593
+ { namespace: "default", local: order, peer: [file("facts/a.md", "peer", 5000)] },
594
+ { conflictPolicy: "newest-wins" },
595
+ ),
596
+ ReconcilePlanInputError,
597
+ );
598
+ }
599
+ });
600
+
601
+ test("the same namespace supplied twice is rejected", () => {
602
+ // Planned independently, one (namespace, path) could draw both push and pull,
603
+ // and the two entries sort equal so batch order would pick the survivor.
604
+ assert.throws(
605
+ () =>
606
+ planReconciliation([
607
+ { namespace: "default", local: [file("facts/a.md", "one")], peer: [] },
608
+ { namespace: "default", local: [], peer: [file("facts/a.md", "two")] },
609
+ ]),
610
+ /namespace default appears twice/,
611
+ );
612
+ });
613
+
614
+ test("an out-of-range mtimeMs cannot decide newest-wins", () => {
615
+ // 1.5 is deliberately absent: fs.stat reports fractional mtimes and
616
+ // offline-sync forwards them unrounded, so they are valid input.
617
+ for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, -1, 8_640_000_000_000_001, "2000"]) {
618
+ for (const side of ["local", "peer", "base"] as const) {
619
+ const record = { path: "facts/a.md", sha256: digest("x"), mtimeMs: bad as unknown as number };
620
+ assert.throws(
621
+ () =>
622
+ planNamespaceReconciliation({
623
+ namespace: "default",
624
+ local: side === "local" ? [record] : [],
625
+ peer: side === "peer" ? [record] : [],
626
+ base: side === "base" ? [record] : undefined,
627
+ }),
628
+ /out-of-range mtimeMs/,
629
+ `${side} census mtimeMs ${String(bad)} must be rejected`,
630
+ );
631
+ }
632
+ }
633
+ });
634
+
635
+ test("malformed public API envelopes raise ReconcilePlanInputError, not TypeError", () => {
636
+ for (const bad of [undefined, null, "nope", 7]) {
637
+ assert.throws(
638
+ () => planReconciliation(bad as unknown as []),
639
+ ReconcilePlanInputError,
640
+ `planReconciliation(${JSON.stringify(bad)})`,
641
+ );
642
+ assert.throws(
643
+ () => planNamespaceReconciliation(bad as unknown as { namespace: string; local: []; peer: [] }),
644
+ ReconcilePlanInputError,
645
+ `planNamespaceReconciliation(${JSON.stringify(bad)})`,
646
+ );
647
+ }
648
+ assert.throws(
649
+ () =>
650
+ planNamespaceReconciliation(
651
+ { namespace: "default", local: [], peer: [] },
652
+ null as unknown as Record<string, never>,
653
+ ),
654
+ ReconcilePlanInputError,
655
+ );
656
+ });
657
+
658
+ test("a fractional mtime from fs.stat is accepted", () => {
659
+ // Node reports sub-millisecond mtimes on common filesystems; rejecting them
660
+ // would fail every normally generated census before planning could start.
661
+ const entries = planNamespaceReconciliation(
662
+ {
663
+ namespace: "default",
664
+ local: [file("facts/a.md", "local", 1_700_000_000_123.456)],
665
+ peer: [file("facts/a.md", "peer", 1_700_000_000_999.789)],
666
+ },
667
+ { conflictPolicy: "newest-wins" },
668
+ );
669
+ assert.equal(entryFor(entries, "facts/a.md").resolution, "peer-wins");
670
+ });
671
+
672
+ test("a non-canonical namespace is rejected so duplicates cannot slip past", () => {
673
+ assert.throws(
674
+ () => planNamespaceReconciliation({ namespace: " team ", local: [], peer: [] }),
675
+ /is not canonical; pass "team"/,
676
+ );
677
+ // The dedup guard would otherwise see two distinct keys for one namespace.
678
+ assert.throws(
679
+ () =>
680
+ planReconciliation([
681
+ { namespace: "team", local: [file("facts/a.md", "one")], peer: [] },
682
+ { namespace: " team ", local: [], peer: [file("facts/a.md", "two")] },
683
+ ]),
684
+ ReconcilePlanInputError,
685
+ );
686
+ });
687
+
688
+ test("a null base cursor is rejected rather than read as a bootstrap", () => {
689
+ // Silently bootstrapping would turn the peer's deletion into a push.
690
+ assert.throws(
691
+ () =>
692
+ planNamespaceReconciliation({
693
+ namespace: "default",
694
+ local: [file("facts/a.md", "v1")],
695
+ peer: [],
696
+ base: null as unknown as undefined,
697
+ }),
698
+ /must be iterable/,
699
+ );
700
+ });
701
+
702
+ test("canonically equivalent Unicode paths are treated as one file", () => {
703
+ // macOS stores decomposed names and compares canonically, so these are the
704
+ // same file there; planning a push AND a pull would race them.
705
+ assert.throws(
706
+ () =>
707
+ planNamespaceReconciliation({
708
+ namespace: "default",
709
+ local: [file("facts/\u00e9.md", "one")],
710
+ peer: [file("facts/e\u0301.md", "two")],
711
+ }),
712
+ /aliasing paths/,
713
+ );
714
+ });
715
+
716
+ test("an array is not a valid options or input envelope", () => {
717
+ assert.throws(
718
+ () =>
719
+ planNamespaceReconciliation(
720
+ { namespace: "default", local: [], peer: [] },
721
+ [] as unknown as Record<string, never>,
722
+ ),
723
+ /options must be a plain object/,
724
+ );
725
+ assert.throws(
726
+ () => planNamespaceReconciliation([] as unknown as { namespace: string; local: []; peer: [] }),
727
+ /namespace input must be a plain object/,
728
+ );
729
+ });
730
+
731
+ test("a peer retraction suppresses our surviving copy instead of pushing it back", () => {
732
+ // Bootstrap: the peer census simply OMITS what it retracted, so without its
733
+ // tombstone set this is indistinguishable from "never had it" and we push
734
+ // the file back, undoing the peer's retraction.
735
+ const entries = planNamespaceReconciliation({
736
+ namespace: "default",
737
+ local: [file("facts/retracted-there.md", "gone"), file("facts/mine.md", "keep")],
738
+ peer: [],
739
+ peerTombstonedFileSha256: [digest("gone")],
740
+ });
741
+ const suppressed = entryFor(entries, "facts/retracted-there.md");
742
+ assert.equal(suppressed.action, "suppress");
743
+ assert.equal(suppressed.suppressSide, "local", "the copy to remove is ours");
744
+ assert.equal(entryFor(entries, "facts/mine.md").action, "push", "unretracted files still push");
745
+ });
746
+
747
+ test("the peer tombstone set is validated like our own", () => {
748
+ assert.throws(
749
+ () =>
750
+ planNamespaceReconciliation({
751
+ namespace: "default",
752
+ local: [],
753
+ peer: [],
754
+ peerTombstonedFileSha256: digest("x") as unknown as string[],
755
+ }),
756
+ /peerTombstonedFileSha256 for namespace default must be a collection of digests/,
757
+ );
758
+ });
759
+
760
+ test("Win32-aliasing path segments are rejected", () => {
761
+ // Windows strips trailing dots and spaces, so `a.md.` and `a.md` are one
762
+ // file there while the fold key keeps them distinct.
763
+ for (const bad of ["facts/a.md.", "facts/a.md ", "facts/dir./a.md"]) {
764
+ assert.throws(
765
+ () =>
766
+ planNamespaceReconciliation({
767
+ namespace: "default",
768
+ local: [{ path: bad, sha256: digest("x") }],
769
+ peer: [],
770
+ }),
771
+ /dot or space/,
772
+ `path ${JSON.stringify(bad)} must be rejected`,
773
+ );
774
+ }
775
+ });
776
+
777
+ test("a supersede link names the newer revision", () => {
778
+ // Without a direction transport must re-derive it or guess which side is
779
+ // linked as superseding the other.
780
+ const newerPeer = planNamespaceReconciliation(
781
+ {
782
+ namespace: "default",
783
+ local: [file("facts/a.md", "local", 1000)],
784
+ peer: [file("facts/a.md", "peer", 2000)],
785
+ },
786
+ { conflictPolicy: "keep-both" },
787
+ );
788
+ assert.equal(entryFor(newerPeer, "facts/a.md").newerSide, "peer");
789
+
790
+ const newerLocal = planNamespaceReconciliation(
791
+ {
792
+ namespace: "default",
793
+ local: [file("facts/a.md", "local", 2000)],
794
+ peer: [file("facts/a.md", "peer", 1000)],
795
+ },
796
+ { conflictPolicy: "keep-both" },
797
+ );
798
+ assert.equal(entryFor(newerLocal, "facts/a.md").newerSide, "local");
799
+ });
800
+
801
+ test("an unorderable supersede link says so instead of picking a side", () => {
802
+ for (const pair of [
803
+ [file("facts/a.md", "local", 5000), file("facts/a.md", "peer", 5000)],
804
+ [file("facts/a.md", "local"), file("facts/a.md", "peer", 5000)],
805
+ ] as const) {
806
+ const entries = planNamespaceReconciliation(
807
+ { namespace: "default", local: [pair[0]], peer: [pair[1]] },
808
+ { conflictPolicy: "keep-both" },
809
+ );
810
+ const entry = entryFor(entries, "facts/a.md");
811
+ assert.equal(entry.resolution, "supersede-link");
812
+ assert.equal(entry.newerSide, undefined, "an unordered link must be visibly unordered");
813
+ }
814
+ });
815
+
816
+ test("Windows-unstorable and reserved path names are rejected", () => {
817
+ for (const bad of ["facts/a:b.md", "facts/CON.md", "facts/com1.txt", 'facts/a"b.md', "facts/a\u0001b.md"]) {
818
+ assert.throws(
819
+ () =>
820
+ planNamespaceReconciliation({
821
+ namespace: "default",
822
+ local: [{ path: bad, sha256: digest("x") }],
823
+ peer: [],
824
+ }),
825
+ ReconcilePlanInputError,
826
+ `path ${JSON.stringify(bad)} must be rejected`,
827
+ );
828
+ }
829
+ // A name that merely CONTAINS a reserved word is fine.
830
+ assert.doesNotThrow(() =>
831
+ planNamespaceReconciliation({
832
+ namespace: "default",
833
+ local: [file("facts/console.md", "x")],
834
+ peer: [],
835
+ }),
836
+ );
837
+ });
838
+
839
+ test("a directionless supersede link is reported as unresolved", () => {
840
+ // The policy nominally settled it, but with no orderable timestamp the link
841
+ // direction is an operator call — reporting unresolved: 0 would hide that.
842
+ const entries = planNamespaceReconciliation(
843
+ {
844
+ namespace: "default",
845
+ local: [file("facts/a.md", "local", 5000)],
846
+ peer: [file("facts/a.md", "peer", 5000)],
847
+ },
848
+ { conflictPolicy: "keep-both" },
849
+ );
850
+ assert.equal(entryFor(entries, "facts/a.md").newerSide, undefined);
851
+ assert.deepEqual(summarizeReconcilePlan(entries), [
852
+ { namespace: "default", pull: 0, push: 0, identical: 0, conflict: 1, suppress: 0, unresolved: 1 },
853
+ ]);
854
+ });
855
+
856
+ test("an ordered supersede link is not counted as unresolved", () => {
857
+ const entries = planNamespaceReconciliation(
858
+ {
859
+ namespace: "default",
860
+ local: [file("facts/a.md", "local", 1000)],
861
+ peer: [file("facts/a.md", "peer", 2000)],
862
+ },
863
+ { conflictPolicy: "keep-both" },
864
+ );
865
+ assert.equal(summarizeReconcilePlan(entries)[0]?.unresolved, 0);
866
+ });
867
+
868
+ test("superscript COM and LPT device aliases are rejected", () => {
869
+ for (const bad of ["facts/COM\u00b9.txt", "facts/LPT\u00b2", "facts/com\u00b3.md"]) {
870
+ assert.throws(
871
+ () =>
872
+ planNamespaceReconciliation({
873
+ namespace: "default",
874
+ local: [{ path: bad, sha256: digest("x") }],
875
+ peer: [],
876
+ }),
877
+ /reserved Windows device name/,
878
+ `path ${JSON.stringify(bad)} must be rejected`,
879
+ );
880
+ }
881
+ });
882
+
883
+ test("a full caseless fold catches sigma-style aliases", () => {
884
+ // Unicode-case-insensitive filesystems treat final and medial sigma as one
885
+ // name; a bare toLowerCase() keeps them distinct.
886
+ assert.throws(
887
+ () =>
888
+ planNamespaceReconciliation({
889
+ namespace: "default",
890
+ local: [file("facts/\u03c2.md", "one")],
891
+ peer: [file("facts/\u03c3.md", "two")],
892
+ }),
893
+ /aliasing paths/,
894
+ );
895
+ });
896
+
897
+ test("non-plain objects are not accepted as envelopes", () => {
898
+ // Each passes typeof === "object" and is not an array, exposes no
899
+ // conflictPolicy, and would silently run under the default policy.
900
+ for (const bad of [new Date(), new Map(), /re/]) {
901
+ assert.throws(
902
+ () =>
903
+ planNamespaceReconciliation(
904
+ { namespace: "default", local: [], peer: [] },
905
+ bad as unknown as Record<string, never>,
906
+ ),
907
+ /options must be a plain object/,
908
+ `options ${Object.prototype.toString.call(bad)}`,
909
+ );
910
+ }
911
+ // A null-prototype record is still a record.
912
+ assert.doesNotThrow(() =>
913
+ planNamespaceReconciliation(
914
+ { namespace: "default", local: [], peer: [] },
915
+ Object.assign(Object.create(null), { conflictPolicy: "manual" }) as Record<string, never>,
916
+ ),
917
+ );
918
+ });
919
+
920
+ test("an empty namespace list still validates its options", () => {
921
+ // The same call must not pass or fail depending on list length.
922
+ for (const bad of [null, new Date(), { conflictPolicy: "nope" }]) {
923
+ assert.throws(
924
+ () => planReconciliation([], bad as unknown as Record<string, never>),
925
+ ReconcilePlanInputError,
926
+ `options ${Object.prototype.toString.call(bad)}`,
927
+ );
928
+ }
929
+ assert.equal(planReconciliation([], { conflictPolicy: "keep-both" }).converged, true);
930
+ });
931
+
932
+ test("sharp-S aliases fold together", () => {
933
+ assert.throws(
934
+ () =>
935
+ planNamespaceReconciliation({
936
+ namespace: "default",
937
+ local: [file("facts/stra\u00dfe.md", "one")],
938
+ peer: [file("facts/stra\u1e9ee.md", "two")],
939
+ }),
940
+ /aliasing paths/,
941
+ );
942
+ });
943
+
944
+ test("a malformed tombstone collection raises the planner's error, not a TypeError", () => {
945
+ for (const bad of [null, 42, { a: 1 }]) {
946
+ assert.throws(
947
+ () =>
948
+ planNamespaceReconciliation({
949
+ namespace: "default",
950
+ local: [],
951
+ peer: [],
952
+ tombstonedFileSha256: bad as unknown as string[],
953
+ }),
954
+ ReconcilePlanInputError,
955
+ `tombstonedFileSha256 ${JSON.stringify(bad)}`,
956
+ );
957
+ }
958
+ });
959
+
960
+ test("an unpaired surrogate path is rejected", () => {
961
+ // Node encodes it as U+FFFD, so it and a literal U+FFFD path are one file on
962
+ // disk while comparing as two.
963
+ for (const bad of ["facts/\ud800.md", "facts/\udc00.md"]) {
964
+ assert.throws(
965
+ () =>
966
+ planNamespaceReconciliation({
967
+ namespace: "default",
968
+ local: [{ path: bad, sha256: digest("x") }],
969
+ peer: [],
970
+ }),
971
+ /unpaired surrogate/,
972
+ );
973
+ }
974
+ // A well-formed pair is fine.
975
+ assert.doesNotThrow(() =>
976
+ planNamespaceReconciliation({
977
+ namespace: "default",
978
+ local: [file("facts/\ud83d\ude00.md", "x")],
979
+ peer: [],
980
+ }),
981
+ );
982
+ });
983
+
984
+ test("the base cursor is validated with the same rules as the other censuses", () => {
985
+ assert.throws(
986
+ () =>
987
+ planNamespaceReconciliation({
988
+ namespace: "default",
989
+ local: [],
990
+ peer: [],
991
+ base: [file("facts/a.md", "one"), file("facts/a.md", "two")],
992
+ }),
993
+ /base census for namespace default lists facts\/a\.md twice with different digests/,
994
+ );
995
+ // And it still drives the one-sided-change decision it exists for.
996
+ const entries = planNamespaceReconciliation({
997
+ namespace: "default",
998
+ local: [file("facts/a.md", "v1")],
999
+ peer: [file("facts/a.md", "v2")],
1000
+ base: [file("facts/a.md", "v1")],
1001
+ });
1002
+ assert.equal(entryFor(entries, "facts/a.md").action, "pull");
1003
+ assert.equal(entryFor(entries, "facts/a.md").baseSha256, digest("v1"));
1004
+ });
1005
+
1006
+ test("a namespace with an unpaired surrogate is rejected", () => {
1007
+ // namespaceIdentityToken() encodes through TextEncoder, so this and a
1008
+ // literal U+FFFD namespace are one directory on disk.
1009
+ assert.throws(
1010
+ () => planNamespaceReconciliation({ namespace: "\ud800", local: [], peer: [] }),
1011
+ /unpaired surrogate/,
1012
+ );
1013
+ });