@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.
Files changed (70) hide show
  1. package/IMPLEMENTATION_SUMMARY.json +150 -0
  2. package/PLUGIN_SYSTEM_SUMMARY.json +149 -0
  3. package/README.md +98 -0
  4. package/TRANSACTION_IMPLEMENTATION.json +119 -0
  5. package/capability-map.md +93 -0
  6. package/docs/api-stability.md +269 -0
  7. package/docs/extensions/plugin-development.md +382 -0
  8. package/package.json +40 -0
  9. package/plugins/registry.json +35 -0
  10. package/src/admission-gate.mjs +414 -0
  11. package/src/api-version.mjs +373 -0
  12. package/src/atomic-admission.mjs +310 -0
  13. package/src/bounds.mjs +289 -0
  14. package/src/bulkhead-manager.mjs +280 -0
  15. package/src/capsule.mjs +524 -0
  16. package/src/crdt.mjs +361 -0
  17. package/src/enhanced-bounds.mjs +614 -0
  18. package/src/executor.mjs +73 -0
  19. package/src/freeze-restore.mjs +521 -0
  20. package/src/index.mjs +62 -0
  21. package/src/materialized-views.mjs +371 -0
  22. package/src/merge.mjs +472 -0
  23. package/src/plugin-isolation.mjs +392 -0
  24. package/src/plugin-manager.mjs +441 -0
  25. package/src/projections-api.mjs +336 -0
  26. package/src/projections-cli.mjs +238 -0
  27. package/src/projections-docs.mjs +300 -0
  28. package/src/projections-ide.mjs +278 -0
  29. package/src/receipt.mjs +340 -0
  30. package/src/rollback.mjs +258 -0
  31. package/src/saga-orchestrator.mjs +355 -0
  32. package/src/schemas.mjs +1330 -0
  33. package/src/storage-optimization.mjs +359 -0
  34. package/src/tool-registry.mjs +272 -0
  35. package/src/transaction.mjs +466 -0
  36. package/src/validators.mjs +485 -0
  37. package/src/work-item.mjs +449 -0
  38. package/templates/plugin-template/README.md +58 -0
  39. package/templates/plugin-template/index.mjs +162 -0
  40. package/templates/plugin-template/plugin.json +19 -0
  41. package/test/admission-gate.test.mjs +583 -0
  42. package/test/api-version.test.mjs +74 -0
  43. package/test/atomic-admission.test.mjs +155 -0
  44. package/test/bounds.test.mjs +341 -0
  45. package/test/bulkhead-manager.test.mjs +236 -0
  46. package/test/capsule.test.mjs +625 -0
  47. package/test/crdt.test.mjs +215 -0
  48. package/test/enhanced-bounds.test.mjs +487 -0
  49. package/test/freeze-restore.test.mjs +472 -0
  50. package/test/materialized-views.test.mjs +243 -0
  51. package/test/merge.test.mjs +665 -0
  52. package/test/plugin-isolation.test.mjs +109 -0
  53. package/test/plugin-manager.test.mjs +208 -0
  54. package/test/projections-api.test.mjs +293 -0
  55. package/test/projections-cli.test.mjs +204 -0
  56. package/test/projections-docs.test.mjs +173 -0
  57. package/test/projections-ide.test.mjs +230 -0
  58. package/test/receipt.test.mjs +295 -0
  59. package/test/rollback.test.mjs +132 -0
  60. package/test/saga-orchestrator.test.mjs +279 -0
  61. package/test/schemas.test.mjs +716 -0
  62. package/test/storage-optimization.test.mjs +503 -0
  63. package/test/tool-registry.test.mjs +341 -0
  64. package/test/transaction.test.mjs +189 -0
  65. package/test/validators.test.mjs +463 -0
  66. package/test/work-item.test.mjs +548 -0
  67. package/test/work-item.test.mjs.bak +548 -0
  68. package/var/kgc/test-atomic-log.json +519 -0
  69. package/var/kgc/test-cascading-log.json +145 -0
  70. 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
+ });