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.
- package/README.md +12 -0
- package/lib/cli.js +146 -8
- package/lib/layout.js +89 -4
- package/lib/shared-realtime-tunnel.js +384 -43
- package/lib/supervisor.js +0 -3
- package/package.json +2 -2
- package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
- package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
- package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
- package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
- package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
- package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
- package/runtime/scripts/amalgm-mcp/index.js +4 -0
- package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
- package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
- package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
- package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
- package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
- package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
- package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
- package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
- package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
- package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
- package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
- package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
- package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
- package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
- package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
- package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
- package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
- package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
- package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
- package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
- package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
- package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
- package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
- package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
- package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
- package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
- package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
- package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
- package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
- package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
- package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
- package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
- package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
- package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
- package/runtime/scripts/local-gateway.js +1 -0
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
- package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
- 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
|
|
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
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
|
41
|
-
*
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
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.
|
|
337
|
-
|
|
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.
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
)
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
}
|
|
733
|
-
|
|
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
|
-
|
|
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 (
|
|
840
|
-
*
|
|
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
|
|
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'
|
|
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
|
-
|
|
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'
|
|
615
|
+
setLastDiskText(entry, onDisk, 'open');
|
|
878
616
|
if (entry.doc.getText(TEXT_KEY).toString() !== onDisk) {
|
|
879
|
-
|
|
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
|
-
//
|
|
979
|
-
//
|
|
980
|
-
|
|
981
|
-
|
|
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
|
|
992
|
-
|
|
993
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
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
|
-
|
|
1175
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1209
|
-
//
|
|
1210
|
-
//
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1345
|
-
//
|
|
1346
|
-
//
|
|
1347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1525
|
-
|
|
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
|
-
|
|
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
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
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
|
|
1856
|
-
const database =
|
|
1219
|
+
function rebindNodePaths(changes) {
|
|
1220
|
+
const database = openLocalDb();
|
|
1857
1221
|
ensureDocStatesSchema(database);
|
|
1858
|
-
const
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
}
|
|
1870
|
-
|
|
1871
|
-
|
|
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
|
-
))
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
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
|
-
|
|
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
|
};
|