amalgm 0.1.115 → 0.1.116

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.115",
3
+ "version": "0.1.116",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -6,6 +6,44 @@ const { openLocalDb } = require('./db');
6
6
  const emitter = new EventEmitter();
7
7
  emitter.setMaxListeners(200);
8
8
 
9
+ const DEFAULT_EVENT_LOG_RETENTION_HOURS = 24;
10
+ const DEFAULT_EVENT_LOG_PRUNE_BATCH_SIZE = 1000;
11
+ const DEFAULT_EVENT_LOG_PRUNE_INTERVAL_MS = 60_000;
12
+
13
+ let lastPruneAttemptAt = 0;
14
+
15
+ function positiveNumber(value, fallback) {
16
+ const parsed = Number(value);
17
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
18
+ }
19
+
20
+ function retentionHours(options = {}) {
21
+ return positiveNumber(
22
+ options.retentionHours ?? process.env.AMALGM_EVENT_LOG_RETENTION_HOURS,
23
+ DEFAULT_EVENT_LOG_RETENTION_HOURS,
24
+ );
25
+ }
26
+
27
+ function pruneBatchSize(options = {}) {
28
+ return Math.max(1, Math.floor(positiveNumber(
29
+ options.batchSize ?? process.env.AMALGM_EVENT_LOG_PRUNE_BATCH_SIZE,
30
+ DEFAULT_EVENT_LOG_PRUNE_BATCH_SIZE,
31
+ )));
32
+ }
33
+
34
+ function pruneIntervalMs(options = {}) {
35
+ return positiveNumber(
36
+ options.intervalMs ?? process.env.AMALGM_EVENT_LOG_PRUNE_INTERVAL_MS,
37
+ DEFAULT_EVENT_LOG_PRUNE_INTERVAL_MS,
38
+ );
39
+ }
40
+
41
+ function retentionCutoffIso(options = {}) {
42
+ const cutoff = options.cutoffIso || options.cutoff;
43
+ if (typeof cutoff === 'string' && cutoff) return cutoff;
44
+ return new Date(Date.now() - retentionHours(options) * 60 * 60 * 1000).toISOString();
45
+ }
46
+
9
47
  function safeJsonStringify(value) {
10
48
  if (value === undefined) return null;
11
49
  return JSON.stringify(value);
@@ -73,6 +111,7 @@ function insertStateEvent(database, input) {
73
111
 
74
112
  function publishStateEvent(event) {
75
113
  if (event) emitter.emit('event', event);
114
+ maybePruneEventLog();
76
115
  }
77
116
 
78
117
  function currentSeq() {
@@ -98,6 +137,75 @@ function listEventsAfter(afterSeq, options = {}) {
98
137
  return rows.map(normalizeEventRow).filter(Boolean);
99
138
  }
100
139
 
140
+ function eventLogBounds(database = openLocalDb()) {
141
+ const row = database
142
+ .prepare('SELECT MIN(seq) AS min_seq, MAX(seq) AS max_seq FROM event_log')
143
+ .get();
144
+ const minSeq = row?.min_seq == null ? 0 : Number(row.min_seq);
145
+ const maxSeq = row?.max_seq == null ? 0 : Number(row.max_seq);
146
+ return { minSeq, maxSeq };
147
+ }
148
+
149
+ function replayGapAfter(afterSeq, database = openLocalDb()) {
150
+ const after = Number.isFinite(Number(afterSeq)) ? Number(afterSeq) : 0;
151
+ if (after <= 0) return null;
152
+ const { minSeq, maxSeq } = eventLogBounds(database);
153
+ if (minSeq > 0 && minSeq > after + 1) {
154
+ return {
155
+ code: 'state_event_gap',
156
+ message: 'State event history has been compacted; fetch a fresh snapshot.',
157
+ after,
158
+ minSeq,
159
+ maxSeq,
160
+ };
161
+ }
162
+ return null;
163
+ }
164
+
165
+ function pruneEventLog(options = {}) {
166
+ const hours = retentionHours(options);
167
+ if (!Number.isFinite(hours) || hours <= 0) {
168
+ return { deleted: 0, skipped: true, reason: 'disabled' };
169
+ }
170
+
171
+ const database = options.database || openLocalDb();
172
+ const cutoffIso = retentionCutoffIso({ ...options, retentionHours: hours });
173
+ const batchSize = pruneBatchSize(options);
174
+ const result = database.prepare(`
175
+ DELETE FROM event_log
176
+ WHERE seq IN (
177
+ SELECT seq
178
+ FROM event_log
179
+ WHERE ts < ?
180
+ ORDER BY seq ASC
181
+ LIMIT ?
182
+ )
183
+ `).run(cutoffIso, batchSize);
184
+
185
+ return {
186
+ deleted: result.changes || 0,
187
+ cutoffIso,
188
+ retentionHours: hours,
189
+ batchSize,
190
+ };
191
+ }
192
+
193
+ function maybePruneEventLog(options = {}) {
194
+ const now = Date.now();
195
+ const intervalMs = pruneIntervalMs(options);
196
+ if (now - lastPruneAttemptAt < intervalMs) {
197
+ return { deleted: 0, skipped: true, reason: 'throttled' };
198
+ }
199
+ lastPruneAttemptAt = now;
200
+ try {
201
+ return pruneEventLog(options);
202
+ } catch (error) {
203
+ const message = error instanceof Error ? error.message : String(error);
204
+ console.warn('[LocalLive] Event log retention failed:', message);
205
+ return { deleted: 0, skipped: true, reason: 'error', error: message };
206
+ }
207
+ }
208
+
101
209
  function subscribeStateEvents(callback) {
102
210
  emitter.on('event', callback);
103
211
  return () => emitter.off('event', callback);
@@ -106,8 +214,12 @@ function subscribeStateEvents(callback) {
106
214
  module.exports = {
107
215
  appendStateEvent,
108
216
  currentSeq,
217
+ eventLogBounds,
109
218
  insertStateEvent,
110
219
  listEventsAfter,
220
+ maybePruneEventLog,
111
221
  publishStateEvent,
222
+ pruneEventLog,
223
+ replayGapAfter,
112
224
  subscribeStateEvents,
113
225
  };
@@ -1,7 +1,11 @@
1
1
  'use strict';
2
2
 
3
3
  const { buildSnapshot } = require('./snapshot');
4
- const { listEventsAfter, subscribeStateEvents } = require('./events');
4
+ const {
5
+ listEventsAfter,
6
+ replayGapAfter,
7
+ subscribeStateEvents,
8
+ } = require('./events');
5
9
 
6
10
  function parsePositiveInt(value, fallback) {
7
11
  const parsed = Number(value);
@@ -14,6 +18,11 @@ function sendSseEvent(res, event) {
14
18
  res.write(`data: ${JSON.stringify(event)}\n\n`);
15
19
  }
16
20
 
21
+ function sendSseControl(res, type, payload) {
22
+ res.write(`event: ${type}\n`);
23
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
24
+ }
25
+
17
26
  async function handleSnapshot(query, sendJson) {
18
27
  sendJson(200, buildSnapshot(query.resources));
19
28
  }
@@ -21,6 +30,11 @@ async function handleSnapshot(query, sendJson) {
21
30
  async function handleEvents(query, sendJson) {
22
31
  const after = parsePositiveInt(query.after, 0);
23
32
  const limit = parsePositiveInt(query.limit, 1000);
33
+ const gap = replayGapAfter(after);
34
+ if (gap) {
35
+ sendJson(409, { ...gap, reset: true });
36
+ return;
37
+ }
24
38
  const events = listEventsAfter(after, { limit });
25
39
  sendJson(200, {
26
40
  events,
@@ -39,6 +53,13 @@ function handleStream(req, res, query) {
39
53
  });
40
54
  res.write(': local-live-store connected\n\n');
41
55
 
56
+ const gap = replayGapAfter(after);
57
+ if (gap) {
58
+ sendSseControl(res, 'reset', { ...gap, reset: true });
59
+ res.end();
60
+ return;
61
+ }
62
+
42
63
  const unsubscribe = subscribeStateEvents((event) => {
43
64
  if (event.seq > after) sendSseEvent(res, event);
44
65
  });
@@ -0,0 +1,68 @@
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-state-events-test-'));
10
+ process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
11
+ process.env.AMALGM_EVENT_LOG_PRUNE_INTERVAL_MS = '0';
12
+
13
+ const { closeLocalDb, openLocalDb } = require('../state/db');
14
+ const {
15
+ eventLogBounds,
16
+ insertStateEvent,
17
+ listEventsAfter,
18
+ pruneEventLog,
19
+ replayGapAfter,
20
+ } = require('../state/events');
21
+
22
+ test.after(() => {
23
+ closeLocalDb();
24
+ fs.rmSync(tempRoot, { recursive: true, force: true });
25
+ });
26
+
27
+ test('event log retention removes old batches and reports replay gaps', () => {
28
+ const db = openLocalDb();
29
+ const oldCutoff = '2026-06-15T00:00:00.000Z';
30
+ const freshCutoff = '2026-06-16T00:00:00.000Z';
31
+
32
+ insertStateEvent(db, {
33
+ resource: 'apps',
34
+ op: 'replace',
35
+ value: [{ id: 'old-1' }],
36
+ source: 'test',
37
+ });
38
+ insertStateEvent(db, {
39
+ resource: 'apps',
40
+ op: 'replace',
41
+ value: [{ id: 'old-2' }],
42
+ source: 'test',
43
+ });
44
+ insertStateEvent(db, {
45
+ resource: 'apps',
46
+ op: 'replace',
47
+ value: [{ id: 'fresh' }],
48
+ source: 'test',
49
+ });
50
+
51
+ db.prepare('UPDATE event_log SET ts = ? WHERE seq IN (1, 2)').run(oldCutoff);
52
+ db.prepare('UPDATE event_log SET ts = ? WHERE seq = 3').run(freshCutoff);
53
+
54
+ const result = pruneEventLog({
55
+ database: db,
56
+ cutoffIso: '2026-06-15T12:00:00.000Z',
57
+ batchSize: 2,
58
+ });
59
+
60
+ assert.equal(result.deleted, 2);
61
+ assert.deepEqual(eventLogBounds(db), { minSeq: 3, maxSeq: 3 });
62
+ assert.equal(replayGapAfter(1, db)?.code, 'state_event_gap');
63
+ assert.equal(replayGapAfter(2, db), null);
64
+ assert.deepEqual(
65
+ listEventsAfter(2).map((event) => event.value),
66
+ [[{ id: 'fresh' }]],
67
+ );
68
+ });
@@ -159,6 +159,7 @@ const MCP_PREFIXES = [
159
159
  '/artifacts',
160
160
  '/agents',
161
161
  '/agent-config',
162
+ '/user-preferences',
162
163
  '/browser',
163
164
  ];
164
165