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
|
@@ -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 {
|
|
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
|
+
});
|