@unrdf/kgc-runtime 26.4.2
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/IMPLEMENTATION_SUMMARY.json +150 -0
- package/PLUGIN_SYSTEM_SUMMARY.json +149 -0
- package/README.md +98 -0
- package/TRANSACTION_IMPLEMENTATION.json +119 -0
- package/capability-map.md +93 -0
- package/docs/api-stability.md +269 -0
- package/docs/extensions/plugin-development.md +382 -0
- package/package.json +40 -0
- package/plugins/registry.json +35 -0
- package/src/admission-gate.mjs +414 -0
- package/src/api-version.mjs +373 -0
- package/src/atomic-admission.mjs +310 -0
- package/src/bounds.mjs +289 -0
- package/src/bulkhead-manager.mjs +280 -0
- package/src/capsule.mjs +524 -0
- package/src/crdt.mjs +361 -0
- package/src/enhanced-bounds.mjs +614 -0
- package/src/executor.mjs +73 -0
- package/src/freeze-restore.mjs +521 -0
- package/src/index.mjs +62 -0
- package/src/materialized-views.mjs +371 -0
- package/src/merge.mjs +472 -0
- package/src/plugin-isolation.mjs +392 -0
- package/src/plugin-manager.mjs +441 -0
- package/src/projections-api.mjs +336 -0
- package/src/projections-cli.mjs +238 -0
- package/src/projections-docs.mjs +300 -0
- package/src/projections-ide.mjs +278 -0
- package/src/receipt.mjs +340 -0
- package/src/rollback.mjs +258 -0
- package/src/saga-orchestrator.mjs +355 -0
- package/src/schemas.mjs +1330 -0
- package/src/storage-optimization.mjs +359 -0
- package/src/tool-registry.mjs +272 -0
- package/src/transaction.mjs +466 -0
- package/src/validators.mjs +485 -0
- package/src/work-item.mjs +449 -0
- package/templates/plugin-template/README.md +58 -0
- package/templates/plugin-template/index.mjs +162 -0
- package/templates/plugin-template/plugin.json +19 -0
- package/test/admission-gate.test.mjs +583 -0
- package/test/api-version.test.mjs +74 -0
- package/test/atomic-admission.test.mjs +155 -0
- package/test/bounds.test.mjs +341 -0
- package/test/bulkhead-manager.test.mjs +236 -0
- package/test/capsule.test.mjs +625 -0
- package/test/crdt.test.mjs +215 -0
- package/test/enhanced-bounds.test.mjs +487 -0
- package/test/freeze-restore.test.mjs +472 -0
- package/test/materialized-views.test.mjs +243 -0
- package/test/merge.test.mjs +665 -0
- package/test/plugin-isolation.test.mjs +109 -0
- package/test/plugin-manager.test.mjs +208 -0
- package/test/projections-api.test.mjs +293 -0
- package/test/projections-cli.test.mjs +204 -0
- package/test/projections-docs.test.mjs +173 -0
- package/test/projections-ide.test.mjs +230 -0
- package/test/receipt.test.mjs +295 -0
- package/test/rollback.test.mjs +132 -0
- package/test/saga-orchestrator.test.mjs +279 -0
- package/test/schemas.test.mjs +716 -0
- package/test/storage-optimization.test.mjs +503 -0
- package/test/tool-registry.test.mjs +341 -0
- package/test/transaction.test.mjs +189 -0
- package/test/validators.test.mjs +463 -0
- package/test/work-item.test.mjs +548 -0
- package/test/work-item.test.mjs.bak +548 -0
- package/var/kgc/test-atomic-log.json +519 -0
- package/var/kgc/test-cascading-log.json +145 -0
- package/vitest.config.mjs +18 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Tests for storage optimizations
|
|
3
|
+
* Tests compression, GC, incremental snapshots, deduplication, indexing, and archival
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
7
|
+
import { promises as fs } from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
import { dirname } from 'path';
|
|
11
|
+
import {
|
|
12
|
+
compressFile,
|
|
13
|
+
decompressFile,
|
|
14
|
+
readCompressed,
|
|
15
|
+
garbageCollectSnapshots,
|
|
16
|
+
archiveReceipts,
|
|
17
|
+
computeDelta,
|
|
18
|
+
applyDelta,
|
|
19
|
+
} from '../src/storage-optimization.mjs';
|
|
20
|
+
import {
|
|
21
|
+
freezeUniverse,
|
|
22
|
+
getSnapshotList,
|
|
23
|
+
reconstructTo,
|
|
24
|
+
} from '../src/freeze-restore.mjs';
|
|
25
|
+
import {
|
|
26
|
+
RunCapsule,
|
|
27
|
+
storeCapsule,
|
|
28
|
+
listCapsules,
|
|
29
|
+
findCapsuleByHash,
|
|
30
|
+
} from '../src/capsule.mjs';
|
|
31
|
+
import { writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
|
|
32
|
+
|
|
33
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
34
|
+
const __dirname = dirname(__filename);
|
|
35
|
+
|
|
36
|
+
const TEST_DIR = path.join(__dirname, '../var/kgc/test-optimization');
|
|
37
|
+
const TEST_SNAPSHOT_DIR = path.join(TEST_DIR, 'snapshots');
|
|
38
|
+
const TEST_CAPSULE_DIR = path.join(TEST_DIR, 'capsules');
|
|
39
|
+
const TEST_RECEIPT_DIR = path.join(TEST_DIR, 'receipts');
|
|
40
|
+
const TEST_ARCHIVE_DIR = path.join(TEST_DIR, 'receipts-archive');
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Clean test directories
|
|
44
|
+
*/
|
|
45
|
+
async function cleanTestDirs() {
|
|
46
|
+
try {
|
|
47
|
+
await fs.rm(TEST_DIR, { recursive: true, force: true });
|
|
48
|
+
} catch {
|
|
49
|
+
// Ignore
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('Storage Optimizations', () => {
|
|
54
|
+
beforeEach(async () => {
|
|
55
|
+
await cleanTestDirs();
|
|
56
|
+
await fs.mkdir(TEST_DIR, { recursive: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(async () => {
|
|
60
|
+
await cleanTestDirs();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('Snapshot Compression (3 tests)', () => {
|
|
64
|
+
it('should compress snapshot with gzip level 6', async () => {
|
|
65
|
+
const universe = {
|
|
66
|
+
entities: Array.from({ length: 100 }, (_, i) => ({
|
|
67
|
+
id: i,
|
|
68
|
+
name: `entity-${i}`,
|
|
69
|
+
data: 'x'.repeat(100),
|
|
70
|
+
})),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const manifest = await freezeUniverse(universe, {
|
|
74
|
+
snapshotDir: TEST_SNAPSHOT_DIR,
|
|
75
|
+
compress: true,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(manifest.compressed).toBe(true);
|
|
79
|
+
expect(manifest.original_size).toBeGreaterThan(0);
|
|
80
|
+
expect(manifest.compressed_size).toBeGreaterThan(0);
|
|
81
|
+
expect(manifest.compressed_size).toBeLessThan(manifest.original_size);
|
|
82
|
+
|
|
83
|
+
// Verify compression ratio (should be 60-80% for typical JSON)
|
|
84
|
+
const ratio = manifest.compressed_size / manifest.original_size;
|
|
85
|
+
expect(ratio).toBeLessThan(0.8);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should store compression metadata in manifest', async () => {
|
|
89
|
+
const universe = { test: 'data', array: [1, 2, 3, 4, 5] };
|
|
90
|
+
|
|
91
|
+
const manifest = await freezeUniverse(universe, {
|
|
92
|
+
snapshotDir: TEST_SNAPSHOT_DIR,
|
|
93
|
+
compress: true,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(manifest).toHaveProperty('compressed');
|
|
97
|
+
expect(manifest).toHaveProperty('original_size');
|
|
98
|
+
expect(manifest).toHaveProperty('compressed_size');
|
|
99
|
+
expect(manifest.compressed).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should decompress on load and verify integrity', async () => {
|
|
103
|
+
const universe = {
|
|
104
|
+
value: 42,
|
|
105
|
+
text: 'Hello, World!',
|
|
106
|
+
nested: { a: 1, b: 2 },
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const manifest = await freezeUniverse(universe, {
|
|
110
|
+
snapshotDir: TEST_SNAPSHOT_DIR,
|
|
111
|
+
compress: true,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Reconstruct should automatically decompress
|
|
115
|
+
const reconstructed = await reconstructTo(BigInt(manifest.timestamp_ns), {
|
|
116
|
+
snapshotDir: TEST_SNAPSHOT_DIR,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(reconstructed).toEqual(universe);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('Garbage Collection (3 tests)', () => {
|
|
124
|
+
it('should delete snapshots exceeding maxSnapshots limit', async () => {
|
|
125
|
+
// Create 10 snapshots
|
|
126
|
+
for (let i = 0; i < 10; i++) {
|
|
127
|
+
await freezeUniverse({ version: i }, {
|
|
128
|
+
snapshotDir: TEST_SNAPSHOT_DIR,
|
|
129
|
+
compress: false,
|
|
130
|
+
});
|
|
131
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const beforeGC = await getSnapshotList({ snapshotDir: TEST_SNAPSHOT_DIR });
|
|
135
|
+
expect(beforeGC).toHaveLength(10);
|
|
136
|
+
|
|
137
|
+
// Run GC with maxSnapshots=5
|
|
138
|
+
const gcResult = await garbageCollectSnapshots(TEST_SNAPSHOT_DIR, {
|
|
139
|
+
maxSnapshots: 5,
|
|
140
|
+
ttlDays: 999, // Don't delete by age
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(gcResult.deleted).toBe(5);
|
|
144
|
+
expect(gcResult.kept).toBe(5);
|
|
145
|
+
|
|
146
|
+
const afterGC = await getSnapshotList({ snapshotDir: TEST_SNAPSHOT_DIR });
|
|
147
|
+
expect(afterGC).toHaveLength(5);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should delete snapshots older than TTL', async () => {
|
|
151
|
+
// Create snapshot with old timestamp
|
|
152
|
+
const oldSnapshot = await freezeUniverse({ version: 'old' }, {
|
|
153
|
+
snapshotDir: TEST_SNAPSHOT_DIR,
|
|
154
|
+
compress: false,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Modify created_at to be 31 days old
|
|
158
|
+
const manifestPath = path.join(
|
|
159
|
+
TEST_SNAPSHOT_DIR,
|
|
160
|
+
oldSnapshot.timestamp_ns,
|
|
161
|
+
'manifest.json'
|
|
162
|
+
);
|
|
163
|
+
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
|
|
164
|
+
const oldDate = new Date();
|
|
165
|
+
oldDate.setDate(oldDate.getDate() - 31);
|
|
166
|
+
manifest.created_at = oldDate.toISOString();
|
|
167
|
+
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
|
168
|
+
|
|
169
|
+
// Create recent snapshot
|
|
170
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
171
|
+
await freezeUniverse({ version: 'new' }, {
|
|
172
|
+
snapshotDir: TEST_SNAPSHOT_DIR,
|
|
173
|
+
compress: false,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Run GC with ttlDays=30
|
|
177
|
+
const gcResult = await garbageCollectSnapshots(TEST_SNAPSHOT_DIR, {
|
|
178
|
+
maxSnapshots: 999,
|
|
179
|
+
ttlDays: 30,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(gcResult.deleted).toBe(1);
|
|
183
|
+
expect(gcResult.kept).toBe(1);
|
|
184
|
+
|
|
185
|
+
const afterGC = await getSnapshotList({ snapshotDir: TEST_SNAPSHOT_DIR });
|
|
186
|
+
expect(afterGC).toHaveLength(1);
|
|
187
|
+
expect(afterGC[0].manifest.timestamp_ns).not.toBe(oldSnapshot.timestamp_ns);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should report bytes freed during GC', async () => {
|
|
191
|
+
// Create snapshots
|
|
192
|
+
for (let i = 0; i < 5; i++) {
|
|
193
|
+
await freezeUniverse({ version: i, data: 'x'.repeat(1000) }, {
|
|
194
|
+
snapshotDir: TEST_SNAPSHOT_DIR,
|
|
195
|
+
compress: false,
|
|
196
|
+
});
|
|
197
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const gcResult = await garbageCollectSnapshots(TEST_SNAPSHOT_DIR, {
|
|
201
|
+
maxSnapshots: 2,
|
|
202
|
+
ttlDays: 999,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect(gcResult.deleted).toBe(3);
|
|
206
|
+
expect(gcResult.bytes_freed).toBeGreaterThan(0);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('Incremental Snapshots (2 tests)', () => {
|
|
211
|
+
it('should create delta snapshot when incremental option is true', async () => {
|
|
212
|
+
// Create base snapshot
|
|
213
|
+
const baseState = {
|
|
214
|
+
entities: [1, 2, 3],
|
|
215
|
+
metadata: { version: 1 },
|
|
216
|
+
};
|
|
217
|
+
await freezeUniverse(baseState, {
|
|
218
|
+
snapshotDir: TEST_SNAPSHOT_DIR,
|
|
219
|
+
compress: false,
|
|
220
|
+
incremental: false,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
224
|
+
|
|
225
|
+
// Create incremental snapshot with small change
|
|
226
|
+
const newState = {
|
|
227
|
+
entities: [1, 2, 3, 4], // Add one entity
|
|
228
|
+
metadata: { version: 2 }, // Change version
|
|
229
|
+
};
|
|
230
|
+
const manifest = await freezeUniverse(newState, {
|
|
231
|
+
snapshotDir: TEST_SNAPSHOT_DIR,
|
|
232
|
+
compress: false,
|
|
233
|
+
incremental: true,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Should be incremental if delta is smaller
|
|
237
|
+
if (manifest.incremental) {
|
|
238
|
+
expect(manifest.incremental).toBe(true);
|
|
239
|
+
expect(manifest.base_snapshot).toBeDefined();
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('should reconstruct state from incremental snapshot', async () => {
|
|
244
|
+
// Create base
|
|
245
|
+
const baseState = { value: 100, items: ['a', 'b'] };
|
|
246
|
+
await freezeUniverse(baseState, {
|
|
247
|
+
snapshotDir: TEST_SNAPSHOT_DIR,
|
|
248
|
+
compress: false,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
252
|
+
|
|
253
|
+
// Create incremental
|
|
254
|
+
const newState = { value: 200, items: ['a', 'b', 'c'] };
|
|
255
|
+
const manifest = await freezeUniverse(newState, {
|
|
256
|
+
snapshotDir: TEST_SNAPSHOT_DIR,
|
|
257
|
+
compress: false,
|
|
258
|
+
incremental: true,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Reconstruct
|
|
262
|
+
const reconstructed = await reconstructTo(BigInt(manifest.timestamp_ns), {
|
|
263
|
+
snapshotDir: TEST_SNAPSHOT_DIR,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
expect(reconstructed).toEqual(newState);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe('Capsule Deduplication (2 tests)', () => {
|
|
271
|
+
it('should detect duplicate capsules and reuse existing file', async () => {
|
|
272
|
+
const data = {
|
|
273
|
+
inputs: { test: 'duplicate' },
|
|
274
|
+
tool_trace: [],
|
|
275
|
+
edits: [],
|
|
276
|
+
artifacts: [],
|
|
277
|
+
bounds: { start: 1000, end: 2000 },
|
|
278
|
+
o_hash_before: 'before',
|
|
279
|
+
o_hash_after: 'after',
|
|
280
|
+
receipts: [],
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const capsule1 = new RunCapsule(data);
|
|
284
|
+
const result1 = await storeCapsule(capsule1, TEST_CAPSULE_DIR);
|
|
285
|
+
expect(result1.deduplicated).toBe(false);
|
|
286
|
+
|
|
287
|
+
// Store same capsule again
|
|
288
|
+
const capsule2 = new RunCapsule(data);
|
|
289
|
+
const result2 = await storeCapsule(capsule2, TEST_CAPSULE_DIR);
|
|
290
|
+
expect(result2.deduplicated).toBe(true);
|
|
291
|
+
|
|
292
|
+
// Should point to same path
|
|
293
|
+
expect(result1.path).toBe(result2.path);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('should achieve space savings through deduplication', async () => {
|
|
297
|
+
const baseData = {
|
|
298
|
+
inputs: { action: 'common' },
|
|
299
|
+
tool_trace: [],
|
|
300
|
+
edits: [],
|
|
301
|
+
artifacts: [],
|
|
302
|
+
bounds: { start: 1000, end: 2000 },
|
|
303
|
+
o_hash_before: 'hash1',
|
|
304
|
+
o_hash_after: 'hash2',
|
|
305
|
+
receipts: [],
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// Store same capsule 5 times
|
|
309
|
+
const results = [];
|
|
310
|
+
for (let i = 0; i < 5; i++) {
|
|
311
|
+
const capsule = new RunCapsule(baseData);
|
|
312
|
+
const result = await storeCapsule(capsule, TEST_CAPSULE_DIR);
|
|
313
|
+
results.push(result);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// First should not be deduplicated, rest should be
|
|
317
|
+
expect(results[0].deduplicated).toBe(false);
|
|
318
|
+
expect(results[1].deduplicated).toBe(true);
|
|
319
|
+
expect(results[2].deduplicated).toBe(true);
|
|
320
|
+
expect(results[3].deduplicated).toBe(true);
|
|
321
|
+
expect(results[4].deduplicated).toBe(true);
|
|
322
|
+
|
|
323
|
+
// All should point to same file
|
|
324
|
+
const paths = new Set(results.map((r) => r.path));
|
|
325
|
+
expect(paths.size).toBe(1);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe('Indexed Queries (2 tests)', () => {
|
|
330
|
+
it('should use hash index for O(1) capsule lookup', async () => {
|
|
331
|
+
// Create and store capsules
|
|
332
|
+
const capsules = [];
|
|
333
|
+
for (let i = 0; i < 10; i++) {
|
|
334
|
+
const capsule = new RunCapsule({
|
|
335
|
+
inputs: { id: i },
|
|
336
|
+
tool_trace: [],
|
|
337
|
+
edits: [],
|
|
338
|
+
artifacts: [],
|
|
339
|
+
bounds: { start: i * 1000, end: (i + 1) * 1000 },
|
|
340
|
+
o_hash_before: `before${i}`,
|
|
341
|
+
o_hash_after: `after${i}`,
|
|
342
|
+
receipts: [],
|
|
343
|
+
});
|
|
344
|
+
await storeCapsule(capsule, TEST_CAPSULE_DIR);
|
|
345
|
+
capsules.push(capsule);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Verify index exists
|
|
349
|
+
const indexPath = path.join(TEST_CAPSULE_DIR, 'index.json');
|
|
350
|
+
expect(existsSync(indexPath)).toBe(true);
|
|
351
|
+
|
|
352
|
+
// Find capsule by hash using index
|
|
353
|
+
const targetCapsule = capsules[5];
|
|
354
|
+
const found = await findCapsuleByHash(
|
|
355
|
+
targetCapsule.capsule_hash,
|
|
356
|
+
TEST_CAPSULE_DIR
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
expect(found).not.toBeNull();
|
|
360
|
+
expect(found.capsule_hash).toBe(targetCapsule.capsule_hash);
|
|
361
|
+
expect(found.inputs.id).toBe(5);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('should fall back to linear search if index is missing', async () => {
|
|
365
|
+
const capsule = new RunCapsule({
|
|
366
|
+
inputs: { test: 'fallback' },
|
|
367
|
+
tool_trace: [],
|
|
368
|
+
edits: [],
|
|
369
|
+
artifacts: [],
|
|
370
|
+
bounds: { start: 1000, end: 2000 },
|
|
371
|
+
o_hash_before: 'before',
|
|
372
|
+
o_hash_after: 'after',
|
|
373
|
+
receipts: [],
|
|
374
|
+
});
|
|
375
|
+
await storeCapsule(capsule, TEST_CAPSULE_DIR);
|
|
376
|
+
|
|
377
|
+
// Remove index
|
|
378
|
+
const indexPath = path.join(TEST_CAPSULE_DIR, 'index.json');
|
|
379
|
+
await fs.unlink(indexPath);
|
|
380
|
+
|
|
381
|
+
// Should still find capsule via linear search
|
|
382
|
+
const found = await findCapsuleByHash(capsule.capsule_hash, TEST_CAPSULE_DIR);
|
|
383
|
+
expect(found).not.toBeNull();
|
|
384
|
+
expect(found.capsule_hash).toBe(capsule.capsule_hash);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe('Receipt Archival (2 tests)', () => {
|
|
389
|
+
it('should archive old receipts based on count threshold', async () => {
|
|
390
|
+
// Create 15 receipt files
|
|
391
|
+
await fs.mkdir(TEST_RECEIPT_DIR, { recursive: true });
|
|
392
|
+
|
|
393
|
+
for (let i = 0; i < 15; i++) {
|
|
394
|
+
const receipt = {
|
|
395
|
+
id: `receipt-${i}`,
|
|
396
|
+
timestamp: new Date(Date.now() - i * 60000).toISOString(),
|
|
397
|
+
operation: 'test',
|
|
398
|
+
inputs: {},
|
|
399
|
+
outputs: {},
|
|
400
|
+
hash: `hash${i}`,
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
writeFileSync(
|
|
404
|
+
path.join(TEST_RECEIPT_DIR, `receipt-${i}.json`),
|
|
405
|
+
JSON.stringify(receipt)
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Archive with keepRecent=10
|
|
410
|
+
const result = await archiveReceipts(
|
|
411
|
+
TEST_RECEIPT_DIR,
|
|
412
|
+
TEST_ARCHIVE_DIR,
|
|
413
|
+
{ keepRecent: 10, keepDays: 999 }
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
expect(result.archived).toBe(5);
|
|
417
|
+
expect(result.kept).toBe(10);
|
|
418
|
+
|
|
419
|
+
// Verify files moved
|
|
420
|
+
const mainFiles = await fs.readdir(TEST_RECEIPT_DIR);
|
|
421
|
+
const archivedFiles = await fs.readdir(TEST_ARCHIVE_DIR);
|
|
422
|
+
|
|
423
|
+
expect(mainFiles.filter((f) => f.startsWith('receipt-'))).toHaveLength(10);
|
|
424
|
+
expect(archivedFiles).toHaveLength(5);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('should archive receipts older than TTL', async () => {
|
|
428
|
+
await fs.mkdir(TEST_RECEIPT_DIR, { recursive: true });
|
|
429
|
+
|
|
430
|
+
// Create old receipt
|
|
431
|
+
const oldReceipt = {
|
|
432
|
+
id: 'receipt-old',
|
|
433
|
+
timestamp: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(), // 8 days old
|
|
434
|
+
operation: 'test',
|
|
435
|
+
inputs: {},
|
|
436
|
+
outputs: {},
|
|
437
|
+
hash: 'hash-old',
|
|
438
|
+
};
|
|
439
|
+
writeFileSync(
|
|
440
|
+
path.join(TEST_RECEIPT_DIR, 'receipt-old.json'),
|
|
441
|
+
JSON.stringify(oldReceipt)
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
// Create recent receipt
|
|
445
|
+
const recentReceipt = {
|
|
446
|
+
id: 'receipt-recent',
|
|
447
|
+
timestamp: new Date().toISOString(),
|
|
448
|
+
operation: 'test',
|
|
449
|
+
inputs: {},
|
|
450
|
+
outputs: {},
|
|
451
|
+
hash: 'hash-recent',
|
|
452
|
+
};
|
|
453
|
+
writeFileSync(
|
|
454
|
+
path.join(TEST_RECEIPT_DIR, 'receipt-recent.json'),
|
|
455
|
+
JSON.stringify(recentReceipt)
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
// Archive with keepDays=7
|
|
459
|
+
const result = await archiveReceipts(
|
|
460
|
+
TEST_RECEIPT_DIR,
|
|
461
|
+
TEST_ARCHIVE_DIR,
|
|
462
|
+
{ keepRecent: 999, keepDays: 7 }
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
expect(result.archived).toBe(1);
|
|
466
|
+
expect(result.kept).toBe(1);
|
|
467
|
+
|
|
468
|
+
// Verify old receipt archived
|
|
469
|
+
const archivedFiles = await fs.readdir(TEST_ARCHIVE_DIR);
|
|
470
|
+
expect(archivedFiles).toContain('receipt-old.json');
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
describe('Delta Computation and Application', () => {
|
|
475
|
+
it('should compute delta between states', () => {
|
|
476
|
+
const previous = { a: 1, b: 2, c: 3 };
|
|
477
|
+
const current = { a: 1, b: 20, d: 4 };
|
|
478
|
+
|
|
479
|
+
const delta = computeDelta(previous, current);
|
|
480
|
+
|
|
481
|
+
expect(delta.added).toEqual({ d: 4 });
|
|
482
|
+
expect(delta.modified).toEqual({ b: 20 });
|
|
483
|
+
expect(delta.deleted).toEqual({ c: true });
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('should apply delta to reconstruct state', () => {
|
|
487
|
+
const base = { x: 10, y: 20, z: 30 };
|
|
488
|
+
const delta = {
|
|
489
|
+
added: { w: 40 },
|
|
490
|
+
modified: { y: 200 },
|
|
491
|
+
deleted: { z: true },
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const result = applyDelta(base, delta);
|
|
495
|
+
|
|
496
|
+
expect(result).toEqual({
|
|
497
|
+
x: 10,
|
|
498
|
+
y: 200,
|
|
499
|
+
w: 40,
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
});
|