amalgm 0.1.149 → 0.1.150

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.
@@ -6,6 +6,12 @@ const {
6
6
  replayGapAfter,
7
7
  subscribeStateEvents,
8
8
  } = require('./events');
9
+ const appResources = require('./app-resources');
10
+
11
+ function sendHandlerError(error, sendJson) {
12
+ const statusCode = Number(error?.statusCode) || 500;
13
+ sendJson(statusCode, { error: error?.message || 'internal error' });
14
+ }
9
15
 
10
16
  function parsePositiveInt(value, fallback) {
11
17
  const parsed = Number(value);
@@ -29,9 +35,70 @@ function sendSseControl(res, type, payload) {
29
35
  }
30
36
 
31
37
  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();
32
41
  sendJson(200, buildSnapshot(query.resources));
33
42
  }
34
43
 
44
+ async function handleResourceRegister(body, sendJson) {
45
+ try {
46
+ sendJson(200, appResources.registerAppResource(body?.resource));
47
+ } catch (error) {
48
+ sendHandlerError(error, sendJson);
49
+ }
50
+ }
51
+
52
+ async function handleResourceUnregister(body, sendJson) {
53
+ try {
54
+ sendJson(200, appResources.unregisterAppResource(String(body?.resource || '')));
55
+ } catch (error) {
56
+ sendHandlerError(error, sendJson);
57
+ }
58
+ }
59
+
60
+ async function handleResourceEmit(body, sendJson) {
61
+ try {
62
+ const events = body?.events ?? body?.event;
63
+ if (events == null) {
64
+ sendJson(400, { error: 'body requires "events" (array) or "event" (single object)' });
65
+ return;
66
+ }
67
+ sendJson(200, appResources.emitAppResourceEvents(body?.resource, events, { source: body?.source }));
68
+ } catch (error) {
69
+ sendHandlerError(error, sendJson);
70
+ }
71
+ }
72
+
73
+ async function handleDocOpen(body, sendJson) {
74
+ try {
75
+ sendJson(200, require('./docs').openDoc(body?.path));
76
+ } catch (error) {
77
+ sendHandlerError(error, sendJson);
78
+ }
79
+ }
80
+
81
+ async function handleDocUpdate(body, sendJson) {
82
+ try {
83
+ const updates = body?.updates ?? body?.update;
84
+ if (updates == null) {
85
+ sendJson(400, { error: 'body requires "updates" (array of base64 Yjs updates) or "update"' });
86
+ return;
87
+ }
88
+ sendJson(200, require('./docs').applyDocUpdates(body?.path, updates));
89
+ } catch (error) {
90
+ sendHandlerError(error, sendJson);
91
+ }
92
+ }
93
+
94
+ async function handleResourceList(sendJson) {
95
+ try {
96
+ sendJson(200, { resources: appResources.listAppResources() });
97
+ } catch (error) {
98
+ sendHandlerError(error, sendJson);
99
+ }
100
+ }
101
+
35
102
  async function handleEvents(query, sendJson) {
36
103
  const after = parsePositiveInt(query.after, 0);
37
104
  const limit = parsePositiveInt(query.limit, 1000);
@@ -48,6 +115,7 @@ async function handleEvents(query, sendJson) {
48
115
  }
49
116
 
50
117
  function handleStream(req, res, query) {
118
+ appResources.ensureAppResourcesLoaded();
51
119
  const after = parsePositiveInt(query.after, 0);
52
120
 
53
121
  res.writeHead(200, {
@@ -98,7 +166,13 @@ function handleStream(req, res, query) {
98
166
  }
99
167
 
100
168
  module.exports = {
169
+ handleDocOpen,
170
+ handleDocUpdate,
101
171
  handleEvents,
172
+ handleResourceEmit,
173
+ handleResourceList,
174
+ handleResourceRegister,
175
+ handleResourceUnregister,
102
176
  handleSnapshot,
103
177
  handleStream,
104
178
  };
@@ -1,96 +1,16 @@
1
1
  'use strict';
2
2
 
3
3
  const { currentSeq } = require('./events');
4
+ const registry = require('./registry');
4
5
 
5
- const DEFAULT_RESOURCES = ['automations', 'triggers', 'workflows', 'automation_runs', 'workflow_cell_runs', 'agents', 'agent_configs', 'skills', 'apps', 'ports', 'toolbox', 'tools', 'tool_actions', 'hooks', 'projects', 'workspaces', 'project_context', 'user_preferences', 'chat_payloads', 'mcp_connections', 'browser_profiles', 'browser_auth_bundles', 'browser_login_sessions'];
6
+ require('./resources').registerBuiltinResources();
6
7
 
7
8
  function normalizeResources(resources) {
8
- const values = resources ? String(resources).split(',') : DEFAULT_RESOURCES;
9
+ const values = resources
10
+ ? String(resources).split(',')
11
+ : registry.listDefaultResources();
9
12
  const clean = values.map((value) => String(value || '').trim()).filter(Boolean);
10
- return clean.length > 0 ? Array.from(new Set(clean)) : DEFAULT_RESOURCES;
11
- }
12
-
13
- function automationGraph(cache) {
14
- cache.automationGraph ||= require('../automations/store').listPublicAutomationGraph();
15
- return cache.automationGraph;
16
- }
17
-
18
- function readResource(resource, cache) {
19
- switch (resource) {
20
- case 'automations':
21
- return automationGraph(cache).automations;
22
- case 'triggers':
23
- return automationGraph(cache).triggers;
24
- case 'automation_runs':
25
- return require('../automations/store').listAutomationRuns({ limit: 300 });
26
- case 'workflow_cell_runs':
27
- return require('../automations/store').listWorkflowCellRuns({ limit: 300 });
28
- case 'tasks':
29
- return require('../tasks/store').loadTasks().tasks;
30
- case 'task_runs':
31
- return require('../tasks/store').listTaskRuns({ limit: 100 });
32
- case 'event_runs':
33
- return require('../events/store').listEventRuns({ limit: 100 });
34
- case 'event_triggers':
35
- return require('../events/store').loadEventTriggers().triggers;
36
- case 'workflows':
37
- return automationGraph(cache).workflows;
38
- case 'agents': {
39
- const { agentWithConfig } = require('../agent-config/store');
40
- return require('../agents/store').getAllAgentsWithBuiltins()
41
- .map((agent) => agentWithConfig(agent))
42
- .filter(Boolean);
43
- }
44
- case 'agent_configs':
45
- return require('../agent-config/store').listAgentConfigs();
46
- case 'skills':
47
- return require('../skills/store').listSkills();
48
- case 'apps':
49
- return require('../apps/store').loadApps().apps;
50
- case 'ports':
51
- return require('./ports').getCurrentPorts();
52
- case 'toolbox':
53
- cache.toolbox ||= require('../toolbox/service').defaultToolboxService.readCatalog();
54
- return cache.toolbox;
55
- case 'tools':
56
- cache.toolbox ||= require('../toolbox/service').defaultToolboxService.readCatalog();
57
- return cache.toolbox.tools;
58
- case 'tool_actions':
59
- cache.toolbox ||= require('../toolbox/service').defaultToolboxService.readCatalog();
60
- return cache.toolbox.toolActions;
61
- case 'hooks':
62
- return require('../agents/hooks').collectNativeHooks();
63
- case 'projects':
64
- case 'workspaces': {
65
- const workspaceStore = require('../workspace/store');
66
- return workspaceStore.listWorkspaces();
67
- }
68
- case 'project_context':
69
- return require('../project-context/store').listProjectContexts();
70
- case 'user_preferences':
71
- return require('../lib/prefs').readUserPreferences();
72
- case 'chat_payloads':
73
- return require('../lib/chat-payloads').listChatPayloads();
74
- case 'uploads':
75
- return require('../fs/rest')._private.buildUploadsResource();
76
- case 'mcp_connections':
77
- return require('../mcp-connections/rest').buildConnectionsSnapshot();
78
- case 'browser_profiles':
79
- return require('../browser/store').listBrowserProfiles();
80
- case 'browser_auth_bundles':
81
- return require('../browser/store').listBrowserAuthBundles();
82
- case 'browser_login_sessions':
83
- return require('../browser/store').listBrowserLoginSessions();
84
- case 'browser_surfaces':
85
- // Surface-open requests are commands, not state: delivered via the
86
- // event stream only, so snapshots always start empty.
87
- return [];
88
- default:
89
- if (String(resource).startsWith('files:')) {
90
- return require('../fs/rest')._private.buildFilesResource(resource);
91
- }
92
- return undefined;
93
- }
13
+ return clean.length > 0 ? Array.from(new Set(clean)) : registry.listDefaultResources();
94
14
  }
95
15
 
96
16
  function buildSnapshot(resourcesInput) {
@@ -99,15 +19,15 @@ function buildSnapshot(resourcesInput) {
99
19
 
100
20
  for (let attempt = 0; attempt < 3; attempt += 1) {
101
21
  const beforeSeq = currentSeq();
102
- // The cache is per-attempt on purpose: a retry exists because a write
103
- // landed mid-read, so reusing reads across attempts would serve the very
104
- // staleness the retry is trying to escape.
105
- const cache = {};
22
+ // The read context is per-attempt on purpose: a retry exists because a
23
+ // write landed mid-read, so reusing reads across attempts would serve the
24
+ // very staleness the retry is trying to escape.
25
+ const ctx = {};
106
26
  const data = {};
107
27
  for (const resource of resources) {
108
28
  let value;
109
29
  try {
110
- value = readResource(resource, cache);
30
+ value = registry.readRegisteredResource(resource, ctx);
111
31
  } catch (error) {
112
32
  console.warn(`[LocalLive] Failed to read snapshot resource "${resource}":`, error?.message || error);
113
33
  throw error;
@@ -128,4 +48,11 @@ function buildSnapshot(resourcesInput) {
128
48
  return { seq: currentSeq(), stable: true, resources: {} };
129
49
  }
130
50
 
131
- module.exports = { DEFAULT_RESOURCES, buildSnapshot, normalizeResources };
51
+ module.exports = {
52
+ // Kept for existing callers; now derived from the registry.
53
+ get DEFAULT_RESOURCES() {
54
+ return registry.listDefaultResources();
55
+ },
56
+ buildSnapshot,
57
+ normalizeResources,
58
+ };
@@ -361,3 +361,30 @@ test('automations tools list actions and compact run_now output', async () => {
361
361
  assert.equal(payload.output, undefined);
362
362
  assert.equal(payload.cellSummaries[0].name, 'quote');
363
363
  });
364
+
365
+ test('workflow example published in tool descriptions compiles', () => {
366
+ const example = automationTools.WORKFLOW_EXAMPLE;
367
+ assert.equal(typeof example, 'string');
368
+
369
+ const validateTool = automationTools.find((tool) => tool.name === 'automations_validate');
370
+ assert.equal(validateTool.inputSchema.properties.workflowText.description.includes(example), true);
371
+
372
+ const ir = compileWorkflowText(example);
373
+ assert.equal(ir.cells.length, 2);
374
+ assert.equal(ir.cells[0].name, 'getData');
375
+ assert.equal(ir.cells[1].name, 'notify');
376
+ });
377
+
378
+ test('workflow compiler rejects non-array cells with a shape-specific error', () => {
379
+ assert.throws(
380
+ () => compileWorkflowText(`export default workflow({
381
+ trigger: "*.*",
382
+ cells: { getData: code("getData", () => ({})) },
383
+ });`),
384
+ /`cells` must be an array of cell-builder calls.*got object/,
385
+ );
386
+ assert.throws(
387
+ () => compileWorkflowText('export default workflow({ trigger: "*.*" });'),
388
+ /Workflow needs at least one cell/,
389
+ );
390
+ });
@@ -0,0 +1,136 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-app-resources-test-'));
10
+ process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
11
+
12
+ const { closeLocalDb } = require('../state/db');
13
+ const registry = require('../state/registry');
14
+ const appResources = require('../state/app-resources');
15
+ const { buildSnapshot } = require('../state/snapshot');
16
+ const { listEventsAfter, currentSeq, subscribeStateEvents } = require('../state/events');
17
+
18
+ test.after(() => {
19
+ closeLocalDb();
20
+ fs.rmSync(tempRoot, { recursive: true, force: true });
21
+ });
22
+
23
+ test('register → emit → snapshot: an app resource rides the same rails', () => {
24
+ const result = appResources.registerAppResource('app:demo:notes');
25
+ assert.equal(result.created, true);
26
+ assert.equal(registry.hasResource('app:demo:notes'), true);
27
+
28
+ // Registration is idempotent.
29
+ assert.equal(appResources.registerAppResource('app:demo:notes').created, false);
30
+
31
+ const live = [];
32
+ const unsubscribe = subscribeStateEvents((event) => live.push(event));
33
+ const before = currentSeq();
34
+
35
+ const emitted = appResources.emitAppResourceEvents('app:demo:notes', [
36
+ { op: 'insert', id: 'n1', value: { id: 'n1', text: 'buy milk' } },
37
+ { op: 'insert', id: 'n2', value: { id: 'n2', text: 'call mom' } },
38
+ { op: 'update', id: 'n1', value: { id: 'n1', text: 'buy oat milk' } },
39
+ ]);
40
+ unsubscribe();
41
+
42
+ assert.equal(emitted.events.length, 3);
43
+ assert.equal(emitted.seq, before + 3);
44
+
45
+ // Live subscribers saw each event, in order, on the shared sequence.
46
+ assert.deepEqual(live.map((e) => [e.resource, e.op, e.id]), [
47
+ ['app:demo:notes', 'insert', 'n1'],
48
+ ['app:demo:notes', 'insert', 'n2'],
49
+ ['app:demo:notes', 'update', 'n1'],
50
+ ]);
51
+
52
+ // The mirror is what snapshots serve.
53
+ const snapshot = buildSnapshot('app:demo:notes');
54
+ assert.deepEqual(snapshot.resources['app:demo:notes'], [
55
+ { id: 'n1', text: 'buy oat milk' },
56
+ { id: 'n2', text: 'call mom' },
57
+ ]);
58
+
59
+ // Events landed in the log for reconnect replay.
60
+ const replay = listEventsAfter(before);
61
+ assert.equal(replay.length, 3);
62
+ assert.equal(replay[0].source, 'app:demo:notes');
63
+ });
64
+
65
+ test('delete and replace maintain the mirror', () => {
66
+ appResources.emitAppResourceEvents('app:demo:notes', { op: 'delete', id: 'n2' });
67
+ assert.deepEqual(
68
+ appResources.listAppResourceRows('app:demo:notes').map((row) => row.id),
69
+ ['n1'],
70
+ );
71
+
72
+ appResources.emitAppResourceEvents('app:demo:notes', {
73
+ op: 'replace',
74
+ value: [{ id: 'x1', text: 'fresh start' }],
75
+ });
76
+ assert.deepEqual(appResources.listAppResourceRows('app:demo:notes'), [
77
+ { id: 'x1', text: 'fresh start' },
78
+ ]);
79
+ });
80
+
81
+ test('registrations survive a registry restart (persisted + rewired)', () => {
82
+ // Simulate a fresh boot: the in-memory registration is gone, sqlite is not.
83
+ registry.unregisterResource('app:demo:notes');
84
+ assert.equal(registry.hasResource('app:demo:notes'), false);
85
+
86
+ // Force a reload pass the way rest.js does on first request.
87
+ const appResourcesPath = require.resolve('../state/app-resources');
88
+ delete require.cache[appResourcesPath];
89
+ const reloaded = require('../state/app-resources');
90
+ reloaded.ensureAppResourcesLoaded();
91
+
92
+ assert.equal(registry.hasResource('app:demo:notes'), true);
93
+ const snapshot = buildSnapshot('app:demo:notes');
94
+ assert.deepEqual(snapshot.resources['app:demo:notes'], [
95
+ { id: 'x1', text: 'fresh start' },
96
+ ]);
97
+ });
98
+
99
+ test('the door is namespaced and validated', () => {
100
+ // Built-in names cannot be claimed through the app door.
101
+ assert.throws(() => appResources.registerAppResource('skills'), /app:<appId>:<name>/);
102
+ assert.throws(() => appResources.registerAppResource('app:bad name:notes'), /app:<appId>:<name>/);
103
+
104
+ // Emitting to an unregistered resource 404s rather than auto-creating.
105
+ assert.throws(
106
+ () => appResources.emitAppResourceEvents('app:demo:ghost', { op: 'insert', id: '1', value: { id: '1' } }),
107
+ /not registered/,
108
+ );
109
+
110
+ // Malformed events are rejected with shape-explicit errors.
111
+ assert.throws(
112
+ () => appResources.emitAppResourceEvents('app:demo:notes', { op: 'insert', value: { text: 'no id' } }),
113
+ /requires an id/,
114
+ );
115
+ assert.throws(
116
+ () => appResources.emitAppResourceEvents('app:demo:notes', { op: 'replace', value: 'nope' }),
117
+ /full array of rows/,
118
+ );
119
+ });
120
+
121
+ test('unregister removes rows, frees the name, and tells listeners', () => {
122
+ const live = [];
123
+ const unsubscribe = subscribeStateEvents((event) => live.push(event));
124
+ const result = appResources.unregisterAppResource('app:demo:notes');
125
+ unsubscribe();
126
+
127
+ assert.equal(result.removed, true);
128
+ assert.equal(registry.hasResource('app:demo:notes'), false);
129
+ assert.equal(live.length, 1);
130
+ assert.equal(live[0].op, 'replace');
131
+ assert.deepEqual(live[0].value, []);
132
+ assert.equal(appResources.listAppResources().length, 0);
133
+
134
+ // Cascade cleared the mirror rows.
135
+ assert.deepEqual(appResources.listAppResourceRows('app:demo:notes'), []);
136
+ });
@@ -0,0 +1,193 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-live-docs-test-'));
10
+ process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
11
+
12
+ const Y = require('yjs');
13
+ const { closeLocalDb } = require('../state/db');
14
+ const docs = require('../state/docs');
15
+ const { buildSnapshot } = require('../state/snapshot');
16
+ const { listEventsAfter, currentSeq } = require('../state/events');
17
+
18
+ const docDir = path.join(tempRoot, 'workspace');
19
+ fs.mkdirSync(docDir, { recursive: true });
20
+
21
+ function writeDoc(name, text) {
22
+ const target = path.join(docDir, name);
23
+ fs.writeFileSync(target, text, 'utf8');
24
+ return target;
25
+ }
26
+
27
+ function sleep(ms) {
28
+ return new Promise((resolve) => setTimeout(resolve, ms));
29
+ }
30
+
31
+ function clientFromState(stateBase64) {
32
+ const doc = new Y.Doc();
33
+ Y.applyUpdate(doc, new Uint8Array(Buffer.from(stateBase64, 'base64')));
34
+ return doc;
35
+ }
36
+
37
+ function encodeLocalEdit(doc, mutate) {
38
+ let captured = null;
39
+ const listener = (update) => { captured = update; };
40
+ doc.on('update', listener);
41
+ mutate(doc.getText('content'));
42
+ doc.off('update', listener);
43
+ assert.ok(captured, 'edit should produce an update');
44
+ return Buffer.from(captured).toString('base64');
45
+ }
46
+
47
+ function applyEventsTo(doc, events) {
48
+ for (const event of events) {
49
+ if (event.patch?.yjs) {
50
+ Y.applyUpdate(doc, new Uint8Array(Buffer.from(event.patch.yjs, 'base64')));
51
+ }
52
+ }
53
+ }
54
+
55
+ test.after(() => {
56
+ docs.closeAllDocs();
57
+ closeLocalDb();
58
+ fs.rmSync(tempRoot, { recursive: true, force: true });
59
+ });
60
+
61
+ test('open seeds the document from disk and returns usable state', () => {
62
+ const target = writeDoc('seed.md', '# hello\n');
63
+ const opened = docs.openDoc(target);
64
+
65
+ assert.equal(opened.path, target);
66
+ assert.ok(opened.resource.startsWith('doc:'));
67
+ assert.equal(docs.pathFromDocResource(opened.resource), target);
68
+
69
+ const client = clientFromState(opened.state);
70
+ assert.equal(client.getText('content').toString(), '# hello\n');
71
+ });
72
+
73
+ test('client updates apply, log as patch events, and flush back to disk', async () => {
74
+ const target = writeDoc('edit.md', 'hello world\n');
75
+ const opened = docs.openDoc(target);
76
+ const client = clientFromState(opened.state);
77
+ const before = currentSeq();
78
+
79
+ const update = encodeLocalEdit(client, (ytext) => ytext.insert(5, ','));
80
+ const result = docs.applyDocUpdates(target, [update]);
81
+ assert.equal(result.applied, 1);
82
+ assert.equal(docs.getDocText(target), 'hello, world\n');
83
+
84
+ // The event carries the update in the patch slot — no value payload.
85
+ const events = listEventsAfter(before).filter((e) => e.resource === opened.resource);
86
+ assert.equal(events.length, 1);
87
+ assert.equal(events[0].op, 'update');
88
+ assert.equal(events[0].id, target);
89
+ assert.ok(events[0].patch?.yjs);
90
+ assert.equal(events[0].value, undefined);
91
+
92
+ // Debounced write-back lands on disk.
93
+ await sleep(500);
94
+ assert.equal(fs.readFileSync(target, 'utf8'), 'hello, world\n');
95
+ });
96
+
97
+ test('two clients editing concurrently converge to the same text', () => {
98
+ const target = writeDoc('merge.md', 'the middle\n');
99
+ const opened = docs.openDoc(target);
100
+ const clientA = clientFromState(opened.state);
101
+ const clientB = clientFromState(opened.state);
102
+ const before = currentSeq();
103
+
104
+ // Both edit from the SAME baseline, unaware of each other.
105
+ const updateA = encodeLocalEdit(clientA, (ytext) => ytext.insert(0, 'A: '));
106
+ const updateB = encodeLocalEdit(clientB, (ytext) => ytext.insert('the middle'.length, ' B'));
107
+
108
+ docs.applyDocUpdates(target, [updateA]);
109
+ docs.applyDocUpdates(target, [updateB]);
110
+
111
+ const engineText = docs.getDocText(target);
112
+ assert.equal(engineText, 'A: the middle B\n');
113
+
114
+ // Each client catches up from the relayed events and lands on the same text.
115
+ const events = listEventsAfter(before).filter((e) => e.resource === opened.resource);
116
+ applyEventsTo(clientA, events);
117
+ applyEventsTo(clientB, events);
118
+ assert.equal(clientA.getText('content').toString(), engineText);
119
+ assert.equal(clientB.getText('content').toString(), engineText);
120
+ });
121
+
122
+ test('external disk edits reconcile into the live doc via the watcher', async () => {
123
+ const target = writeDoc('agent.md', 'agent line 1\n');
124
+ const opened = docs.openDoc(target);
125
+ const before = currentSeq();
126
+
127
+ // An "agent" writes the file directly, bypassing the doc API.
128
+ fs.writeFileSync(target, 'agent line 1\nagent line 2\n', 'utf8');
129
+ await sleep(700);
130
+
131
+ assert.equal(docs.getDocText(target), 'agent line 1\nagent line 2\n');
132
+ const events = listEventsAfter(before).filter((e) => e.resource === opened.resource);
133
+ assert.ok(events.length >= 1, 'external edit should emit at least one doc event');
134
+ assert.equal(events[events.length - 1].source, 'doc:disk');
135
+ });
136
+
137
+ test('write-back does not echo into an event loop', async () => {
138
+ const target = writeDoc('loop.md', 'quiet\n');
139
+ const opened = docs.openDoc(target);
140
+ const client = clientFromState(opened.state);
141
+
142
+ const update = encodeLocalEdit(client, (ytext) => ytext.insert(0, 'still '));
143
+ docs.applyDocUpdates(target, [update]);
144
+
145
+ // Let the write-back land AND the watcher's reconcile window pass.
146
+ await sleep(900);
147
+ const settled = currentSeq();
148
+ await sleep(700);
149
+ const after = listEventsAfter(settled).filter((e) => e.resource === opened.resource);
150
+ assert.equal(after.length, 0, `write-back must not re-emit (got ${JSON.stringify(after)})`);
151
+ assert.equal(fs.readFileSync(target, 'utf8'), 'still quiet\n');
152
+ });
153
+
154
+ test('documents survive close: persisted state + disk agree on reopen', () => {
155
+ const target = writeDoc('reopen.md', 'v1\n');
156
+ const opened = docs.openDoc(target);
157
+ const client = clientFromState(opened.state);
158
+ const update = encodeLocalEdit(client, (ytext) => ytext.insert(0, 'v2 over '));
159
+ docs.applyDocUpdates(target, [update]);
160
+
161
+ docs.closeAllDocs(); // flushes disk + state
162
+
163
+ const reopened = docs.openDoc(target);
164
+ const fresh = clientFromState(reopened.state);
165
+ assert.equal(fresh.getText('content').toString(), 'v2 over v1\n');
166
+ assert.equal(fs.readFileSync(target, 'utf8'), 'v2 over v1\n');
167
+ });
168
+
169
+ test('snapshots serve open docs only and never break on bad names', () => {
170
+ const target = writeDoc('snap.md', 'snapshot me\n');
171
+ const opened = docs.openDoc(target);
172
+
173
+ const withDoc = buildSnapshot(opened.resource);
174
+ assert.equal(clientFromState(withDoc.resources[opened.resource].state)
175
+ .getText('content').toString(), 'snapshot me\n');
176
+
177
+ // Unopened doc → absent, garbage name → absent; snapshot itself stays fine.
178
+ const unopened = docs.docResourceName(path.join(docDir, 'never-opened.md'));
179
+ const garbage = buildSnapshot(`doc:!!!not-base64!!!,${unopened}`);
180
+ assert.deepEqual(garbage.resources, {});
181
+ });
182
+
183
+ test('guards: binary, missing, oversized, and malformed inputs are rejected', () => {
184
+ assert.throws(() => docs.openDoc(writeDoc('image.png', 'not really')), /text files only/);
185
+ assert.throws(() => docs.openDoc(path.join(docDir, 'missing.md')), /not found/);
186
+
187
+ const big = writeDoc('big.md', 'x'.repeat(2 * 1024 * 1024 + 1));
188
+ assert.throws(() => docs.openDoc(big), /live document limit/);
189
+
190
+ const target = writeDoc('guarded.md', 'ok\n');
191
+ assert.throws(() => docs.applyDocUpdates(target, [123]), /base64-encoded/);
192
+ assert.throws(() => docs.applyDocUpdates(target, ['aGVsbG8=']), /invalid Yjs update/);
193
+ });