amalgm 0.1.239 → 0.1.241
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/lib/shared-realtime-tunnel.js +18 -4
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +68 -20
- package/runtime/scripts/amalgm-mcp/project-context/store.js +87 -19
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +13 -0
- package/runtime/scripts/amalgm-mcp/state/attachments.js +40 -12
- package/runtime/scripts/amalgm-mcp/state/db.js +216 -17
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +550 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +1101 -301
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +99 -0
- package/runtime/scripts/amalgm-mcp/state/mutations.js +507 -130
- package/runtime/scripts/amalgm-mcp/state/promotions.js +21 -1
- package/runtime/scripts/amalgm-mcp/state/replicas.js +134 -13
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +14 -8
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +193 -0
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +313 -94
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +1 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +32 -17
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +84 -0
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +376 -13
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +309 -0
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +758 -30
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +72 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +33 -6
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +27 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +334 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +24 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +1999 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +364 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +98 -4
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +156 -8
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +185 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +174 -269
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +26 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +2060 -407
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +119 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +1446 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +5319 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +191 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +3140 -17
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +135 -637
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +5 -1
- package/runtime/scripts/amalgm-mcp/workspace/card.js +7 -7
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +36 -12
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +436 -173
- package/runtime/scripts/amalgm-mcp/workspace/content.js +32 -0
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +45 -0
- package/runtime/scripts/amalgm-mcp/workspace/git.js +391 -37
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +535 -0
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +89 -6
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +31 -0
- 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 +72 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +165 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +5 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +2 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +2201 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +19 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +18 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +18 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +33 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +47 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +43 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +114 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +84 -0
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +185 -55
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +616 -168
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +240 -0
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +1979 -570
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +49 -28
- package/runtime/scripts/amalgm-mcp/workspace/store.js +77 -4
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +240 -0
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +1729 -192
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +100 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +7 -1
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +73 -61
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +127 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +12 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +51 -6
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +125 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +79 -8
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +76 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +3480 -165
- package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +3766 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +899 -71
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +352 -497
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
const test = require('node:test');
|
|
4
4
|
const assert = require('node:assert/strict');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
5
6
|
const fs = require('fs');
|
|
6
7
|
const os = require('os');
|
|
7
8
|
const path = require('path');
|
|
9
|
+
const { spawn } = require('node:child_process');
|
|
8
10
|
|
|
9
11
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-live-docs-test-'));
|
|
10
12
|
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
@@ -28,6 +30,19 @@ function sleep(ms) {
|
|
|
28
30
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
function sha256(text) {
|
|
34
|
+
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeFromAnotherProcessAfter(file, text, delayMs) {
|
|
38
|
+
return spawn(process.execPath, [
|
|
39
|
+
'-e',
|
|
40
|
+
'const fs=require("fs");'
|
|
41
|
+
+ `setTimeout(()=>fs.writeFileSync(${JSON.stringify(file)},`
|
|
42
|
+
+ `${JSON.stringify(text)},"utf8"),${delayMs});`,
|
|
43
|
+
], { stdio: 'ignore' });
|
|
44
|
+
}
|
|
45
|
+
|
|
31
46
|
function clientFromState(stateBase64) {
|
|
32
47
|
const doc = new Y.Doc();
|
|
33
48
|
Y.applyUpdate(doc, new Uint8Array(Buffer.from(stateBase64, 'base64')));
|
|
@@ -70,7 +85,7 @@ test('open seeds the document from disk and returns usable state', () => {
|
|
|
70
85
|
assert.equal(client.getText('content').toString(), '# hello\n');
|
|
71
86
|
});
|
|
72
87
|
|
|
73
|
-
test('client updates apply, log as patch events, and
|
|
88
|
+
test('client updates apply, log as patch events, and materialize to disk', async () => {
|
|
74
89
|
const target = writeDoc('edit.md', 'hello world\n');
|
|
75
90
|
const opened = docs.openDoc(target);
|
|
76
91
|
const client = clientFromState(opened.state);
|
|
@@ -89,7 +104,7 @@ test('client updates apply, log as patch events, and flush back to disk', async
|
|
|
89
104
|
assert.ok(events[0].patch?.yjs);
|
|
90
105
|
assert.equal(events[0].value, undefined);
|
|
91
106
|
|
|
92
|
-
//
|
|
107
|
+
// The saved-local response already stands on a crossed file transaction.
|
|
93
108
|
await sleep(500);
|
|
94
109
|
assert.equal(fs.readFileSync(target, 'utf8'), 'hello, world\n');
|
|
95
110
|
});
|
|
@@ -119,14 +134,14 @@ test('two clients editing concurrently converge to the same text', () => {
|
|
|
119
134
|
assert.equal(clientB.getText('content').toString(), engineText);
|
|
120
135
|
});
|
|
121
136
|
|
|
122
|
-
test('
|
|
137
|
+
test('bounded verification reconciles a dropped external disk notification', () => {
|
|
123
138
|
const target = writeDoc('agent.md', 'agent line 1\n');
|
|
124
139
|
const opened = docs.openDoc(target);
|
|
125
140
|
const before = currentSeq();
|
|
126
141
|
|
|
127
142
|
// An "agent" writes the file directly, bypassing the doc API.
|
|
128
143
|
fs.writeFileSync(target, 'agent line 1\nagent line 2\n', 'utf8');
|
|
129
|
-
|
|
144
|
+
docs._private.verifyOpenDocs();
|
|
130
145
|
|
|
131
146
|
assert.equal(docs.getDocText(target), 'agent line 1\nagent line 2\n');
|
|
132
147
|
const events = listEventsAfter(before).filter((e) => e.resource === opened.resource);
|
|
@@ -134,6 +149,243 @@ test('external disk edits reconcile into the live doc via the watcher', async ()
|
|
|
134
149
|
assert.equal(events[events.length - 1].source, 'doc:disk');
|
|
135
150
|
});
|
|
136
151
|
|
|
152
|
+
test('a dropped watcher cannot let a later versioned API write erase the disk save', () => {
|
|
153
|
+
const base = 'left\nright\n';
|
|
154
|
+
const target = writeDoc('dropped-watcher-api.md', base);
|
|
155
|
+
docs.openDoc(target);
|
|
156
|
+
|
|
157
|
+
fs.writeFileSync(target, 'left from disk\nright\n', 'utf8');
|
|
158
|
+
const written = docs.writeDocText(target, 'left\nright from api\n', {
|
|
159
|
+
baseHash: sha256(base),
|
|
160
|
+
mutationId: 'dropped_watcher_api_001',
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
assert.equal(written.merge, 'merged');
|
|
164
|
+
assert.equal(docs.getDocText(target), 'left from disk\nright from api\n');
|
|
165
|
+
assert.equal(fs.readFileSync(target, 'utf8'), 'left from disk\nright from api\n');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('the final native CAS retains a save that lands during an API materialization', () => {
|
|
169
|
+
const base = 'first\nsecond\n';
|
|
170
|
+
const target = writeDoc('api-final-cas.md', base);
|
|
171
|
+
docs.openDoc(target);
|
|
172
|
+
const child = writeFromAnotherProcessAfter(
|
|
173
|
+
target,
|
|
174
|
+
'first from disk\nsecond\n',
|
|
175
|
+
75,
|
|
176
|
+
);
|
|
177
|
+
docs._private.setDiskTransactionTestHooks({ pauseBeforeInstall: 350 });
|
|
178
|
+
try {
|
|
179
|
+
docs.writeDocText(target, 'first\nsecond from api\n', {
|
|
180
|
+
baseHash: sha256(base),
|
|
181
|
+
mutationId: 'api_final_cas_race_001',
|
|
182
|
+
});
|
|
183
|
+
} finally {
|
|
184
|
+
docs._private.setDiskTransactionTestHooks();
|
|
185
|
+
}
|
|
186
|
+
child.unref();
|
|
187
|
+
assert.equal(docs.getDocText(target), 'first from disk\nsecond from api\n');
|
|
188
|
+
assert.equal(fs.readFileSync(target, 'utf8'), 'first from disk\nsecond from api\n');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('the API plan stays pinned when disk changes after merge derivation', () => {
|
|
192
|
+
const base = 'first\nsecond\n';
|
|
193
|
+
const target = writeDoc('api-derived-base-cas.md', base);
|
|
194
|
+
docs.openDoc(target);
|
|
195
|
+
const child = writeFromAnotherProcessAfter(
|
|
196
|
+
target,
|
|
197
|
+
'first from disk\nsecond\n',
|
|
198
|
+
75,
|
|
199
|
+
);
|
|
200
|
+
docs._private.setDiskTransactionTestHooks({ pauseAfterEditBeforeJournal: 350 });
|
|
201
|
+
let written;
|
|
202
|
+
try {
|
|
203
|
+
written = docs.writeDocText(target, 'first\nsecond from api\n', {
|
|
204
|
+
baseHash: sha256(base),
|
|
205
|
+
mutationId: 'api_derived_base_cas_001',
|
|
206
|
+
});
|
|
207
|
+
} finally {
|
|
208
|
+
docs._private.setDiskTransactionTestHooks();
|
|
209
|
+
}
|
|
210
|
+
child.unref();
|
|
211
|
+
assert.equal(docs.getDocText(target), 'first from disk\nsecond from api\n');
|
|
212
|
+
assert.equal(fs.readFileSync(target, 'utf8'), 'first from disk\nsecond from api\n');
|
|
213
|
+
assert.equal(written.sha256, sha256('first from disk\nsecond from api\n'));
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('a save after native install becomes the successor during receipt recovery', () => {
|
|
217
|
+
const base = 'first\nsecond\n';
|
|
218
|
+
const target = writeDoc('api-post-install-successor.md', base);
|
|
219
|
+
docs.openDoc(target);
|
|
220
|
+
|
|
221
|
+
docs._private.setDiskTransactionTestHooks({ crashAfterInstall: true });
|
|
222
|
+
try {
|
|
223
|
+
assert.throws(
|
|
224
|
+
() => docs.writeDocText(target, 'first\nsecond from api\n', {
|
|
225
|
+
baseHash: sha256(base),
|
|
226
|
+
mutationId: 'api_post_install_successor_001',
|
|
227
|
+
}),
|
|
228
|
+
/native file transaction failed/,
|
|
229
|
+
);
|
|
230
|
+
} finally {
|
|
231
|
+
docs._private.setDiskTransactionTestHooks();
|
|
232
|
+
}
|
|
233
|
+
assert.equal(fs.readFileSync(target, 'utf8'), 'first\nsecond from api\n');
|
|
234
|
+
|
|
235
|
+
// The helper's receipt proves the API target crossed. This later save is B,
|
|
236
|
+
// not evidence that A failed, and must become a durable successor.
|
|
237
|
+
fs.writeFileSync(target, 'first from disk\nsecond\n', 'utf8');
|
|
238
|
+
docs._private.verifyOpenDocs();
|
|
239
|
+
|
|
240
|
+
assert.equal(docs.getDocText(target), 'first from disk\nsecond from api\n');
|
|
241
|
+
assert.equal(fs.readFileSync(target, 'utf8'), 'first from disk\nsecond from api\n');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('UUID ownership recovers a crossed edit after its parent folder is renamed', () => {
|
|
245
|
+
const beforeDir = path.join(docDir, 'rename-before');
|
|
246
|
+
const afterDir = path.join(docDir, 'rename-after');
|
|
247
|
+
fs.mkdirSync(beforeDir);
|
|
248
|
+
const beforePath = path.join(beforeDir, 'owned.md');
|
|
249
|
+
const afterPath = path.join(afterDir, 'owned.md');
|
|
250
|
+
fs.writeFileSync(beforePath, 'before\n', 'utf8');
|
|
251
|
+
docs.openDoc(beforePath);
|
|
252
|
+
|
|
253
|
+
docs._private.setDiskTransactionTestHooks({ crashAfterInstall: true });
|
|
254
|
+
try {
|
|
255
|
+
assert.throws(
|
|
256
|
+
() => docs.writeDocText(beforePath, 'after API\n', {
|
|
257
|
+
baseHash: sha256('before\n'),
|
|
258
|
+
mutationId: 'uuid_parent_rename_recovery_001',
|
|
259
|
+
}),
|
|
260
|
+
/native file transaction failed/,
|
|
261
|
+
);
|
|
262
|
+
} finally {
|
|
263
|
+
docs._private.setDiskTransactionTestHooks();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
fs.renameSync(beforeDir, afterDir);
|
|
267
|
+
docs.rebindNodePaths([{
|
|
268
|
+
nodeId: '11111111-1111-4111-8111-111111111111',
|
|
269
|
+
oldPath: beforePath,
|
|
270
|
+
newPath: afterPath,
|
|
271
|
+
}]);
|
|
272
|
+
docs._private.verifyOpenDocs();
|
|
273
|
+
|
|
274
|
+
assert.equal(docs.getDocText(afterPath), 'after API\n');
|
|
275
|
+
assert.equal(fs.readFileSync(afterPath, 'utf8'), 'after API\n');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('the bounded verifier schedule reconciles a file whose watcher failed', async () => {
|
|
279
|
+
const isolatedDir = path.join(docDir, 'scheduled-verifier');
|
|
280
|
+
fs.mkdirSync(isolatedDir, { recursive: true });
|
|
281
|
+
const target = path.join(isolatedDir, 'agent.md');
|
|
282
|
+
fs.writeFileSync(target, 'before\n', 'utf8');
|
|
283
|
+
const originalWatch = fs.watch;
|
|
284
|
+
let watchAttempts = 0;
|
|
285
|
+
fs.watch = (...args) => {
|
|
286
|
+
watchAttempts += 1;
|
|
287
|
+
if (watchAttempts === 1) {
|
|
288
|
+
const error = new Error('simulated dropped watcher');
|
|
289
|
+
error.code = 'EMFILE';
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
return originalWatch.apply(fs, args);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
const scheduledBefore = docs._private.watchVerificationStats();
|
|
297
|
+
const opened = docs.openDoc(target);
|
|
298
|
+
const before = currentSeq();
|
|
299
|
+
fs.writeFileSync(target, 'after\n', 'utf8');
|
|
300
|
+
|
|
301
|
+
const deadline = Date.now() + 7_000;
|
|
302
|
+
while (docs.getDocText(target) !== 'after\n' && Date.now() < deadline) {
|
|
303
|
+
await sleep(50);
|
|
304
|
+
}
|
|
305
|
+
assert.equal(
|
|
306
|
+
docs.getDocText(target),
|
|
307
|
+
'after\n',
|
|
308
|
+
JSON.stringify({
|
|
309
|
+
scheduledBefore,
|
|
310
|
+
scheduledAfter: docs._private.watchVerificationStats(),
|
|
311
|
+
watchAttempts,
|
|
312
|
+
disk: fs.readFileSync(target, 'utf8'),
|
|
313
|
+
}),
|
|
314
|
+
);
|
|
315
|
+
const events = listEventsAfter(before).filter((event) => event.resource === opened.resource);
|
|
316
|
+
assert.equal(events.length, 1);
|
|
317
|
+
assert.equal(events[0].source, 'doc:disk');
|
|
318
|
+
assert.ok(watchAttempts >= 2, 'the scheduled pass retried the failed watcher');
|
|
319
|
+
const scheduledAfter = docs._private.watchVerificationStats();
|
|
320
|
+
assert.equal(scheduledAfter.active, true);
|
|
321
|
+
assert.ok(scheduledAfter.passes > scheduledBefore.passes);
|
|
322
|
+
} finally {
|
|
323
|
+
fs.watch = originalWatch;
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('a transient journal failure leaves the exact disk observation retryable', () => {
|
|
328
|
+
const target = writeDoc('agent-retry.md', 'before\n');
|
|
329
|
+
const opened = docs.openDoc(target);
|
|
330
|
+
const before = currentSeq();
|
|
331
|
+
const mutations = require('../state/mutations');
|
|
332
|
+
const commitLocalMutation = mutations.commitLocalMutation;
|
|
333
|
+
fs.writeFileSync(target, 'after\n', 'utf8');
|
|
334
|
+
mutations.commitLocalMutation = () => {
|
|
335
|
+
throw new Error('simulated journal outage');
|
|
336
|
+
};
|
|
337
|
+
try {
|
|
338
|
+
docs._private.verifyOpenDocs();
|
|
339
|
+
} finally {
|
|
340
|
+
mutations.commitLocalMutation = commitLocalMutation;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
assert.equal(docs.getDocText(target), 'before\n');
|
|
344
|
+
assert.equal(
|
|
345
|
+
listEventsAfter(before).filter((event) => event.resource === opened.resource).length,
|
|
346
|
+
0,
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
docs._private.verifyOpenDocs();
|
|
350
|
+
assert.equal(docs.getDocText(target), 'after\n');
|
|
351
|
+
assert.equal(fs.readFileSync(target, 'utf8'), 'after\n');
|
|
352
|
+
const events = listEventsAfter(before).filter((event) => event.resource === opened.resource);
|
|
353
|
+
assert.equal(events.length, 1);
|
|
354
|
+
assert.ok(events.every((event) => event.source === 'doc:disk'));
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test('a transient journal failure cannot consume a whole-text API write', () => {
|
|
358
|
+
const target = writeDoc('api-retry.md', 'before\n');
|
|
359
|
+
const opened = docs.openDoc(target);
|
|
360
|
+
const before = currentSeq();
|
|
361
|
+
const mutations = require('../state/mutations');
|
|
362
|
+
const commitLocalMutation = mutations.commitLocalMutation;
|
|
363
|
+
mutations.commitLocalMutation = () => {
|
|
364
|
+
throw new Error('simulated journal outage');
|
|
365
|
+
};
|
|
366
|
+
try {
|
|
367
|
+
assert.throws(
|
|
368
|
+
() => docs.writeDocText(target, 'after\n', { mutationId: 'api_retry_write_0001' }),
|
|
369
|
+
/simulated journal outage/,
|
|
370
|
+
);
|
|
371
|
+
} finally {
|
|
372
|
+
mutations.commitLocalMutation = commitLocalMutation;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
assert.equal(docs.getDocText(target), 'before\n');
|
|
376
|
+
assert.equal(fs.readFileSync(target, 'utf8'), 'before\n');
|
|
377
|
+
assert.equal(
|
|
378
|
+
listEventsAfter(before).filter((event) => event.resource === opened.resource).length,
|
|
379
|
+
0,
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
docs.writeDocText(target, 'after\n', { mutationId: 'api_retry_write_0001' });
|
|
383
|
+
assert.equal(docs.getDocText(target), 'after\n');
|
|
384
|
+
const events = listEventsAfter(before).filter((event) => event.resource === opened.resource);
|
|
385
|
+
assert.equal(events.length, 1);
|
|
386
|
+
assert.equal(events[0].source, 'doc:write');
|
|
387
|
+
});
|
|
388
|
+
|
|
137
389
|
test('internal protocol documents never treat their own disk flush as another writer', async () => {
|
|
138
390
|
const checkpointDir = path.join(process.env.AMALGM_DIR, 'state', 'repo-checkpoints');
|
|
139
391
|
const projectDir = path.join(process.env.AMALGM_DIR, 'state', 'code-projects');
|
|
@@ -185,7 +437,7 @@ test('documents survive close: persisted state + disk agree on reopen', () => {
|
|
|
185
437
|
const update = encodeLocalEdit(client, (ytext) => ytext.insert(0, 'v2 over '));
|
|
186
438
|
docs.applyDocUpdates(target, [update]);
|
|
187
439
|
|
|
188
|
-
docs.closeAllDocs(); //
|
|
440
|
+
docs.closeAllDocs(); // captures final disk observations and releases memory
|
|
189
441
|
|
|
190
442
|
const reopened = docs.openDoc(target);
|
|
191
443
|
const fresh = clientFromState(reopened.state);
|
|
@@ -224,8 +476,33 @@ test('guards: binary, missing, oversized, and malformed inputs are rejected', ()
|
|
|
224
476
|
assert.throws(() => docs.applyDocUpdates(target, ['aGVsbG8=']), /invalid Yjs update/);
|
|
225
477
|
});
|
|
226
478
|
|
|
479
|
+
test('all managed document writes cross the one native materialization boundary', () => {
|
|
480
|
+
const source = fs.readFileSync(require.resolve('../state/docs'), 'utf8');
|
|
481
|
+
const rawWriteTargets = Array.from(
|
|
482
|
+
source.matchAll(/fs\.writeFileSync\(\s*([^,\n]+)/g),
|
|
483
|
+
(match) => match[1].trim(),
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
assert.deepEqual(
|
|
487
|
+
rawWriteTargets,
|
|
488
|
+
['sidecarPath'],
|
|
489
|
+
'only the independent metadata sidecar may bypass document materialization',
|
|
490
|
+
);
|
|
491
|
+
assert.doesNotMatch(source, /\bflushDocToDisk\b|\bwriteFileAtomic\b|fs\.renameSync\(/);
|
|
492
|
+
assert.match(source, /docDisk\.cross\(/);
|
|
493
|
+
assert.equal(
|
|
494
|
+
(source.match(/docDisk\.insertSnapshotBase\(/g) || []).length,
|
|
495
|
+
1,
|
|
496
|
+
'every snapshot attempt must acquire its physical base through one capture-and-pin helper',
|
|
497
|
+
);
|
|
498
|
+
assert.match(
|
|
499
|
+
source,
|
|
500
|
+
/function planCloudDocSnapshot[\s\S]*?captureDiskBeforeMutation[\s\S]*?docDisk\.insertSnapshotBase/,
|
|
501
|
+
);
|
|
502
|
+
});
|
|
503
|
+
|
|
227
504
|
// ---------------------------------------------------------------------------
|
|
228
|
-
// Durability: a crash (SIGKILL — no
|
|
505
|
+
// Durability: a crash (SIGKILL — no graceful close) must never lose
|
|
229
506
|
// an acknowledged edit. The contract is per acknowledgment, not per update:
|
|
230
507
|
// state is persisted before applyDocUpdates returns (before the POST response
|
|
231
508
|
// acknowledges); the recorded last-disk hash lets reopen tell "disk is merely
|
|
@@ -234,20 +511,20 @@ test('guards: binary, missing, oversized, and malformed inputs are rejected', ()
|
|
|
234
511
|
|
|
235
512
|
function crashAndReloadDocs() {
|
|
236
513
|
// Simulate SIGKILL: drop the module (and its open docs, timers untouched)
|
|
237
|
-
// without
|
|
514
|
+
// without graceful cleanup. The reloaded module sees only durable state.
|
|
238
515
|
delete require.cache[require.resolve('../state/docs')];
|
|
239
516
|
return require('../state/docs');
|
|
240
517
|
}
|
|
241
518
|
|
|
242
|
-
test('
|
|
519
|
+
test('an acknowledged edit is on disk before a kill and remains current on reopen', () => {
|
|
243
520
|
const target = writeDoc('crash.md', 'before the crash\n');
|
|
244
521
|
const opened = docs.openDoc(target);
|
|
245
522
|
const client = clientFromState(opened.state);
|
|
246
523
|
const update = encodeLocalEdit(client, (ytext) => ytext.insert(0, 'acknowledged edit / '));
|
|
247
524
|
docs.applyDocUpdates(target, [update]);
|
|
248
525
|
|
|
249
|
-
//
|
|
250
|
-
assert.equal(fs.readFileSync(target, 'utf8'), 'before the crash\n');
|
|
526
|
+
// Acknowledgment is earned only after the native file transaction crossed.
|
|
527
|
+
assert.equal(fs.readFileSync(target, 'utf8'), 'acknowledged edit / before the crash\n');
|
|
251
528
|
const reloaded = crashAndReloadDocs();
|
|
252
529
|
|
|
253
530
|
const reopened = reloaded.openDoc(target);
|
|
@@ -258,8 +535,7 @@ test('a kill inside the disk-write debounce loses nothing: reopen restores the e
|
|
|
258
535
|
'persisted state must win over a disk that is merely behind',
|
|
259
536
|
);
|
|
260
537
|
|
|
261
|
-
//
|
|
262
|
-
await sleep(500);
|
|
538
|
+
// No timer or later process lifetime is needed to make the save real.
|
|
263
539
|
assert.equal(fs.readFileSync(target, 'utf8'), 'acknowledged edit / before the crash\n');
|
|
264
540
|
reloaded.closeAllDocs();
|
|
265
541
|
});
|
|
@@ -277,7 +553,7 @@ test('a multi-update batch is fully persisted by the time it returns', () => {
|
|
|
277
553
|
const result = docs.applyDocUpdates(target, updates);
|
|
278
554
|
assert.equal(result.applied, 3);
|
|
279
555
|
|
|
280
|
-
// Crash with no
|
|
556
|
+
// Crash with no graceful close: the journal and crossed files carry all three
|
|
281
557
|
// updates into doc_states.
|
|
282
558
|
const reloaded = crashAndReloadDocs();
|
|
283
559
|
const reopened = reloaded.openDoc(target);
|
|
@@ -306,3 +582,90 @@ test('an external edit made while closed still folds in on reopen', () => {
|
|
|
306
582
|
'a genuinely changed disk is folded in, not overwritten',
|
|
307
583
|
);
|
|
308
584
|
});
|
|
585
|
+
|
|
586
|
+
test('restart reuses a crossed successor instead of inventing another mutation', () => {
|
|
587
|
+
const base = 'first\nsecond\n';
|
|
588
|
+
const target = writeDoc('crossed-successor-restart.md', base);
|
|
589
|
+
const opened = docs.openDoc(target);
|
|
590
|
+
const before = currentSeq();
|
|
591
|
+
const mutations = require('../state/mutations');
|
|
592
|
+
const baselineMutationIds = new Set(
|
|
593
|
+
mutations.listMutations({ localResource: opened.resource })
|
|
594
|
+
.map((mutation) => mutation.mutationId),
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
docs._private.setDiskTransactionTestHooks({ crashAfterInstall: true });
|
|
598
|
+
try {
|
|
599
|
+
assert.throws(
|
|
600
|
+
() => docs.writeDocText(target, 'first\nsecond from api\n', {
|
|
601
|
+
baseHash: sha256(base),
|
|
602
|
+
mutationId: 'restart_crossed_original_001',
|
|
603
|
+
}),
|
|
604
|
+
/native file transaction failed/,
|
|
605
|
+
);
|
|
606
|
+
} finally {
|
|
607
|
+
docs._private.setDiskTransactionTestHooks();
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
fs.writeFileSync(target, 'first from disk\nsecond\n', 'utf8');
|
|
611
|
+
const completeMaterializedMutation = mutations.completeMaterializedMutation;
|
|
612
|
+
let interrupted = false;
|
|
613
|
+
mutations.completeMaterializedMutation = (...args) => {
|
|
614
|
+
if (!interrupted) {
|
|
615
|
+
interrupted = true;
|
|
616
|
+
throw new Error('simulated process death before predecessor completion');
|
|
617
|
+
}
|
|
618
|
+
return completeMaterializedMutation(...args);
|
|
619
|
+
};
|
|
620
|
+
try {
|
|
621
|
+
assert.throws(
|
|
622
|
+
() => docs.writeDocText(target, 'unreached later API\n'),
|
|
623
|
+
/simulated process death before predecessor completion/,
|
|
624
|
+
);
|
|
625
|
+
} finally {
|
|
626
|
+
mutations.completeMaterializedMutation = completeMaterializedMutation;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const beforeRestart = mutations.listMutations({ localResource: opened.resource });
|
|
630
|
+
assert.equal(
|
|
631
|
+
beforeRestart.length,
|
|
632
|
+
baselineMutationIds.size + 2,
|
|
633
|
+
`one API edit and one disk successor are durable: ${JSON.stringify(
|
|
634
|
+
beforeRestart.map((mutation) => ({
|
|
635
|
+
mutationId: mutation.mutationId,
|
|
636
|
+
state: mutation.state,
|
|
637
|
+
operation: mutation.operation,
|
|
638
|
+
})),
|
|
639
|
+
)}`,
|
|
640
|
+
);
|
|
641
|
+
assert.ok(
|
|
642
|
+
beforeRestart
|
|
643
|
+
.filter((mutation) => !baselineMutationIds.has(mutation.mutationId))
|
|
644
|
+
.every((mutation) => mutation.state === 'saving-local'),
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
const reloaded = crashAndReloadDocs();
|
|
648
|
+
const reopened = reloaded.openDoc(target);
|
|
649
|
+
const finalText = fs.readFileSync(target, 'utf8');
|
|
650
|
+
assert.match(finalText, /^first from disk\n/);
|
|
651
|
+
assert.match(finalText, /second from api\n/);
|
|
652
|
+
assert.equal(clientFromState(reopened.state).getText('content').toString(), finalText);
|
|
653
|
+
|
|
654
|
+
const afterRestart = mutations.listMutations({ localResource: opened.resource });
|
|
655
|
+
assert.equal(
|
|
656
|
+
afterRestart.length,
|
|
657
|
+
baselineMutationIds.size + 2,
|
|
658
|
+
'restart must not assign the successor a second ID',
|
|
659
|
+
);
|
|
660
|
+
assert.ok(
|
|
661
|
+
afterRestart
|
|
662
|
+
.filter((mutation) => !baselineMutationIds.has(mutation.mutationId))
|
|
663
|
+
.every((mutation) => mutation.state === 'saved-local'),
|
|
664
|
+
);
|
|
665
|
+
assert.equal(
|
|
666
|
+
listEventsAfter(before).filter((event) => event.resource === opened.resource).length,
|
|
667
|
+
2,
|
|
668
|
+
'one real API edit plus one real disk save produce exactly two events',
|
|
669
|
+
);
|
|
670
|
+
reloaded.closeAllDocs();
|
|
671
|
+
});
|