@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,813 @@
1
+ import { normalizeNamespaceIdentity } from "../namespaces/identity.js";
2
+ import { OFFLINE_SYNC_MAX_MTIME_MS } from "../offline-sync.js";
3
+ import { validateArchiveRelativePath } from "../transfer/fs-utils.js";
4
+ import type { OfflineSyncFileState } from "../offline-sync.js";
5
+
6
+ /**
7
+ * Bootstrap reconciliation planner for two peer daemons whose corpora have
8
+ * already diverged (issue #2150).
9
+ *
10
+ * The offline-sync protocol assumes a satellite that shares a common base with
11
+ * one daemon: every path it does not know about is a pull, and a conflict is
12
+ * rare. Two long-lived daemons are the opposite case — each side holds months
13
+ * of history the other never saw, both sides are authoritative, and there is
14
+ * NO common base on the first run. That is a different decision problem, so it
15
+ * gets its own planner rather than another mode flag inside `applyOfflineSync*`.
16
+ *
17
+ * This module is deliberately pure: it takes two file censuses and returns what
18
+ * to do. No I/O, no transport, no clock. Everything that makes reconciliation
19
+ * risky — which side wins, what counts as converged, what is reported — is
20
+ * decided here where it can be exhaustively tested, and the transport layer is
21
+ * left to carry out an already-settled plan.
22
+ */
23
+
24
+ /** Which way a path must move for the two corpora to agree. */
25
+ export type ReconcileAction = "pull" | "push" | "identical" | "conflict" | "suppress";
26
+
27
+ /**
28
+ * How a path present on BOTH sides with different content is settled.
29
+ *
30
+ * `manual` is the safe default: on a bootstrap merge the two sides are equally
31
+ * authoritative, so silently picking one is data loss with extra steps. An
32
+ * operator opts into an automatic rule once they know the shape of their split.
33
+ */
34
+ export type ReconcileConflictPolicy = "manual" | "newest-wins" | "keep-both";
35
+
36
+ /**
37
+ * What the planner decided for a conflicting path.
38
+ *
39
+ * `keep-both` never overwrites: the older side is retained and linked as
40
+ * superseded by the newer one, which is the only resolution that cannot lose a
41
+ * fact neither corpus has seen.
42
+ */
43
+ export type ReconcileResolution = "local-wins" | "peer-wins" | "supersede-link" | "unresolved";
44
+
45
+ export interface ReconcilePlanEntry {
46
+ path: string;
47
+ namespace: string;
48
+ action: ReconcileAction;
49
+ /** Stable machine-readable cause, safe to assert on and to aggregate. */
50
+ reason: ReconcileReason;
51
+ localSha256?: string;
52
+ peerSha256?: string;
53
+ /** Present only when a prior converged run left a cursor covering this path. */
54
+ baseSha256?: string;
55
+ /** Set only when `action` is `conflict`. */
56
+ resolution?: ReconcileResolution;
57
+ /**
58
+ * Which side holds the retracted revision. Set only when `action` is
59
+ * `suppress`: with different digests on each side and only one retracted,
60
+ * the entry would otherwise be identical either way and transport could
61
+ * delete the live copy instead of the retracted one.
62
+ */
63
+ suppressSide?: "local" | "peer" | "both";
64
+ /**
65
+ * Which revision is newer, set only for a `supersede-link` resolution.
66
+ *
67
+ * The contract is that the older revision is linked as superseded by the
68
+ * newer one, so transport needs the direction. Absent when the timestamps
69
+ * cannot order the two - which is exactly when `newest-wins` degrades to
70
+ * `supersede-link` - and the link direction is then an operator decision.
71
+ */
72
+ newerSide?: "local" | "peer";
73
+ }
74
+
75
+ export type ReconcileReason =
76
+ | "peer_only"
77
+ | "local_only"
78
+ /** Cursor showed only that side moved since agreement; both still hold the path. */
79
+ | "peer_changed"
80
+ | "local_changed"
81
+ | "same_content"
82
+ | "both_modified"
83
+ | "peer_deleted"
84
+ | "local_deleted"
85
+ /** Peer deleted it while this side edited it — the offline-sync delete/modify pair. */
86
+ | "local_modified_peer_deleted"
87
+ | "local_deleted_peer_modified"
88
+ | "tombstoned";
89
+
90
+ export interface ReconcileNamespaceReport {
91
+ namespace: string;
92
+ pull: number;
93
+ push: number;
94
+ identical: number;
95
+ conflict: number;
96
+ /** Local retractions the peer must be told about before the pair agrees. */
97
+ suppress: number;
98
+ /**
99
+ * Conflicts still needing an operator: those the policy declined to settle,
100
+ * plus supersede links whose direction could not be determined.
101
+ */
102
+ unresolved: number;
103
+ }
104
+
105
+ export interface ReconcilePlan {
106
+ entries: ReconcilePlanEntry[];
107
+ byNamespace: ReconcileNamespaceReport[];
108
+ /**
109
+ * True when the two corpora already agree — every shared path matches and
110
+ * neither side holds a path the other lacks.
111
+ *
112
+ * This is the idempotency contract from #2150: running reconciliation against
113
+ * an already-converged peer must be a no-op, and a caller can skip the whole
114
+ * transfer phase on this flag alone.
115
+ */
116
+ converged: boolean;
117
+ }
118
+
119
+ /** Minimal shape the planner needs; `OfflineSyncFileState` satisfies it. */
120
+ export type ReconcileFileState = Pick<OfflineSyncFileState, "path" | "sha256"> &
121
+ Partial<Pick<OfflineSyncFileState, "mtimeMs" | "bytes">>;
122
+
123
+ export interface ReconcileNamespaceInput {
124
+ namespace: string;
125
+ local: Iterable<ReconcileFileState>;
126
+ peer: Iterable<ReconcileFileState>;
127
+ /**
128
+ * File states agreed at the end of the last converged run with THIS peer.
129
+ * Absent on a bootstrap merge, which is why a path missing from one side is
130
+ * read as "never seen" rather than "deleted".
131
+ */
132
+ base?: Iterable<ReconcileFileState>;
133
+ /**
134
+ * Digests of FILES this side has retracted, in the same form as
135
+ * `ReconcileFileState.sha256` — a hash of the serialized file.
136
+ *
137
+ * Deliberately NOT `TombstoneEntry.contentHash`, which hashes the canonical
138
+ * raw fact text and therefore never equals a file digest (§13: one content
139
+ * form, everywhere). Mapping retracted fact hashes onto the file digests that
140
+ * carry them is the caller's job, because only the caller can read its own
141
+ * corpus; handing this the wrong form would silently plan `pull` and
142
+ * resurrect every retracted fact, so the parameter name states the form.
143
+ */
144
+ tombstonedFileSha256?: Iterable<string>;
145
+ /**
146
+ * Digests the PEER has retracted, same form.
147
+ *
148
+ * Without this a bootstrap merge cannot tell "the peer never had it" from
149
+ * "the peer deliberately retracted it": the peer census simply omits both,
150
+ * so a file we still hold is planned `push` and the peer's retraction is
151
+ * undone. Reconciliation is symmetric, so retraction has to be too.
152
+ *
153
+ * SCOPE: these sets decide what happens to files that still EXIST on one
154
+ * side. Sharing the retraction records themselves is not modelled here -
155
+ * tombstones are corpus files (`state/tombstones.jsonl`), so they reconcile
156
+ * as ordinary paths through this same plan. A digest both censuses have
157
+ * already dropped therefore produces no entry, by design: there is no file
158
+ * left to act on, and synthesizing a path-less entry would hand transport
159
+ * something it cannot apply.
160
+ */
161
+ peerTombstonedFileSha256?: Iterable<string>;
162
+ }
163
+
164
+ export interface ReconcileOptions {
165
+ conflictPolicy?: ReconcileConflictPolicy;
166
+ }
167
+
168
+ /**
169
+ * A census the planner refuses to reason about.
170
+ *
171
+ * Dropping a malformed or contradictory record would let the planner return
172
+ * `converged: true` for a corpus it could not actually read, and transport is
173
+ * invited to skip everything on that flag — so bad input fails loudly instead
174
+ * (§1/§39).
175
+ */
176
+ export class ReconcilePlanInputError extends Error {
177
+ constructor(message: string) {
178
+ super(message);
179
+ this.name = "ReconcilePlanInputError";
180
+ }
181
+ }
182
+
183
+ /** Matches `assertSha256` in offline-sync so both surfaces reject the same values (§40). */
184
+ const SHA256_PATTERN = /^[a-f0-9]{64}$/i;
185
+
186
+ /**
187
+ * Validate a census record's identity fields.
188
+ *
189
+ * The digest matters as much as the path: two records that both omit `sha256`
190
+ * compare `undefined === undefined` and plan as `identical`, converging a
191
+ * corpus the planner never actually read.
192
+ */
193
+ function assertCensusRecord(
194
+ file: ReconcileFileState | undefined,
195
+ side: string,
196
+ namespace: string,
197
+ ): ReconcileFileState {
198
+ const path = file?.path;
199
+ if (typeof path !== "string" || path.length === 0) {
200
+ throw new ReconcilePlanInputError(
201
+ `reconcile: ${side} census for namespace ${namespace} contains a record with no path`,
202
+ );
203
+ }
204
+ // Same boundary offline-sync applies to this field: a peer census is
205
+ // untrusted input, and an absolute or traversal path would otherwise become
206
+ // a transfer instruction pointing outside the corpus root.
207
+ try {
208
+ validateArchiveRelativePath(path, `reconcile: ${side} census for namespace ${namespace}`);
209
+ } catch (err) {
210
+ throw new ReconcilePlanInputError(err instanceof Error ? err.message : String(err));
211
+ }
212
+ assertPortablePathSegments(path, side, namespace);
213
+ return {
214
+ ...file,
215
+ path,
216
+ sha256: assertDigest(file?.sha256, `${side} census for namespace ${namespace} entry ${path}`),
217
+ ...(file?.mtimeMs === undefined
218
+ ? {}
219
+ : { mtimeMs: assertMtimeMs(file.mtimeMs, `${side} census for namespace ${namespace} entry ${path}`) }),
220
+ };
221
+ }
222
+
223
+ /**
224
+ * `newest-wins` decides which corpus keeps its history from this number, so a
225
+ * NaN, an Infinity or a value past the Date range must not pick the winner.
226
+ *
227
+ * Fractional values ARE valid: `fs.stat()` reports sub-millisecond mtimes on
228
+ * common filesystems and offline-sync forwards them unrounded, so this matches
229
+ * its `assertOfflineSyncMtimeMs` — non-negative finite within Date range — and
230
+ * deliberately does not require an integer.
231
+ */
232
+ function assertMtimeMs(value: unknown, context: string): number {
233
+ if (
234
+ typeof value !== "number"
235
+ || !Number.isFinite(value)
236
+ || value < 0
237
+ || value > OFFLINE_SYNC_MAX_MTIME_MS
238
+ ) {
239
+ throw new ReconcilePlanInputError(
240
+ `reconcile: ${context} has an out-of-range mtimeMs; expected a finite value between 0 and ${OFFLINE_SYNC_MAX_MTIME_MS}`,
241
+ );
242
+ }
243
+ return value;
244
+ }
245
+
246
+ /**
247
+ * Digests are canonicalized to lowercase, exactly as offline-sync's
248
+ * `assertSha256` does. Keeping the caller's spelling would make two forms of
249
+ * one digest compare unequal, so identical files would plan `both_modified`
250
+ * and tombstone lookups would silently miss.
251
+ */
252
+ function assertDigest(value: unknown, context: string): string {
253
+ if (typeof value !== "string" || !SHA256_PATTERN.test(value)) {
254
+ throw new ReconcilePlanInputError(
255
+ `reconcile: ${context} must carry a 64-character sha256 hex digest`,
256
+ );
257
+ }
258
+ return value.toLowerCase();
259
+ }
260
+
261
+ function isPlainObject(value: unknown): boolean {
262
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
263
+ const proto: unknown = Object.getPrototypeOf(value);
264
+ return proto === Object.prototype || proto === null;
265
+ }
266
+
267
+ function assertNamespace(namespace: unknown): string {
268
+ if (typeof namespace !== "string" || namespace.length === 0) {
269
+ throw new ReconcilePlanInputError("reconcile: every namespace input needs a non-empty namespace");
270
+ }
271
+ // `team` and ` team ` are one namespace to the rest of core, so accepting
272
+ // both here would slip two inputs past the duplicate check and let them plan
273
+ // contradictory actions for the same path. Rejected rather than silently
274
+ // rewritten, so the namespace on every entry is the caller's own string.
275
+ if (LONE_SURROGATE.test(namespace)) {
276
+ // `namespaceIdentityToken()` encodes through TextEncoder, so a lone
277
+ // surrogate and a literal U+FFFD collapse to ONE storage identity while
278
+ // comparing as two here - slipping both past the duplicate-namespace guard.
279
+ throw new ReconcilePlanInputError(
280
+ "reconcile: namespace contains an unpaired surrogate; it would collide with another namespace on disk",
281
+ );
282
+ }
283
+ if (normalizeNamespaceIdentity(namespace) !== namespace) {
284
+ throw new ReconcilePlanInputError(
285
+ `reconcile: namespace ${JSON.stringify(namespace)} is not canonical; pass ${JSON.stringify(normalizeNamespaceIdentity(namespace))}`,
286
+ );
287
+ }
288
+ return namespace;
289
+ }
290
+
291
+ function assertIterable(value: unknown, side: string, namespace: string): Iterable<ReconcileFileState> {
292
+ if (value === null || typeof value !== "object" || typeof (value as Iterable<ReconcileFileState>)[Symbol.iterator] !== "function") {
293
+ throw new ReconcilePlanInputError(`reconcile: ${side} census for namespace ${namespace} must be iterable`);
294
+ }
295
+ return value as Iterable<ReconcileFileState>;
296
+ }
297
+
298
+ /**
299
+ * Two paths that a participant's filesystem resolves to ONE file must not draw
300
+ * separate push/pull work, or application order decides which revision
301
+ * survives. Remnic is explicitly multi-platform, so all three aliasing rules
302
+ * are folded together and a collision is rejected rather than raced:
303
+ * case (macOS, Windows), Unicode normalization (macOS stores decomposed and
304
+ * compares canonically), and Win32 trailing dots/spaces, which the Windows API
305
+ * strips before touching disk.
306
+ */
307
+ // eslint-disable-next-line no-control-regex -- control characters are exactly what this rejects
308
+ const WIN32_INVALID_CHARS = /[<>:"|?*\u0000-\u001f]/;
309
+ // Windows treats the superscripts as their digits in COM/LPT device names.
310
+ const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
311
+ const WIN32_RESERVED_NAMES = /^(con|prn|aux|nul|(com|lpt)[1-9\u00b9\u00b2\u00b3])$/i;
312
+
313
+ function assertPortablePathSegments(path: string, side: string, namespace: string): void {
314
+ for (const segment of path.split("/")) {
315
+ if (segment.length === 0) continue;
316
+ if (segment.endsWith(".") || segment.endsWith(" ")) {
317
+ throw new ReconcilePlanInputError(
318
+ `reconcile: ${side} census for namespace ${namespace} path ${path} has a segment ending in a dot or space; ` +
319
+ "Windows strips those and would alias it onto another file",
320
+ );
321
+ }
322
+ // `:` opens an alternate data stream, the rest cannot be created at all,
323
+ // and a reserved device name resolves to hardware. A plan containing any of
324
+ // them cannot be applied on a Windows participant.
325
+ if (LONE_SURROGATE.test(segment)) {
326
+ // Node encodes an unpaired surrogate as U+FFFD, so this path and a
327
+ // literal U+FFFD path are one file on disk while comparing as distinct.
328
+ throw new ReconcilePlanInputError(
329
+ `reconcile: ${side} census for namespace ${namespace} path ${path} contains an unpaired surrogate`,
330
+ );
331
+ }
332
+ if (WIN32_INVALID_CHARS.test(segment)) {
333
+ throw new ReconcilePlanInputError(
334
+ `reconcile: ${side} census for namespace ${namespace} path ${path} contains a character Windows cannot store`,
335
+ );
336
+ }
337
+ if (WIN32_RESERVED_NAMES.test(segment.split(".")[0] ?? "")) {
338
+ throw new ReconcilePlanInputError(
339
+ `reconcile: ${side} census for namespace ${namespace} path ${path} uses a reserved Windows device name`,
340
+ );
341
+ }
342
+ }
343
+ }
344
+
345
+ function assertNoPathAlias(
346
+ seen: Map<string, string>,
347
+ path: string,
348
+ namespace: string,
349
+ ): void {
350
+ // NFC first: macOS stores decomposed names and compares canonically, so
351
+ // `é` (U+00E9) and `é` (e + U+0301) are ONE file there. Then a FULL caseless
352
+ // fold - upper-then-lower, which collapses forms a bare toLowerCase() keeps
353
+ // apart, such as final sigma `ς` against `σ`. Lowercasing alone would leave
354
+ // those distinct and the planner would emit both a push and a pull.
355
+ // `ß` upper-folds to `ss` while `ẞ` folds to `ß`, so one more pass equalizes
356
+ // the pair that a single upper-then-lower still leaves apart.
357
+ const folded = path.normalize("NFC").toUpperCase().toLowerCase().replace(/\u00df/g, "ss");
358
+ const existing = seen.get(folded);
359
+ if (existing !== undefined && existing !== path) {
360
+ throw new ReconcilePlanInputError(
361
+ `reconcile: namespace ${namespace} has aliasing paths (${existing}, ${path}); ` +
362
+ "they resolve to one file on a case-insensitive or Unicode-normalizing peer",
363
+ );
364
+ }
365
+ seen.set(folded, path);
366
+ }
367
+
368
+ /**
369
+ * Duplicate paths are accepted only when they agree. Two digests for one path
370
+ * make the plan depend on which record arrived last, which would break the
371
+ * byte-stable ordering the convergence report relies on.
372
+ */
373
+ function rejectConflictingDuplicate(
374
+ existing: { sha256: string; mtimeMs?: number } | undefined,
375
+ incoming: { sha256: string; mtimeMs?: number },
376
+ path: string,
377
+ side: string,
378
+ namespace: string,
379
+ ): boolean {
380
+ if (!existing) return false;
381
+ if (existing.sha256 !== incoming.sha256) {
382
+ throw new ReconcilePlanInputError(
383
+ `reconcile: ${side} census for namespace ${namespace} lists ${path} twice with different digests`,
384
+ );
385
+ }
386
+ // Same bytes but a different mtime is still ambiguous: `newest-wins` reads
387
+ // that timestamp, so accepting the first arrival would let input order decide
388
+ // the winner.
389
+ if (existing.mtimeMs !== incoming.mtimeMs) {
390
+ throw new ReconcilePlanInputError(
391
+ `reconcile: ${side} census for namespace ${namespace} lists ${path} twice with different mtimeMs`,
392
+ );
393
+ }
394
+ return true;
395
+ }
396
+
397
+ function indexByPath(
398
+ files: Iterable<ReconcileFileState>,
399
+ side: string,
400
+ namespace: string,
401
+ ): Map<string, ReconcileFileState> {
402
+ const index = new Map<string, ReconcileFileState>();
403
+ for (const raw of files) {
404
+ const file = assertCensusRecord(raw, side, namespace);
405
+ if (rejectConflictingDuplicate(index.get(file.path), file, file.path, side, namespace)) continue;
406
+ index.set(file.path, file);
407
+ }
408
+ return index;
409
+ }
410
+
411
+ /**
412
+ * A bare string satisfies `Iterable<string>`, and `new Set("abc…")` would split
413
+ * it into 64 one-character members — every membership test then misses and each
414
+ * retracted file is planned as `pull` and resurrected. Reject the scalar and
415
+ * canonicalize every member.
416
+ */
417
+ function parseTombstonedDigests(
418
+ value: Iterable<string> | undefined,
419
+ namespace: string,
420
+ field = "tombstonedFileSha256",
421
+ ): Set<string> {
422
+ if (value === undefined) return new Set();
423
+ if (typeof value === "string") {
424
+ throw new ReconcilePlanInputError(
425
+ `reconcile: ${field} for namespace ${namespace} must be a collection of digests, not a single string`,
426
+ );
427
+ }
428
+ if (value === null || typeof value !== "object" || typeof value[Symbol.iterator] !== "function") {
429
+ // Otherwise the for...of below throws a raw TypeError and a caller handling
430
+ // ReconcilePlanInputError cannot tell a bad request from a planner bug.
431
+ throw new ReconcilePlanInputError(
432
+ `reconcile: ${field} for namespace ${namespace} must be an iterable collection of digests`,
433
+ );
434
+ }
435
+ const digests = new Set<string>();
436
+ for (const entry of value) {
437
+ digests.add(assertDigest(entry, `${field} for namespace ${namespace}`));
438
+ }
439
+ return digests;
440
+ }
441
+
442
+ /**
443
+ * Validate a census while keeping ONLY path -> digest.
444
+ *
445
+ * The base cursor is read for digests alone, so indexing full records and
446
+ * compacting afterwards would hold a corpus-sized record map and its copy at
447
+ * once - the peak this avoids.
448
+ */
449
+ function indexDigestsByPath(
450
+ files: Iterable<ReconcileFileState>,
451
+ side: string,
452
+ namespace: string,
453
+ ): Map<string, string> {
454
+ const index = new Map<string, string>();
455
+ for (const raw of files) {
456
+ const file = assertCensusRecord(raw, side, namespace);
457
+ const existing = index.get(file.path);
458
+ // One map, not a parallel `seen`: the base's own mtimeMs is never a
459
+ // decision input (only the local and peer timestamps order a conflict), so
460
+ // a duplicate that agrees on the digest is unambiguous here regardless of
461
+ // its timestamp, and the digest alone is enough to detect a real clash.
462
+ if (existing !== undefined) {
463
+ if (existing !== file.sha256) {
464
+ throw new ReconcilePlanInputError(
465
+ `reconcile: ${side} census for namespace ${namespace} lists ${file.path} twice with different digests`,
466
+ );
467
+ }
468
+ continue;
469
+ }
470
+ index.set(file.path, file.sha256);
471
+ }
472
+ return index;
473
+ }
474
+
475
+ const RECONCILE_CONFLICT_POLICIES: readonly ReconcileConflictPolicy[] = ["manual", "newest-wins", "keep-both"];
476
+
477
+ function assertConflictPolicy(value: ReconcileConflictPolicy | undefined): ReconcileConflictPolicy {
478
+ if (value === undefined) return "manual";
479
+ if (!RECONCILE_CONFLICT_POLICIES.includes(value)) {
480
+ throw new ReconcilePlanInputError(
481
+ `reconcile: unknown conflictPolicy ${JSON.stringify(value)}; expected one of ${RECONCILE_CONFLICT_POLICIES.join(", ")}`,
482
+ );
483
+ }
484
+ return value;
485
+ }
486
+
487
+ /**
488
+ * Total ordering for plan entries (§12): namespace, then path. Both are unique
489
+ * per entry, so equal keys are impossible and the comparator never has to
490
+ * return 0 for distinct rows — the output is byte-identical across runs, which
491
+ * is what makes a convergence report diffable.
492
+ */
493
+ function compareEntries(a: ReconcilePlanEntry, b: ReconcilePlanEntry): number {
494
+ if (a.namespace !== b.namespace) return a.namespace < b.namespace ? -1 : 1;
495
+ if (a.path !== b.path) return a.path < b.path ? -1 : 1;
496
+ return 0;
497
+ }
498
+
499
+ /**
500
+ * Direction for a supersede link, when the timestamps can order the pair.
501
+ * Omitted otherwise so an unordered link is visibly unordered rather than
502
+ * silently defaulted to one side.
503
+ */
504
+ function newerSideOf(
505
+ local: ReconcileFileState,
506
+ peer: ReconcileFileState,
507
+ ): { newerSide?: "local" | "peer" } {
508
+ const localMs = local.mtimeMs;
509
+ const peerMs = peer.mtimeMs;
510
+ if (typeof localMs !== "number" || typeof peerMs !== "number" || localMs === peerMs) return {};
511
+ return { newerSide: localMs > peerMs ? "local" : "peer" };
512
+ }
513
+
514
+ function resolveConflict(
515
+ policy: ReconcileConflictPolicy,
516
+ local: ReconcileFileState,
517
+ peer: ReconcileFileState,
518
+ ): ReconcileResolution {
519
+ if (policy === "keep-both") return "supersede-link";
520
+ if (policy !== "newest-wins") return "unresolved";
521
+ const localMs = typeof local.mtimeMs === "number" && Number.isFinite(local.mtimeMs) ? local.mtimeMs : null;
522
+ const peerMs = typeof peer.mtimeMs === "number" && Number.isFinite(peer.mtimeMs) ? peer.mtimeMs : null;
523
+ // Without a usable timestamp on both sides "newest" is not decidable, and a
524
+ // coin flip here silently discards one side's history. Fall back to keeping
525
+ // both rather than inventing an order.
526
+ if (localMs === null || peerMs === null) return "supersede-link";
527
+ if (localMs === peerMs) return "supersede-link";
528
+ return localMs > peerMs ? "local-wins" : "peer-wins";
529
+ }
530
+
531
+ /**
532
+ * Plan one namespace. Exposed for callers that stream namespaces one at a time.
533
+ *
534
+ * Residency is one peer index + a set of local paths + the entry list. The
535
+ * local census is consumed as a stream and never materialized, so a 100k-file
536
+ * corpus does not hold two full censuses at once — but this is NOT constant
537
+ * memory, and a caller holding its own census arrays adds to that.
538
+ */
539
+ export function planNamespaceReconciliation(
540
+ input: ReconcileNamespaceInput,
541
+ options: ReconcileOptions = {},
542
+ ): ReconcilePlanEntry[] {
543
+ if (!isPlainObject(input)) {
544
+ throw new ReconcilePlanInputError("reconcile: namespace input must be a plain object");
545
+ }
546
+ if (!isPlainObject(options)) {
547
+ // A Date, Map or RegExp passes a bare typeof check, exposes no
548
+ // `conflictPolicy`, and would silently take the default policy - hiding a
549
+ // malformed request behind `manual`.
550
+ throw new ReconcilePlanInputError("reconcile: options must be a plain object");
551
+ }
552
+ const namespace = assertNamespace(input.namespace);
553
+ const policy = assertConflictPolicy(options.conflictPolicy);
554
+ const localCensus = assertIterable(input.local, "local", namespace);
555
+ const caseFold = new Map<string, string>();
556
+ // Index the peer census only, then stream the local one against it, removing
557
+ // each match as it is consumed. Peak residency is one index plus the entry
558
+ // list rather than two full censuses (round 2, codex P2).
559
+ const peer = indexByPath(assertIterable(input.peer, "peer", namespace), "peer", namespace);
560
+ // Only `base.sha256` is ever read, so the base is compacted to path -> digest
561
+ // instead of a second full record index (round 7, codex P2).
562
+ // Only an ABSENT base means bootstrap. A null/invalid cursor silently read as
563
+ // "no prior agreement" would turn a peer-side deletion into a push and
564
+ // resurrect it.
565
+ const base = input.base === undefined
566
+ ? null
567
+ : indexDigestsByPath(assertIterable(input.base, "base", namespace), "base", namespace);
568
+ const tombstoned = parseTombstonedDigests(input.tombstonedFileSha256, namespace);
569
+ const peerTombstoned = parseTombstonedDigests(
570
+ input.peerTombstonedFileSha256,
571
+ namespace,
572
+ "peerTombstonedFileSha256",
573
+ );
574
+ const entries: ReconcilePlanEntry[] = [];
575
+
576
+ // Path -> digest only, never the file objects: enough to make the stream
577
+ // idempotent and to catch contradictory duplicates without materializing the
578
+ // second census.
579
+ // Base paths participate in the collision check: a base `Facts/A.md` against
580
+ // a local `facts/a.md` is the same delete/change ambiguity on a
581
+ // case-insensitive participant.
582
+ if (base) for (const basePath of base.keys()) assertNoPathAlias(caseFold, basePath, namespace);
583
+ // Path -> decision-relevant fields only, never the whole record: enough to
584
+ // make the stream idempotent and to catch contradictory duplicates without
585
+ // materializing the second census.
586
+ const seenLocal = new Map<string, { sha256: string; mtimeMs?: number }>();
587
+ for (const rawLocal of localCensus) {
588
+ const localFile = assertCensusRecord(rawLocal, "local", namespace);
589
+ const path = localFile.path;
590
+ assertNoPathAlias(caseFold, path, namespace);
591
+ const seen = seenLocal.get(path);
592
+ if (seen !== undefined) {
593
+ // Same rule the indexed censuses get: mtimeMs is decision-relevant under
594
+ // `newest-wins`, so a duplicate that disagrees on it is ambiguous too.
595
+ if (rejectConflictingDuplicate(seen, localFile, path, "local", namespace)) continue;
596
+ }
597
+ seenLocal.set(path, { sha256: localFile.sha256, mtimeMs: localFile.mtimeMs });
598
+ const peerFile = peer.get(path);
599
+ // Consumed: whatever remains in the index afterwards is peer-only.
600
+ peer.delete(path);
601
+ const baseSha256 = base?.get(path);
602
+ // Retraction outranks every other decision for this path, on EITHER side.
603
+ // Checked before hash comparison and conflict resolution: a retracted peer
604
+ // revision reaching the conflict ladder can win under `newest-wins`, and a
605
+ // retracted LOCAL revision would otherwise be pushed - which is also how a
606
+ // suppression undoes itself on the next run, since the local copy survives
607
+ // the peer-side delete (round 4).
608
+ // Either side's retraction removes the copy that carries the digest, so a
609
+ // peer retraction of something we still hold suppresses OUR copy rather
610
+ // than pushing it back.
611
+ const localRetracted = tombstoned.has(localFile.sha256) || peerTombstoned.has(localFile.sha256);
612
+ const peerRetracted =
613
+ peerFile !== undefined && (tombstoned.has(peerFile.sha256) || peerTombstoned.has(peerFile.sha256));
614
+ if (localRetracted || peerRetracted) {
615
+ entries.push({
616
+ path,
617
+ namespace,
618
+ action: "suppress",
619
+ reason: "tombstoned",
620
+ localSha256: localFile.sha256,
621
+ ...(peerFile ? { peerSha256: peerFile.sha256 } : {}),
622
+ ...(baseSha256 === undefined ? {} : { baseSha256 }),
623
+ suppressSide: localRetracted && peerRetracted ? "both" : localRetracted ? "local" : "peer",
624
+ });
625
+ continue;
626
+ }
627
+ if (!peerFile) {
628
+ // Base PRESENCE is what proves a deletion, not equality with whatever the
629
+ // surviving side now holds. If we also edited since the base, this is
630
+ // delete-versus-modify: still a conflict, and pushing it would resurrect
631
+ // a deliberate deletion. Without a base the peer simply never saw the
632
+ // path, and a bootstrap merge must push — both sides hold unique data.
633
+ if (baseSha256 !== undefined) {
634
+ entries.push({
635
+ path,
636
+ namespace,
637
+ action: "conflict",
638
+ reason: baseSha256 === localFile.sha256 ? "peer_deleted" : "local_modified_peer_deleted",
639
+ localSha256: localFile.sha256,
640
+ baseSha256,
641
+ resolution: "unresolved",
642
+ });
643
+ continue;
644
+ }
645
+ entries.push({
646
+ path,
647
+ namespace,
648
+ action: "push",
649
+ reason: "local_only",
650
+ localSha256: localFile.sha256,
651
+ });
652
+ continue;
653
+ }
654
+ if (peerFile.sha256 === localFile.sha256) {
655
+ entries.push({
656
+ path,
657
+ namespace,
658
+ action: "identical",
659
+ reason: "same_content",
660
+ localSha256: localFile.sha256,
661
+ peerSha256: peerFile.sha256,
662
+ ...(baseSha256 === undefined ? {} : { baseSha256 }),
663
+ });
664
+ continue;
665
+ }
666
+ // A base that matches one side turns a "conflict" into an ordinary
667
+ // one-sided change: that side is the only one that moved since agreement.
668
+ if (baseSha256 !== undefined && baseSha256 === localFile.sha256) {
669
+ entries.push({
670
+ path,
671
+ namespace,
672
+ action: "pull",
673
+ reason: "peer_changed",
674
+ localSha256: localFile.sha256,
675
+ peerSha256: peerFile.sha256,
676
+ baseSha256,
677
+ });
678
+ continue;
679
+ }
680
+ if (baseSha256 !== undefined && baseSha256 === peerFile.sha256) {
681
+ entries.push({
682
+ path,
683
+ namespace,
684
+ action: "push",
685
+ reason: "local_changed",
686
+ localSha256: localFile.sha256,
687
+ peerSha256: peerFile.sha256,
688
+ baseSha256,
689
+ });
690
+ continue;
691
+ }
692
+ const resolution = resolveConflict(policy, localFile, peerFile);
693
+ entries.push({
694
+ path,
695
+ namespace,
696
+ action: "conflict",
697
+ reason: "both_modified",
698
+ localSha256: localFile.sha256,
699
+ peerSha256: peerFile.sha256,
700
+ ...(baseSha256 === undefined ? {} : { baseSha256 }),
701
+ resolution,
702
+ ...(resolution === "supersede-link" ? newerSideOf(localFile, peerFile) : {}),
703
+ });
704
+ }
705
+
706
+ for (const [path, peerFile] of peer) {
707
+ assertNoPathAlias(caseFold, path, namespace);
708
+ const baseSha256 = base?.get(path);
709
+ if (tombstoned.has(peerFile.sha256) || peerTombstoned.has(peerFile.sha256)) {
710
+ // Retracted here on purpose, and the peer still serves it. Pulling it
711
+ // back would undo the retraction; calling it `identical` would be worse,
712
+ // because a converged plan lets transport skip everything and the peer
713
+ // keeps serving the retracted fact forever. It is work: propagate the
714
+ // tombstone.
715
+ entries.push({
716
+ path,
717
+ namespace,
718
+ action: "suppress",
719
+ reason: "tombstoned",
720
+ peerSha256: peerFile.sha256,
721
+ ...(baseSha256 === undefined ? {} : { baseSha256 }),
722
+ suppressSide: "peer",
723
+ });
724
+ continue;
725
+ }
726
+ if (baseSha256 !== undefined) {
727
+ entries.push({
728
+ path,
729
+ namespace,
730
+ action: "conflict",
731
+ reason: baseSha256 === peerFile.sha256 ? "local_deleted" : "local_deleted_peer_modified",
732
+ peerSha256: peerFile.sha256,
733
+ baseSha256,
734
+ resolution: "unresolved",
735
+ });
736
+ continue;
737
+ }
738
+ entries.push({
739
+ path,
740
+ namespace,
741
+ action: "pull",
742
+ reason: "peer_only",
743
+ peerSha256: peerFile.sha256,
744
+ });
745
+ }
746
+
747
+ return entries.sort(compareEntries);
748
+ }
749
+
750
+ /** Aggregate entries into the per-namespace convergence report (#2150). */
751
+ export function summarizeReconcilePlan(entries: readonly ReconcilePlanEntry[]): ReconcileNamespaceReport[] {
752
+ const byNamespace = new Map<string, ReconcileNamespaceReport>();
753
+ for (const entry of entries) {
754
+ let report = byNamespace.get(entry.namespace);
755
+ if (!report) {
756
+ report = { namespace: entry.namespace, pull: 0, push: 0, identical: 0, conflict: 0, suppress: 0, unresolved: 0 };
757
+ byNamespace.set(entry.namespace, report);
758
+ }
759
+ report[entry.action] += 1;
760
+ // A supersede link with no `newerSide` could not be ordered, and the link
761
+ // direction is then an operator decision - so it counts as unresolved even
762
+ // though the policy nominally settled it.
763
+ const needsOperator =
764
+ entry.resolution === "unresolved"
765
+ || (entry.resolution === "supersede-link" && entry.newerSide === undefined);
766
+ if (entry.action === "conflict" && needsOperator) report.unresolved += 1;
767
+ }
768
+ return [...byNamespace.values()].sort((a, b) => (a.namespace === b.namespace ? 0 : a.namespace < b.namespace ? -1 : 1));
769
+ }
770
+
771
+ /**
772
+ * Plan a full reconciliation across namespaces.
773
+ *
774
+ * `converged` is an affirmative claim that nothing needs to move, so it is
775
+ * derived from the entries rather than tracked alongside them: any action other
776
+ * than `identical` disproves it.
777
+ */
778
+ export function planReconciliation(
779
+ namespaces: readonly ReconcileNamespaceInput[],
780
+ options: ReconcileOptions = {},
781
+ ): ReconcilePlan {
782
+ if (!Array.isArray(namespaces)) {
783
+ throw new ReconcilePlanInputError("reconcile: planReconciliation expects an array of namespace inputs");
784
+ }
785
+ // Validated here too: with an empty array the per-namespace path never runs,
786
+ // and a malformed options envelope would return `converged` instead of
787
+ // raising - the same call failing or succeeding based on list length.
788
+ if (!isPlainObject(options)) {
789
+ throw new ReconcilePlanInputError("reconcile: options must be a plain object");
790
+ }
791
+ assertConflictPolicy(options.conflictPolicy);
792
+ const entries: ReconcilePlanEntry[] = [];
793
+ // Two inputs for one namespace are planned independently, so the same
794
+ // (namespace, path) can draw contradictory actions - and because those
795
+ // entries also sort equal, batch order would decide which revision survives.
796
+ const seenNamespaces = new Set<string>();
797
+ for (const namespace of namespaces) {
798
+ const name = assertNamespace(namespace?.namespace);
799
+ if (seenNamespaces.has(name)) {
800
+ throw new ReconcilePlanInputError(
801
+ `reconcile: namespace ${name} appears twice; merge its censuses before planning`,
802
+ );
803
+ }
804
+ seenNamespaces.add(name);
805
+ entries.push(...planNamespaceReconciliation(namespace, options));
806
+ }
807
+ entries.sort(compareEntries);
808
+ return {
809
+ entries,
810
+ byNamespace: summarizeReconcilePlan(entries),
811
+ converged: entries.every((entry) => entry.action === "identical"),
812
+ };
813
+ }