amalgm 0.1.245 → 0.1.246

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 (157) hide show
  1. package/README.md +12 -0
  2. package/lib/cli.js +146 -8
  3. package/lib/layout.js +89 -4
  4. package/lib/shared-realtime-tunnel.js +384 -43
  5. package/lib/supervisor.js +0 -3
  6. package/package.json +2 -2
  7. package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
  8. package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
  9. package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
  10. package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
  11. package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
  12. package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
  13. package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
  14. package/runtime/scripts/amalgm-mcp/index.js +4 -0
  15. package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
  16. package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
  17. package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
  18. package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
  19. package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
  20. package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
  21. package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
  22. package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
  23. package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
  24. package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
  25. package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
  26. package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
  27. package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
  28. package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
  29. package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
  30. package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
  31. package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
  32. package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
  33. package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
  34. package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
  35. package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
  36. package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
  37. package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
  38. package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
  39. package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
  40. package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
  41. package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
  42. package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
  43. package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
  44. package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
  45. package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
  46. package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
  47. package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
  48. package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
  49. package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
  50. package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
  51. package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
  52. package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
  53. package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
  54. package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
  55. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
  56. package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
  57. package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
  58. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
  59. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
  60. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
  61. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
  62. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
  63. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
  64. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
  65. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
  66. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
  67. package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
  68. package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
  69. package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
  70. package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
  71. package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
  72. package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
  73. package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
  74. package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
  75. package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
  76. package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
  77. package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
  78. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
  79. package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
  80. package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
  81. package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
  82. package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
  83. package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
  84. package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
  85. package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
  86. package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
  87. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
  88. package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
  89. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
  90. package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
  91. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
  92. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
  93. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
  94. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
  95. package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
  96. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
  97. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
  98. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
  99. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
  100. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
  101. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
  102. package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
  103. package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
  104. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
  105. package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
  106. package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
  107. package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
  108. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
  109. package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
  110. package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
  111. package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
  112. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
  113. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
  114. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
  115. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
  116. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
  117. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
  118. package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
  119. package/runtime/scripts/local-gateway.js +1 -0
  120. package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
  121. package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
  122. package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
  123. package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
  124. package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
  125. package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
  126. package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
  127. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
  128. package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
  129. package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
  130. package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
  131. package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
  132. package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
  133. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
  134. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
  135. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
  136. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
  137. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
  138. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
  139. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
  140. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
  141. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
  142. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
  143. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
  144. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
  145. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
  146. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
  147. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
  148. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
  149. package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
  150. package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
  151. package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
  152. package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
  153. package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
  154. package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
  155. package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
  156. package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
  157. package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +0 -3766
@@ -6,8 +6,8 @@
6
6
  * A document is a text file (markdown, code — anything in the fs layer's
7
7
  * text-extension set) whose contents are held in a Y.Doc while open. Edits
8
8
  * travel as Yjs updates in the event log's `patch` slot; the file on disk
9
- * remains the artifact, so everything that reads the file — agents, exports,
10
- * git — keeps working unchanged.
9
+ * remains the artifact (debounced write-back), so everything that reads the
10
+ * file — agents, exports, git — keeps working unchanged.
11
11
  *
12
12
  * Wire contract:
13
13
  * POST /state/docs/open {path} → {resource, path, state, seq}
@@ -30,28 +30,22 @@
30
30
  * (entry.lastDiskText) — our own write-backs and stale watcher events can
31
31
  * never splice, so they can never re-emit.
32
32
  *
33
- * When the doc has ALSO diverged from lastDiskText (live edits arrived since
34
- * the filesystem writer's apparent base), splicing disk text wholesale would
35
- * delete those edits. Instead the disk change three-way merges against
33
+ * When the doc has ALSO diverged from lastDiskText (live edits inside the
34
+ * write-back debounce window), splicing disk text wholesale would delete
35
+ * those edits. Instead the disk change three-way merges against
36
36
  * lastDiskText as the base (see ./merge3): non-overlapping hunks from both
37
37
  * sides land automatically; overlapping hunks keep the live doc's text, and
38
38
  * the full incoming disk text is preserved in a `<name>.conflicted-<stamp>`
39
39
  * sidecar next to the file, with a `conflict` field on the emitted doc
40
- * event. The journal-owned native file transaction then puts the merged text
41
- * on disk only if that exact observed predecessor is still there.
40
+ * event. The next write-back then puts the merged text on disk — the
41
+ * incoming version it replaces lives on in the sidecar.
42
42
  */
43
43
 
44
44
  const crypto = require('crypto');
45
45
  const fs = require('fs');
46
46
  const path = require('path');
47
47
  const { cachedStatement, ensureDocStatesSchema, openLocalDb } = require('./db');
48
- const docDisk = require('./doc-disk');
49
- const {
50
- appendStateEvent,
51
- currentSeq,
52
- insertStateEvent,
53
- publishStateEvent,
54
- } = require('./events');
48
+ const { appendStateEvent, currentSeq } = require('./events');
55
49
  const { merge3 } = require('./merge3');
56
50
  const { trace } = require('./trace');
57
51
 
@@ -59,8 +53,8 @@ const DOC_RESOURCE_PREFIX = 'doc:';
59
53
  const TEXT_KEY = 'content';
60
54
  const MAX_DOC_BYTES = Number.parseInt(process.env.AMALGM_DOC_MAX_BYTES || '', 10) || 2 * 1024 * 1024;
61
55
  const MAX_OPEN_DOCS = 64;
56
+ const DISK_WRITE_DEBOUNCE_MS = 300;
62
57
  const WATCH_RECONCILE_DEBOUNCE_MS = 200;
63
- const WATCH_VERIFY_MS = 5_000;
64
58
  const MAX_UPDATES_PER_CALL = 200;
65
59
 
66
60
  // Bounds on the per-doc disk-sync history ring (see pushDiskHistory): a small
@@ -84,8 +78,8 @@ const WRITE_EXAMPLE = '{"path": "notes.md", "text": "full new file contents", '
84
78
  // doc or feed the reconcile loop.
85
79
  const SIDECAR_NAME_RE = /\.conflicted-\d{8}-\d{6}(?:-\d+)?(?:\.[^./\\]*)?$/;
86
80
 
87
- // resolved path -> { doc, resource, reconcileTimer, lastAccess,
88
- // lastDiskText, lastDiskHash, pathAliases }
81
+ // resolved path -> { doc, resource, reconcileTimer, diskWriteTimer,
82
+ // lastAccess, lastDiskText, lastDiskHash, pathAliases }
89
83
  const openDocs = new Map();
90
84
 
91
85
  // pathInput (as the client sent it) -> resolved path, held only while the doc
@@ -98,9 +92,6 @@ const resolvedPathCache = new Map();
98
92
  // per directory instead of one per doc: N docs in a folder cost one fd, and
99
93
  // the watcher dies when its last doc closes (docs.size is the refcount).
100
94
  const dirWatchers = new Map();
101
- let watchVerificationTimer = null;
102
- let watchVerificationPasses = 0;
103
- let diskTransactionTestHooks = {};
104
95
 
105
96
  function fsPrivate() {
106
97
  return require('../fs/rest')._private;
@@ -122,10 +113,6 @@ function invalid(message, statusCode = 400) {
122
113
  return Object.assign(new Error(message), { statusCode });
123
114
  }
124
115
 
125
- function isJournalCursor(value) {
126
- return Number.isSafeInteger(value) && value >= 0;
127
- }
128
-
129
116
  function hashText(text) {
130
117
  return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
131
118
  }
@@ -146,16 +133,15 @@ function newMutationId() {
146
133
  }
147
134
 
148
135
  // The only way lastDiskText is ever assigned: the hash is what persistence
149
- // writes, and hashing at assignment (rare: open, reconcile, materialize) keeps the
136
+ // writes, and hashing at assignment (rare: open, reconcile, flush) keeps the
150
137
  // full-text SHA-256 off the per-batch persist path. `origin` says how the
151
138
  // text got onto disk — 'disk' (external write folded in), 'flush' (the live
152
- // doc's own native materialization), 'open' (seed of an unchanged file) — and is what the
139
+ // doc's own write-back), 'open' (seed of an unchanged file) — and is what the
153
140
  // overwrite guard reads: a 'flush' head means the last disk state was
154
141
  // produced through the live channel.
155
- function setLastDiskText(entry, text, origin, observation = null) {
142
+ function setLastDiskText(entry, text, origin) {
156
143
  entry.lastDiskText = text;
157
144
  entry.lastDiskHash = text == null ? null : hashText(text);
158
- entry.lastDiskObservation = observation;
159
145
  if (text != null) pushDiskHistory(entry, text, entry.lastDiskHash, origin || 'disk');
160
146
  }
161
147
 
@@ -168,6 +154,7 @@ function pushDiskHistory(entry, text, sha256, origin) {
168
154
  const history = entry.diskHistory;
169
155
  const head = history[history.length - 1];
170
156
  if (head && head.sha256 === sha256) {
157
+ head.seq = currentSeq();
171
158
  head.at = Date.now();
172
159
  return;
173
160
  }
@@ -299,48 +286,21 @@ function isLowSurrogate(code) {
299
286
  return code >= 0xdc00 && code <= 0xdfff;
300
287
  }
301
288
 
302
- function resetMutationAuthor(Y, entry) {
303
- entry.mutationAuthor?.destroy();
304
- entry.mutationAuthor = new Y.Doc();
305
- Y.applyUpdate(
306
- entry.mutationAuthor,
307
- Y.encodeStateAsUpdate(entry.doc),
308
- { amalgmMutation: true, source: 'doc:author-reset', patch: null },
309
- );
310
- }
311
-
312
289
  /**
313
- * Produce the exact update for a whole-text change without touching the live
314
- * document. One stable author is mirrored from the live document before each
315
- * splice, so its Yjs client clock stays bounded. commitDocMutation is the only
316
- * place that applies the bytes, after the durable mutation row exists.
290
+ * Splice `nextText` into the live doc and hand back the exact update it
291
+ * produced, ready for commitDocMutation. The live doc's own clientID keeps
292
+ * the Yjs state vector at one entry per open session — a fresh doc per splice
293
+ * would grow it forever. The in-memory apply preceding the journal insert is
294
+ * safe: nothing persists before materialization, so a death in the gap leaves
295
+ * disk ahead of the persisted state and reopen simply re-reconciles (a write
296
+ * API caller got no acknowledgement and retries). Returns null when the text
297
+ * is already current.
317
298
  */
318
- function spliceDocText(
319
- Y,
320
- entry,
321
- nextText,
322
- source,
323
- mutationId = newMutationId(),
324
- diskObservation = null,
325
- ) {
326
- if (!entry.mutationAuthor) resetMutationAuthor(Y, entry);
327
- Y.applyUpdate(
328
- entry.mutationAuthor,
329
- Y.encodeStateAsUpdate(entry.doc),
330
- { amalgmMutation: true, source: 'doc:author-mirror', patch: null },
331
- );
332
- const ytext = entry.mutationAuthor.getText(TEXT_KEY);
333
- if (ytext.toString() === nextText) return null;
334
- const before = Y.encodeStateVector(entry.mutationAuthor);
299
+ function spliceDocText(Y, entry, nextText, source, mutationId = newMutationId()) {
335
300
  const origin = { amalgmMutation: true, source, patch: null };
336
- spliceTextInto(Y, entry.mutationAuthor, ytext, nextText, origin);
337
- const bytes = Buffer.from(Y.encodeStateAsUpdate(entry.mutationAuthor, before));
338
- return {
339
- mutationId,
340
- update: bytes.toString('base64'),
341
- bytes,
342
- diskObservation,
343
- };
301
+ const changed = spliceTextInto(Y, entry.doc, entry.doc.getText(TEXT_KEY), nextText, origin);
302
+ if (!changed) return null;
303
+ return { mutationId, update: origin.patch.yjs, bytes: null };
344
304
  }
345
305
 
346
306
  function canCurrentUserEditDoc(entry) {
@@ -353,123 +313,23 @@ function requireCurrentUserDocEdit(entry) {
353
313
  if (!canCurrentUserEditDoc(entry)) throw invalid('document edit is forbidden', 403);
354
314
  }
355
315
 
356
- function localMutationInput(entry, replica, edit) {
357
- return {
358
- channelId: replica ? `resource:${replica.resourceId}` : entry.resource,
359
- localResource: entry.resource,
360
- sharedResourceId: replica?.resourceId,
361
- mutationId: edit.mutationId,
362
- deviceId: replica ? require('../workspace/identity').machineId() : null,
363
- authorityEpoch: replica?.authorityEpoch || 0,
364
- baseCloudVersion: replica?.appliedVersion || 0,
365
- contract: replica?.contract || 'text-yjs@1',
366
- schemaVersion: replica?.schemaVersion || 1,
367
- operationKind: 'yjs.update',
368
- operation: { kind: 'yjs.update', update: edit.update },
369
- };
370
- }
371
-
372
- function materializeDocDiskMutation(
373
- Y,
374
- entry,
375
- mutation,
376
- testHooks = {},
377
- ) {
378
- const targetText = entry.doc.getText(TEXT_KEY).toString();
379
- let plan = docDisk.prepare(mutation.channelId, mutation.mutationId, targetText);
380
- for (let attempt = 0; attempt < 8; attempt += 1) {
381
- const result = docDisk.cross(plan, openLocalDb(), testHooks);
382
- trace('doc-disk-cross', {
383
- mutationId: mutation.mutationId,
384
- attempt,
385
- status: result.status,
386
- reason: result.reason,
387
- target: hashText(targetText),
388
- });
389
- if (result.status === 'applied') {
390
- const installed = docDisk.observe(entry.path);
391
- if (installed.text !== targetText) {
392
- // The receipt proves this mutation crossed. A different current file
393
- // is therefore causally later local reality, including the narrow
394
- // helper-return/process-crash window. Journal it as the successor
395
- // before allowing the older mutation to complete.
396
- const successor = stageDiskObservation(Y, entry, installed, {
397
- predecessorChannelId: mutation.channelId,
398
- predecessorMutationId: mutation.mutationId,
399
- predecessorCrossed: true,
400
- });
401
- setLastDiskText(entry, installed.text, 'disk', installed);
402
- persistDocState(Y, entry);
403
- return {
404
- crossed: true,
405
- superseded: false,
406
- successor,
407
- };
408
- }
409
- setLastDiskText(
410
- entry,
411
- installed.text,
412
- plan.mutationSource === 'doc:disk' ? 'disk' : 'flush',
413
- installed,
414
- );
415
- persistDocState(Y, entry);
416
- return { crossed: true, superseded: false };
417
- }
418
- if (result.status === 'pending') {
419
- plan = docDisk.get(mutation.channelId, mutation.mutationId);
420
- if (attempt < 7) continue;
421
- throw new Error(
422
- `document disk writer is still active: ${entry.path}: ${result.reason || 'pending'}`,
423
- );
424
- }
425
- if (result.status !== 'changed') {
426
- throw new Error(`unexpected document disk transaction state: ${result.status}`);
427
- }
428
- const newer = docDisk.observe(entry.path);
429
- if (newer.text === targetText) {
430
- plan = docDisk.rebaseExpected(mutation.channelId, mutation.mutationId, newer);
431
- continue;
432
- }
433
- const successor = stageDiskObservation(Y, entry, newer, {
434
- predecessorChannelId: mutation.channelId,
435
- predecessorMutationId: mutation.mutationId,
436
- });
437
- if (!successor) {
438
- plan = docDisk.rebaseExpected(mutation.channelId, mutation.mutationId, newer);
439
- continue;
440
- }
441
- return { crossed: false, superseded: true, successor };
442
- }
443
- throw new Error(`document disk kept changing during materialization: ${entry.path}`);
444
- }
445
-
446
316
  /**
447
317
  * The one door. Every locally produced document edit — a client Yjs update,
448
318
  * a whole-text API write, a disk reconcile — becomes exactly one journal
449
319
  * mutation: durable row, then materialization, then the projected event;
450
- * shared docs land in the cloud outbox by the same row. Updates arrive as
451
- * bytes and are applied here only after that row exists. `entry.pendingConflict`
452
- * (set by the merge that produced this edit) rides the projected event and
453
- * is consumed here.
320
+ * shared docs land in the cloud outbox by the same row. `bytes` is null when
321
+ * the update came from spliceDocText — the live doc already holds it and
322
+ * materialization only persists; client updates arrive as bytes and are
323
+ * applied here. `entry.pendingConflict` (set by the merge that produced this
324
+ * edit) rides the projected event and is consumed here.
454
325
  */
455
- function commitDocMutation(
456
- Y,
457
- entry,
458
- {
459
- mutationId,
460
- update,
461
- bytes,
462
- diskObservation = null,
463
- materializationBase = null,
464
- },
465
- source,
466
- ) {
467
- // A local file save is full local reality before the API or cloud edit that
468
- // races it. Capture it durably first. The disk-origin path already carries
469
- // that exact observation and must not recurse through capture.
470
- const capturedBase = diskObservation
471
- || materializationBase
472
- || (source === 'doc:disk' ? null : captureDiskBeforeMutation(Y, entry));
326
+ function commitDocMutation(Y, entry, { mutationId, update, bytes }, source) {
327
+ // Finish before starting: a journal row whose materialization failed (a
328
+ // full disk, a permission flip) is completed here — original mutation id,
329
+ // event, cloud-outbox eligibility — before any new edit is journaled. This
330
+ // is what keeps journal order and the cloud stream gap-free; if the disk is
331
+ // still broken, the new edit fails too instead of shipping past a hole.
332
+ recoverPendingDocMutations(Y, entry);
473
333
  const replica = require('./replicas').getReplicaByLocalResource(entry.resource);
474
334
  trace('doc-commit', {
475
335
  mutationId,
@@ -478,53 +338,39 @@ function commitDocMutation(
478
338
  shared: replica?.resourceId,
479
339
  });
480
340
  const origin = { amalgmMutation: true, source, patch: null };
481
- const edit = { mutationId, update, bytes, diskObservation };
482
- const input = localMutationInput(entry, replica, edit);
483
- const base = capturedBase || docDisk.observe(entry.path);
484
- let diskOutcome = null;
485
- try {
486
- const committed = require('./mutations').commitLocalMutation(input, {
487
- persist(database, mutation) {
488
- docDisk.insertBase(
489
- database,
490
- mutation,
491
- base,
492
- source,
493
- );
494
- },
495
- materialize(mutation) {
496
- if (bytes) Y.applyUpdate(entry.doc, new Uint8Array(bytes), origin);
497
- persistDocState(Y, entry);
498
- diskOutcome = materializeDocDiskMutation(
499
- Y,
500
- entry,
501
- mutation,
502
- diskTransactionTestHooks,
503
- );
504
- },
505
- event: () => {
506
- const patch = origin.patch || { yjs: update };
507
- if (entry.pendingConflict) {
508
- patch.conflict = entry.pendingConflict;
509
- entry.pendingConflict = null;
510
- }
511
- return { resource: entry.resource, op: 'update', id: entry.path, patch, source };
512
- },
513
- });
514
- docDisk.finish(input.channelId, input.mutationId);
515
- if (diskOutcome?.superseded || diskOutcome?.successor) {
516
- recoverPendingDocMutations(Y, entry);
517
- }
518
- return committed;
519
- } catch (error) {
520
- // The journal row, when one exists, owns recovery. If no row exists, the
521
- // unchanged live document (or disk observation) is the retry source. In
522
- // either case an author that ran ahead must not make a retry look like a
523
- // no-op.
524
- entry.pendingConflict = null;
525
- resetMutationAuthor(Y, entry);
526
- throw error;
527
- }
341
+ return require('./mutations').commitLocalMutation({
342
+ channelId: replica ? `resource:${replica.resourceId}` : entry.resource,
343
+ localResource: entry.resource,
344
+ sharedResourceId: replica?.resourceId,
345
+ mutationId,
346
+ deviceId: replica ? require('../workspace/identity').machineId() : null,
347
+ authorityEpoch: replica?.authorityEpoch || 0,
348
+ baseCloudVersion: replica?.appliedVersion || 0,
349
+ contract: replica?.contract || 'text-yjs@1',
350
+ schemaVersion: replica?.schemaVersion || 1,
351
+ operationKind: 'yjs.update',
352
+ operation: { kind: 'yjs.update', update },
353
+ }, {
354
+ materialize() {
355
+ if (bytes) Y.applyUpdate(entry.doc, new Uint8Array(bytes), origin);
356
+ persistDocState(Y, entry);
357
+ if (replica) {
358
+ // Shared files have a strict saved-local boundary: the user-visible
359
+ // file is current before the event/HTTP acknowledgement.
360
+ flushDocToDisk(Y, entry);
361
+ } else {
362
+ scheduleDiskWrite(Y, entry);
363
+ }
364
+ },
365
+ event: () => {
366
+ const patch = origin.patch || { yjs: update };
367
+ if (entry.pendingConflict) {
368
+ patch.conflict = entry.pendingConflict;
369
+ entry.pendingConflict = null;
370
+ }
371
+ return { resource: entry.resource, op: 'update', id: entry.path, patch, source };
372
+ },
373
+ });
528
374
  }
529
375
 
530
376
  // A merge where every incoming hunk conflicted leaves the doc text unchanged:
@@ -561,6 +407,58 @@ function persistDocState(Y, entry) {
561
407
  );
562
408
  }
563
409
 
410
+ function scheduleDiskWrite(Y, entry) {
411
+ if (entry.diskWriteTimer) return;
412
+ entry.diskWriteTimer = setTimeout(() => {
413
+ entry.diskWriteTimer = null;
414
+ // The debounced write-back has no caller to answer to. A failure here
415
+ // loses nothing durable — the acknowledged state lives in SQLite — so it
416
+ // is logged and the next flush (edit, close, reopen) retries.
417
+ try {
418
+ flushDocToDisk(Y, entry);
419
+ } catch (error) {
420
+ console.warn(`[LiveDoc] Debounced write-back failed for "${entry.path}":`, error?.message || error);
421
+ }
422
+ }, DISK_WRITE_DEBOUNCE_MS);
423
+ if (typeof entry.diskWriteTimer.unref === 'function') entry.diskWriteTimer.unref();
424
+ }
425
+
426
+ // Replace-by-rename so the file is always either the old text or the new
427
+ // text, never a torn partial write. The rename replaces the inode, so the
428
+ // original's permission bits must be carried over explicitly — chmod, not an
429
+ // open-mode, because open(2) modes are masked by the umask. An executable
430
+ // script stays executable through a live edit.
431
+ function writeFileAtomic(filePath, text) {
432
+ const tmpPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.amalgm-write`);
433
+ let mode = null;
434
+ try {
435
+ mode = fs.statSync(filePath).mode;
436
+ } catch (error) {
437
+ if (error.code !== 'ENOENT') throw error;
438
+ }
439
+ fs.writeFileSync(tmpPath, text, 'utf8');
440
+ if (mode != null) fs.chmodSync(tmpPath, mode);
441
+ fs.renameSync(tmpPath, filePath);
442
+ }
443
+
444
+ // Throws on failure: "saved locally" must mean the file is really current.
445
+ // Callers that acknowledge an edit (mutation materialization) propagate the
446
+ // failure so the journal row is never completed and no event is projected;
447
+ // only fire-and-forget callers (debounce timer, close) may catch and log.
448
+ function flushDocToDisk(Y, entry) {
449
+ const text = entry.doc.getText(TEXT_KEY).toString();
450
+ let onDisk = null;
451
+ try {
452
+ onDisk = fs.readFileSync(entry.path, 'utf8');
453
+ } catch (error) {
454
+ // A deleted file is not a failed save — the write below recreates it.
455
+ if (error.code !== 'ENOENT') throw error;
456
+ }
457
+ if (onDisk !== text) writeFileAtomic(entry.path, text);
458
+ setLastDiskText(entry, text, 'flush');
459
+ persistDocState(Y, entry);
460
+ }
461
+
564
462
  function isConflictSidecar(name) {
565
463
  return SIDECAR_NAME_RE.test(name);
566
464
  }
@@ -602,59 +500,31 @@ function writeConflictSidecar(entry, theirsText, hunkCount) {
602
500
  );
603
501
  }
604
502
 
605
- function diskEditForObservation(Y, entry, observation) {
606
- const onDisk = observation.text;
607
- if (onDisk === null) return null;
608
- if (
609
- onDisk === entry.lastDiskText
610
- && (
611
- !entry.lastDiskObservation
612
- || docDisk.sameObservation(observation, entry.lastDiskObservation)
613
- )
614
- ) return null;
615
- const ytext = entry.doc.getText(TEXT_KEY);
616
- const ours = ytext.toString();
617
- // Synchronous materialization means the newest history value may be a
618
- // local Yjs edit that a filesystem writer never read. When that writer
619
- // replaces the file from the immediately preceding disk version, merge
620
- // against that predecessor just as the former debounce window did.
621
- const history = entry.diskHistory;
622
- const prior = history.length > 1 ? history[history.length - 2] : null;
623
- const base = liveEditsSinceLastDiskState(entry) && prior
624
- ? prior.text
625
- : entry.lastDiskText;
626
- let nextText = onDisk;
627
- if (
628
- base != null
629
- && ours !== base
630
- && ours !== onDisk
631
- && overwriteDeletesText(ours, onDisk)
632
- ) {
633
- const merged = merge3(base, ours, onDisk);
634
- nextText = merged.text;
635
- if (merged.conflicts.length > 0) {
636
- const conflictedOursBytes = merged.conflicts.reduce(
637
- (total, conflict) => total + Buffer.byteLength(conflict.ours, 'utf8'),
638
- 0,
639
- );
640
- const oursBytes = Math.max(1, Buffer.byteLength(ours, 'utf8'));
641
- if (conflictedOursBytes / oursBytes >= 0.8) {
642
- // A raw filesystem writer is allowed to replace the whole file. Keep
643
- // its rewrite in the main artifact while preserving the displaced
644
- // live value and surfacing the loss boundary.
645
- nextText = onDisk;
646
- const sidecarPath = writePreservationSidecar(
647
- entry,
648
- ours,
649
- 'unversioned disk write replaced flushed live edits; '
650
- + 'replaced text preserved in the sidecar',
651
- );
652
- entry.pendingConflict = {
653
- reason: 'unversioned-overwrite',
654
- sidecarPath,
655
- at: new Date().toISOString(),
656
- };
657
- } else {
503
+ function reconcileFromDisk(Y, entry) {
504
+ try {
505
+ const onDisk = fs.readFileSync(entry.path, 'utf8');
506
+ // Only fold in disk content that actually CHANGED since we last read or
507
+ // wrote it. Without this, a watcher event firing while the doc is ahead
508
+ // of disk (write-back still debounced) would "reconcile" the stale disk
509
+ // text back over live edits. (This is also the THEIRS === BASE no-op.)
510
+ if (onDisk === entry.lastDiskText) return;
511
+ if (!canCurrentUserEditDoc(entry)) {
512
+ entry.heldDiskChange = { reason: 'forbidden', at: new Date().toISOString() };
513
+ return;
514
+ }
515
+ entry.heldDiskChange = null;
516
+ const ytext = entry.doc.getText(TEXT_KEY);
517
+ const ours = ytext.toString();
518
+ const base = entry.lastDiskText;
519
+ let nextText = onDisk;
520
+ // OURS === BASE (no live divergence) or no base at all (first seed):
521
+ // disk text applies wholesale, exactly as before. Otherwise both sides
522
+ // moved since the last sync — three-way merge against BASE so a writer
523
+ // that read the file before our live edits cannot delete them.
524
+ if (base != null && ours !== base && ours !== onDisk) {
525
+ const merged = merge3(base, ours, onDisk);
526
+ nextText = merged.text;
527
+ if (merged.conflicts.length > 0) {
658
528
  const sidecarPath = writeConflictSidecar(entry, onDisk, merged.conflicts.length);
659
529
  entry.pendingConflict = {
660
530
  reason: 'overlapping-hunks',
@@ -663,181 +533,52 @@ function diskEditForObservation(Y, entry, observation) {
663
533
  at: new Date().toISOString(),
664
534
  };
665
535
  }
666
- }
667
- } else if (liveEditsSinceLastDiskState(entry) && overwriteDeletesText(ours, onDisk)) {
668
- const sidecarPath = writePreservationSidecar(
669
- entry,
670
- ours,
671
- 'unversioned disk write replaced flushed live edits; '
672
- + 'replaced text preserved in the sidecar',
673
- );
674
- entry.pendingConflict = {
675
- reason: 'unversioned-overwrite',
676
- sidecarPath,
677
- at: new Date().toISOString(),
678
- };
679
- }
680
- return spliceDocText(
681
- Y,
682
- entry,
683
- nextText,
684
- 'doc:disk',
685
- newMutationId(),
686
- observation,
687
- );
688
- }
689
-
690
- function isKnownDiskObservation(entry, observation) {
691
- return Boolean(
692
- observation.text !== null
693
- && observation.text === entry.lastDiskText
694
- && (
695
- !entry.lastDiskObservation
696
- || docDisk.sameObservation(observation, entry.lastDiskObservation)
697
- )
698
- );
699
- }
700
-
701
- function holdDiskObservation(entry, observation, reason) {
702
- entry.heldDiskChange = {
703
- reason,
704
- identity: observation.identity,
705
- at: new Date().toISOString(),
706
- };
707
- throw invalid(`document disk change is held: ${reason}`, 409);
708
- }
709
-
710
- function stageDiskObservation(Y, entry, observation, options = {}) {
711
- if (isKnownDiskObservation(entry, observation)) {
712
- entry.heldDiskChange = null;
713
- return null;
714
- }
715
- if (observation.text === null) {
716
- return holdDiskObservation(entry, observation, 'missing');
717
- }
718
- if (!canCurrentUserEditDoc(entry)) {
719
- return holdDiskObservation(entry, observation, 'forbidden');
720
- }
721
- entry.heldDiskChange = null;
722
- let edit = diskEditForObservation(Y, entry, observation);
723
- if (!edit) {
724
- if (entry.pendingConflict) {
725
- const bytes = Buffer.from(Y.encodeStateAsUpdate(entry.doc));
726
- edit = {
727
- mutationId: newMutationId(),
728
- update: bytes.toString('base64'),
729
- bytes,
730
- diskObservation: observation,
536
+ } else if (liveEditsSinceLastDiskState(entry) && overwriteDeletesText(ours, onDisk)) {
537
+ // Wholesale accept, but the incoming write DELETES text that arrived
538
+ // through the live channel and was already flushed (ours ===
539
+ // lastDiskText, so the merge gate above sees no divergence — this was
540
+ // the silent-loss window). A raw fs write carries no base, so lineage
541
+ // is genuinely ambiguous: the writer keeps the freedom to rewrite the
542
+ // file, but the replaced live text is preserved first and the
543
+ // overwrite is surfaced, never silent. An insertion-only write (a vim
544
+ // append, a new paragraph) destroys nothing and applies silently.
545
+ const sidecarPath = writePreservationSidecar(
546
+ entry,
547
+ ours,
548
+ 'unversioned disk write replaced flushed live edits; '
549
+ + 'replaced text preserved in the sidecar',
550
+ );
551
+ entry.pendingConflict = {
552
+ reason: 'unversioned-overwrite',
553
+ sidecarPath,
554
+ at: new Date().toISOString(),
731
555
  };
732
- } else if (observation.text === entry.doc.getText(TEXT_KEY).toString()) {
733
- setLastDiskText(entry, observation.text, 'disk', observation);
556
+ }
557
+ setLastDiskText(entry, onDisk, 'disk');
558
+ const edit = spliceDocText(Y, entry, nextText, 'doc:disk');
559
+ if (edit) {
560
+ // The external edit walks through the same door as a client mutation:
561
+ // journal row, materialization (which writes any merged text back — the
562
+ // file artifact follows the doc), event, and the cloud outbox when the
563
+ // doc is shared.
564
+ commitDocMutation(Y, entry, edit, 'doc:disk');
565
+ } else {
734
566
  publishConflictOnly(entry, 'doc:disk');
567
+ // Even a no-op still refreshed lastDiskText; record its hash.
735
568
  persistDocState(Y, entry);
736
- } else {
737
- return null;
738
- }
739
- if (!edit) return null;
740
- }
741
- const replica = require('./replicas').getReplicaByLocalResource(entry.resource);
742
- const input = localMutationInput(entry, replica, edit);
743
- const persist = (database, mutation) => {
744
- docDisk.insertBase(database, mutation, observation, 'doc:disk');
745
- if (options.predecessorChannelId && options.predecessorMutationId) {
746
- if (options.predecessorCrossed) {
747
- docDisk.linkCrossedSuccessor(
748
- database,
749
- options.predecessorChannelId,
750
- options.predecessorMutationId,
751
- mutation.channelId,
752
- mutation.mutationId,
753
- );
754
- } else {
755
- docDisk.markSuperseded(
756
- database,
757
- options.predecessorChannelId,
758
- options.predecessorMutationId,
759
- mutation.channelId,
760
- mutation.mutationId,
761
- );
762
- }
763
569
  }
764
- };
765
- if (!options.predecessorChannelId && !options.predecessorMutationId) {
766
- return {
767
- edit,
768
- input,
769
- committed: commitDocMutation(Y, entry, edit, 'doc:disk'),
770
- };
771
- }
772
- const started = require('./mutations').journalMutation(input, {
773
- persist(database, mutation) {
774
- persist(database, mutation);
775
- },
776
- });
777
- return { edit, input, mutation: started.mutation };
778
- }
779
-
780
- function captureDiskBeforeMutation(Y, entry) {
781
- // A snapshot helper can die after installing official bytes but before the
782
- // in-memory document and cursor commit. Recover that physical receipt first;
783
- // otherwise a cached entry can mistake those official bytes for a new local
784
- // save and publish an echo before the snapshot is replayed.
785
- recoverPendingDocSnapshots(Y, entry);
786
- recoverPendingDocMutations(Y, entry);
787
- for (let attempt = 0; attempt < 8; attempt += 1) {
788
- const observation = docDisk.observe(entry.path);
789
- const staged = stageDiskObservation(Y, entry, observation);
790
- if (!staged) {
791
- // Return the exact observation whose provenance was just proved. A
792
- // later reread could contain an unjournaled save and must never become
793
- // overwrite permission for the caller's native CAS.
794
- return observation;
795
- }
796
- recoverPendingDocMutations(Y, entry);
797
- }
798
- throw new Error(`document disk kept changing during capture: ${entry.path}`);
799
- }
800
-
801
- function reconcileFromDisk(Y, entry, strict = false) {
802
- try {
803
- captureDiskBeforeMutation(Y, entry);
804
570
  } catch (error) {
805
- if (strict) throw error;
571
+ // File deleted or unreadable — the doc simply stops following the disk.
806
572
  console.warn(`[LiveDoc] Reconcile from disk failed for "${entry.path}":`, error?.message || error);
807
573
  }
808
574
  }
809
575
 
810
- function restoreDurableDiskBase(entry, persisted) {
811
- const plan = docDisk.firstBase(entry.resource);
812
- if (plan?.baseText !== null && plan?.baseText !== undefined) {
813
- // Every destructive writer seals its exact semantic predecessor beside
814
- // the physical CAS. That text remains the merge base even when a crash
815
- // happens after the desired Y.Doc state was persisted but before the
816
- // file crossed.
817
- setLastDiskText(entry, plan.baseText, 'open');
818
- return true;
819
- }
820
-
821
- const knownHash = persisted?.last_disk_hash;
822
- if (!knownHash) return false;
823
- const persistedText = entry.doc.getText(TEXT_KEY).toString();
824
- if (hashText(persistedText) !== knownHash) return false;
825
-
826
- // The persisted Y.Doc and last-disk hash together prove the semantic base
827
- // that was on disk before the process stopped. The file may have changed
828
- // since then, so this is merge provenance—not permission to replace its
829
- // current inode. Physical permission still comes only from the durable
830
- // plan's expected identity and the native CAS.
831
- setLastDiskText(entry, persistedText, 'open');
832
- return true;
833
- }
834
-
835
576
  /**
836
577
  * First look at the disk after (re)opening a doc. Three cases:
837
578
  * - no persisted history → the disk seeds the doc (first ever open);
838
579
  * - disk matches the hash we recorded before dying → disk is unchanged;
839
- * if the persisted doc is ahead (an interrupted journal-owned file
840
- * transaction), finish that durable mutation instead of splicing stale disk text
580
+ * if the persisted doc is ahead (edits inside the unflushed debounce
581
+ * window), write the doc back out instead of splicing stale disk text
841
582
  * over those edits;
842
583
  * - disk differs from the recorded hash → a genuine external edit while we
843
584
  * were closed; fold it in like any other disk change.
@@ -847,11 +588,10 @@ function seedFromDisk(Y, entry, persisted) {
847
588
  if (!knownHash) {
848
589
  if (!canCurrentUserEditDoc(entry)) {
849
590
  try {
850
- const observation = docDisk.observe(entry.path);
851
- const onDisk = observation.text;
591
+ const onDisk = fs.readFileSync(entry.path, 'utf8');
852
592
  const origin = { amalgmMutation: true, source: 'doc:read-only-seed', patch: null };
853
593
  spliceTextInto(Y, entry.doc, entry.doc.getText(TEXT_KEY), onDisk, origin);
854
- setLastDiskText(entry, onDisk, 'open', observation);
594
+ setLastDiskText(entry, onDisk, 'open');
855
595
  persistDocState(Y, entry);
856
596
  } catch {
857
597
  // Unreadable/deleted: same posture as reconcile — stop following disk.
@@ -862,10 +602,8 @@ function seedFromDisk(Y, entry, persisted) {
862
602
  return;
863
603
  }
864
604
  let onDisk = null;
865
- let observation = null;
866
605
  try {
867
- observation = docDisk.observe(entry.path);
868
- onDisk = observation.text;
606
+ onDisk = fs.readFileSync(entry.path, 'utf8');
869
607
  } catch {
870
608
  // Unreadable/deleted: same posture as reconcile — stop following disk.
871
609
  return;
@@ -874,15 +612,9 @@ function seedFromDisk(Y, entry, persisted) {
874
612
  reconcileFromDisk(Y, entry);
875
613
  return;
876
614
  }
877
- setLastDiskText(entry, onDisk, 'open', observation);
615
+ setLastDiskText(entry, onDisk, 'open');
878
616
  if (entry.doc.getText(TEXT_KEY).toString() !== onDisk) {
879
- const bytes = Buffer.from(Y.encodeStateAsUpdate(entry.doc));
880
- commitDocMutation(Y, entry, {
881
- mutationId: newMutationId(),
882
- update: bytes.toString('base64'),
883
- bytes,
884
- diskObservation: observation,
885
- }, 'doc:recovery');
617
+ scheduleDiskWrite(Y, entry);
886
618
  }
887
619
  }
888
620
 
@@ -895,35 +627,6 @@ function scheduleReconcile(Y, entry) {
895
627
  if (typeof entry.reconcileTimer.unref === 'function') entry.reconcileTimer.unref();
896
628
  }
897
629
 
898
- /**
899
- * Filesystem notifications make ordinary edits fast, but they are not
900
- * durable evidence. This bounded pass makes every open document converge
901
- * even when the operating system coalesces or drops a notification.
902
- */
903
- function verifyOpenDocs() {
904
- watchVerificationPasses += 1;
905
- const Y = loadYjs();
906
- for (const entry of openDocs.values()) {
907
- if (isInternalStateDoc(entry.path)) continue;
908
- reconcileFromDisk(Y, entry);
909
- if (!dirWatchers.has(path.dirname(entry.path))) watchDocFile(Y, entry);
910
- }
911
- }
912
-
913
- function ensureWatchVerification() {
914
- if (watchVerificationTimer || openDocs.size === 0) return;
915
- watchVerificationTimer = setInterval(verifyOpenDocs, WATCH_VERIFY_MS);
916
- if (typeof watchVerificationTimer.unref === 'function') {
917
- watchVerificationTimer.unref();
918
- }
919
- }
920
-
921
- function stopWatchVerificationIfIdle() {
922
- if (openDocs.size > 0 || !watchVerificationTimer) return;
923
- clearInterval(watchVerificationTimer);
924
- watchVerificationTimer = null;
925
- }
926
-
927
630
  function watchDocFile(Y, entry) {
928
631
  const dir = path.dirname(entry.path);
929
632
  let shared = dirWatchers.get(dir);
@@ -971,36 +674,36 @@ function unwatchDocFile(entry) {
971
674
  }
972
675
 
973
676
  function closeDocEntry(Y, entry) {
677
+ if (entry.diskWriteTimer) {
678
+ clearTimeout(entry.diskWriteTimer);
679
+ entry.diskWriteTimer = null;
680
+ }
974
681
  if (entry.reconcileTimer) {
975
682
  clearTimeout(entry.reconcileTimer);
976
683
  entry.reconcileTimer = null;
977
684
  }
978
- // Capture a last-moment external save before closing. If journaling fails,
979
- // leave that disk value untouched; reopen sees it as unknown input and
980
- // retries. Close is still allowed to release memory.
981
- if (!isInternalStateDoc(entry.path)) reconcileFromDisk(Y, entry);
685
+ // Close must complete even when the disk is unwritable: every acknowledged
686
+ // edit is already durable in SQLite, and reopen flushes the file current.
687
+ try {
688
+ flushDocToDisk(Y, entry);
689
+ } catch (error) {
690
+ console.warn(`[LiveDoc] Close flush failed for "${entry.path}":`, error?.message || error);
691
+ }
982
692
  unwatchDocFile(entry);
983
693
  for (const alias of entry.pathAliases) resolvedPathCache.delete(alias);
984
- entry.mutationAuthor?.destroy();
985
694
  entry.doc.destroy();
986
695
  openDocs.delete(entry.path);
987
- stopWatchVerificationIfIdle();
988
696
  }
989
697
 
990
698
  function recoverPendingDocMutations(Y, entry) {
991
- const mutations = require('./mutations');
992
- for (let pass = 0; pass < 1000; pass += 1) {
993
- const [mutation] = mutations.listMutations({
994
- state: 'saving-local',
995
- localResource: entry.resource,
996
- limit: 1,
997
- });
998
- if (!mutation) return;
699
+ const pending = require('./mutations').listMutations({ state: 'saving-local', limit: 1000 })
700
+ .filter((mutation) => mutation.localResource === entry.resource);
701
+ for (const mutation of pending) {
999
702
  if (
1000
703
  mutation.operationKind !== 'yjs.update'
1001
704
  || typeof mutation.operation?.update !== 'string'
1002
705
  ) {
1003
- throw new Error(`document mutation ${mutation.mutationId} has an unsupported operation`);
706
+ continue;
1004
707
  }
1005
708
  const bytes = Buffer.from(mutation.operation.update, 'base64');
1006
709
  try {
@@ -1009,52 +712,11 @@ function recoverPendingDocMutations(Y, entry) {
1009
712
  throw invalid(`cannot recover mutation ${mutation.mutationId}: ${error?.message || error}`, 500);
1010
713
  }
1011
714
  const origin = { amalgmMutation: true, source: 'recovery', patch: null };
1012
- const beforeMutationText = entry.doc.getText(TEXT_KEY).toString();
1013
715
  Y.applyUpdate(entry.doc, new Uint8Array(bytes), origin);
1014
716
  persistDocState(Y, entry);
1015
- let plan = docDisk.get(mutation.channelId, mutation.mutationId);
1016
- if (!plan) {
1017
- // Legacy saving-local rows predate physical plans. The current file may
1018
- // be a newer save, so make it the old plan's base and immediately ask
1019
- // the ordinary disk detector whether it is a successor. Only an
1020
- // unchanged predecessor may then be replaced.
1021
- const base = docDisk.observe(entry.path);
1022
- if (entry.lastDiskText === null) {
1023
- setLastDiskText(entry, beforeMutationText, 'open');
1024
- }
1025
- docDisk.insertBase(openLocalDb(), mutation, base, 'doc:recovery');
1026
- stageDiskObservation(Y, entry, base, {
1027
- predecessorChannelId: mutation.channelId,
1028
- predecessorMutationId: mutation.mutationId,
1029
- });
1030
- plan = docDisk.get(mutation.channelId, mutation.mutationId);
1031
- }
1032
- const ownsSuccessor = (
1033
- plan.successorChannelId !== null
1034
- && plan.successorMutationId !== null
1035
- );
1036
- if (plan.state === 'superseded' || (plan.state === 'crossed' && ownsSuccessor)) {
1037
- const successor = mutations.getMutation(
1038
- plan.successorChannelId,
1039
- plan.successorMutationId,
1040
- { revealOperation: true },
1041
- );
1042
- if (!successor) {
1043
- throw new Error(`document mutation ${mutation.mutationId} lost its disk successor`);
1044
- }
1045
- // A crossed predecessor with a linked successor has already proved both
1046
- // sides of the handoff. Re-running its physical materializer would
1047
- // observe the successor as a new edit and assign the same save a second
1048
- // birth certificate.
1049
- } else {
1050
- materializeDocDiskMutation(
1051
- Y,
1052
- entry,
1053
- mutation,
1054
- diskTransactionTestHooks,
1055
- );
1056
- }
1057
- mutations.completeMaterializedMutation(
717
+ if (mutation.sharedResourceId) flushDocToDisk(Y, entry);
718
+ else scheduleDiskWrite(Y, entry);
719
+ require('./mutations').completeMaterializedMutation(
1058
720
  mutation.channelId,
1059
721
  mutation.mutationId,
1060
722
  {
@@ -1062,37 +724,10 @@ function recoverPendingDocMutations(Y, entry) {
1062
724
  op: 'update',
1063
725
  id: entry.path,
1064
726
  patch: origin.patch || { yjs: mutation.operation.update },
1065
- source: mutation.origin === 'cloud'
1066
- ? 'doc:cloud-recovery'
1067
- : plan.mutationSource === 'doc:disk'
1068
- ? 'doc:disk'
1069
- : 'doc:recovery',
727
+ source: mutation.origin === 'cloud' ? 'doc:cloud-recovery' : 'doc:recovery',
1070
728
  resourceVersion: mutation.officialCloudVersion,
1071
729
  },
1072
730
  );
1073
- docDisk.finish(mutation.channelId, mutation.mutationId);
1074
- }
1075
- throw new Error(`document mutation recovery exceeded its finite bound: ${entry.path}`);
1076
- }
1077
-
1078
- function releaseCompletedDocPlans(entry) {
1079
- const mutations = require('./mutations');
1080
- for (const plan of docDisk.listMutations(entry.resource)) {
1081
- const mutation = mutations.getMutation(
1082
- plan.channelId,
1083
- plan.mutationId,
1084
- { revealOperation: true },
1085
- );
1086
- if (!mutation) {
1087
- // Retention may prune a completed mutation after its event is durable
1088
- // but before a crash-left receipt is released. The plan itself proves
1089
- // whether it crossed; finish still rejects an incomplete orphan.
1090
- docDisk.finish(plan.channelId, plan.mutationId);
1091
- continue;
1092
- }
1093
- if (mutation.state !== 'saving-local') {
1094
- docDisk.finish(plan.channelId, plan.mutationId);
1095
- }
1096
731
  }
1097
732
  }
1098
733
 
@@ -1148,13 +783,13 @@ function loadDocEntry(pathInput) {
1148
783
  nodeId,
1149
784
  doc,
1150
785
  reconcileTimer: null,
786
+ diskWriteTimer: null,
1151
787
  lastAccess: Date.now(),
1152
788
  // The disk text as of our last read/write; reconcile skips anything that
1153
789
  // hasn't changed past it (see reconcileFromDisk). Assigned only through
1154
790
  // setLastDiskText so the hash that persistence writes stays in step.
1155
791
  lastDiskText: null,
1156
792
  lastDiskHash: null,
1157
- lastDiskObservation: null,
1158
793
  // Bounded ring of recent disk-sync states {text, sha256, seq, at, origin}
1159
794
  // (see pushDiskHistory): the base-resolution window for versioned writes
1160
795
  // and the live-edit detector for unversioned overwrites. Newest last.
@@ -1163,26 +798,19 @@ function loadDocEntry(pathInput) {
1163
798
  // consumed by the very next disk-origin update event (the merge splice is
1164
799
  // synchronous) so the conflict rides the doc's own event patch.
1165
800
  pendingConflict: null,
1166
- // Whole-text changes are generated by one stable Yjs writer but do not
1167
- // touch the live doc until their mutation row exists.
1168
- mutationAuthor: null,
1169
801
  // Every pathInput form seen for this doc, so close can clear its
1170
802
  // resolvedPathCache entries.
1171
803
  pathAliases: new Set([pathInput]),
1172
804
  };
1173
805
 
1174
- const readPersisted = () => (
1175
- nodeId
1176
- ? cachedStatement(database, `
806
+ let persisted = nodeId
807
+ ? cachedStatement(database, `
1177
808
  SELECT state, last_disk_hash FROM doc_states
1178
809
  WHERE node_id = ? OR path = ?
1179
810
  ORDER BY CASE WHEN node_id = ? THEN 0 ELSE 1 END
1180
811
  LIMIT 1
1181
812
  `).get(nodeId, resolved, nodeId) || null
1182
- : cachedStatement(database, 'SELECT state, last_disk_hash FROM doc_states WHERE path = ?')
1183
- .get(resolved) || null
1184
- );
1185
- let persisted = readPersisted();
813
+ : cachedStatement(database, 'SELECT state, last_disk_hash FROM doc_states WHERE path = ?').get(resolved) || null;
1186
814
  if (persisted?.state) {
1187
815
  try {
1188
816
  Y.applyUpdate(doc, new Uint8Array(persisted.state), 'persisted');
@@ -1205,52 +833,33 @@ function loadDocEntry(pathInput) {
1205
833
  origin.patch = { yjs: Buffer.from(update).toString('base64') };
1206
834
  });
1207
835
 
1208
- // Journal completion and native-receipt release are separately durable.
1209
- // A crash between them leaves a harmless receipt, which is released here
1210
- // before any new plan can accumulate beside it.
1211
- releaseCompletedDocPlans(entry);
1212
-
1213
- // Restore the last proven semantic disk base before any durable recovery.
1214
- // A pending official row and a disjoint save made while this process was
1215
- // down must both derive from the exact text sealed in its physical plan;
1216
- // the persisted hash is only the legacy fallback.
1217
- restoreDurableDiskBase(entry, persisted);
1218
-
1219
- // A crash may leave an official snapshot physically crossed but not yet
1220
- // registered. Finish that recorded plan before ordinary disk seeding can
1221
- // misclassify its bytes as a new local edit.
1222
- recoverPendingDocSnapshots(Y, entry);
836
+ // Disk at open: an externally changed file folds in as an edit, but a file
837
+ // that merely lags our persisted state (the process died inside the disk
838
+ // write debounce) gets the doc written back out — never spliced over it.
839
+ seedFromDisk(Y, entry, persisted);
840
+ if (nodeId) {
841
+ cachedStatement(database, 'UPDATE doc_states SET node_id = ?, path = ? WHERE node_id = ? OR path = ?')
842
+ .run(nodeId, resolved, nodeId, resolved);
843
+ }
1223
844
 
1224
845
  // A process can die after the durable journal insert but before Y.Doc/file
1225
846
  // materialization or event projection. Replay those exact operation IDs
1226
- // before ordinary disk seeding. Otherwise a successor already owned by a
1227
- // pending row can look like a fresh save and receive a second mutation ID.
847
+ // before this open is acknowledged, so a restart cannot bury an edit.
1228
848
  try {
1229
849
  recoverPendingDocMutations(Y, entry);
1230
850
  } catch (error) {
851
+ if (entry.diskWriteTimer) clearTimeout(entry.diskWriteTimer);
1231
852
  if (entry.reconcileTimer) clearTimeout(entry.reconcileTimer);
1232
853
  doc.destroy();
1233
854
  throw error;
1234
855
  }
1235
856
 
1236
- // Disk at open: an externally changed file folds in as an edit, but a file
1237
- // that merely lags our persisted state is recovered from the journal first.
1238
- // Re-read the hash because recovery above may have advanced both the live
1239
- // document and its exact physical provenance.
1240
- persisted = readPersisted();
1241
- seedFromDisk(Y, entry, persisted);
1242
- if (nodeId) {
1243
- cachedStatement(database, 'UPDATE doc_states SET node_id = ?, path = ? WHERE node_id = ? OR path = ?')
1244
- .run(nodeId, resolved, nodeId, resolved);
1245
- }
1246
-
1247
857
  evictIfNeeded(Y);
1248
858
  openDocs.set(resolved, entry);
1249
859
  // Protocol documents are written only through this module. Watching their
1250
- // own native materialization as if it were an external user edit creates a second
860
+ // own atomic flush as if it were an external user edit creates a second
1251
861
  // concurrent mutation of the same value.
1252
862
  if (!isInternalStateDoc(resolved)) watchDocFile(Y, entry);
1253
- ensureWatchVerification();
1254
863
  resolvedPathCache.set(pathInput, resolved);
1255
864
  return entry;
1256
865
  }
@@ -1341,10 +950,12 @@ function writeDocText(pathInput, textInput, options = {}) {
1341
950
  const entry = loadDocEntry(pathInput);
1342
951
  entry.lastAccess = Date.now();
1343
952
  requireCurrentUserDocEdit(entry);
1344
- // A dropped watcher cannot make a direct disk save disappear under this
1345
- // API write. Capture and materialize every unexplained byte first, then
1346
- // derive the merge from the resulting live state.
1347
- const materializationBase = captureDiskBeforeMutation(Y, entry);
953
+ // Every respond() below acknowledges saved state, so it must stand on a
954
+ // settled journal — most pointedly the noop return: a retry of a write
955
+ // whose disk flush failed finds the text already in memory, and without
956
+ // this the original mutation would stay stuck while the caller is told all
957
+ // is well. (Edits that DO reach commitDocMutation settle there.)
958
+ recoverPendingDocMutations(Y, entry);
1348
959
  const ytext = entry.doc.getText(TEXT_KEY);
1349
960
  const ours = ytext.toString();
1350
961
 
@@ -1410,29 +1021,9 @@ function writeDocText(pathInput, textInput, options = {}) {
1410
1021
 
1411
1022
  entry.pendingConflict = conflict;
1412
1023
  const edit = spliceDocText(Y, entry, nextText, 'doc:write', mutationId);
1413
- if (edit) {
1414
- if (diskTransactionTestHooks.pauseAfterEditBeforeJournal > 0) {
1415
- Atomics.wait(
1416
- new Int32Array(new SharedArrayBuffer(4)),
1417
- 0,
1418
- 0,
1419
- diskTransactionTestHooks.pauseAfterEditBeforeJournal,
1420
- );
1421
- }
1422
- commitDocMutation(
1423
- Y,
1424
- entry,
1425
- { ...edit, materializationBase },
1426
- 'doc:write',
1427
- );
1428
- }
1024
+ if (edit) commitDocMutation(Y, entry, edit, 'doc:write');
1429
1025
  else publishConflictOnly(entry, 'doc:write');
1430
- return respond(
1431
- merge,
1432
- entry.doc.getText(TEXT_KEY).toString(),
1433
- conflict,
1434
- edit?.mutationId || null,
1435
- );
1026
+ return respond(merge, nextText, conflict, edit?.mutationId || null);
1436
1027
  }
1437
1028
 
1438
1029
  function normalizeDocMutations(Y, mutationsInput) {
@@ -1468,7 +1059,6 @@ function applyDocMutations(pathInput, mutationsInput) {
1468
1059
  const entry = loadDocEntry(pathInput);
1469
1060
  entry.lastAccess = Date.now();
1470
1061
  requireCurrentUserDocEdit(entry);
1471
- captureDiskBeforeMutation(Y, entry);
1472
1062
  const results = mutations.map((mutation) => commitDocMutation(Y, entry, mutation, 'doc:update'));
1473
1063
 
1474
1064
  return {
@@ -1503,30 +1093,18 @@ function applyCloudDocMutation(input) {
1503
1093
  if (entry.resource !== replica.localResource) {
1504
1094
  throw invalid(`shared resource ${envelope.resourceId} local binding changed`, 409);
1505
1095
  }
1506
- // A save whose watcher was delayed is causally before the incoming cloud
1507
- // event and must acquire durable local provenance before cloud ingest can
1508
- // advance the official cursor.
1509
- const base = captureDiskBeforeMutation(Y, entry);
1510
1096
  const origin = { amalgmMutation: true, source: 'cloud', patch: null };
1511
- let diskOutcome = null;
1512
- const result = require('./mutations').ingestCloudMutation({
1097
+ return require('./mutations').ingestCloudMutation({
1513
1098
  envelope,
1514
1099
  version: input?.version,
1515
1100
  committedAt: input?.committedAt,
1516
1101
  localResource: entry.resource,
1517
1102
  }, {
1518
- persist(database, journaled) {
1519
- docDisk.insertBase(database, journaled, base, 'doc:cloud');
1520
- },
1521
- materialize(journaled) {
1103
+ materialize() {
1522
1104
  Y.applyUpdate(entry.doc, new Uint8Array(mutation.bytes), origin);
1523
1105
  persistDocState(Y, entry);
1524
- diskOutcome = materializeDocDiskMutation(
1525
- Y,
1526
- entry,
1527
- journaled,
1528
- diskTransactionTestHooks,
1529
- );
1106
+ // Recipient file first; the projected event that wakes its UI follows.
1107
+ flushDocToDisk(Y, entry);
1530
1108
  },
1531
1109
  event: () => ({
1532
1110
  resource: entry.resource,
@@ -1537,233 +1115,10 @@ function applyCloudDocMutation(input) {
1537
1115
  resourceVersion: Number(input.version),
1538
1116
  }),
1539
1117
  });
1540
- docDisk.finish(`resource:${envelope.resourceId}`, envelope.mutationId);
1541
- if (diskOutcome?.superseded || diskOutcome?.successor) {
1542
- recoverPendingDocMutations(Y, entry);
1543
- }
1544
- require('../workspace/tree-cloud').reconcileContentOverlay(envelope.resourceId);
1545
- return result;
1546
- }
1547
-
1548
- function normalizedSnapshotPayload(entry, input, snapshotBase64, checksum) {
1549
- const envelope = require('./replicas').validateSnapshotEnvelope(input, {
1550
- contract: 'text-yjs@1',
1551
- schemaVersion: 1,
1552
- });
1553
- return {
1554
- resourceId: envelope.resourceId,
1555
- localResource: entry.resource,
1556
- localPath: entry.path,
1557
- snapshotBase64,
1558
- snapshotChecksum: checksum,
1559
- snapshotVersion: envelope.snapshotVersion,
1560
- headVersion: envelope.headVersion,
1561
- authorityEpoch: envelope.authorityEpoch,
1562
- schemaVersion: envelope.schemaVersion,
1563
- contract: envelope.contract,
1564
- journalCursor: isJournalCursor(input?.attachmentJournalCursor)
1565
- ? input.attachmentJournalCursor
1566
- : isJournalCursor(input?.journalCursor)
1567
- ? input.journalCursor
1568
- : Number(
1569
- cachedStatement(
1570
- openLocalDb(),
1571
- 'SELECT COALESCE(MAX(journal_id), 0) AS cursor FROM mutation_journal',
1572
- ).get().cursor,
1573
- ),
1574
- };
1575
- }
1576
-
1577
- function snapshotTargetText(Y, entry, snapshotBase64) {
1578
- const candidate = new Y.Doc();
1579
- try {
1580
- Y.applyUpdate(
1581
- candidate,
1582
- Y.encodeStateAsUpdate(entry.doc),
1583
- { amalgmMutation: true, source: 'snapshot-candidate-base', patch: null },
1584
- );
1585
- Y.applyUpdate(
1586
- candidate,
1587
- new Uint8Array(Buffer.from(snapshotBase64, 'base64')),
1588
- { amalgmMutation: true, source: 'snapshot-candidate-official', patch: null },
1589
- );
1590
- return candidate.getText(TEXT_KEY).toString();
1591
- } finally {
1592
- candidate.destroy();
1593
- }
1594
- }
1595
-
1596
- function commitSnapshotCursor(Y, entry, plan) {
1597
- const payload = plan.snapshotPayload;
1598
- const replicas = require('./replicas');
1599
- const database = openLocalDb();
1600
- let event = null;
1601
- let replica = null;
1602
- database.transaction(() => {
1603
- const held = docDisk.get(plan.channelId, plan.mutationId, database);
1604
- if (!held || held.state !== 'crossed') {
1605
- throw new Error(`snapshot ${payload.resourceId}@${payload.snapshotVersion} did not cross`);
1606
- }
1607
- const existing = replicas.getReplicaByResourceId(payload.resourceId, { database });
1608
- if (existing && existing.localResource !== entry.resource) {
1609
- throw invalid(`shared resource ${payload.resourceId} is bound to a different local file`, 409);
1610
- }
1611
- if (existing) {
1612
- replica = replicas.advanceReplicaSnapshot({
1613
- resourceId: payload.resourceId,
1614
- snapshotVersion: payload.snapshotVersion,
1615
- snapshotChecksum: payload.snapshotChecksum,
1616
- authorityEpoch: payload.authorityEpoch,
1617
- }, { database });
1618
- } else {
1619
- replica = replicas.registerReplica({
1620
- resourceId: payload.resourceId,
1621
- localResource: entry.resource,
1622
- localPath: entry.path,
1623
- contract: payload.contract,
1624
- schemaVersion: payload.schemaVersion,
1625
- authorityEpoch: payload.authorityEpoch,
1626
- appliedVersion: payload.snapshotVersion,
1627
- snapshotVersion: payload.snapshotVersion,
1628
- snapshotChecksum: payload.snapshotChecksum,
1629
- }, { database }).replica;
1630
- }
1631
- // Close the attachment fence. Local edits born after the snapshot plan
1632
- // began were not in the official snapshot; re-home them onto the newly
1633
- // installed cloud channel without changing their mutation IDs or local
1634
- // event order.
1635
- cachedStatement(database, `
1636
- UPDATE mutation_journal
1637
- SET channel_id = ?,
1638
- shared_resource_id = ?,
1639
- device_id = COALESCE(device_id, ?),
1640
- authority_epoch = ?,
1641
- base_cloud_version = ?,
1642
- updated_at = ?
1643
- WHERE local_resource = ?
1644
- AND shared_resource_id IS NULL
1645
- AND mutation_origin = 'local'
1646
- AND journal_id > ?
1647
- `).run(
1648
- `resource:${replica.resourceId}`,
1649
- replica.resourceId,
1650
- require('../workspace/identity').machineId(),
1651
- replica.authorityEpoch,
1652
- replica.snapshotVersion,
1653
- new Date().toISOString(),
1654
- entry.resource,
1655
- payload.journalCursor,
1656
- );
1657
- if (held.snapshotEventSeq === null) {
1658
- event = insertStateEvent(database, {
1659
- resource: entry.resource,
1660
- op: 'update',
1661
- id: entry.path,
1662
- patch: { yjs: payload.snapshotBase64 },
1663
- mutationId: plan.mutationId,
1664
- sharedResourceId: payload.resourceId,
1665
- source: 'doc:cloud-snapshot',
1666
- resourceVersion: payload.snapshotVersion,
1667
- });
1668
- docDisk.markSnapshotCommitted(
1669
- plan.channelId,
1670
- plan.mutationId,
1671
- event.seq,
1672
- database,
1673
- );
1674
- }
1675
- })();
1676
- if (event) publishStateEvent(event);
1677
- return { replica, event };
1678
- }
1679
-
1680
- function materializeCloudDocSnapshot(Y, entry, initialPlan) {
1681
- let plan = initialPlan;
1682
- const payload = plan.snapshotPayload;
1683
-
1684
- for (let attempt = 0; attempt < 8; attempt += 1) {
1685
- const targetText = plan.targetContent
1686
- ? plan.targetContent.toString('utf8')
1687
- : snapshotTargetText(Y, entry, payload.snapshotBase64);
1688
- if (!plan.target) {
1689
- plan = docDisk.prepare(plan.channelId, plan.mutationId, targetText);
1690
- } else if (plan.targetContent.toString('utf8') !== targetText) {
1691
- throw new Error(`snapshot ${payload.resourceId}@${payload.snapshotVersion} changed its target`);
1692
- }
1693
- const result = docDisk.cross(plan, openLocalDb(), diskTransactionTestHooks);
1694
- if (result.status === 'pending') {
1695
- plan = docDisk.get(plan.channelId, plan.mutationId);
1696
- if (attempt < 7) continue;
1697
- throw new Error(
1698
- `snapshot disk writer is still active: ${entry.path}: ${result.reason || 'pending'}`,
1699
- );
1700
- }
1701
- if (result.status === 'changed') {
1702
- const newer = docDisk.observe(entry.path);
1703
- if (newer.text === targetText) {
1704
- plan = docDisk.rebaseExpected(plan.channelId, plan.mutationId, newer);
1705
- continue;
1706
- }
1707
- // No byte was replaced. Retire this physical attempt, capture the newer
1708
- // save through the ordinary mutation journal, then derive a new target
1709
- // for the same official snapshot over that overlay.
1710
- docDisk.cancel(plan.channelId, plan.mutationId);
1711
- if (attempt === 7) break;
1712
- plan = planCloudDocSnapshot(Y, entry, payload);
1713
- continue;
1714
- }
1715
- if (result.status !== 'applied') {
1716
- throw new Error(`unexpected snapshot disk transaction state: ${result.status}`);
1717
- }
1718
-
1719
- const origin = { amalgmMutation: true, source: 'cloud-snapshot', patch: null };
1720
- Y.applyUpdate(
1721
- entry.doc,
1722
- new Uint8Array(Buffer.from(payload.snapshotBase64, 'base64')),
1723
- origin,
1724
- );
1725
- // The native receipt proves targetText was installed at the crossing.
1726
- // A later external save is causally after the snapshot and is captured by
1727
- // the immediately following reconcile or the restart seed.
1728
- setLastDiskText(entry, targetText, 'flush');
1729
- persistDocState(Y, entry);
1730
- const committed = commitSnapshotCursor(Y, entry, plan);
1731
- docDisk.finish(plan.channelId, plan.mutationId);
1732
- reconcileFromDisk(Y, entry, true);
1733
- require('../workspace/tree-cloud').reconcileContentOverlay(payload.resourceId);
1734
- return committed;
1735
- }
1736
- throw new Error(`document disk kept changing during snapshot install: ${entry.path}`);
1737
- }
1738
-
1739
- function recoverPendingDocSnapshots(Y, entry) {
1740
- for (let pass = 0; pass < 100; pass += 1) {
1741
- const plan = docDisk.listSnapshots(entry.resource)[0];
1742
- if (!plan) return;
1743
- materializeCloudDocSnapshot(Y, entry, plan);
1744
- }
1745
- throw new Error(`snapshot recovery exceeded its finite bound: ${entry.path}`);
1746
- }
1747
-
1748
- function planCloudDocSnapshot(Y, entry, payload) {
1749
- const base = captureDiskBeforeMutation(Y, entry);
1750
- if (diskTransactionTestHooks.pauseAfterSnapshotCapture > 0) {
1751
- Atomics.wait(
1752
- new Int32Array(new SharedArrayBuffer(4)),
1753
- 0,
1754
- 0,
1755
- diskTransactionTestHooks.pauseAfterSnapshotCapture,
1756
- );
1757
- }
1758
- return docDisk.insertSnapshotBase(openLocalDb(), payload, base);
1759
1118
  }
1760
1119
 
1761
1120
  function installCloudDocSnapshot(pathInput, input) {
1762
1121
  const Y = loadYjs();
1763
- const envelope = require('./replicas').validateSnapshotEnvelope(input, {
1764
- contract: 'text-yjs@1',
1765
- schemaVersion: 1,
1766
- });
1767
1122
  const snapshotBase64 = input?.snapshotBase64;
1768
1123
  if (typeof snapshotBase64 !== 'string' || !snapshotBase64) {
1769
1124
  throw invalid('snapshotBase64 is required');
@@ -1776,9 +1131,8 @@ function installCloudDocSnapshot(pathInput, input) {
1776
1131
  } catch (error) {
1777
1132
  throw invalid(`invalid Yjs snapshot: ${error?.message || error}`);
1778
1133
  }
1779
- const entry = loadDocEntry(pathInput);
1780
- recoverPendingDocSnapshots(Y, entry);
1781
1134
  const replicas = require('./replicas');
1135
+ const entry = loadDocEntry(pathInput);
1782
1136
  const existingReplica = replicas.getReplicaByResourceId(input?.resourceId);
1783
1137
  if (existingReplica) {
1784
1138
  // Rehydration: this replica fell behind the pruned official tail, so the
@@ -1788,36 +1142,46 @@ function installCloudDocSnapshot(pathInput, input) {
1788
1142
  if (existingReplica.localResource !== entry.resource) {
1789
1143
  throw invalid(`shared resource ${input?.resourceId} is bound to a different local file`, 409);
1790
1144
  }
1791
- const incomingVersion = envelope.snapshotVersion;
1792
- if (incomingVersion <= existingReplica.appliedVersion) {
1793
- const checked = replicas.advanceReplicaSnapshot({
1794
- resourceId: envelope.resourceId,
1795
- snapshotVersion: incomingVersion,
1796
- snapshotChecksum: checksum,
1797
- authorityEpoch: envelope.authorityEpoch,
1798
- });
1799
- if (
1800
- incomingVersion === existingReplica.snapshotVersion
1801
- && existingReplica.snapshotChecksum === checksum
1802
- ) {
1803
- return { replica: checked, event: null, duplicate: true };
1804
- }
1805
- return { replica: checked, event: null, duplicate: true, stale: true };
1806
- }
1807
- } else if (
1808
- entry.doc.getText(TEXT_KEY).toString().length > 0
1809
- && !isJournalCursor(input?.attachmentJournalCursor)
1810
- ) {
1145
+ } else if (entry.doc.getText(TEXT_KEY).toString().length > 0) {
1811
1146
  throw invalid('cloud snapshot attachment requires an empty local text file', 409);
1812
1147
  }
1813
- const payload = normalizedSnapshotPayload(entry, {
1814
- ...input,
1815
- ...envelope,
1816
- contract: input?.contract ?? existingReplica?.contract,
1817
- schemaVersion: input?.schemaVersion ?? existingReplica?.schemaVersion,
1818
- }, snapshotBase64, checksum);
1819
- const plan = planCloudDocSnapshot(Y, entry, payload);
1820
- return materializeCloudDocSnapshot(Y, entry, plan);
1148
+ const origin = { amalgmMutation: true, source: 'cloud-snapshot', patch: null };
1149
+ Y.applyUpdate(entry.doc, new Uint8Array(bytes), origin);
1150
+ persistDocState(Y, entry);
1151
+ flushDocToDisk(Y, entry);
1152
+ const registered = existingReplica
1153
+ ? {
1154
+ replica: replicas.advanceReplicaSnapshot({
1155
+ resourceId: input?.resourceId,
1156
+ snapshotVersion: input?.snapshotVersion,
1157
+ snapshotChecksum: checksum,
1158
+ authorityEpoch: input?.authorityEpoch,
1159
+ }),
1160
+ }
1161
+ : replicas.registerReplica({
1162
+ resourceId: input?.resourceId,
1163
+ localResource: entry.resource,
1164
+ localPath: entry.path,
1165
+ contract: input?.contract,
1166
+ schemaVersion: input?.schemaVersion,
1167
+ authorityEpoch: input?.authorityEpoch,
1168
+ appliedVersion: input?.snapshotVersion,
1169
+ snapshotVersion: input?.snapshotVersion,
1170
+ snapshotChecksum: checksum,
1171
+ });
1172
+ // File and SQLite binding are current before a mounted UI can observe the
1173
+ // snapshot. Applying this full Yjs state is idempotent for an already-open
1174
+ // recipient surface.
1175
+ const event = appendStateEvent({
1176
+ resource: entry.resource,
1177
+ op: 'update',
1178
+ id: entry.path,
1179
+ patch: origin.patch || { yjs: snapshotBase64 },
1180
+ sharedResourceId: input.resourceId,
1181
+ source: 'doc:cloud-snapshot',
1182
+ resourceVersion: Number(input.snapshotVersion),
1183
+ });
1184
+ return { replica: registered.replica, event };
1821
1185
  }
1822
1186
 
1823
1187
  /**
@@ -1852,165 +1216,36 @@ function getDocText(pathInput) {
1852
1216
  * Repoint persisted and open document materializations after tree identity
1853
1217
  * moved. The resource remains doc:<node UUID>; only the local path changes.
1854
1218
  */
1855
- function rebindNodePaths(changes, options = {}) {
1856
- const database = options.database || openLocalDb();
1219
+ function rebindNodePaths(changes) {
1220
+ const database = openLocalDb();
1857
1221
  ensureDocStatesSchema(database);
1858
- const token = crypto.randomBytes(16).toString('hex');
1859
- const nodeIds = new Set();
1860
- const newPaths = new Set();
1861
- const normalized = [];
1862
- for (const [index, value] of Array.from(changes || []).entries()) {
1863
- if (!value?.nodeId || !value?.newPath) continue;
1864
- const oldPath = path.resolve(value.oldPath);
1865
- const newPath = path.resolve(value.newPath);
1866
- if (oldPath === newPath) continue;
1867
- if (nodeIds.has(value.nodeId) || newPaths.has(newPath)) {
1868
- throw new Error('document path rebind is not one-to-one');
1869
- }
1870
- nodeIds.add(value.nodeId);
1871
- newPaths.add(newPath);
1872
- normalized.push({
1873
- nodeId: value.nodeId,
1874
- oldPath,
1875
- newPath,
1876
- newResource: `${DOC_RESOURCE_PREFIX}${value.nodeId}`,
1877
- oldLocalResource: `${DOC_RESOURCE_PREFIX}${
1878
- fsPrivate().base64UrlEncode(oldPath)
1879
- }`,
1880
- temporaryPath: path.join(
1881
- path.dirname(oldPath),
1882
- `.amalgm-rebind-${token}-${index}`,
1883
- ),
1884
- });
1885
- }
1886
- if (normalized.length === 0) return options.deferOpenDocs === true ? () => {} : undefined;
1887
-
1888
- const currentEntries = Array.from(new Set(openDocs.values()));
1889
- const assignments = normalized.map((change) => ({
1890
- change,
1891
- entry: currentEntries.find((candidate) => (
1222
+ for (const change of changes || []) {
1223
+ if (!change?.nodeId || !change?.newPath || change.oldPath === change.newPath) continue;
1224
+ database.transaction(() => {
1225
+ database.prepare('UPDATE doc_states SET path = ? WHERE node_id = ? OR path = ?')
1226
+ .run(change.newPath, change.nodeId, change.oldPath);
1227
+ database.prepare('UPDATE shared_resource_replicas SET local_path = ?, updated_at = ? WHERE local_resource = ? OR local_path = ?')
1228
+ .run(change.newPath, new Date().toISOString(), `${DOC_RESOURCE_PREFIX}${change.nodeId}`, change.oldPath);
1229
+ database.prepare('UPDATE shared_resource_promotions SET local_path = ?, updated_at = ? WHERE local_resource = ? OR local_path = ?')
1230
+ .run(change.newPath, new Date().toISOString(), `${DOC_RESOURCE_PREFIX}${change.nodeId}`, change.oldPath);
1231
+ database.prepare('UPDATE shared_resource_attachments SET local_path = ?, updated_at = ? WHERE local_path = ?')
1232
+ .run(change.newPath, new Date().toISOString(), change.oldPath);
1233
+ })();
1234
+
1235
+ const entry = Array.from(openDocs.values()).find((candidate) => (
1892
1236
  candidate.nodeId === change.nodeId || candidate.path === change.oldPath
1893
- )) || null,
1894
- })).filter((assignment) => assignment.entry);
1895
- const affectedEntries = new Set(assignments.map((assignment) => assignment.entry));
1896
- for (const { change } of assignments) {
1897
- const occupant = currentEntries.find((entry) => entry.path === change.newPath);
1898
- if (occupant && !affectedEntries.has(occupant)) {
1899
- throw new Error(`document path rebind destination is already open: ${change.newPath}`);
1900
- }
1237
+ ));
1238
+ if (!entry) continue;
1239
+ unwatchDocFile(entry);
1240
+ openDocs.delete(entry.path);
1241
+ entry.path = change.newPath;
1242
+ entry.nodeId = change.nodeId;
1243
+ entry.resource = `${DOC_RESOURCE_PREFIX}${change.nodeId}`;
1244
+ entry.pathAliases.add(change.newPath);
1245
+ resolvedPathCache.set(change.newPath, change.newPath);
1246
+ openDocs.set(change.newPath, entry);
1247
+ if (!isInternalStateDoc(change.newPath)) watchDocFile(loadYjs(), entry);
1901
1248
  }
1902
-
1903
- // Every path-bearing table moves through a private temporary namespace
1904
- // before any final path is installed. Swaps and longer cycles are therefore
1905
- // one set-wise transition instead of a sequence that can collide with
1906
- // UNIQUE(local_path) or doc_states' path primary key.
1907
- database.transaction(() => {
1908
- const now = new Date().toISOString();
1909
- for (const change of normalized) {
1910
- database.prepare('UPDATE doc_states SET path = ? WHERE node_id = ? OR path = ?')
1911
- .run(change.temporaryPath, change.nodeId, change.oldPath);
1912
- database.prepare(`
1913
- UPDATE shared_resource_replicas
1914
- SET local_resource = ?, local_path = ?, updated_at = ?
1915
- WHERE resource_id = ? OR local_resource = ? OR local_resource = ? OR local_path = ?
1916
- `).run(
1917
- change.newResource,
1918
- change.temporaryPath,
1919
- now,
1920
- change.nodeId,
1921
- change.oldLocalResource,
1922
- change.newResource,
1923
- change.oldPath,
1924
- );
1925
- database.prepare(`
1926
- UPDATE shared_resource_promotions
1927
- SET local_resource = ?, local_path = ?, updated_at = ?
1928
- WHERE resource_id = ? OR local_resource = ? OR local_resource = ? OR local_path = ?
1929
- `).run(
1930
- change.newResource,
1931
- change.temporaryPath,
1932
- now,
1933
- change.nodeId,
1934
- change.oldLocalResource,
1935
- change.newResource,
1936
- change.oldPath,
1937
- );
1938
- database.prepare(`
1939
- UPDATE shared_resource_attachments
1940
- SET local_path = ?, updated_at = ?
1941
- WHERE resource_id = ? OR local_path = ?
1942
- `).run(change.temporaryPath, now, change.nodeId, change.oldPath);
1943
- }
1944
- for (const change of normalized) {
1945
- database.prepare('UPDATE doc_states SET path = ? WHERE node_id = ? OR path = ?')
1946
- .run(change.newPath, change.nodeId, change.temporaryPath);
1947
- database.prepare(`
1948
- UPDATE mutation_journal
1949
- SET local_resource = ?, updated_at = ?
1950
- WHERE local_resource = ? OR local_resource = ?
1951
- `).run(
1952
- change.newResource,
1953
- now,
1954
- change.oldLocalResource,
1955
- change.newResource,
1956
- );
1957
- docDisk.rebindLocalResource(database, {
1958
- oldLocalResource: change.oldLocalResource,
1959
- newLocalResource: change.newResource,
1960
- newPath: change.newPath,
1961
- });
1962
- database.prepare(`
1963
- UPDATE shared_resource_replicas
1964
- SET local_resource = ?, local_path = ?, updated_at = ?
1965
- WHERE resource_id = ? OR local_resource = ? OR local_path = ?
1966
- `).run(
1967
- change.newResource,
1968
- change.newPath,
1969
- now,
1970
- change.nodeId,
1971
- change.newResource,
1972
- change.temporaryPath,
1973
- );
1974
- database.prepare(`
1975
- UPDATE shared_resource_promotions
1976
- SET local_resource = ?, local_path = ?, updated_at = ?
1977
- WHERE resource_id = ? OR local_resource = ? OR local_path = ?
1978
- `).run(
1979
- change.newResource,
1980
- change.newPath,
1981
- now,
1982
- change.nodeId,
1983
- change.newResource,
1984
- change.temporaryPath,
1985
- );
1986
- database.prepare(`
1987
- UPDATE shared_resource_attachments
1988
- SET local_path = ?, updated_at = ?
1989
- WHERE resource_id = ? OR local_path = ?
1990
- `).run(change.newPath, now, change.nodeId, change.temporaryPath);
1991
- }
1992
- })();
1993
-
1994
- const finishOpenDocs = () => {
1995
- for (const entry of affectedEntries) unwatchDocFile(entry);
1996
- for (const [key, entry] of Array.from(openDocs.entries())) {
1997
- if (affectedEntries.has(entry)) openDocs.delete(key);
1998
- }
1999
- for (const { change, entry } of assignments) {
2000
- entry.path = change.newPath;
2001
- entry.nodeId = change.nodeId;
2002
- entry.resource = change.newResource;
2003
- entry.pathAliases.add(change.newPath);
2004
- resolvedPathCache.set(change.newPath, change.newPath);
2005
- openDocs.set(change.newPath, entry);
2006
- }
2007
- for (const entry of affectedEntries) {
2008
- if (!isInternalStateDoc(entry.path)) watchDocFile(loadYjs(), entry);
2009
- }
2010
- };
2011
- if (options.deferOpenDocs === true) return finishOpenDocs;
2012
- finishOpenDocs();
2013
- return undefined;
2014
1249
  }
2015
1250
 
2016
1251
  function closeDocByNode(nodeId) {
@@ -2031,28 +1266,7 @@ function closeDocsByNodeIds(nodeIds) {
2031
1266
  return closed;
2032
1267
  }
2033
1268
 
2034
- function captureDocsByNodeIds(nodeIds, options = {}) {
2035
- const ids = Array.from(nodeIds instanceof Set ? nodeIds : new Set(nodeIds || []));
2036
- if (!ids.length) return 0;
2037
- const database = options.database || openLocalDb();
2038
- const placeholders = ids.map(() => '?').join(',');
2039
- const replicas = database.prepare(`
2040
- SELECT resource_id, local_path
2041
- FROM shared_resource_replicas
2042
- WHERE resource_id IN (${placeholders})
2043
- AND contract = 'text-yjs@1'
2044
- AND access_status = 'active'
2045
- `).all(...ids);
2046
- if (!replicas.length) return 0;
2047
- const Y = loadYjs();
2048
- for (const replica of replicas) {
2049
- const entry = loadDocEntry(replica.local_path);
2050
- captureDiskBeforeMutation(Y, entry);
2051
- }
2052
- return replicas.length;
2053
- }
2054
-
2055
- /** Test/shutdown helper: capture final disk observations and close every open document. */
1269
+ /** Test/shutdown helper: flush and close every open document. */
2056
1270
  function closeAllDocs() {
2057
1271
  let Y;
2058
1272
  try {
@@ -2068,7 +1282,6 @@ module.exports = {
2068
1282
  applyCloudDocMutation,
2069
1283
  applyDocMutations,
2070
1284
  applyDocUpdates,
2071
- captureDocsByNodeIds,
2072
1285
  closeDocByNode,
2073
1286
  closeDocsByNodeIds,
2074
1287
  closeAllDocs,
@@ -2081,18 +1294,5 @@ module.exports = {
2081
1294
  rebindNodePaths,
2082
1295
  writeDocText,
2083
1296
  // Test hooks only — not part of the module contract.
2084
- _private: {
2085
- isInternalStateDoc,
2086
- openDocs,
2087
- setDiskTransactionTestHooks(hooks = {}) {
2088
- diskTransactionTestHooks = { ...hooks };
2089
- },
2090
- verifyOpenDocs,
2091
- watchVerificationStats() {
2092
- return {
2093
- active: watchVerificationTimer !== null,
2094
- passes: watchVerificationPasses,
2095
- };
2096
- },
2097
- },
1297
+ _private: { isInternalStateDoc, openDocs },
2098
1298
  };