amalgm 0.1.158 → 0.1.159

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.158",
3
+ "version": "0.1.159",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -12,7 +12,7 @@ const { appendStateEvent } = require('../state/events');
12
12
  const { createFsSearchHandler } = require('./search');
13
13
 
14
14
  const TEXT_EXTENSIONS = new Set([
15
- 'txt', 'md', 'json', 'js', 'ts', 'tsx', 'jsx', 'py', 'html', 'css',
15
+ 'txt', 'md', 'json', 'jsonl', 'js', 'ts', 'tsx', 'jsx', 'py', 'html', 'css',
16
16
  'xml', 'yaml', 'yml', 'sh', 'bash', 'env', 'gitignore', 'dockerfile',
17
17
  'svg', 'csv', 'sql', 'toml', 'rs', 'go', 'rb', 'java', 'kt', 'swift',
18
18
  'c', 'cpp', 'cs', 'php', 'scss', 'log', 'ini', 'cfg', 'conf',
@@ -127,6 +127,8 @@ async function boot() {
127
127
  startAppRouteSyncLoop();
128
128
  startHealthMonitor();
129
129
  require('./state/ports').startPortsBridge();
130
+ require('./workspace/cards').startRepoCardService();
131
+ require('./workspace/wall').startWallService();
130
132
  }
131
133
 
132
134
  boot().catch((err) => {
@@ -59,6 +59,10 @@ async function handleStateRoutes(ctx) {
59
59
  await stateRest.handleResourceEmit(await ctx.readJsonBody(), ctx.sendJson);
60
60
  return true;
61
61
  }
62
+ if (ctx.pathname === '/state/presence') {
63
+ await stateRest.handlePresenceUpdate(await ctx.readJsonBody(), ctx.sendJson);
64
+ return true;
65
+ }
62
66
  if (ctx.pathname === '/state/docs/open') {
63
67
  await stateRest.handleDocOpen(await ctx.readJsonBody(), ctx.sendJson);
64
68
  return true;
@@ -51,6 +51,22 @@ async function handleWorkspaceRoutes(ctx) {
51
51
  workspaceRest.handleWorktreeDelete(await ctx.readJsonBody(), ctx.sendJson);
52
52
  return true;
53
53
  }
54
+ if (ctx.pathname === '/workspace/repos' && ctx.method === 'GET') {
55
+ workspaceRest.handleRepoCards(ctx.sendJson);
56
+ return true;
57
+ }
58
+ if (ctx.pathname === '/workspace/repos/refresh' && ctx.method === 'POST') {
59
+ await workspaceRest.handleRepoCardsRefresh(ctx.sendJson);
60
+ return true;
61
+ }
62
+ if (ctx.pathname === '/workspace/sync/stats' && ctx.method === 'GET') {
63
+ workspaceRest.handleWorkspaceSyncStats(ctx.sendJson);
64
+ return true;
65
+ }
66
+ if (ctx.pathname === '/workspace/machines' && ctx.method === 'GET') {
67
+ workspaceRest.handleWorkspaceMachines(ctx.sendJson);
68
+ return true;
69
+ }
54
70
  return false;
55
71
  }
56
72
 
@@ -231,11 +231,54 @@ function getAttachment(attachmentId, options = {}) {
231
231
  );
232
232
  }
233
233
 
234
+ /** The one durable attachment request for a resource, if any. */
235
+ function getAttachmentByResource(resourceId, options = {}) {
236
+ const database = options.database || openLocalDb();
237
+ return normalizeRow(
238
+ cachedStatement(database, 'SELECT * FROM shared_resource_attachments WHERE resource_id = ?')
239
+ .get(resourceId),
240
+ );
241
+ }
242
+
243
+ /**
244
+ * Cycle a refused (or stuck) attachment request back to pending — for a
245
+ * binding whose cloud answer may have changed since the refusal, e.g. a
246
+ * derived-id resource that lost its creation race and exists now. Unlike
247
+ * requestRehydration this needs no replica: it retries the first binding.
248
+ */
249
+ function retryAttachment(resourceIdInput, options = {}) {
250
+ const resourceId = requiredText(resourceIdInput, 'resourceId', 132);
251
+ if (!SHARED_RESOURCE_ID.test(resourceId)) throw invalid('resourceId has an invalid format');
252
+ const database = options.database || openLocalDb();
253
+ const now = new Date().toISOString();
254
+ const result = cachedStatement(database, `
255
+ UPDATE shared_resource_attachments
256
+ SET attachment_state = 'pending',
257
+ next_attempt_at = NULL,
258
+ last_error = NULL,
259
+ updated_at = ?
260
+ WHERE resource_id = ? AND attachment_state != 'attached'
261
+ `).run(now, resourceId);
262
+ if (!result.changes) throw invalid(`no retryable attachment for ${resourceId}`, 404);
263
+ const attachment = getAttachmentByResource(resourceId, { database });
264
+ appendStateEvent({
265
+ resource: 'shared:attachments',
266
+ op: 'update',
267
+ id: attachment.attachmentId,
268
+ sharedResourceId: resourceId,
269
+ value: { state: 'pending' },
270
+ source: 'shared:attachment-retry',
271
+ });
272
+ return attachment;
273
+ }
274
+
234
275
  module.exports = {
235
276
  acknowledgeAttachment,
236
277
  claimAttachments,
237
278
  enqueueAttachment,
238
279
  failAttachment,
239
280
  getAttachment,
281
+ getAttachmentByResource,
240
282
  requestRehydration,
283
+ retryAttachment,
241
284
  };
@@ -58,6 +58,7 @@ function openLocalDb() {
58
58
  }
59
59
 
60
60
  function migrate(database = openLocalDb()) {
61
+ dropLegacyRepoCards(database);
61
62
  database.exec(`
62
63
  CREATE TABLE IF NOT EXISTS event_log (
63
64
  seq INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -531,6 +532,29 @@ function migrate(database = openLocalDb()) {
531
532
  CREATE INDEX IF NOT EXISTS workspaces_location_type_idx
532
533
  ON workspaces(location_type);
533
534
 
535
+ CREATE TABLE IF NOT EXISTS repo_replicas (
536
+ git_dir TEXT PRIMARY KEY,
537
+ replica_id TEXT NOT NULL UNIQUE,
538
+ root_commit TEXT,
539
+ created_at TEXT NOT NULL
540
+ );
541
+
542
+ CREATE TABLE IF NOT EXISTS repo_cards (
543
+ replica_id TEXT PRIMARY KEY,
544
+ path TEXT NOT NULL,
545
+ git_dir TEXT NOT NULL,
546
+ root TEXT NOT NULL,
547
+ health TEXT NOT NULL,
548
+ fingerprint TEXT NOT NULL,
549
+ generation INTEGER NOT NULL,
550
+ first_seen_at TEXT NOT NULL,
551
+ updated_at TEXT NOT NULL,
552
+ card_json TEXT NOT NULL
553
+ );
554
+
555
+ CREATE INDEX IF NOT EXISTS repo_cards_path_idx
556
+ ON repo_cards(path);
557
+
534
558
  CREATE TABLE IF NOT EXISTS browser_profiles (
535
559
  id TEXT PRIMARY KEY,
536
560
  name TEXT NOT NULL,
@@ -621,6 +645,17 @@ function ensureColumn(database, tableName, columnName, declaration) {
621
645
  database.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${declaration}`);
622
646
  }
623
647
 
648
+ // repo-card identity moved from the path to minted replica ids
649
+ // (repo-card@2). Cards are derived observations, so the v1 table is
650
+ // dropped rather than migrated — the next refresh rebuilds every card
651
+ // straight from git.
652
+ function dropLegacyRepoCards(database) {
653
+ const columns = database.prepare('PRAGMA table_info(repo_cards)').all();
654
+ if (columns.length > 0 && !columns.some((column) => column.name === 'replica_id')) {
655
+ database.exec('DROP TABLE repo_cards');
656
+ }
657
+ }
658
+
624
659
  // ---------------------------------------------------------------------------
625
660
  // Lazily created tables. The DDL lives here so this file owns every schema
626
661
  // statement, but these tables are only created when their feature is first
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The ephemeral presence lane (realtime-sdk.md §6, "Ephemeral").
5
+ *
6
+ * Presence is the channel connection made visible: per resource, the set of
7
+ * live bindings (sessions) and the small opaque position blob each one last
8
+ * published. Everything here is the inverse of the durable lane in events.js:
9
+ * never written to the event log, never in snapshots or replay, expired by
10
+ * TTL instead of retained — a caret that outlives its author is a lie, where
11
+ * a doc byte must outlive everything.
12
+ *
13
+ * Ownership rules the wire shape enforces:
14
+ * - Identity is stamped HERE from the enrolled account (workspace/identity),
15
+ * never accepted from the publisher. A blob cannot impersonate.
16
+ * - The blob is opaque. Only the surface bound to the same resource on the
17
+ * other end knows its coordinate system (text caret, canvas x,y, nothing).
18
+ * - Absence is the default presence must re-earn: entries live TTL_MS from
19
+ * their last publish, and a session's death (stream teardown or expiry)
20
+ * removes it everywhere without its cooperation.
21
+ *
22
+ * Broadcast shape: the full peer list per resource, re-sent on every change.
23
+ * Removal is implicit (absent from the list), so there is no ordering, no
24
+ * seq, and nothing to replay — receivers replace, never merge.
25
+ */
26
+
27
+ const { EventEmitter } = require('events');
28
+
29
+ const emitter = new EventEmitter();
30
+ emitter.setMaxListeners(200);
31
+
32
+ /** A peer whose publisher has gone silent this long is gone, no exceptions. */
33
+ const PRESENCE_TTL_MS = 30_000;
34
+ const SWEEP_INTERVAL_MS = 10_000;
35
+ /** Positions are small facts; anything bigger is data smuggling on the lane. */
36
+ const MAX_BLOB_JSON_BYTES = 8 * 1024;
37
+
38
+ /** resource -> (session -> { session, user, blob, at, activeAt }) */
39
+ const channels = new Map();
40
+ let sweepTimer = null;
41
+
42
+ function requireString(value, field) {
43
+ if (typeof value !== 'string' || !value.trim()) {
44
+ const error = new Error(`presence requires a non-empty "${field}" string`);
45
+ error.statusCode = 400;
46
+ throw error;
47
+ }
48
+ return value.trim();
49
+ }
50
+
51
+ function stampedUser() {
52
+ // Lazy require: identity reads config/db at load, and presence must stay
53
+ // importable in tests that stub neither.
54
+ try {
55
+ return require('../workspace/identity').userId() || null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ function peersOf(resource) {
62
+ const peers = channels.get(resource);
63
+ return peers ? Array.from(peers.values()) : [];
64
+ }
65
+
66
+ function broadcast(resource) {
67
+ emitter.emit('presence', { resource, peers: peersOf(resource) });
68
+ }
69
+
70
+ function ensureSweeper() {
71
+ if (sweepTimer) return;
72
+ sweepTimer = setInterval(() => {
73
+ const cutoff = Date.now() - PRESENCE_TTL_MS;
74
+ for (const [resource, peers] of channels) {
75
+ let changed = false;
76
+ for (const [session, entry] of peers) {
77
+ if (entry.at < cutoff) {
78
+ peers.delete(session);
79
+ changed = true;
80
+ }
81
+ }
82
+ if (peers.size === 0) channels.delete(resource);
83
+ if (changed) broadcast(resource);
84
+ }
85
+ // An empty lane must not tick: the timer exists only while peers do.
86
+ if (channels.size === 0) {
87
+ clearInterval(sweepTimer);
88
+ sweepTimer = null;
89
+ }
90
+ }, SWEEP_INTERVAL_MS);
91
+ if (typeof sweepTimer.unref === 'function') sweepTimer.unref();
92
+ }
93
+
94
+ /**
95
+ * Publish one binding's presence on a resource. `blob` is the surface's
96
+ * opaque position state (may be null — present without a position);
97
+ * `active` marks input activity, so chrome can render idle separately from
98
+ * gone. Re-publishing is the heartbeat: it is the only way to stay present.
99
+ */
100
+ function updatePresence(input) {
101
+ const resource = requireString(input?.resource, 'resource');
102
+ const session = requireString(input?.session, 'session');
103
+ const blob = input?.blob === undefined ? null : input.blob;
104
+ if (blob !== null && JSON.stringify(blob).length > MAX_BLOB_JSON_BYTES) {
105
+ const error = new Error('presence blob exceeds the ephemeral lane size limit');
106
+ error.statusCode = 413;
107
+ throw error;
108
+ }
109
+
110
+ const now = Date.now();
111
+ let peers = channels.get(resource);
112
+ if (!peers) {
113
+ peers = new Map();
114
+ channels.set(resource, peers);
115
+ }
116
+ const previous = peers.get(session);
117
+ peers.set(session, {
118
+ session,
119
+ user: stampedUser(),
120
+ blob,
121
+ at: now,
122
+ activeAt: input?.active ? now : (previous?.activeAt ?? now),
123
+ });
124
+ ensureSweeper();
125
+ broadcast(resource);
126
+ return { resource, session, peers: peers.size };
127
+ }
128
+
129
+ /** Explicit goodbye for one binding on one resource. */
130
+ function leavePresence(input) {
131
+ const resource = requireString(input?.resource, 'resource');
132
+ const session = requireString(input?.session, 'session');
133
+ const peers = channels.get(resource);
134
+ if (!peers || !peers.delete(session)) return { resource, session, peers: peers?.size ?? 0 };
135
+ if (peers.size === 0) channels.delete(resource);
136
+ broadcast(resource);
137
+ return { resource, session, peers: peers?.size ?? 0 };
138
+ }
139
+
140
+ /**
141
+ * A session's connection died: remove it from every resource it was present
142
+ * on. The stream teardown calls this — the "dead connection" layer between
143
+ * the clean goodbye and TTL expiry.
144
+ */
145
+ function evictSession(session) {
146
+ if (typeof session !== 'string' || !session) return;
147
+ for (const [resource, peers] of Array.from(channels)) {
148
+ if (!peers.delete(session)) continue;
149
+ if (peers.size === 0) channels.delete(resource);
150
+ broadcast(resource);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Current state as broadcast frames — sent to a stream on connect so a late
156
+ * joiner sees existing peers immediately instead of waiting out their next
157
+ * heartbeat. Empty resources produce nothing.
158
+ */
159
+ function snapshotFrames() {
160
+ return Array.from(channels.keys(), (resource) => ({ resource, peers: peersOf(resource) }));
161
+ }
162
+
163
+ function subscribePresence(callback) {
164
+ emitter.on('presence', callback);
165
+ return () => emitter.off('presence', callback);
166
+ }
167
+
168
+ module.exports = {
169
+ PRESENCE_TTL_MS,
170
+ evictSession,
171
+ leavePresence,
172
+ snapshotFrames,
173
+ subscribePresence,
174
+ updatePresence,
175
+ };
@@ -314,10 +314,20 @@ function getPromotion(promotionId, options = {}) {
314
314
  );
315
315
  }
316
316
 
317
+ /** The one durable promotion request for a resource, if any. */
318
+ function getPromotionByResource(resourceId, options = {}) {
319
+ const database = options.database || openLocalDb();
320
+ return normalizeRow(
321
+ cachedStatement(database, 'SELECT * FROM shared_resource_promotions WHERE resource_id = ?')
322
+ .get(resourceId),
323
+ );
324
+ }
325
+
317
326
  module.exports = {
318
327
  acknowledgePromotion,
319
328
  claimPromotions,
320
329
  enqueuePromotion,
321
330
  failPromotion,
322
331
  getPromotion,
332
+ getPromotionByResource,
323
333
  };
@@ -7,6 +7,7 @@ const {
7
7
  subscribeStateEvents,
8
8
  } = require('./events');
9
9
  const appResources = require('./app-resources');
10
+ const presence = require('./presence');
10
11
 
11
12
  function sendHandlerError(error, sendJson) {
12
13
  const statusCode = Number(error?.statusCode) || 500;
@@ -128,6 +129,21 @@ async function handleDocWrite(body, sendJson) {
128
129
  }
129
130
  }
130
131
 
132
+ // The ephemeral lane's one REST door: publish (the heartbeat) or leave.
133
+ // Identity is stamped server-side in state/presence — the body never
134
+ // carries who you are, only where you are (the opaque surface blob).
135
+ async function handlePresenceUpdate(body, sendJson) {
136
+ try {
137
+ if (body?.leave) {
138
+ sendJson(200, presence.leavePresence(body));
139
+ return;
140
+ }
141
+ sendJson(200, presence.updatePresence(body));
142
+ } catch (error) {
143
+ sendHandlerError(error, sendJson);
144
+ }
145
+ }
146
+
131
147
  async function handleResourceList(sendJson) {
132
148
  try {
133
149
  sendJson(200, { resources: appResources.listAppResources() });
@@ -158,6 +174,13 @@ function handleStream(req, res, query) {
158
174
  // ignored — the snapshot IS the resume point.
159
175
  const bootstrap = String(query.bootstrap || '').trim() === '1';
160
176
  const after = bootstrap ? 0 : parsePositiveInt(query.after, 0);
177
+ // The client's binding session, when it has one: ties this connection to
178
+ // the presence the same session publishes over POST /state/presence, so a
179
+ // dead stream takes its presence down with it (the layer between the clean
180
+ // goodbye and TTL expiry). Optional — presence-less clients pass nothing.
181
+ const presenceSession = typeof query.session === 'string' && query.session.trim()
182
+ ? query.session.trim()
183
+ : null;
161
184
 
162
185
  res.writeHead(200, {
163
186
  'Content-Type': 'text/event-stream; charset=utf-8',
@@ -195,12 +218,17 @@ function handleStream(req, res, query) {
195
218
  // on a 'close' that never fires is how dead streams pile up.
196
219
  let heartbeat = null;
197
220
  let unsubscribe = null;
221
+ let unsubscribePresence = null;
198
222
  let tornDown = false;
199
223
  const teardown = () => {
200
224
  if (tornDown) return;
201
225
  tornDown = true;
202
226
  if (heartbeat) clearInterval(heartbeat);
203
227
  if (unsubscribe) unsubscribe();
228
+ if (unsubscribePresence) unsubscribePresence();
229
+ // The connection was the presence: its death removes this session's
230
+ // entries everywhere, and the broadcast tells every other stream.
231
+ if (presenceSession) presence.evictSession(presenceSession);
204
232
  try {
205
233
  res.end();
206
234
  } catch {
@@ -265,6 +293,18 @@ function handleStream(req, res, query) {
265
293
  }
266
294
  }
267
295
 
296
+ // Presence rides the same stream as its own frame type — never through the
297
+ // event log, never with a seq, invisible to replay/backlog. Current state
298
+ // goes out first (a late joiner must not wait out other peers' heartbeats),
299
+ // after the snapshot/backlog so ordered-bootstrap clients have their
300
+ // snapshot before any presence frame arrives.
301
+ for (const frame of presence.snapshotFrames()) {
302
+ writeSafe(() => sendSseControl(res, 'presence', frame));
303
+ }
304
+ unsubscribePresence = presence.subscribePresence((frame) => {
305
+ writeSafe(() => sendSseControl(res, 'presence', frame));
306
+ });
307
+
268
308
  // A named event (not an SSE comment) so EventSource clients can observe it
269
309
  // and detect a silently dead stream, e.g. tunnel frame drops. It doubles as
270
310
  // the server-side liveness probe: writing to a dead socket tears down.
@@ -288,6 +328,7 @@ module.exports = {
288
328
  handleDocUpdate,
289
329
  handleDocWrite,
290
330
  handleEvents,
331
+ handlePresenceUpdate,
291
332
  handleResourceEmit,
292
333
  handleResourceList,
293
334
  handleResourceRegister,
@@ -12,7 +12,9 @@ const fs = require('fs');
12
12
 
13
13
  function sendHandlerError(error, sendJson) {
14
14
  const statusCode = Number(error?.statusCode) || 500;
15
- sendJson(statusCode, { error: error?.message || 'internal error' });
15
+ const payload = { error: error?.message || 'internal error' };
16
+ if (typeof error?.code === 'string' && error.code) payload.code = error.code;
17
+ sendJson(statusCode, payload);
16
18
  }
17
19
 
18
20
  function parsePositiveInt(value, fallback) {
@@ -71,6 +73,23 @@ async function handleReplicaRehydrate(body, sendJson) {
71
73
 
72
74
  async function handlePromotionStart(body, sendJson) {
73
75
  try {
76
+ // One owner per file (workspace-sync.md §1). A file tracked by a
77
+ // repository is git-owned: it syncs through commits on the workspace
78
+ // rail, and it may join the doc rail only via a future explicit
79
+ // ownership handoff — never by ambient promotion.
80
+ if (typeof body?.path === 'string' && body.path) {
81
+ const owner = await require('../workspace/git').gitOwnerOf(body.path);
82
+ if (owner) {
83
+ throw Object.assign(
84
+ new Error(
85
+ `${body.path} is git-owned: tracked by the repository at ${owner.toplevel}. `
86
+ + 'Repository files sync through git (commits are the sync points), not the doc rail. '
87
+ + 'If this file truly belongs on the rail, untrack it first (git rm --cached <file>).',
88
+ ),
89
+ { statusCode: 409, code: 'file_git_owned' },
90
+ );
91
+ }
92
+ }
74
93
  const opened = require('./docs').openDoc(body?.path);
75
94
  const result = require('./promotions').enqueuePromotion({
76
95
  promotionId: body?.promotionId || `prm_${crypto.randomBytes(16).toString('base64url')}`,
@@ -0,0 +1,130 @@
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
+ const { EventEmitter } = require('events');
9
+
10
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-presence-test-'));
11
+ process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
12
+
13
+ const { closeLocalDb } = require('../state/db');
14
+ const { listEventsAfter } = require('../state/events');
15
+ const presence = require('../state/presence');
16
+ const rest = require('../state/rest');
17
+
18
+ test.after(() => {
19
+ closeLocalDb();
20
+ fs.rmSync(tempRoot, { recursive: true, force: true });
21
+ });
22
+
23
+ function fakeRequest() {
24
+ return new EventEmitter();
25
+ }
26
+
27
+ function fakeResponse() {
28
+ const res = new EventEmitter();
29
+ res.writes = [];
30
+ res.destroyed = false;
31
+ res.writableEnded = false;
32
+ res.writeHead = () => res;
33
+ res.write = (chunk) => {
34
+ res.writes.push(String(chunk));
35
+ return true;
36
+ };
37
+ res.end = () => {
38
+ res.writableEnded = true;
39
+ };
40
+ return res;
41
+ }
42
+
43
+ function presenceFrames(res) {
44
+ return res.writes
45
+ .map((write, index) => (write === 'event: presence\n' ? JSON.parse(res.writes[index + 1].replace(/^data: /, '')) : null))
46
+ .filter(Boolean);
47
+ }
48
+
49
+ test('publish → broadcast; leave → removal broadcast; frames replace wholesale', () => {
50
+ const frames = [];
51
+ const unsubscribe = presence.subscribePresence((frame) => frames.push(frame));
52
+
53
+ presence.updatePresence({ resource: 'doc:t1', session: 'ps_a', blob: { kind: 'text-caret@1', anchor: 1, head: 4 } });
54
+ presence.updatePresence({ resource: 'doc:t1', session: 'ps_b', blob: null });
55
+ assert.equal(frames.length, 2);
56
+ assert.equal(frames[1].peers.length, 2, 'second frame carries the full list');
57
+
58
+ presence.leavePresence({ resource: 'doc:t1', session: 'ps_a' });
59
+ const last = frames[frames.length - 1];
60
+ assert.deepEqual(last.peers.map((peer) => peer.session), ['ps_b'], 'leave broadcasts the survivor list');
61
+
62
+ presence.leavePresence({ resource: 'doc:t1', session: 'ps_b' });
63
+ assert.equal(frames[frames.length - 1].peers.length, 0, 'last leave broadcasts an empty list');
64
+ unsubscribe();
65
+ });
66
+
67
+ test('the lane never touches the durable event log', () => {
68
+ const before = listEventsAfter(0).length;
69
+ presence.updatePresence({ resource: 'doc:t2', session: 'ps_x', blob: { p: 1 } });
70
+ presence.leavePresence({ resource: 'doc:t2', session: 'ps_x' });
71
+ assert.equal(listEventsAfter(0).length, before, 'presence must not journal');
72
+ });
73
+
74
+ test('identity cannot ride in the payload: user is stamped server-side', () => {
75
+ const frames = [];
76
+ const unsubscribe = presence.subscribePresence((frame) => frames.push(frame));
77
+ presence.updatePresence({ resource: 'doc:t3', session: 'ps_i', user: 'liar', blob: null });
78
+ const peer = frames[0].peers[0];
79
+ assert.notEqual(peer.user, 'liar', 'client-supplied user must be ignored');
80
+ presence.leavePresence({ resource: 'doc:t3', session: 'ps_i' });
81
+ unsubscribe();
82
+ });
83
+
84
+ test('oversized blobs are refused — the lane carries positions, not data', () => {
85
+ assert.throws(
86
+ () => presence.updatePresence({ resource: 'doc:t4', session: 'ps_big', blob: { pad: 'x'.repeat(9000) } }),
87
+ /size limit/,
88
+ );
89
+ });
90
+
91
+ test('a stream gets current presence on connect, live frames after, and evicts its session on close', () => {
92
+ presence.updatePresence({ resource: 'doc:t5', session: 'ps_existing', blob: { at: 1 } });
93
+
94
+ const req = fakeRequest();
95
+ const res = fakeResponse();
96
+ rest.handleStream(req, res, { session: 'ps_stream' });
97
+
98
+ const catchUp = presenceFrames(res);
99
+ assert.ok(
100
+ catchUp.some((frame) => frame.resource === 'doc:t5'),
101
+ 'connect must deliver existing presence without waiting for a heartbeat',
102
+ );
103
+
104
+ presence.updatePresence({ resource: 'doc:t5', session: 'ps_stream', blob: { at: 2 } });
105
+ const live = presenceFrames(res);
106
+ assert.equal(live[live.length - 1].peers.length, 2, 'live broadcasts reach the open stream');
107
+
108
+ // The connection dies → its session's presence goes with it, and the
109
+ // other streams hear about it (our own subscriber here is torn down).
110
+ const frames = [];
111
+ const unsubscribe = presence.subscribePresence((frame) => frames.push(frame));
112
+ req.emit('close');
113
+ const after = frames.find((frame) => frame.resource === 'doc:t5');
114
+ assert.ok(after, 'teardown broadcasts the eviction');
115
+ assert.deepEqual(after.peers.map((peer) => peer.session), ['ps_existing'], 'only the dead session is evicted');
116
+
117
+ presence.leavePresence({ resource: 'doc:t5', session: 'ps_existing' });
118
+ unsubscribe();
119
+ });
120
+
121
+ test('presence frames are absent from replay surfaces', () => {
122
+ presence.updatePresence({ resource: 'doc:t6', session: 'ps_r', blob: null });
123
+ const req = fakeRequest();
124
+ const res = fakeResponse();
125
+ rest.handleStream(req, res, {});
126
+ const stateFrames = res.writes.filter((write) => write.startsWith('event: state\n'));
127
+ assert.equal(stateFrames.length, 0, 'no presence rides the durable frame type');
128
+ req.emit('close');
129
+ presence.leavePresence({ resource: 'doc:t6', session: 'ps_r' });
130
+ });