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.
@@ -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
- const line = `[event-tunnel] ${message}`;
333
- if (foreground) console.log(line);
338
+ console.log(`[event-tunnel] ${message}`);
334
339
  }
335
340
 
336
341
  function warn(message) {
337
- const line = `[event-tunnel] ${message}`;
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
- // Ignore malformed gateway frames.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.151",
3
+ "version": "0.1.152",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -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
- function applyMirror(database, name, event) {
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
- database.prepare('DELETE FROM app_resource_rows WHERE resource = ?').run(name);
146
- const upsert = database.prepare(`
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
- for (const row of event.value) upsert.run(name, String(row.id), JSON.stringify(row));
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.prepare('DELETE FROM app_resource_rows WHERE resource = ? AND id = ?').run(name, event.id);
169
+ cachedStatement(database, 'DELETE FROM app_resource_rows WHERE resource = ? AND id = ?').run(name, event.id);
158
170
  return;
159
171
  }
160
- database.prepare(`
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, JSON.stringify(event.value));
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.prepare('SELECT 1 FROM app_resources WHERE name = ?').get(name);
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
- applyMirror(database, name, event);
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
- CREATE INDEX IF NOT EXISTS event_log_resource_seq_idx
56
- ON event_log(resource, seq);
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-document directory watcher: on file change, disk text is diffed into
27
- * the Y.Doc as a splice, which emits a normal update event. The cycle is
28
- * loop-safe because reconcile only acts when disk content changed past the
29
- * last text we read or wrote (entry.lastDiskText) — our own write-backs and
30
- * stale watcher events can never splice, so they can never re-emit.
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, watcher, reconcileTimer, diskWriteTimer,
48
- // lastAccess, suppressDiskWriteOnce }
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
- const database = openLocalDb();
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.lastDiskText == null ? null : hashText(entry.lastDiskText),
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.lastDiskText = text;
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.lastDiskText = onDisk;
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
- const changed = spliceTextInto(Y, entry.doc, ytext, onDisk, 'disk');
207
- // The splice's update handler persists; a no-op splice still needs the
208
- // refreshed lastDiskText hash recorded.
209
- if (!changed) persistDocState(Y, entry);
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.lastDiskText = onDisk;
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
- const base = path.basename(entry.path);
252
- try {
253
- entry.watcher = fs.watch(dir, { persistent: false }, (eventType, filename) => {
254
- // Some platforms omit filename; reconcile is content-compare based and
255
- // idempotent, so over-firing is safe.
256
- if (filename && filename !== base) return;
257
- if (entry.reconcileTimer) return;
258
- entry.reconcileTimer = setTimeout(() => {
259
- entry.reconcileTimer = null;
260
- reconcileFromDisk(Y, entry);
261
- }, WATCH_RECONCILE_DEBOUNCE_MS);
262
- if (typeof entry.reconcileTimer.unref === 'function') entry.reconcileTimer.unref();
263
- });
264
- entry.watcher.on('error', () => {
265
- try { entry.watcher.close(); } catch {}
266
- entry.watcher = null;
267
- });
268
- if (typeof entry.watcher.unref === 'function') entry.watcher.unref();
269
- } catch {
270
- // Watching is best-effort; external edits still reconcile on next open.
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
- if (entry.watcher) {
285
- try { entry.watcher.close(); } catch {}
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 > MAX_OPEN_DOCS) {
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.prepare('SELECT state, last_disk_hash FROM doc_states WHERE path = ?').get(resolved) || null;
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, state persisted, then (except
343
- // disk-origin) disk out. Persisting on every update means a kill even
344
- // SIGKILL can never lose an acknowledged edit; at worst the disk artifact
345
- // lags by the debounce window until the doc is next opened.
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
- evictIfNeeded(Y);
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
- for (const update of updates) {
397
- try {
398
- Y.applyUpdate(entry.doc, new Uint8Array(update), 'client');
399
- } catch (error) {
400
- throw invalid(`invalid Yjs update: ${error?.message || error}`);
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
- const DEFAULT_EVENT_LOG_RETENTION_HOURS = 0.25;
14
- const DEFAULT_EVENT_LOG_PRUNE_BATCH_SIZE = 1000;
15
- const DEFAULT_EVENT_LOG_PRUNE_INTERVAL_MS = 60_000;
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
- const info = database.prepare(`
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
- input.id ?? null,
107
- safeJsonStringify(input.value),
116
+ id,
117
+ valueJson,
108
118
  safeJsonStringify(input.patch),
109
- input.clientMutationId ?? input.client_mutation_id ?? null,
110
- input.source ?? null,
111
- input.version ?? input.resourceVersion ?? null,
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 rows = openLocalDb()
153
- .prepare('SELECT * FROM event_log WHERE seq > ? ORDER BY seq ASC LIMIT ?')
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.prepare(`
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; stale rows are removed on open.
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(`id: ${event.seq}\n`);
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
- // Restores persisted app registrations so their resources are readable
39
- // even before the owning app has spoken this boot.
40
- appResources.ensureAppResourcesLoaded();
41
- sendJson(200, buildSnapshot(query.resources));
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
- heartbeat = setInterval(() => {
192
- writeSafe(() => sendSseControl(res, 'ping', { t: Date.now() }));
193
- }, 25_000);
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
- if (lastUnstable) {
45
- console.warn(`[LocalLive] Snapshot did not stabilize after 3 attempts (writes kept landing mid-read); serving best-effort data at seq ${lastUnstable.seq}.`);
46
- return lastUnstable;
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. State is persisted on every update; the recorded
198
- // last-disk hash lets reopen tell "disk is merely behind" (write the doc back
199
- // out) from "disk changed externally" (fold it in).
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(eventLogBounds(db), { minSeq: 3, maxSeq: 3 });
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
- function fakeStream() {
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
- writeHead: () => {},
28
- write: (chunk) => {
29
- chunks.push(String(chunk));
30
- },
31
- end: () => {
32
- ended = true;
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();