amalgm 0.1.151 → 0.1.152
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/tunnel-events.js +16 -6
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +7 -3
- package/runtime/scripts/amalgm-mcp/state/app-resources.js +24 -10
- package/runtime/scripts/amalgm-mcp/state/db.js +25 -2
- package/runtime/scripts/amalgm-mcp/state/docs.js +135 -58
- package/runtime/scripts/amalgm-mcp/state/events.js +58 -34
- package/runtime/scripts/amalgm-mcp/state/migrations.js +28 -1
- package/runtime/scripts/amalgm-mcp/state/registry.js +0 -5
- package/runtime/scripts/amalgm-mcp/state/rest.js +33 -10
- package/runtime/scripts/amalgm-mcp/state/snapshot.js +4 -10
- package/runtime/scripts/amalgm-mcp/tests/state-app-resources.test.js +21 -0
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +29 -3
- package/runtime/scripts/amalgm-mcp/tests/state-events-retention.test.js +1 -2
- package/runtime/scripts/amalgm-mcp/tests/state-registry.test.js +0 -2
- package/runtime/scripts/amalgm-mcp/tests/state-stream-replay.test.js +41 -10
- package/runtime/scripts/amalgm-mcp/tests/state-stream.test.js +33 -0
package/lib/tunnel-events.js
CHANGED
|
@@ -325,17 +325,21 @@ function createEventTunnel({ record, foreground = false }) {
|
|
|
325
325
|
let backoffMs = 1000;
|
|
326
326
|
let connectStartedAt = 0;
|
|
327
327
|
let lastGatewayFrameAt = 0;
|
|
328
|
+
let malformedFrames = 0;
|
|
328
329
|
const upstreamSockets = new Map();
|
|
329
330
|
const upstreamStreams = new Map();
|
|
330
331
|
|
|
332
|
+
// Always emit: daemon mode pipes stdio to daemon.log (cli.js start path),
|
|
333
|
+
// which is where anyone hunting a dead stream looks first. The tunnel is
|
|
334
|
+
// the flakiest link in the realtime chain — a mute tunnel means reconnects,
|
|
335
|
+
// heartbeat timeouts, and close codes leave no trace exactly when they
|
|
336
|
+
// matter. These fire on lifecycle events, not per frame.
|
|
331
337
|
function log(message) {
|
|
332
|
-
|
|
333
|
-
if (foreground) console.log(line);
|
|
338
|
+
console.log(`[event-tunnel] ${message}`);
|
|
334
339
|
}
|
|
335
340
|
|
|
336
341
|
function warn(message) {
|
|
337
|
-
|
|
338
|
-
if (foreground) console.warn(line);
|
|
342
|
+
console.warn(`[event-tunnel] ${message}`);
|
|
339
343
|
}
|
|
340
344
|
|
|
341
345
|
function send(frame) {
|
|
@@ -660,8 +664,14 @@ function createEventTunnel({ record, foreground = false }) {
|
|
|
660
664
|
lastGatewayFrameAt = Date.now();
|
|
661
665
|
try {
|
|
662
666
|
handleFrame(JSON.parse(data.toString()));
|
|
663
|
-
} catch {
|
|
664
|
-
//
|
|
667
|
+
} catch (error) {
|
|
668
|
+
// A malformed frame is the silent-drop failure mode the tunnel war is
|
|
669
|
+
// about — count it and sample-log so it's visible without flooding on
|
|
670
|
+
// a corrupt burst.
|
|
671
|
+
malformedFrames += 1;
|
|
672
|
+
if (malformedFrames === 1 || malformedFrames % 100 === 0) {
|
|
673
|
+
warn(`malformed gateway frame #${malformedFrames} dropped: ${error?.message || error}`);
|
|
674
|
+
}
|
|
665
675
|
}
|
|
666
676
|
});
|
|
667
677
|
ws.on('close', (code, reason) => {
|
package/package.json
CHANGED
|
@@ -4,12 +4,16 @@ const stateRest = require('../../state/rest');
|
|
|
4
4
|
|
|
5
5
|
async function handleStateRoutes(ctx) {
|
|
6
6
|
if (ctx.method === 'GET') {
|
|
7
|
+
// Async handlers must be awaited: a rejection that escapes here is an
|
|
8
|
+
// unhandled promise, and the client hangs on a response that never
|
|
9
|
+
// arrives instead of getting the router's 500. handleStream is
|
|
10
|
+
// synchronous, so its throws already propagate.
|
|
7
11
|
if (ctx.pathname === '/state/snapshot') {
|
|
8
|
-
stateRest.handleSnapshot(ctx.getQuery(), ctx.sendJson);
|
|
12
|
+
await stateRest.handleSnapshot(ctx.getQuery(), ctx.sendJson);
|
|
9
13
|
return true;
|
|
10
14
|
}
|
|
11
15
|
if (ctx.pathname === '/state/events') {
|
|
12
|
-
stateRest.handleEvents(ctx.getQuery(), ctx.sendJson);
|
|
16
|
+
await stateRest.handleEvents(ctx.getQuery(), ctx.sendJson);
|
|
13
17
|
return true;
|
|
14
18
|
}
|
|
15
19
|
if (ctx.pathname === '/state/stream') {
|
|
@@ -17,7 +21,7 @@ async function handleStateRoutes(ctx) {
|
|
|
17
21
|
return true;
|
|
18
22
|
}
|
|
19
23
|
if (ctx.pathname === '/state/resources') {
|
|
20
|
-
stateRest.handleResourceList(ctx.sendJson);
|
|
24
|
+
await stateRest.handleResourceList(ctx.sendJson);
|
|
21
25
|
return true;
|
|
22
26
|
}
|
|
23
27
|
return false;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* them into the in-memory registry on first use.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
const { openLocalDb } = require('./db');
|
|
16
|
+
const { cachedStatement, openLocalDb } = require('./db');
|
|
17
17
|
const registry = require('./registry');
|
|
18
18
|
const { insertStateEvent, publishStateEvent } = require('./events');
|
|
19
19
|
|
|
@@ -140,30 +140,42 @@ function normalizeEmitEvent(name, input, index) {
|
|
|
140
140
|
return { op, id: String(id), value: input.value };
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
|
|
143
|
+
// event.value is stringified exactly once per event; the mirror rows and the
|
|
144
|
+
// event-log insert both consume this. For replace, JSON.stringify of an array
|
|
145
|
+
// is precisely its element JSON joined by commas, so the whole-array form is
|
|
146
|
+
// composed from the per-row strings instead of serializing twice.
|
|
147
|
+
function serializeEmitEvent(event) {
|
|
144
148
|
if (event.op === 'replace') {
|
|
145
|
-
|
|
146
|
-
|
|
149
|
+
const rowJson = event.value.map((row) => JSON.stringify(row));
|
|
150
|
+
return { rowJson, valueJson: `[${rowJson.join(',')}]` };
|
|
151
|
+
}
|
|
152
|
+
return { valueJson: event.value === undefined ? null : JSON.stringify(event.value) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function applyMirror(database, name, event, serialized) {
|
|
156
|
+
if (event.op === 'replace') {
|
|
157
|
+
cachedStatement(database, 'DELETE FROM app_resource_rows WHERE resource = ?').run(name);
|
|
158
|
+
const upsert = cachedStatement(database, `
|
|
147
159
|
INSERT INTO app_resource_rows (resource, id, value_json, updated_at)
|
|
148
160
|
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
149
161
|
ON CONFLICT(resource, id) DO UPDATE SET
|
|
150
162
|
value_json = excluded.value_json,
|
|
151
163
|
updated_at = excluded.updated_at
|
|
152
164
|
`);
|
|
153
|
-
|
|
165
|
+
event.value.forEach((row, index) => upsert.run(name, String(row.id), serialized.rowJson[index]));
|
|
154
166
|
return;
|
|
155
167
|
}
|
|
156
168
|
if (event.op === 'delete') {
|
|
157
|
-
database
|
|
169
|
+
cachedStatement(database, 'DELETE FROM app_resource_rows WHERE resource = ? AND id = ?').run(name, event.id);
|
|
158
170
|
return;
|
|
159
171
|
}
|
|
160
|
-
database
|
|
172
|
+
cachedStatement(database, `
|
|
161
173
|
INSERT INTO app_resource_rows (resource, id, value_json, updated_at)
|
|
162
174
|
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
163
175
|
ON CONFLICT(resource, id) DO UPDATE SET
|
|
164
176
|
value_json = excluded.value_json,
|
|
165
177
|
updated_at = excluded.updated_at
|
|
166
|
-
`).run(name, event.id,
|
|
178
|
+
`).run(name, event.id, serialized.valueJson);
|
|
167
179
|
}
|
|
168
180
|
|
|
169
181
|
/**
|
|
@@ -176,7 +188,7 @@ function emitAppResourceEvents(name, eventsInput, options = {}) {
|
|
|
176
188
|
throw invalid(`resource name must look like "app:<appId>:<name>" (got ${JSON.stringify(name)})`);
|
|
177
189
|
}
|
|
178
190
|
const database = openLocalDb();
|
|
179
|
-
const registered = database
|
|
191
|
+
const registered = cachedStatement(database, 'SELECT 1 FROM app_resources WHERE name = ?').get(name);
|
|
180
192
|
if (!registered) {
|
|
181
193
|
const error = new Error(`resource "${name}" is not registered; call /state/resources/register first`);
|
|
182
194
|
error.statusCode = 404;
|
|
@@ -194,12 +206,14 @@ function emitAppResourceEvents(name, eventsInput, options = {}) {
|
|
|
194
206
|
const inserted = database.transaction(() => {
|
|
195
207
|
const out = [];
|
|
196
208
|
for (const event of normalized) {
|
|
197
|
-
|
|
209
|
+
const serialized = serializeEmitEvent(event);
|
|
210
|
+
applyMirror(database, name, event, serialized);
|
|
198
211
|
out.push(insertStateEvent(database, {
|
|
199
212
|
resource: name,
|
|
200
213
|
op: event.op,
|
|
201
214
|
id: event.id,
|
|
202
215
|
value: event.value,
|
|
216
|
+
valueJson: serialized.valueJson,
|
|
203
217
|
source,
|
|
204
218
|
}));
|
|
205
219
|
}
|
|
@@ -14,6 +14,26 @@ const { LOCAL_DB_FILE, STORAGE_DIR } = require('../config');
|
|
|
14
14
|
|
|
15
15
|
let db = null;
|
|
16
16
|
|
|
17
|
+
// Prepared statements cached per database handle: preparing is a parse+plan
|
|
18
|
+
// every time, and the realtime hot paths (event insert, replay reads, mirror
|
|
19
|
+
// upserts, doc persistence) run the same fixed SQL for the process lifetime.
|
|
20
|
+
// WeakMap so a closed handle's statements are collected with it.
|
|
21
|
+
const statementCaches = new WeakMap();
|
|
22
|
+
|
|
23
|
+
function cachedStatement(database, sql) {
|
|
24
|
+
let cache = statementCaches.get(database);
|
|
25
|
+
if (!cache) {
|
|
26
|
+
cache = new Map();
|
|
27
|
+
statementCaches.set(database, cache);
|
|
28
|
+
}
|
|
29
|
+
let statement = cache.get(sql);
|
|
30
|
+
if (!statement) {
|
|
31
|
+
statement = database.prepare(sql);
|
|
32
|
+
cache.set(sql, statement);
|
|
33
|
+
}
|
|
34
|
+
return statement;
|
|
35
|
+
}
|
|
36
|
+
|
|
17
37
|
function getBetterSqlite3() {
|
|
18
38
|
try {
|
|
19
39
|
return require('better-sqlite3');
|
|
@@ -52,8 +72,10 @@ function migrate(database = openLocalDb()) {
|
|
|
52
72
|
resource_version INTEGER
|
|
53
73
|
);
|
|
54
74
|
|
|
55
|
-
|
|
56
|
-
|
|
75
|
+
-- No query filters event_log by resource (replay and pruning walk seq/ts);
|
|
76
|
+
-- the index only taxed every insert. Dropped on databases that carry it
|
|
77
|
+
-- from earlier releases.
|
|
78
|
+
DROP INDEX IF EXISTS event_log_resource_seq_idx;
|
|
57
79
|
|
|
58
80
|
CREATE TABLE IF NOT EXISTS local_meta (
|
|
59
81
|
key TEXT PRIMARY KEY,
|
|
@@ -454,6 +476,7 @@ function closeLocalDb() {
|
|
|
454
476
|
}
|
|
455
477
|
|
|
456
478
|
module.exports = {
|
|
479
|
+
cachedStatement,
|
|
457
480
|
closeLocalDb,
|
|
458
481
|
openLocalDb,
|
|
459
482
|
};
|
|
@@ -23,17 +23,18 @@
|
|
|
23
23
|
* realtime layer is affected.
|
|
24
24
|
*
|
|
25
25
|
* External writers (agents editing the file directly) are folded in by a
|
|
26
|
-
* per-
|
|
27
|
-
* the Y.Doc as a splice, which emits a
|
|
28
|
-
* loop-safe because reconcile only acts
|
|
29
|
-
* last text we read or wrote
|
|
30
|
-
*
|
|
26
|
+
* per-directory watcher shared by every open doc in that folder: on file
|
|
27
|
+
* change, disk text is diffed into the Y.Doc as a splice, which emits a
|
|
28
|
+
* normal update event. The cycle is loop-safe because reconcile only acts
|
|
29
|
+
* when disk content changed past the last text we read or wrote
|
|
30
|
+
* (entry.lastDiskText) — our own write-backs and stale watcher events can
|
|
31
|
+
* never splice, so they can never re-emit.
|
|
31
32
|
*/
|
|
32
33
|
|
|
33
34
|
const crypto = require('crypto');
|
|
34
35
|
const fs = require('fs');
|
|
35
36
|
const path = require('path');
|
|
36
|
-
const { openLocalDb } = require('./db');
|
|
37
|
+
const { cachedStatement, openLocalDb } = require('./db');
|
|
37
38
|
const { appendStateEvent, currentSeq } = require('./events');
|
|
38
39
|
|
|
39
40
|
const DOC_RESOURCE_PREFIX = 'doc:';
|
|
@@ -44,10 +45,21 @@ const DISK_WRITE_DEBOUNCE_MS = 300;
|
|
|
44
45
|
const WATCH_RECONCILE_DEBOUNCE_MS = 200;
|
|
45
46
|
const MAX_UPDATES_PER_CALL = 200;
|
|
46
47
|
|
|
47
|
-
// path -> { doc, resource,
|
|
48
|
-
//
|
|
48
|
+
// resolved path -> { doc, resource, reconcileTimer, diskWriteTimer,
|
|
49
|
+
// lastAccess, lastDiskText, lastDiskHash, pathAliases }
|
|
49
50
|
const openDocs = new Map();
|
|
50
51
|
|
|
52
|
+
// pathInput (as the client sent it) -> resolved path, held only while the doc
|
|
53
|
+
// is open (entries die with closeDocEntry). The update POST is the hot path,
|
|
54
|
+
// and re-running the realpath/stat/containment chain per call buys nothing
|
|
55
|
+
// for a doc whose guards were enforced at open.
|
|
56
|
+
const resolvedPathCache = new Map();
|
|
57
|
+
|
|
58
|
+
// parent directory -> { watcher, docs: Map<basename, entry> }. One watcher
|
|
59
|
+
// per directory instead of one per doc: N docs in a folder cost one fd, and
|
|
60
|
+
// the watcher dies when its last doc closes (docs.size is the refcount).
|
|
61
|
+
const dirWatchers = new Map();
|
|
62
|
+
|
|
51
63
|
function fsPrivate() {
|
|
52
64
|
return require('../fs/rest')._private;
|
|
53
65
|
}
|
|
@@ -92,6 +104,14 @@ function hashText(text) {
|
|
|
92
104
|
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
|
|
93
105
|
}
|
|
94
106
|
|
|
107
|
+
// The only way lastDiskText is ever assigned: the hash is what persistence
|
|
108
|
+
// writes, and hashing at assignment (rare: open, reconcile, flush) keeps the
|
|
109
|
+
// full-text SHA-256 off the per-batch persist path.
|
|
110
|
+
function setLastDiskText(entry, text) {
|
|
111
|
+
entry.lastDiskText = text;
|
|
112
|
+
entry.lastDiskHash = text == null ? null : hashText(text);
|
|
113
|
+
}
|
|
114
|
+
|
|
95
115
|
function docResourceName(absolutePath) {
|
|
96
116
|
return `${DOC_RESOURCE_PREFIX}${fsPrivate().base64UrlEncode(absolutePath)}`;
|
|
97
117
|
}
|
|
@@ -154,10 +174,10 @@ function isLowSurrogate(code) {
|
|
|
154
174
|
return code >= 0xdc00 && code <= 0xdfff;
|
|
155
175
|
}
|
|
156
176
|
|
|
177
|
+
// Schema is ensured once at loadDocEntry; every entry that reaches here came
|
|
178
|
+
// through it.
|
|
157
179
|
function persistDocState(Y, entry) {
|
|
158
|
-
|
|
159
|
-
ensureSchema(database);
|
|
160
|
-
database.prepare(`
|
|
180
|
+
cachedStatement(openLocalDb(), `
|
|
161
181
|
INSERT INTO doc_states (path, state, updated_at, last_disk_hash)
|
|
162
182
|
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), ?)
|
|
163
183
|
ON CONFLICT(path) DO UPDATE SET
|
|
@@ -167,7 +187,7 @@ function persistDocState(Y, entry) {
|
|
|
167
187
|
`).run(
|
|
168
188
|
entry.path,
|
|
169
189
|
Buffer.from(Y.encodeStateAsUpdate(entry.doc)),
|
|
170
|
-
entry.
|
|
190
|
+
entry.lastDiskHash,
|
|
171
191
|
);
|
|
172
192
|
}
|
|
173
193
|
|
|
@@ -185,7 +205,7 @@ function flushDocToDisk(Y, entry) {
|
|
|
185
205
|
const text = entry.doc.getText(TEXT_KEY).toString();
|
|
186
206
|
const onDisk = fs.readFileSync(entry.path, 'utf8');
|
|
187
207
|
if (onDisk !== text) fs.writeFileSync(entry.path, text, 'utf8');
|
|
188
|
-
entry
|
|
208
|
+
setLastDiskText(entry, text);
|
|
189
209
|
persistDocState(Y, entry);
|
|
190
210
|
} catch (error) {
|
|
191
211
|
console.warn(`[LiveDoc] Failed to flush "${entry.path}" to disk:`, error?.message || error);
|
|
@@ -200,13 +220,13 @@ function reconcileFromDisk(Y, entry) {
|
|
|
200
220
|
// of disk (write-back still debounced) would "reconcile" the stale disk
|
|
201
221
|
// text back over live edits.
|
|
202
222
|
if (onDisk === entry.lastDiskText) return;
|
|
203
|
-
entry
|
|
223
|
+
setLastDiskText(entry, onDisk);
|
|
204
224
|
const ytext = entry.doc.getText(TEXT_KEY);
|
|
205
225
|
// origin 'disk' keeps the update handler from writing the same bytes back.
|
|
206
|
-
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
226
|
+
spliceTextInto(Y, entry.doc, ytext, onDisk, 'disk');
|
|
227
|
+
// Persist even for a no-op splice: the refreshed lastDiskText hash still
|
|
228
|
+
// needs recording.
|
|
229
|
+
persistDocState(Y, entry);
|
|
210
230
|
} catch (error) {
|
|
211
231
|
// File deleted or unreadable — the doc simply stops following the disk.
|
|
212
232
|
console.warn(`[LiveDoc] Reconcile from disk failed for "${entry.path}":`, error?.message || error);
|
|
@@ -240,34 +260,61 @@ function seedFromDisk(Y, entry, persisted) {
|
|
|
240
260
|
reconcileFromDisk(Y, entry);
|
|
241
261
|
return;
|
|
242
262
|
}
|
|
243
|
-
entry
|
|
263
|
+
setLastDiskText(entry, onDisk);
|
|
244
264
|
if (entry.doc.getText(TEXT_KEY).toString() !== onDisk) {
|
|
245
265
|
scheduleDiskWrite(Y, entry);
|
|
246
266
|
}
|
|
247
267
|
}
|
|
248
268
|
|
|
269
|
+
function scheduleReconcile(Y, entry) {
|
|
270
|
+
if (entry.reconcileTimer) return;
|
|
271
|
+
entry.reconcileTimer = setTimeout(() => {
|
|
272
|
+
entry.reconcileTimer = null;
|
|
273
|
+
reconcileFromDisk(Y, entry);
|
|
274
|
+
}, WATCH_RECONCILE_DEBOUNCE_MS);
|
|
275
|
+
if (typeof entry.reconcileTimer.unref === 'function') entry.reconcileTimer.unref();
|
|
276
|
+
}
|
|
277
|
+
|
|
249
278
|
function watchDocFile(Y, entry) {
|
|
250
279
|
const dir = path.dirname(entry.path);
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
280
|
+
let shared = dirWatchers.get(dir);
|
|
281
|
+
if (!shared) {
|
|
282
|
+
const docs = new Map();
|
|
283
|
+
try {
|
|
284
|
+
const watcher = fs.watch(dir, { persistent: false }, (eventType, filename) => {
|
|
285
|
+
// Some platforms omit filename; reconcile is content-compare based
|
|
286
|
+
// and idempotent, so fanning out to every doc in the directory is
|
|
287
|
+
// safe when we cannot tell which file changed.
|
|
288
|
+
if (filename) {
|
|
289
|
+
const target = docs.get(filename);
|
|
290
|
+
if (target) scheduleReconcile(Y, target);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
for (const target of docs.values()) scheduleReconcile(Y, target);
|
|
294
|
+
});
|
|
295
|
+
watcher.on('error', () => {
|
|
296
|
+
try { watcher.close(); } catch {}
|
|
297
|
+
dirWatchers.delete(dir);
|
|
298
|
+
});
|
|
299
|
+
if (typeof watcher.unref === 'function') watcher.unref();
|
|
300
|
+
shared = { watcher, docs };
|
|
301
|
+
dirWatchers.set(dir, shared);
|
|
302
|
+
} catch {
|
|
303
|
+
// Watching is best-effort; external edits still reconcile on next open.
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
shared.docs.set(path.basename(entry.path), entry);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function unwatchDocFile(entry) {
|
|
311
|
+
const dir = path.dirname(entry.path);
|
|
312
|
+
const shared = dirWatchers.get(dir);
|
|
313
|
+
if (!shared) return;
|
|
314
|
+
shared.docs.delete(path.basename(entry.path));
|
|
315
|
+
if (shared.docs.size === 0) {
|
|
316
|
+
try { shared.watcher.close(); } catch {}
|
|
317
|
+
dirWatchers.delete(dir);
|
|
271
318
|
}
|
|
272
319
|
}
|
|
273
320
|
|
|
@@ -281,16 +328,16 @@ function closeDocEntry(Y, entry) {
|
|
|
281
328
|
entry.reconcileTimer = null;
|
|
282
329
|
}
|
|
283
330
|
flushDocToDisk(Y, entry);
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
entry.watcher = null;
|
|
287
|
-
}
|
|
331
|
+
unwatchDocFile(entry);
|
|
332
|
+
for (const alias of entry.pathAliases) resolvedPathCache.delete(alias);
|
|
288
333
|
entry.doc.destroy();
|
|
289
334
|
openDocs.delete(entry.path);
|
|
290
335
|
}
|
|
291
336
|
|
|
337
|
+
// Called before inserting a new entry: evicts down to MAX_OPEN_DOCS - 1 so
|
|
338
|
+
// the insert lands exactly at the cap, never past it.
|
|
292
339
|
function evictIfNeeded(Y) {
|
|
293
|
-
while (openDocs.size
|
|
340
|
+
while (openDocs.size >= MAX_OPEN_DOCS) {
|
|
294
341
|
let oldest = null;
|
|
295
342
|
for (const entry of openDocs.values()) {
|
|
296
343
|
if (!oldest || entry.lastAccess < oldest.lastAccess) oldest = entry;
|
|
@@ -302,10 +349,23 @@ function evictIfNeeded(Y) {
|
|
|
302
349
|
|
|
303
350
|
function loadDocEntry(pathInput) {
|
|
304
351
|
const Y = loadYjs();
|
|
352
|
+
|
|
353
|
+
const cachedResolved = resolvedPathCache.get(pathInput);
|
|
354
|
+
if (cachedResolved !== undefined) {
|
|
355
|
+
const open = openDocs.get(cachedResolved);
|
|
356
|
+
if (open) {
|
|
357
|
+
open.lastAccess = Date.now();
|
|
358
|
+
return open;
|
|
359
|
+
}
|
|
360
|
+
resolvedPathCache.delete(pathInput);
|
|
361
|
+
}
|
|
362
|
+
|
|
305
363
|
const resolved = resolveDocPath(pathInput);
|
|
306
364
|
const existing = openDocs.get(resolved);
|
|
307
365
|
if (existing) {
|
|
308
366
|
existing.lastAccess = Date.now();
|
|
367
|
+
existing.pathAliases.add(pathInput);
|
|
368
|
+
resolvedPathCache.set(pathInput, resolved);
|
|
309
369
|
return existing;
|
|
310
370
|
}
|
|
311
371
|
|
|
@@ -317,16 +377,20 @@ function loadDocEntry(pathInput) {
|
|
|
317
377
|
path: resolved,
|
|
318
378
|
resource: docResourceName(resolved),
|
|
319
379
|
doc,
|
|
320
|
-
watcher: null,
|
|
321
380
|
reconcileTimer: null,
|
|
322
381
|
diskWriteTimer: null,
|
|
323
382
|
lastAccess: Date.now(),
|
|
324
383
|
// The disk text as of our last read/write; reconcile skips anything that
|
|
325
|
-
// hasn't changed past it (see reconcileFromDisk).
|
|
384
|
+
// hasn't changed past it (see reconcileFromDisk). Assigned only through
|
|
385
|
+
// setLastDiskText so the hash that persistence writes stays in step.
|
|
326
386
|
lastDiskText: null,
|
|
387
|
+
lastDiskHash: null,
|
|
388
|
+
// Every pathInput form seen for this doc, so close can clear its
|
|
389
|
+
// resolvedPathCache entries.
|
|
390
|
+
pathAliases: new Set([pathInput]),
|
|
327
391
|
};
|
|
328
392
|
|
|
329
|
-
let persisted = database
|
|
393
|
+
let persisted = cachedStatement(database, 'SELECT state, last_disk_hash FROM doc_states WHERE path = ?').get(resolved) || null;
|
|
330
394
|
if (persisted?.state) {
|
|
331
395
|
try {
|
|
332
396
|
Y.applyUpdate(doc, new Uint8Array(persisted.state), 'persisted');
|
|
@@ -339,10 +403,13 @@ function loadDocEntry(pathInput) {
|
|
|
339
403
|
}
|
|
340
404
|
|
|
341
405
|
// Every applied change — client update, disk reconcile, or seed — flows
|
|
342
|
-
// through this one pipeline: event out,
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
406
|
+
// through this one pipeline: event out, then (except disk-origin) disk out.
|
|
407
|
+
// State persistence is NOT per update: the durability contract only needs
|
|
408
|
+
// the state durable before the caller acknowledges, so each mutation entry
|
|
409
|
+
// point (applyDocUpdates, reconcileFromDisk, flushDocToDisk) persists once
|
|
410
|
+
// when it is done. A kill — even SIGKILL — still never loses an
|
|
411
|
+
// acknowledged edit; at worst the disk artifact lags by the debounce window
|
|
412
|
+
// until the doc is next opened.
|
|
346
413
|
doc.on('update', (update, origin) => {
|
|
347
414
|
appendStateEvent({
|
|
348
415
|
resource: entry.resource,
|
|
@@ -351,7 +418,6 @@ function loadDocEntry(pathInput) {
|
|
|
351
418
|
patch: { yjs: Buffer.from(update).toString('base64') },
|
|
352
419
|
source: origin === 'disk' ? 'doc:disk' : 'doc:update',
|
|
353
420
|
});
|
|
354
|
-
persistDocState(Y, entry);
|
|
355
421
|
if (origin !== 'disk') scheduleDiskWrite(Y, entry);
|
|
356
422
|
});
|
|
357
423
|
|
|
@@ -360,9 +426,10 @@ function loadDocEntry(pathInput) {
|
|
|
360
426
|
// write debounce) gets the doc written back out — never spliced over it.
|
|
361
427
|
seedFromDisk(Y, entry, persisted);
|
|
362
428
|
|
|
429
|
+
evictIfNeeded(Y);
|
|
363
430
|
openDocs.set(resolved, entry);
|
|
364
431
|
watchDocFile(Y, entry);
|
|
365
|
-
|
|
432
|
+
resolvedPathCache.set(pathInput, resolved);
|
|
366
433
|
return entry;
|
|
367
434
|
}
|
|
368
435
|
|
|
@@ -393,12 +460,22 @@ function applyDocUpdates(pathInput, updatesInput) {
|
|
|
393
460
|
|
|
394
461
|
const entry = loadDocEntry(pathInput);
|
|
395
462
|
entry.lastAccess = Date.now();
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
463
|
+
let applied = 0;
|
|
464
|
+
try {
|
|
465
|
+
for (const update of updates) {
|
|
466
|
+
try {
|
|
467
|
+
Y.applyUpdate(entry.doc, new Uint8Array(update), 'client');
|
|
468
|
+
} catch (error) {
|
|
469
|
+
throw invalid(`invalid Yjs update: ${error?.message || error}`);
|
|
470
|
+
}
|
|
471
|
+
applied += 1;
|
|
401
472
|
}
|
|
473
|
+
} finally {
|
|
474
|
+
// One persist per batch, before the HTTP response acknowledges: that is
|
|
475
|
+
// the whole durability contract. It also runs when a later update in the
|
|
476
|
+
// batch is rejected — events for the applied prefix are already in the
|
|
477
|
+
// log, and the persisted state must never lag them.
|
|
478
|
+
if (applied > 0) persistDocState(Y, entry);
|
|
402
479
|
}
|
|
403
480
|
return { applied: updates.length, seq: currentSeq() };
|
|
404
481
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { EventEmitter } = require('events');
|
|
4
|
-
const { openLocalDb } = require('./db');
|
|
4
|
+
const { cachedStatement, openLocalDb } = require('./db');
|
|
5
5
|
|
|
6
6
|
const emitter = new EventEmitter();
|
|
7
7
|
emitter.setMaxListeners(200);
|
|
@@ -10,9 +10,13 @@ emitter.setMaxListeners(200);
|
|
|
10
10
|
// bridge brief stream blips from it, and anything older is served better by a
|
|
11
11
|
// fresh snapshot (the stream sends `reset` when history is gone). Kept just
|
|
12
12
|
// long enough to ride out sleep/wake and flaky-network windows.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
//
|
|
14
|
+
// The env knobs are resolved once at load: they exist for overrides set
|
|
15
|
+
// before the process starts, and re-parsing them on every publish was pure
|
|
16
|
+
// hot-path waste. Explicit options still win per call.
|
|
17
|
+
const EVENT_LOG_RETENTION_HOURS = positiveNumber(process.env.AMALGM_EVENT_LOG_RETENTION_HOURS, 0.25);
|
|
18
|
+
const EVENT_LOG_PRUNE_BATCH_SIZE = Math.max(1, Math.floor(positiveNumber(process.env.AMALGM_EVENT_LOG_PRUNE_BATCH_SIZE, 1000)));
|
|
19
|
+
const EVENT_LOG_PRUNE_INTERVAL_MS = positiveNumber(process.env.AMALGM_EVENT_LOG_PRUNE_INTERVAL_MS, 60_000);
|
|
16
20
|
const EVENT_LOG_PRUNE_DELAY_MS = 1_000;
|
|
17
21
|
|
|
18
22
|
let lastPruneAttemptAt = 0;
|
|
@@ -24,24 +28,15 @@ function positiveNumber(value, fallback) {
|
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
function retentionHours(options = {}) {
|
|
27
|
-
return positiveNumber(
|
|
28
|
-
options.retentionHours ?? process.env.AMALGM_EVENT_LOG_RETENTION_HOURS,
|
|
29
|
-
DEFAULT_EVENT_LOG_RETENTION_HOURS,
|
|
30
|
-
);
|
|
31
|
+
return positiveNumber(options.retentionHours, EVENT_LOG_RETENTION_HOURS);
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
function pruneBatchSize(options = {}) {
|
|
34
|
-
return Math.max(1, Math.floor(positiveNumber(
|
|
35
|
-
options.batchSize ?? process.env.AMALGM_EVENT_LOG_PRUNE_BATCH_SIZE,
|
|
36
|
-
DEFAULT_EVENT_LOG_PRUNE_BATCH_SIZE,
|
|
37
|
-
)));
|
|
35
|
+
return Math.max(1, Math.floor(positiveNumber(options.batchSize, EVENT_LOG_PRUNE_BATCH_SIZE)));
|
|
38
36
|
}
|
|
39
37
|
|
|
40
38
|
function pruneIntervalMs(options = {}) {
|
|
41
|
-
return positiveNumber(
|
|
42
|
-
options.intervalMs ?? process.env.AMALGM_EVENT_LOG_PRUNE_INTERVAL_MS,
|
|
43
|
-
DEFAULT_EVENT_LOG_PRUNE_INTERVAL_MS,
|
|
44
|
-
);
|
|
39
|
+
return positiveNumber(options.intervalMs, EVENT_LOG_PRUNE_INTERVAL_MS);
|
|
45
40
|
}
|
|
46
41
|
|
|
47
42
|
function retentionCutoffIso(options = {}) {
|
|
@@ -88,8 +83,22 @@ function insertStateEvent(database, input) {
|
|
|
88
83
|
throw new Error('state event requires resource and op');
|
|
89
84
|
}
|
|
90
85
|
|
|
91
|
-
|
|
86
|
+
// ts is generated here (same format sqlite's strftime('%Y-%m-%dT%H:%M:%fZ')
|
|
87
|
+
// default produces: millisecond precision, Z suffix) and written into the
|
|
88
|
+
// row, so the event can be constructed in-process instead of SELECTing the
|
|
89
|
+
// row back out and re-parsing JSON the caller already had in hand.
|
|
90
|
+
const ts = new Date().toISOString();
|
|
91
|
+
// input.valueJson lets callers that already serialized the value (e.g. the
|
|
92
|
+
// app-resource mirror) share that one stringify with the log insert.
|
|
93
|
+
const valueJson = input.valueJson !== undefined ? input.valueJson : safeJsonStringify(input.value);
|
|
94
|
+
const id = input.id ?? null;
|
|
95
|
+
const clientMutationId = input.clientMutationId ?? input.client_mutation_id ?? null;
|
|
96
|
+
const source = input.source ?? null;
|
|
97
|
+
const version = input.version ?? input.resourceVersion ?? null;
|
|
98
|
+
|
|
99
|
+
const info = cachedStatement(database, `
|
|
92
100
|
INSERT INTO event_log (
|
|
101
|
+
ts,
|
|
93
102
|
resource,
|
|
94
103
|
op,
|
|
95
104
|
id,
|
|
@@ -99,20 +108,36 @@ function insertStateEvent(database, input) {
|
|
|
99
108
|
source,
|
|
100
109
|
resource_version
|
|
101
110
|
)
|
|
102
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
111
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
103
112
|
`).run(
|
|
113
|
+
ts,
|
|
104
114
|
input.resource,
|
|
105
115
|
input.op,
|
|
106
|
-
|
|
107
|
-
|
|
116
|
+
id,
|
|
117
|
+
valueJson,
|
|
108
118
|
safeJsonStringify(input.patch),
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
);
|
|
113
|
-
return normalizeEventRow(
|
|
114
|
-
database.prepare('SELECT * FROM event_log WHERE seq = ?').get(info.lastInsertRowid),
|
|
119
|
+
clientMutationId,
|
|
120
|
+
source,
|
|
121
|
+
version,
|
|
115
122
|
);
|
|
123
|
+
|
|
124
|
+
// Key order matches normalizeEventRow — JSON.stringify follows insertion
|
|
125
|
+
// order, so the wire shape stays byte-identical with replayed rows. String
|
|
126
|
+
// coercion on id/clientMutationId/source mirrors the TEXT column affinity a
|
|
127
|
+
// read-back would have applied.
|
|
128
|
+
const event = {
|
|
129
|
+
seq: Number(info.lastInsertRowid),
|
|
130
|
+
ts,
|
|
131
|
+
resource: input.resource,
|
|
132
|
+
op: input.op,
|
|
133
|
+
};
|
|
134
|
+
if (id != null) event.id = String(id);
|
|
135
|
+
if (clientMutationId != null) event.clientMutationId = String(clientMutationId);
|
|
136
|
+
if (source != null) event.source = String(source);
|
|
137
|
+
if (version != null) event.version = Number(version);
|
|
138
|
+
if (input.value !== undefined) event.value = input.value;
|
|
139
|
+
if (input.patch !== undefined) event.patch = input.patch;
|
|
140
|
+
return event;
|
|
116
141
|
}
|
|
117
142
|
|
|
118
143
|
function publishStateEvent(event) {
|
|
@@ -133,8 +158,7 @@ function scheduleEventLogPrune() {
|
|
|
133
158
|
}
|
|
134
159
|
|
|
135
160
|
function currentSeq() {
|
|
136
|
-
const row = openLocalDb()
|
|
137
|
-
.prepare('SELECT COALESCE(MAX(seq), 0) AS seq FROM event_log')
|
|
161
|
+
const row = cachedStatement(openLocalDb(), 'SELECT COALESCE(MAX(seq), 0) AS seq FROM event_log')
|
|
138
162
|
.get();
|
|
139
163
|
return Number(row?.seq || 0);
|
|
140
164
|
}
|
|
@@ -149,15 +173,14 @@ function appendStateEvent(input) {
|
|
|
149
173
|
function listEventsAfter(afterSeq, options = {}) {
|
|
150
174
|
const after = Number.isFinite(Number(afterSeq)) ? Number(afterSeq) : 0;
|
|
151
175
|
const limit = Math.max(1, Math.min(Number(options.limit) || 1000, 5000));
|
|
152
|
-
const
|
|
153
|
-
|
|
176
|
+
const database = openLocalDb();
|
|
177
|
+
const rows = cachedStatement(database, 'SELECT * FROM event_log WHERE seq > ? ORDER BY seq ASC LIMIT ?')
|
|
154
178
|
.all(after, limit);
|
|
155
179
|
return rows.map(normalizeEventRow).filter(Boolean);
|
|
156
180
|
}
|
|
157
181
|
|
|
158
182
|
function eventLogBounds(database = openLocalDb()) {
|
|
159
|
-
const row = database
|
|
160
|
-
.prepare('SELECT MIN(seq) AS min_seq, MAX(seq) AS max_seq FROM event_log')
|
|
183
|
+
const row = cachedStatement(database, 'SELECT MIN(seq) AS min_seq, MAX(seq) AS max_seq FROM event_log')
|
|
161
184
|
.get();
|
|
162
185
|
const minSeq = row?.min_seq == null ? 0 : Number(row.min_seq);
|
|
163
186
|
const maxSeq = row?.max_seq == null ? 0 : Number(row.max_seq);
|
|
@@ -189,7 +212,7 @@ function pruneEventLog(options = {}) {
|
|
|
189
212
|
const database = options.database || openLocalDb();
|
|
190
213
|
const cutoffIso = retentionCutoffIso({ ...options, retentionHours: hours });
|
|
191
214
|
const batchSize = pruneBatchSize(options);
|
|
192
|
-
const result = database
|
|
215
|
+
const result = cachedStatement(database, `
|
|
193
216
|
DELETE FROM event_log
|
|
194
217
|
WHERE seq IN (
|
|
195
218
|
SELECT seq
|
|
@@ -232,11 +255,12 @@ function subscribeStateEvents(callback) {
|
|
|
232
255
|
module.exports = {
|
|
233
256
|
appendStateEvent,
|
|
234
257
|
currentSeq,
|
|
235
|
-
eventLogBounds,
|
|
236
258
|
insertStateEvent,
|
|
237
259
|
listEventsAfter,
|
|
238
260
|
maybePruneEventLog,
|
|
239
261
|
publishStateEvent,
|
|
262
|
+
// Exported as the retention test's deterministic seam: the production path
|
|
263
|
+
// (debounced timer → maybePruneEventLog) cannot be driven predictably.
|
|
240
264
|
pruneEventLog,
|
|
241
265
|
replayGapAfter,
|
|
242
266
|
subscribeStateEvents,
|
|
@@ -64,9 +64,29 @@ function migrateLegacyToolboxTables(database) {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
const INHERITED_HARNESS_TOOLS_KEY = 'inherited_harness_tools_removed_v1';
|
|
68
|
+
|
|
69
|
+
function metaValue(database, key) {
|
|
70
|
+
return database.prepare('SELECT value FROM local_meta WHERE key = ?').get(key)?.value || null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function setMetaValue(database, key, value) {
|
|
74
|
+
database.prepare(`
|
|
75
|
+
INSERT INTO local_meta (key, value, updated_at)
|
|
76
|
+
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
77
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
78
|
+
value = excluded.value,
|
|
79
|
+
updated_at = excluded.updated_at
|
|
80
|
+
`).run(key, value);
|
|
81
|
+
}
|
|
82
|
+
|
|
67
83
|
// Harness-inherited codex catalog tools were seeded by an older release and
|
|
68
|
-
// are now provided dynamically
|
|
84
|
+
// are now provided dynamically. The DELETEs are idempotent, so a machine
|
|
85
|
+
// missing the stamp re-runs them harmlessly — but stamping in local_meta
|
|
86
|
+
// stops two full-table scans on every single boot after the first.
|
|
69
87
|
function removeInheritedHarnessTools(database) {
|
|
88
|
+
if (metaValue(database, INHERITED_HARNESS_TOOLS_KEY)) return;
|
|
89
|
+
|
|
70
90
|
database.prepare(`
|
|
71
91
|
DELETE FROM tool_actions
|
|
72
92
|
WHERE tool_id IN (
|
|
@@ -81,6 +101,13 @@ function removeInheritedHarnessTools(database) {
|
|
|
81
101
|
WHERE owner IN ('codex')
|
|
82
102
|
AND origin = 'catalog'
|
|
83
103
|
`).run();
|
|
104
|
+
|
|
105
|
+
// While the pre-rename toolbox tables exist, migrateLegacyToolboxTables can
|
|
106
|
+
// re-import the very rows just deleted on a later boot — stamping then
|
|
107
|
+
// would let them resurrect for good.
|
|
108
|
+
if (!tableExists(database, 'toolbox_tools')) {
|
|
109
|
+
setMetaValue(database, INHERITED_HARNESS_TOOLS_KEY, '1');
|
|
110
|
+
}
|
|
84
111
|
}
|
|
85
112
|
|
|
86
113
|
function runLegacyMigrations(database) {
|
|
@@ -89,15 +89,10 @@ function listDefaultResources() {
|
|
|
89
89
|
return names;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
function listRegisteredResources() {
|
|
93
|
-
return Array.from(resources.keys());
|
|
94
|
-
}
|
|
95
|
-
|
|
96
92
|
module.exports = {
|
|
97
93
|
getResourceSpec,
|
|
98
94
|
hasResource,
|
|
99
95
|
listDefaultResources,
|
|
100
|
-
listRegisteredResources,
|
|
101
96
|
readRegisteredResource,
|
|
102
97
|
registerResource,
|
|
103
98
|
registerResourcePrefix,
|
|
@@ -23,10 +23,22 @@ function parsePositiveInt(value, fallback) {
|
|
|
23
23
|
// overwrites itself, so the client is told to reset instead.
|
|
24
24
|
const STREAM_REPLAY_MAX_EVENTS = 1000;
|
|
25
25
|
|
|
26
|
+
// One frame per event no matter how many subscribers receive it: built on
|
|
27
|
+
// first write and cached on the event object itself, which covers both live
|
|
28
|
+
// publishes and backlog replay rows. Non-enumerable (defineProperty default)
|
|
29
|
+
// so the cache can never leak into JSON.stringify(event) — the frame embeds
|
|
30
|
+
// exactly that JSON.
|
|
31
|
+
function sseFrameFor(event) {
|
|
32
|
+
let frame = event.__sseFrame;
|
|
33
|
+
if (frame === undefined) {
|
|
34
|
+
frame = `id: ${event.seq}\nevent: state\ndata: ${JSON.stringify(event)}\n\n`;
|
|
35
|
+
Object.defineProperty(event, '__sseFrame', { value: frame });
|
|
36
|
+
}
|
|
37
|
+
return frame;
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
function sendSseEvent(res, event) {
|
|
27
|
-
res.write(
|
|
28
|
-
res.write('event: state\n');
|
|
29
|
-
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
41
|
+
res.write(sseFrameFor(event));
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
function sendSseControl(res, type, payload) {
|
|
@@ -35,10 +47,16 @@ function sendSseControl(res, type, payload) {
|
|
|
35
47
|
}
|
|
36
48
|
|
|
37
49
|
async function handleSnapshot(query, sendJson) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
50
|
+
try {
|
|
51
|
+
// Restores persisted app registrations so their resources are readable
|
|
52
|
+
// even before the owning app has spoken this boot.
|
|
53
|
+
appResources.ensureAppResourcesLoaded();
|
|
54
|
+
sendJson(200, buildSnapshot(query.resources));
|
|
55
|
+
} catch (error) {
|
|
56
|
+
// buildSnapshot rethrows a failed resource read on purpose; without this
|
|
57
|
+
// the client waits forever on a response that never comes.
|
|
58
|
+
sendHandlerError(error, sendJson);
|
|
59
|
+
}
|
|
42
60
|
}
|
|
43
61
|
|
|
44
62
|
async function handleResourceRegister(body, sendJson) {
|
|
@@ -188,9 +206,14 @@ function handleStream(req, res, query) {
|
|
|
188
206
|
// A named event (not an SSE comment) so EventSource clients can observe it
|
|
189
207
|
// and detect a silently dead stream, e.g. tunnel frame drops. It doubles as
|
|
190
208
|
// the server-side liveness probe: writing to a dead socket tears down.
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
209
|
+
// Guarded: a stream that died during backlog replay has already torn down,
|
|
210
|
+
// and arming after teardown would leak an interval nothing ever clears.
|
|
211
|
+
if (!tornDown) {
|
|
212
|
+
heartbeat = setInterval(() => {
|
|
213
|
+
writeSafe(() => sendSseControl(res, 'ping', { t: Date.now() }));
|
|
214
|
+
}, 25_000);
|
|
215
|
+
if (typeof heartbeat.unref === 'function') heartbeat.unref();
|
|
216
|
+
}
|
|
194
217
|
|
|
195
218
|
req.on('close', teardown);
|
|
196
219
|
req.on('error', teardown);
|
|
@@ -41,18 +41,12 @@ function buildSnapshot(resourcesInput) {
|
|
|
41
41
|
lastUnstable = { seq: beforeSeq, stable: false, resources: data };
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return { seq: currentSeq(), stable: true, resources: {} };
|
|
44
|
+
// Reaching here means every attempt saw a write land mid-read, and each
|
|
45
|
+
// one recorded its result — lastUnstable is always set.
|
|
46
|
+
console.warn(`[LocalLive] Snapshot did not stabilize after 3 attempts (writes kept landing mid-read); serving best-effort data at seq ${lastUnstable.seq}.`);
|
|
47
|
+
return lastUnstable;
|
|
49
48
|
}
|
|
50
49
|
|
|
51
50
|
module.exports = {
|
|
52
|
-
// Kept for existing callers; now derived from the registry.
|
|
53
|
-
get DEFAULT_RESOURCES() {
|
|
54
|
-
return registry.listDefaultResources();
|
|
55
|
-
},
|
|
56
51
|
buildSnapshot,
|
|
57
|
-
normalizeResources,
|
|
58
52
|
};
|
|
@@ -14,6 +14,7 @@ const registry = require('../state/registry');
|
|
|
14
14
|
const appResources = require('../state/app-resources');
|
|
15
15
|
const { buildSnapshot } = require('../state/snapshot');
|
|
16
16
|
const { listEventsAfter, currentSeq, subscribeStateEvents } = require('../state/events');
|
|
17
|
+
const stateRest = require('../state/rest');
|
|
17
18
|
|
|
18
19
|
test.after(() => {
|
|
19
20
|
closeLocalDb();
|
|
@@ -134,3 +135,23 @@ test('unregister removes rows, frees the name, and tells listeners', () => {
|
|
|
134
135
|
// Cascade cleared the mirror rows.
|
|
135
136
|
assert.deepEqual(appResources.listAppResourceRows('app:demo:notes'), []);
|
|
136
137
|
});
|
|
138
|
+
|
|
139
|
+
test('a resource whose read() always throws answers 500 instead of hanging', async () => {
|
|
140
|
+
registry.registerResource('app:demo:boom', {
|
|
141
|
+
read: () => {
|
|
142
|
+
throw new Error('boom: backing store is gone');
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
try {
|
|
146
|
+
let status = null;
|
|
147
|
+
let body = null;
|
|
148
|
+
await stateRest.handleSnapshot({ resources: 'app:demo:boom' }, (s, b) => {
|
|
149
|
+
status = s;
|
|
150
|
+
body = b;
|
|
151
|
+
});
|
|
152
|
+
assert.equal(status, 500, 'the snapshot must answer, not hang');
|
|
153
|
+
assert.match(String(body?.error), /boom/);
|
|
154
|
+
} finally {
|
|
155
|
+
registry.unregisterResource('app:demo:boom');
|
|
156
|
+
}
|
|
157
|
+
});
|
|
@@ -194,9 +194,10 @@ test('guards: binary, missing, oversized, and malformed inputs are rejected', ()
|
|
|
194
194
|
|
|
195
195
|
// ---------------------------------------------------------------------------
|
|
196
196
|
// Durability: a crash (SIGKILL — no flush, no graceful close) must never lose
|
|
197
|
-
// an acknowledged edit.
|
|
198
|
-
//
|
|
199
|
-
//
|
|
197
|
+
// an acknowledged edit. The contract is per acknowledgment, not per update:
|
|
198
|
+
// state is persisted before applyDocUpdates returns (before the POST response
|
|
199
|
+
// acknowledges); the recorded last-disk hash lets reopen tell "disk is merely
|
|
200
|
+
// behind" (write the doc back out) from "disk changed externally" (fold it in).
|
|
200
201
|
// ---------------------------------------------------------------------------
|
|
201
202
|
|
|
202
203
|
function crashAndReloadDocs() {
|
|
@@ -231,6 +232,31 @@ test('a kill inside the disk-write debounce loses nothing: reopen restores the e
|
|
|
231
232
|
reloaded.closeAllDocs();
|
|
232
233
|
});
|
|
233
234
|
|
|
235
|
+
test('a multi-update batch is fully persisted by the time it returns', () => {
|
|
236
|
+
const target = writeDoc('batch.md', 'base\n');
|
|
237
|
+
const opened = docs.openDoc(target);
|
|
238
|
+
const client = clientFromState(opened.state);
|
|
239
|
+
const updates = [
|
|
240
|
+
encodeLocalEdit(client, (ytext) => ytext.insert(0, 'one ')),
|
|
241
|
+
encodeLocalEdit(client, (ytext) => ytext.insert(0, 'two ')),
|
|
242
|
+
encodeLocalEdit(client, (ytext) => ytext.insert(0, 'three ')),
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
const result = docs.applyDocUpdates(target, updates);
|
|
246
|
+
assert.equal(result.applied, 3);
|
|
247
|
+
|
|
248
|
+
// Crash with no flush: the batch persist alone must carry all three
|
|
249
|
+
// updates into doc_states.
|
|
250
|
+
const reloaded = crashAndReloadDocs();
|
|
251
|
+
const reopened = reloaded.openDoc(target);
|
|
252
|
+
assert.equal(
|
|
253
|
+
clientFromState(reopened.state).getText('content').toString(),
|
|
254
|
+
'three two one base\n',
|
|
255
|
+
'reopen after a kill must restore the whole batch',
|
|
256
|
+
);
|
|
257
|
+
reloaded.closeAllDocs();
|
|
258
|
+
});
|
|
259
|
+
|
|
234
260
|
test('an external edit made while closed still folds in on reopen', () => {
|
|
235
261
|
const target = writeDoc('closed-edit.md', 'line one\n');
|
|
236
262
|
const opened = docs.openDoc(target);
|
|
@@ -12,7 +12,6 @@ process.env.AMALGM_EVENT_LOG_PRUNE_INTERVAL_MS = '0';
|
|
|
12
12
|
|
|
13
13
|
const { closeLocalDb, openLocalDb } = require('../state/db');
|
|
14
14
|
const {
|
|
15
|
-
eventLogBounds,
|
|
16
15
|
insertStateEvent,
|
|
17
16
|
listEventsAfter,
|
|
18
17
|
pruneEventLog,
|
|
@@ -58,7 +57,7 @@ test('event log retention removes old batches and reports replay gaps', () => {
|
|
|
58
57
|
});
|
|
59
58
|
|
|
60
59
|
assert.equal(result.deleted, 2);
|
|
61
|
-
assert.deepEqual(
|
|
60
|
+
assert.deepEqual(listEventsAfter(0).map((event) => event.seq), [3], 'only the fresh event survives');
|
|
62
61
|
assert.equal(replayGapAfter(1, db)?.code, 'state_event_gap');
|
|
63
62
|
assert.equal(replayGapAfter(2, db), null);
|
|
64
63
|
assert.deepEqual(
|
|
@@ -27,8 +27,6 @@ const LEGACY_DEFAULT_RESOURCES = ['automations', 'triggers', 'workflows', 'autom
|
|
|
27
27
|
|
|
28
28
|
test('default snapshot set matches the legacy hardcoded list exactly', () => {
|
|
29
29
|
assert.deepEqual(registry.listDefaultResources(), LEGACY_DEFAULT_RESOURCES);
|
|
30
|
-
assert.deepEqual(snapshot.DEFAULT_RESOURCES, LEGACY_DEFAULT_RESOURCES);
|
|
31
|
-
assert.deepEqual(snapshot.normalizeResources(undefined), LEGACY_DEFAULT_RESOURCES);
|
|
32
30
|
});
|
|
33
31
|
|
|
34
32
|
test('non-default built-ins are registered but excluded from the default set', () => {
|
|
@@ -19,18 +19,23 @@ test.after(() => {
|
|
|
19
19
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
// The response must be an EventEmitter: handleStream subscribes to its
|
|
23
|
+
// close/error events for teardown.
|
|
24
|
+
function fakeStream({ failAfterWrites = Infinity } = {}) {
|
|
23
25
|
const req = new EventEmitter();
|
|
24
26
|
const chunks = [];
|
|
25
27
|
let ended = false;
|
|
26
|
-
const res =
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
const res = new EventEmitter();
|
|
29
|
+
res.writeHead = () => res;
|
|
30
|
+
res.write = (chunk) => {
|
|
31
|
+
if (chunks.length >= failAfterWrites) {
|
|
32
|
+
throw new Error('write after socket gone');
|
|
33
|
+
}
|
|
34
|
+
chunks.push(String(chunk));
|
|
35
|
+
return true;
|
|
36
|
+
};
|
|
37
|
+
res.end = () => {
|
|
38
|
+
ended = true;
|
|
34
39
|
};
|
|
35
40
|
const close = () => req.emit('close');
|
|
36
41
|
return {
|
|
@@ -59,12 +64,13 @@ test('stream replays a small backlog as state events', () => {
|
|
|
59
64
|
const stream = fakeStream();
|
|
60
65
|
|
|
61
66
|
handleStream(stream.req, stream.res, { after: '0' });
|
|
62
|
-
stream.close();
|
|
63
67
|
|
|
64
68
|
const output = stream.output();
|
|
65
69
|
assert.equal((output.match(/event: state/g) || []).length, 3);
|
|
66
70
|
assert.ok(!output.includes('event: reset'), 'small backlog must not reset');
|
|
67
71
|
assert.equal(stream.ended(), false, 'stream stays open for live events');
|
|
72
|
+
stream.close();
|
|
73
|
+
assert.equal(stream.ended(), true, 'close tears the stream down');
|
|
68
74
|
});
|
|
69
75
|
|
|
70
76
|
test('stream resets instead of replaying an oversized backlog', () => {
|
|
@@ -91,3 +97,28 @@ test('stream replays only events after the requested seq', () => {
|
|
|
91
97
|
assert.equal((output.match(/event: state/g) || []).length, 2);
|
|
92
98
|
assert.ok(!output.includes('event: reset'));
|
|
93
99
|
});
|
|
100
|
+
|
|
101
|
+
test('a stream that dies during backlog replay arms no heartbeat', () => {
|
|
102
|
+
// The SSE preamble succeeds, then the first backlog write throws →
|
|
103
|
+
// teardown fires mid-replay, before the heartbeat would be armed. An
|
|
104
|
+
// interval armed after that has no clearer and pins the event loop —
|
|
105
|
+
// this suite exiting without --test-force-exit is the systemic proof.
|
|
106
|
+
const stream = fakeStream({ failAfterWrites: 1 });
|
|
107
|
+
const armed = [];
|
|
108
|
+
const realSetInterval = global.setInterval;
|
|
109
|
+
global.setInterval = (...args) => {
|
|
110
|
+
const timer = realSetInterval(...args);
|
|
111
|
+
armed.push(timer);
|
|
112
|
+
return timer;
|
|
113
|
+
};
|
|
114
|
+
try {
|
|
115
|
+
// after 1101 → a 2-event backlog: replay (not reset) is the path under test.
|
|
116
|
+
handleStream(stream.req, stream.res, { after: '1101' });
|
|
117
|
+
} finally {
|
|
118
|
+
global.setInterval = realSetInterval;
|
|
119
|
+
for (const timer of armed) clearInterval(timer);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
assert.equal(armed.length, 0, 'teardown during replay must not arm the heartbeat');
|
|
123
|
+
assert.equal(stream.ended(), true, 'teardown ends the response');
|
|
124
|
+
});
|
|
@@ -80,6 +80,39 @@ test('a write failure tears the stream down: no orphaned subscriber', () => {
|
|
|
80
80
|
assert.equal(res.writableEnded, true, 'teardown ends the response');
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
+
test('one publish fans out one identical frame per subscriber, and the frame never leaks into JSON', () => {
|
|
84
|
+
const streams = [0, 1, 2].map(() => {
|
|
85
|
+
const req = fakeRequest();
|
|
86
|
+
const res = fakeResponse();
|
|
87
|
+
rest.handleStream(req, res, {});
|
|
88
|
+
return { req, res };
|
|
89
|
+
});
|
|
90
|
+
// Backlog replay from earlier tests lands on connect; only the new publish
|
|
91
|
+
// counts.
|
|
92
|
+
const marks = streams.map(({ res }) => res.writes.length);
|
|
93
|
+
|
|
94
|
+
const event = appendStateEvent({
|
|
95
|
+
resource: 'automations',
|
|
96
|
+
op: 'replace',
|
|
97
|
+
value: [{ id: 'fan-out' }],
|
|
98
|
+
source: 'test',
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const frames = streams.map(({ res }, i) => res.writes.slice(marks[i]));
|
|
102
|
+
for (const writes of frames) {
|
|
103
|
+
assert.equal(writes.length, 1, 'a published event is exactly one write per subscriber');
|
|
104
|
+
}
|
|
105
|
+
assert.equal(frames[0][0], frames[1][0]);
|
|
106
|
+
assert.equal(frames[1][0], frames[2][0]);
|
|
107
|
+
|
|
108
|
+
const json = JSON.stringify(event);
|
|
109
|
+
assert.equal(frames[0][0], `id: ${event.seq}\nevent: state\ndata: ${json}\n\n`);
|
|
110
|
+
assert.ok(!json.includes('__sseFrame'), 'the cached frame must never serialize');
|
|
111
|
+
assert.ok(!Object.keys(event).includes('__sseFrame'));
|
|
112
|
+
|
|
113
|
+
for (const { req } of streams) req.emit('close');
|
|
114
|
+
});
|
|
115
|
+
|
|
83
116
|
test('close and error firing together tear down exactly once', () => {
|
|
84
117
|
const req = fakeRequest();
|
|
85
118
|
const res = fakeResponse();
|