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.
@@ -0,0 +1,308 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The machine wall: every machine's repo cards, shared across the
5
+ * account's devices on the wave-3 rail.
6
+ *
7
+ * The wall is one shared live document per account — a jsonl file where
8
+ * each machine owns exactly one line, its full card set. Single-writer
9
+ * per line means concurrent publishes from different machines are
10
+ * disjoint edits the doc rail merges perfectly; the doc itself rides the
11
+ * exact journal → outbox → gateway-stamp → fanout → catch-up spine every
12
+ * shared document rides. No new transport, no new failure modes.
13
+ *
14
+ * The wall's resource id is derived from the account, so every machine
15
+ * finds it without a rendezvous service: attach first; if the cloud has
16
+ * never heard of it, promote it into existence; if two machines race,
17
+ * exactly one create wins and the loser attaches. Access is enforced by
18
+ * the gateway's membership check — another account attaching this id gets
19
+ * a flat 403, which is the cross-account gate this slice must prove.
20
+ *
21
+ * Publishing is gated on the replica binding: until the wall is attached,
22
+ * the local file stays empty (which is exactly what attachment requires)
23
+ * and nothing is written. Cards therefore never ride an unbound file.
24
+ */
25
+
26
+ const crypto = require('crypto');
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+ const { AMALGM_DIR } = require('../config');
30
+ const { subscribeStateEvents } = require('../state/events');
31
+ const { machineId, userId } = require('./identity');
32
+ const { listRepoCards } = require('./cards');
33
+
34
+ const WALL_CONTRACT = 'machine-cards@1';
35
+ const WALL_FILE = path.join(AMALGM_DIR, 'state', 'machine-cards.jsonl');
36
+ const PUBLISH_QUIET_MS = 300;
37
+ const ENSURE_STEP_TIMEOUT_MS = 20_000;
38
+ const ENSURE_RETRY_MS = 15_000;
39
+ const ATTACH_REJECTED = /shared_resource_read_forbidden/;
40
+
41
+ const service = {
42
+ started: false,
43
+ stopRequested: false,
44
+ publishTimer: null,
45
+ ensureTimer: null,
46
+ unsubscribe: null,
47
+ ensure: { state: 'idle', attempts: 0, lastError: null },
48
+ counters: { publishes: 0, publishSkips: 0, publishFailures: 0 },
49
+ lastPublishedAt: null,
50
+ };
51
+
52
+ function sha256(text) {
53
+ return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
54
+ }
55
+
56
+ /** The account's wall resource id — same on every machine of the account. */
57
+ function wallResourceId(forUserId = userId()) {
58
+ if (!forUserId) return null;
59
+ const digest = crypto.createHash('sha256')
60
+ .update(`amalgm-machine-wall@1|${forUserId}`)
61
+ .digest('base64url');
62
+ return `shr_mw1${digest.slice(0, 40)}`;
63
+ }
64
+
65
+ function wallReplica() {
66
+ const resourceId = wallResourceId();
67
+ if (!resourceId) return null;
68
+ return require('../state/replicas').getReplicaByResourceId(resourceId);
69
+ }
70
+
71
+ function parseWallLine(line) {
72
+ try {
73
+ const entry = JSON.parse(line);
74
+ if (entry?.wall !== WALL_CONTRACT || !entry.machine || !Array.isArray(entry.cards)) return null;
75
+ return entry;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Every machine's published line, keyed by machine — the wall as this
83
+ * machine currently sees it. Lines that do not parse as wall entries are
84
+ * ignored; the next publish from their machine replaces them.
85
+ */
86
+ function readWall() {
87
+ if (!fs.existsSync(WALL_FILE)) return [];
88
+ const entries = new Map();
89
+ for (const line of require('../state/docs').getDocText(WALL_FILE).split('\n')) {
90
+ if (!line) continue;
91
+ const entry = parseWallLine(line);
92
+ if (entry) entries.set(entry.machine, entry);
93
+ }
94
+ return [...entries.values()];
95
+ }
96
+
97
+ /**
98
+ * Write this machine's line — and only this machine's line — into the
99
+ * wall. Other machines' lines are preserved byte-for-byte in place, so
100
+ * the resulting doc edit is exactly one line's splice and concurrent
101
+ * publishes from other machines never overlap it.
102
+ */
103
+ function publishWall() {
104
+ const replica = wallReplica();
105
+ if (!replica) {
106
+ service.counters.publishSkips += 1;
107
+ return { published: false, reason: 'wall-not-bound' };
108
+ }
109
+
110
+ const docs = require('../state/docs');
111
+ const current = docs.getDocText(WALL_FILE);
112
+ const mine = JSON.stringify({
113
+ wall: WALL_CONTRACT,
114
+ machine: machineId(),
115
+ publishedAt: new Date().toISOString(),
116
+ cards: listRepoCards(),
117
+ });
118
+
119
+ const lines = current.split('\n').filter((line) => line.length > 0);
120
+ const index = lines.findIndex((line) => parseWallLine(line)?.machine === machineId());
121
+ const unchanged = index >= 0
122
+ && withoutPublishedAt(lines[index]) === withoutPublishedAt(mine);
123
+ if (unchanged) {
124
+ service.counters.publishSkips += 1;
125
+ return { published: false, reason: 'unchanged' };
126
+ }
127
+ if (index >= 0) lines[index] = mine;
128
+ else lines.push(mine);
129
+
130
+ const result = docs.writeDocText(WALL_FILE, `${lines.join('\n')}\n`, { baseHash: sha256(current) });
131
+ service.counters.publishes += 1;
132
+ service.lastPublishedAt = new Date().toISOString();
133
+ return { published: true, merge: result.merge, cards: listRepoCards().length };
134
+ }
135
+
136
+ function withoutPublishedAt(line) {
137
+ const entry = parseWallLine(line);
138
+ if (!entry) return line;
139
+ const { publishedAt, ...rest } = entry;
140
+ return JSON.stringify(rest);
141
+ }
142
+
143
+ // --- binding the wall to the rail -----------------------------------------
144
+
145
+ function sleep(ms) {
146
+ return new Promise((resolve) => {
147
+ const timer = setTimeout(resolve, ms);
148
+ timer.unref?.();
149
+ service.ensureTimer = timer;
150
+ });
151
+ }
152
+
153
+ async function waitForSettled(read) {
154
+ const deadline = Date.now() + ENSURE_STEP_TIMEOUT_MS;
155
+ while (Date.now() < deadline && !service.stopRequested) {
156
+ if (wallReplica()) return read();
157
+ const row = read();
158
+ if (row && row.state !== 'pending' && row.state !== 'syncing') return row;
159
+ await sleep(500);
160
+ }
161
+ return read();
162
+ }
163
+
164
+ /**
165
+ * Bind this machine to the account wall, retrying for as long as the
166
+ * service runs. The attachment row is the one durable request per
167
+ * resource: create it if absent, cycle it back to pending after a
168
+ * refusal, and — when the refusal means the cloud has never heard of the
169
+ * wall — promote the empty file into existence exactly once. Two
170
+ * machines racing the creation resolve cleanly: one create wins, the
171
+ * loser's next attach retry finds the wall existing. The tunnel delivers
172
+ * queued requests whenever it connects, so an offline boot binds on
173
+ * reconnect.
174
+ */
175
+ async function ensureWallBound() {
176
+ const resourceId = wallResourceId();
177
+ if (!resourceId) {
178
+ service.ensure.state = 'no-account';
179
+ return;
180
+ }
181
+ const shared = require('../state/shared-rest');
182
+ const attachments = require('../state/attachments');
183
+ const promotions = require('../state/promotions');
184
+
185
+ while (!service.stopRequested && !wallReplica()) {
186
+ service.ensure.attempts += 1;
187
+ try {
188
+ fs.mkdirSync(path.dirname(WALL_FILE), { recursive: true });
189
+ if (!fs.existsSync(WALL_FILE)) fs.writeFileSync(WALL_FILE, '');
190
+
191
+ let request = attachments.getAttachmentByResource(resourceId);
192
+ if (!request) {
193
+ service.ensure.state = 'attaching';
194
+ await callLocal(shared.handleAttachmentStart, { resourceId, path: WALL_FILE });
195
+ } else if (request.state === 'rejected') {
196
+ if (ATTACH_REJECTED.test(request.lastError || '') && !promotions.getPromotionByResource(resourceId)) {
197
+ // The cloud has never heard of this wall — or access is gone,
198
+ // and a create attempt on an existing id is refused, so trying
199
+ // is safe either way. Promote the empty file into existence.
200
+ service.ensure.state = 'creating';
201
+ await callLocal(shared.handlePromotionStart, { resourceId, path: WALL_FILE });
202
+ await waitForSettled(() => promotions.getPromotionByResource(resourceId));
203
+ if (wallReplica()) break;
204
+ }
205
+ service.ensure.state = 'attaching';
206
+ attachments.retryAttachment(resourceId);
207
+ }
208
+ const settled = await waitForSettled(() => attachments.getAttachmentByResource(resourceId));
209
+ service.ensure.lastError = settled?.lastError || null;
210
+ } catch (error) {
211
+ service.ensure.lastError = error.message;
212
+ }
213
+ if (wallReplica()) break;
214
+ service.ensure.state = 'waiting';
215
+ await sleep(ENSURE_RETRY_MS);
216
+ }
217
+
218
+ if (wallReplica()) {
219
+ service.ensure.state = 'bound';
220
+ service.ensure.lastError = null;
221
+ schedulePublish();
222
+ }
223
+ }
224
+
225
+ /** Adapt a shared-rest handler (which speaks sendJson) into a promise. */
226
+ function callLocal(handler, body) {
227
+ return new Promise((resolve, reject) => {
228
+ handler(body, (status, payload) => {
229
+ if (status >= 400) reject(new Error(payload?.error || `local shared route failed with ${status}`));
230
+ else resolve(payload);
231
+ });
232
+ });
233
+ }
234
+
235
+ // --- service ---------------------------------------------------------------
236
+
237
+ function schedulePublish() {
238
+ if (!service.started || service.publishTimer) return;
239
+ service.publishTimer = setTimeout(() => {
240
+ service.publishTimer = null;
241
+ try {
242
+ publishWall();
243
+ } catch (error) {
244
+ service.counters.publishFailures += 1;
245
+ console.warn('[MachineWall] publish failed:', error.message);
246
+ }
247
+ }, PUBLISH_QUIET_MS);
248
+ service.publishTimer.unref?.();
249
+ }
250
+
251
+ function startWallService() {
252
+ if (service.started) return;
253
+ service.started = true;
254
+ service.stopRequested = false;
255
+ service.unsubscribe = subscribeStateEvents((event) => {
256
+ if (event.resource === 'repo-cards') schedulePublish();
257
+ });
258
+ ensureWallBound().catch((error) => {
259
+ service.ensure.state = 'failed';
260
+ service.ensure.lastError = error.message;
261
+ console.warn('[MachineWall] binding failed:', error.message);
262
+ });
263
+ }
264
+
265
+ function stopWallService() {
266
+ if (!service.started) return;
267
+ service.started = false;
268
+ service.stopRequested = true;
269
+ service.unsubscribe?.();
270
+ service.unsubscribe = null;
271
+ if (service.publishTimer) clearTimeout(service.publishTimer);
272
+ service.publishTimer = null;
273
+ if (service.ensureTimer) clearTimeout(service.ensureTimer);
274
+ service.ensureTimer = null;
275
+ }
276
+
277
+ function wallStats() {
278
+ const replica = wallReplica();
279
+ return {
280
+ contract: WALL_CONTRACT,
281
+ resourceId: wallResourceId(),
282
+ path: WALL_FILE,
283
+ replica: replica && {
284
+ appliedVersion: replica.appliedVersion,
285
+ snapshotVersion: replica.snapshotVersion,
286
+ authorityEpoch: replica.authorityEpoch,
287
+ syncStatus: replica.syncStatus,
288
+ lastError: replica.lastError,
289
+ updatedAt: replica.updatedAt,
290
+ },
291
+ ensure: { ...service.ensure },
292
+ publishes: service.counters.publishes,
293
+ publishSkips: service.counters.publishSkips,
294
+ publishFailures: service.counters.publishFailures,
295
+ lastPublishedAt: service.lastPublishedAt,
296
+ machines: readWall().length,
297
+ };
298
+ }
299
+
300
+ module.exports = {
301
+ WALL_CONTRACT,
302
+ publishWall,
303
+ readWall,
304
+ startWallService,
305
+ stopWallService,
306
+ wallResourceId,
307
+ wallStats,
308
+ };