amalgm 0.1.152 → 0.1.153

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.
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('crypto');
3
4
  const fs = require('fs');
4
5
  const http = require('http');
5
6
  const os = require('os');
@@ -22,6 +23,31 @@ const DEFAULT_TARGET_PORT = DEFAULT_MCP_PORT;
22
23
  const CONNECT_TIMEOUT_MS = 20_000;
23
24
  const WATCHDOG_INTERVAL_MS = 15_000;
24
25
  const HEARTBEAT_TIMEOUT_MS = 75_000;
26
+ // Per-process identity: a zombie runtime holding a stale gateway slot is
27
+ // indistinguishable from the live one without this. AMALGM_RUNTIME_INSTANCE_ID
28
+ // is directory-derived and inherited via env, so a zombie can share it —
29
+ // this id is fresh per process, always.
30
+ const INSTANCE_ID = crypto.randomBytes(8).toString('hex');
31
+ const BOOT_TS = Date.now();
32
+ const PROTOCOL_VERSION = 2;
33
+ // Gateway close code: this process lost slot arbitration to a newer runtime
34
+ // instance (same computer_id + runtime_label). Reconnecting fast would just
35
+ // steal the slot back and reset the winner's streams — the historical
36
+ // "stuck streams" ping-pong. Retry slowly instead of stopping: the newer
37
+ // instance may die, and regaining the slot then is correct.
38
+ const SUPERSEDED_CLOSE_CODE = 4009;
39
+ const DEFAULT_SUPERSEDED_RETRY_MS = 5 * 60 * 1000;
40
+ // Backoff only resets after a connection has proven stable for this long;
41
+ // resetting on open turns any slot fight into a 1s reconnect war.
42
+ const DEFAULT_STABLE_RESET_MS = 30_000;
43
+ // Send-path tuning: frames above BULK_FRAME_BYTES ride the bulk queue so a
44
+ // big file read cannot head-of-line-block keystroke-sized events; bodies
45
+ // above RES_CHUNK_BYTES additionally split into chunk frames on v2 peers.
46
+ const BULK_FRAME_BYTES = 32 * 1024;
47
+ const RES_CHUNK_BYTES = 256 * 1024;
48
+ const DEFAULT_SEND_HWM_BYTES = 1024 * 1024;
49
+ const DEFAULT_BULK_QUEUE_MAX_BYTES = 64 * 1024 * 1024;
50
+ const DRAIN_POLL_MS = 50;
25
51
  const DEFAULT_RUNTIME_PORTS = new Map([
26
52
  ['gateway', DEFAULT_GATEWAY_PORT],
27
53
  ['portMonitor', DEFAULT_PORT_MONITOR_PORT],
@@ -84,6 +110,15 @@ const WS_HANDSHAKE_HEADERS = new Set([
84
110
  'sec-websocket-protocol',
85
111
  ]);
86
112
 
113
+ function envBytes(name, fallback) {
114
+ const value = Number(process.env[name]);
115
+ return Number.isFinite(value) && value > 0 ? value : fallback;
116
+ }
117
+
118
+ function msAgo(ts, now) {
119
+ return ts ? `${now - ts}ms ago` : 'never';
120
+ }
121
+
87
122
  function tunnelUrl(rawUrl) {
88
123
  const url = new URL(rawUrl);
89
124
  url.searchParams.delete('token');
@@ -326,6 +361,23 @@ function createEventTunnel({ record, foreground = false }) {
326
361
  let connectStartedAt = 0;
327
362
  let lastGatewayFrameAt = 0;
328
363
  let malformedFrames = 0;
364
+ // v2+ gateways acknowledge hello with their protocol version; the deployed
365
+ // v1 gateway never sends one, so chunking stays off against it.
366
+ let peerProtocolVersion = 1;
367
+ // Heartbeat forensics: cheap timestamps so a timeout can say whether the
368
+ // network went silent, we stopped sending, or backpressure stalled us.
369
+ let connectedAt = 0;
370
+ let lastPingSentAt = 0;
371
+ let lastPongAt = 0;
372
+ let lastFrameReceivedAt = 0;
373
+ let lastFrameSentAt = 0;
374
+ const sendHwmBytes = envBytes('AMALGM_TUNNEL_HWM_BYTES', DEFAULT_SEND_HWM_BYTES);
375
+ const bulkQueueMaxBytes = envBytes('AMALGM_TUNNEL_BULK_MAX_BYTES', DEFAULT_BULK_QUEUE_MAX_BYTES);
376
+ const supersededRetryMs = envBytes('AMALGM_TUNNEL_SUPERSEDED_RETRY_MS', DEFAULT_SUPERSEDED_RETRY_MS);
377
+ const stableResetMs = envBytes('AMALGM_TUNNEL_STABLE_RESET_MS', DEFAULT_STABLE_RESET_MS);
378
+ const bulkQueue = [];
379
+ let bulkQueuedBytes = 0;
380
+ let drainTimer = null;
329
381
  const upstreamSockets = new Map();
330
382
  const upstreamStreams = new Map();
331
383
 
@@ -342,12 +394,146 @@ function createEventTunnel({ record, foreground = false }) {
342
394
  console.warn(`[event-tunnel] ${message}`);
343
395
  }
344
396
 
345
- function send(frame) {
397
+ function sendPayload(payload) {
346
398
  if (!ws || ws.readyState !== WebSocket.OPEN) return false;
347
- ws.send(JSON.stringify(frame));
399
+ ws.send(payload);
400
+ lastFrameSentAt = Date.now();
348
401
  return true;
349
402
  }
350
403
 
404
+ // Control path: hello, heartbeats, stream events, small responses. Always
405
+ // sends immediately so it can never starve behind a queued response body.
406
+ function send(frame) {
407
+ return sendPayload(JSON.stringify(frame));
408
+ }
409
+
410
+ // A bulk queue entry: `bytes` is the exact frame size (known up front, so
411
+ // the queue budget applies BEFORE anything is materialized) and `build`
412
+ // produces the JSON payload only when the frame is about to hit the wire.
413
+ // A chunked response therefore peaks at body + one in-flight frame, never
414
+ // body + every frame (~2.5x the body) like the eager version did.
415
+ function payloadItem(payload) {
416
+ return { bytes: payload.length, build: () => payload };
417
+ }
418
+
419
+ // Bulk path: big response bodies queue behind ws.bufferedAmount so they
420
+ // drain between control frames instead of head-of-line-blocking them.
421
+ // The queue is bounded — a dropped response must fail loudly for its
422
+ // req_id, never hang silently.
423
+ function enqueueBulk(items, reqId) {
424
+ // Same contract as send(): a response finishing after the connection
425
+ // died is dropped, never parked for the next connection's req ids.
426
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
427
+ const total = items.reduce((sum, item) => sum + item.bytes, 0);
428
+ if (bulkQueuedBytes + total > bulkQueueMaxBytes) {
429
+ warn(`bulk queue overflow (queued=${bulkQueuedBytes} incoming=${total} max=${bulkQueueMaxBytes}); dropping response for req ${reqId}`);
430
+ send({
431
+ type: 'res',
432
+ req_id: reqId,
433
+ status: 503,
434
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
435
+ body_b64: Buffer.from('Tunnel backpressure: response dropped').toString('base64'),
436
+ });
437
+ return;
438
+ }
439
+ for (const item of items) bulkQueue.push(item);
440
+ bulkQueuedBytes += total;
441
+ drainBulk();
442
+ }
443
+
444
+ function drainBulk() {
445
+ while (bulkQueue.length > 0) {
446
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
447
+ if (ws.bufferedAmount > sendHwmBytes) {
448
+ // ws has no drain event — poll bufferedAmount, but only while blocked.
449
+ if (!drainTimer) {
450
+ drainTimer = setTimeout(() => {
451
+ drainTimer = null;
452
+ drainBulk();
453
+ }, DRAIN_POLL_MS);
454
+ if (typeof drainTimer.unref === 'function') drainTimer.unref();
455
+ }
456
+ return;
457
+ }
458
+ const item = bulkQueue.shift();
459
+ bulkQueuedBytes -= item.bytes;
460
+ sendPayload(item.build());
461
+ }
462
+ }
463
+
464
+ function resetBulkQueue(reason) {
465
+ if (drainTimer) {
466
+ clearTimeout(drainTimer);
467
+ drainTimer = null;
468
+ }
469
+ if (bulkQueue.length > 0) {
470
+ warn(`dropping ${bulkQueue.length} queued bulk frames (${bulkQueuedBytes} bytes): ${reason}`);
471
+ }
472
+ bulkQueue.length = 0;
473
+ bulkQueuedBytes = 0;
474
+ }
475
+
476
+ // Base64 output length for n raw bytes. Base64's alphabet needs no JSON
477
+ // escaping, so frame sizes computed with this are exact.
478
+ function base64Length(bytes) {
479
+ return Math.ceil(bytes / 3) * 4;
480
+ }
481
+
482
+ // Small bodies ship as today's single res frame, byte-identical. Big ones
483
+ // ride the bulk queue, and on v2 peers split into res_start/res_chunk/
484
+ // res_end so control frames interleave between chunks. Chunk frames are
485
+ // sized exactly but built lazily at send time (see payloadItem): the
486
+ // budget check runs before any frame exists, and an over-budget response
487
+ // is dropped without ever materializing its frames.
488
+ function sendResponse(reqId, status, headers, body) {
489
+ if (peerProtocolVersion < 2 || body.length <= RES_CHUNK_BYTES) {
490
+ // The v1-peer big-body case has no choice but to build the full frame:
491
+ // the v1 wire format is a single res frame.
492
+ const payload = JSON.stringify({
493
+ type: 'res',
494
+ req_id: reqId,
495
+ status,
496
+ headers,
497
+ body_b64: body.toString('base64'),
498
+ });
499
+ if (payload.length > BULK_FRAME_BYTES) enqueueBulk([payloadItem(payload)], reqId);
500
+ else sendPayload(payload);
501
+ return;
502
+ }
503
+
504
+ const items = [payloadItem(JSON.stringify({
505
+ type: 'res_start',
506
+ req_id: reqId,
507
+ status,
508
+ headers,
509
+ body_bytes: body.length,
510
+ }))];
511
+ let index = 0;
512
+ for (let offset = 0; offset < body.length; offset += RES_CHUNK_BYTES) {
513
+ const chunkIndex = index;
514
+ const chunkStart = offset;
515
+ const chunkLength = Math.min(RES_CHUNK_BYTES, body.length - offset);
516
+ const envelopeBytes = JSON.stringify({
517
+ type: 'res_chunk',
518
+ req_id: reqId,
519
+ index: chunkIndex,
520
+ body_b64: '',
521
+ }).length;
522
+ items.push({
523
+ bytes: envelopeBytes + base64Length(chunkLength),
524
+ build: () => JSON.stringify({
525
+ type: 'res_chunk',
526
+ req_id: reqId,
527
+ index: chunkIndex,
528
+ body_b64: body.subarray(chunkStart, chunkStart + RES_CHUNK_BYTES).toString('base64'),
529
+ }),
530
+ });
531
+ index += 1;
532
+ }
533
+ items.push(payloadItem(JSON.stringify({ type: 'res_end', req_id: reqId, chunks: index })));
534
+ enqueueBulk(items, reqId);
535
+ }
536
+
351
537
  function terminateSocket(reason) {
352
538
  if (!ws) return;
353
539
  warn(`${reason}; reconnecting`);
@@ -362,6 +548,23 @@ function createEventTunnel({ record, foreground = false }) {
362
548
  }
363
549
  }
364
550
 
551
+ function helloFrame() {
552
+ const appRoutes = readAppRoutes();
553
+ return {
554
+ type: 'hello',
555
+ app_version: require('../package.json').version,
556
+ runtime_version: require('../package.json').version,
557
+ runtime_label: record.runtime_label || AMALGM_RUNTIME_LABEL,
558
+ branch: record.branch || AMALGM_BRANCH,
559
+ instance_id: INSTANCE_ID,
560
+ boot_ts: BOOT_TS,
561
+ protocol_version: PROTOCOL_VERSION,
562
+ runtime_gateway_port: runtimeGatewayPort(),
563
+ app_routes: appRoutes,
564
+ artifact_routes: legacyArtifactRoutes(appRoutes),
565
+ };
566
+ }
567
+
365
568
  function startWatchdog() {
366
569
  if (watchdogTimer) return;
367
570
 
@@ -378,25 +581,25 @@ function createEventTunnel({ record, foreground = false }) {
378
581
 
379
582
  if (ws.readyState !== WebSocket.OPEN) return;
380
583
 
381
- const appRoutes = readAppRoutes();
382
- send({
383
- type: 'hello',
384
- app_version: require('../package.json').version,
385
- runtime_label: record.runtime_label || AMALGM_RUNTIME_LABEL,
386
- branch: record.branch || AMALGM_BRANCH,
387
- runtime_gateway_port: runtimeGatewayPort(),
388
- app_routes: appRoutes,
389
- artifact_routes: legacyArtifactRoutes(appRoutes),
390
- });
584
+ send(helloFrame());
391
585
 
392
586
  try {
393
587
  ws.ping();
588
+ lastPingSentAt = now;
394
589
  } catch {
395
590
  terminateSocket('ping failed');
396
591
  return;
397
592
  }
398
593
 
399
594
  if (lastGatewayFrameAt && now - lastGatewayFrameAt > HEARTBEAT_TIMEOUT_MS) {
595
+ // One structured line to tell apart "network went silent" (stale
596
+ // lastFrameIn/lastPong), "we stopped sending" (stale lastFrameOut),
597
+ // and a backpressure stall (buffered pinned high).
598
+ warn(
599
+ `heartbeat timeout: lastPong=${msAgo(lastPongAt, now)}, lastFrameIn=${msAgo(lastFrameReceivedAt, now)}, `
600
+ + `lastFrameOut=${msAgo(lastFrameSentAt, now)}, lastPingSent=${msAgo(lastPingSentAt, now)}, `
601
+ + `buffered=${ws.bufferedAmount}, bulkQueued=${bulkQueuedBytes}`,
602
+ );
400
603
  terminateSocket('heartbeat timeout');
401
604
  }
402
605
  }, WATCHDOG_INTERVAL_MS);
@@ -440,13 +643,12 @@ function createEventTunnel({ record, foreground = false }) {
440
643
  function handleRequest(frame) {
441
644
  const targetPort = resolveTargetPort(frame);
442
645
  if (!targetPort) {
443
- send({
444
- type: 'res',
445
- req_id: frame.req_id,
446
- status: 403,
447
- headers: { 'content-type': 'application/json' },
448
- body_b64: Buffer.from(JSON.stringify({ error: 'Target port is not allowed' })).toString('base64'),
449
- });
646
+ sendResponse(
647
+ frame.req_id,
648
+ 403,
649
+ { 'content-type': 'application/json' },
650
+ Buffer.from(JSON.stringify({ error: 'Target port is not allowed' })),
651
+ );
450
652
  return;
451
653
  }
452
654
  const body = frame.body_b64 ? Buffer.from(frame.body_b64, 'base64') : Buffer.alloc(0);
@@ -462,25 +664,18 @@ function createEventTunnel({ record, foreground = false }) {
462
664
  const chunks = [];
463
665
  res.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
464
666
  res.on('end', () => {
465
- send({
466
- type: 'res',
467
- req_id: frame.req_id,
468
- status: res.statusCode || 502,
469
- headers: responseHeaders(res.headers),
470
- body_b64: Buffer.concat(chunks).toString('base64'),
471
- });
667
+ sendResponse(frame.req_id, res.statusCode || 502, responseHeaders(res.headers), Buffer.concat(chunks));
472
668
  });
473
669
  },
474
670
  );
475
671
 
476
672
  req.on('error', (error) => {
477
- send({
478
- type: 'res',
479
- req_id: frame.req_id,
480
- status: 502,
481
- headers: { 'content-type': 'text/plain; charset=utf-8' },
482
- body_b64: Buffer.from(error.message || 'Local request failed').toString('base64'),
483
- });
673
+ sendResponse(
674
+ frame.req_id,
675
+ 502,
676
+ { 'content-type': 'text/plain; charset=utf-8' },
677
+ Buffer.from(error.message || 'Local request failed'),
678
+ );
484
679
  });
485
680
 
486
681
  req.end(body);
@@ -596,6 +791,10 @@ function createEventTunnel({ record, foreground = false }) {
596
791
 
597
792
  function handleFrame(frame) {
598
793
  if (!frame || typeof frame.type !== 'string') return;
794
+ if (frame.type === 'hello_ack') {
795
+ peerProtocolVersion = Number(frame.protocol_version) || 1;
796
+ return;
797
+ }
599
798
  if (frame.type === 'ping') {
600
799
  send({ type: 'pong' });
601
800
  return;
@@ -639,19 +838,20 @@ function createEventTunnel({ record, foreground = false }) {
639
838
  startWatchdog();
640
839
 
641
840
  ws.on('open', () => {
642
- backoffMs = 1000;
841
+ // Deliberately NOT resetting backoffMs here: it resets in the close
842
+ // handler only once the connection has survived stableResetMs. An
843
+ // instant reset on open let two live runtimes ping-pong one gateway
844
+ // slot at ~1s cadence forever.
643
845
  connectStartedAt = 0;
644
- lastGatewayFrameAt = Date.now();
645
- log(`connected ${record.computer_id}`);
846
+ connectedAt = Date.now();
847
+ lastGatewayFrameAt = connectedAt;
848
+ lastPingSentAt = 0;
849
+ lastPongAt = 0;
850
+ lastFrameReceivedAt = 0;
851
+ lastFrameSentAt = 0;
852
+ log(`connected ${record.computer_id} instance=${INSTANCE_ID}`);
646
853
  void postPresence(record, true);
647
- const appRoutes = readAppRoutes();
648
- send({
649
- type: 'hello',
650
- app_version: require('../package.json').version,
651
- runtime_gateway_port: runtimeGatewayPort(),
652
- app_routes: appRoutes,
653
- artifact_routes: legacyArtifactRoutes(appRoutes),
654
- });
854
+ send(helloFrame());
655
855
  if (!routeTimer) {
656
856
  routeTimer = setInterval(advertiseApps, 30000);
657
857
  if (typeof routeTimer.unref === 'function') routeTimer.unref();
@@ -659,9 +859,11 @@ function createEventTunnel({ record, foreground = false }) {
659
859
  });
660
860
  ws.on('pong', () => {
661
861
  lastGatewayFrameAt = Date.now();
862
+ lastPongAt = lastGatewayFrameAt;
662
863
  });
663
864
  ws.on('message', (data) => {
664
865
  lastGatewayFrameAt = Date.now();
866
+ lastFrameReceivedAt = lastGatewayFrameAt;
665
867
  try {
666
868
  handleFrame(JSON.parse(data.toString()));
667
869
  } catch (error) {
@@ -675,14 +877,30 @@ function createEventTunnel({ record, foreground = false }) {
675
877
  }
676
878
  });
677
879
  ws.on('close', (code, reason) => {
880
+ const lifetimeMs = connectedAt ? Date.now() - connectedAt : 0;
678
881
  connectStartedAt = 0;
882
+ connectedAt = 0;
679
883
  lastGatewayFrameAt = 0;
680
- // Streams and sockets belong to THIS gateway connection: their req/conn
681
- // ids mean nothing to the next one, and its cancel frames will never
682
- // reference them. Dropping them here is the only teardown they get —
683
- // orphaned streams otherwise hold local connections open forever.
884
+ peerProtocolVersion = 1;
885
+ // Streams, sockets, and queued response frames belong to THIS gateway
886
+ // connection: their req/conn ids mean nothing to the next one, and its
887
+ // cancel frames will never reference them. Dropping them here is the
888
+ // only teardown they get — orphaned streams otherwise hold local
889
+ // connections open forever.
890
+ resetBulkQueue('event tunnel connection closed');
684
891
  dropActiveProxies('event tunnel connection closed');
685
- warn(`closed code=${code} reason=${reason ? reason.toString() : ''}`);
892
+ warn(`closed code=${code} reason=${reason ? reason.toString() : ''} lifetime=${lifetimeMs}ms`);
893
+ // Backoff resets only after a connection proved stable; a short-lived
894
+ // connection keeps (and grows) the previous backoff so contention
895
+ // degrades to slow retries instead of a 1s war.
896
+ if (lifetimeMs >= stableResetMs) backoffMs = 1000;
897
+ if (code === SUPERSEDED_CLOSE_CODE) {
898
+ warn(
899
+ `superseded: gateway refused this instance (a newer runtime instance holds the slot). `
900
+ + `own instance=${INSTANCE_ID} boot_ts=${BOOT_TS}; retrying in ${supersededRetryMs}ms`,
901
+ );
902
+ backoffMs = supersededRetryMs;
903
+ }
686
904
  scheduleReconnect();
687
905
  });
688
906
  ws.on('error', (error) => {
@@ -716,6 +934,7 @@ function createEventTunnel({ record, foreground = false }) {
716
934
  if (reconnectTimer) clearTimeout(reconnectTimer);
717
935
  if (routeTimer) clearInterval(routeTimer);
718
936
  stopWatchdog();
937
+ resetBulkQueue('event tunnel stopped');
719
938
  dropActiveProxies('event tunnel stopped');
720
939
  void postPresence(record, false);
721
940
  if (ws) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.152",
3
+ "version": "0.1.153",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -48,6 +48,10 @@ async function handleStateRoutes(ctx) {
48
48
  await stateRest.handleDocUpdate(await ctx.readJsonBody(), ctx.sendJson);
49
49
  return true;
50
50
  }
51
+ if (ctx.pathname === '/state/docs/write') {
52
+ await stateRest.handleDocWrite(await ctx.readJsonBody(), ctx.sendJson);
53
+ return true;
54
+ }
51
55
  return false;
52
56
  }
53
57
 
@@ -13,7 +13,7 @@
13
13
  * them into the in-memory registry on first use.
14
14
  */
15
15
 
16
- const { cachedStatement, openLocalDb } = require('./db');
16
+ const { cachedStatement, ensureAppResourcesSchema, openLocalDb } = require('./db');
17
17
  const registry = require('./registry');
18
18
  const { insertStateEvent, publishStateEvent } = require('./events');
19
19
 
@@ -23,25 +23,6 @@ const VALID_OPS = new Set(['insert', 'update', 'delete', 'replace']);
23
23
 
24
24
  let loaded = false;
25
25
 
26
- function ensureSchema(database) {
27
- database.exec(`
28
- CREATE TABLE IF NOT EXISTS app_resources (
29
- name TEXT PRIMARY KEY,
30
- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
31
- updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
32
- );
33
-
34
- CREATE TABLE IF NOT EXISTS app_resource_rows (
35
- resource TEXT NOT NULL,
36
- id TEXT NOT NULL,
37
- value_json TEXT NOT NULL,
38
- updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
39
- PRIMARY KEY (resource, id),
40
- FOREIGN KEY (resource) REFERENCES app_resources(name) ON DELETE CASCADE
41
- );
42
- `);
43
- }
44
-
45
26
  function isValidAppResourceName(name) {
46
27
  return typeof name === 'string' && APP_RESOURCE_NAME_RE.test(name);
47
28
  }
@@ -71,7 +52,7 @@ function wireIntoRegistry(name) {
71
52
  function ensureAppResourcesLoaded() {
72
53
  if (loaded) return;
73
54
  const database = openLocalDb();
74
- ensureSchema(database);
55
+ ensureAppResourcesSchema(database);
75
56
  const names = database.prepare('SELECT name FROM app_resources ORDER BY name ASC').all();
76
57
  for (const { name } of names) wireIntoRegistry(name);
77
58
  loaded = true;
@@ -469,6 +469,54 @@ function migrate(database = openLocalDb()) {
469
469
  require('./migrations').runLegacyMigrations(database);
470
470
  }
471
471
 
472
+ // ---------------------------------------------------------------------------
473
+ // Lazily created tables. The DDL lives here so this file owns every schema
474
+ // statement, but these tables are only created when their feature is first
475
+ // touched — each ensure below is called from that first-use site (docs.js,
476
+ // app-resources.js), preserving the original lazy-creation semantics.
477
+
478
+ // Live documents (state/docs.js): persisted Y.Doc state per open file.
479
+ function ensureDocStatesSchema(database = openLocalDb()) {
480
+ database.exec(`
481
+ CREATE TABLE IF NOT EXISTS doc_states (
482
+ path TEXT PRIMARY KEY,
483
+ state BLOB NOT NULL,
484
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
485
+ );
486
+ `);
487
+ // last_disk_hash records the disk content as we last read or wrote it.
488
+ // On reopen it distinguishes "disk changed externally" (fold it in) from
489
+ // "disk is merely behind our persisted state" (write the doc back out) —
490
+ // without it, a restart would revert edits that hadn't hit the debounced
491
+ // disk write yet.
492
+ try {
493
+ database.exec('ALTER TABLE doc_states ADD COLUMN last_disk_hash TEXT');
494
+ } catch {
495
+ // Column already exists.
496
+ }
497
+ }
498
+
499
+ // App-registered resources (state/app-resources.js): the engine-side mirror
500
+ // of rows that out-of-process apps push over the local-live rails.
501
+ function ensureAppResourcesSchema(database = openLocalDb()) {
502
+ database.exec(`
503
+ CREATE TABLE IF NOT EXISTS app_resources (
504
+ name TEXT PRIMARY KEY,
505
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
506
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
507
+ );
508
+
509
+ CREATE TABLE IF NOT EXISTS app_resource_rows (
510
+ resource TEXT NOT NULL,
511
+ id TEXT NOT NULL,
512
+ value_json TEXT NOT NULL,
513
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
514
+ PRIMARY KEY (resource, id),
515
+ FOREIGN KEY (resource) REFERENCES app_resources(name) ON DELETE CASCADE
516
+ );
517
+ `);
518
+ }
519
+
472
520
  function closeLocalDb() {
473
521
  if (!db) return;
474
522
  db.close();
@@ -478,5 +526,7 @@ function closeLocalDb() {
478
526
  module.exports = {
479
527
  cachedStatement,
480
528
  closeLocalDb,
529
+ ensureAppResourcesSchema,
530
+ ensureDocStatesSchema,
481
531
  openLocalDb,
482
532
  };