@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,295 @@
1
+ /**
2
+ * @fileoverview Tests for Receipt generation, validation, and storage
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
6
+ import { mkdirSync, rmSync, existsSync, readdirSync, readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import {
9
+ generateReceipt,
10
+ verifyReceiptHash,
11
+ verifyReceiptChain,
12
+ ReceiptStore,
13
+ } from '../src/receipt.mjs';
14
+
15
+ const TEST_RECEIPT_DIR = './var/kgc/receipts-test';
16
+
17
+ describe('Receipt Generation and Validation', () => {
18
+ describe('generateReceipt', () => {
19
+ it('should generate valid receipt with hash', async () => {
20
+ const receipt = await generateReceipt(
21
+ 'test_operation',
22
+ { input1: 'value1' },
23
+ { output1: 'result1' }
24
+ );
25
+
26
+ expect(receipt).toHaveProperty('id');
27
+ expect(receipt).toHaveProperty('hash');
28
+ expect(receipt).toHaveProperty('timestamp');
29
+ expect(receipt.operation).toBe('test_operation');
30
+ expect(receipt.inputs).toEqual({ input1: 'value1' });
31
+ expect(receipt.outputs).toEqual({ output1: 'result1' });
32
+ expect(receipt.hash).toMatch(/^[a-f0-9]{64}$/);
33
+ });
34
+
35
+ it('should generate receipt with parent hash for chaining', async () => {
36
+ const parentReceipt = await generateReceipt(
37
+ 'parent_op',
38
+ { a: 1 },
39
+ { b: 2 }
40
+ );
41
+
42
+ const childReceipt = await generateReceipt(
43
+ 'child_op',
44
+ { c: 3 },
45
+ { d: 4 },
46
+ parentReceipt.hash
47
+ );
48
+
49
+ expect(childReceipt.parentHash).toBe(parentReceipt.hash);
50
+ });
51
+
52
+ it('should produce deterministic hash for same inputs', async () => {
53
+ const receipt1 = await generateReceipt(
54
+ 'op',
55
+ { x: 1 },
56
+ { y: 2 }
57
+ );
58
+
59
+ const receipt2 = await generateReceipt(
60
+ 'op',
61
+ { x: 1 },
62
+ { y: 2 }
63
+ );
64
+
65
+ // Different IDs and timestamps, but operation data is same
66
+ expect(receipt1.id).not.toBe(receipt2.id);
67
+ });
68
+ });
69
+
70
+ describe('verifyReceiptHash', () => {
71
+ it('should verify valid receipt hash', async () => {
72
+ const receipt = await generateReceipt(
73
+ 'verify_test',
74
+ { test: 'data' },
75
+ { result: 'success' }
76
+ );
77
+
78
+ const isValid = await verifyReceiptHash(receipt);
79
+ expect(isValid).toBe(true);
80
+ });
81
+
82
+ it('should reject tampered receipt', async () => {
83
+ const receipt = await generateReceipt(
84
+ 'tamper_test',
85
+ { data: 'original' },
86
+ { result: 'ok' }
87
+ );
88
+
89
+ // Tamper with output
90
+ receipt.outputs.result = 'tampered';
91
+
92
+ const isValid = await verifyReceiptHash(receipt);
93
+ expect(isValid).toBe(false);
94
+ });
95
+ });
96
+
97
+ describe('verifyReceiptChain', () => {
98
+ it('should verify valid chain of receipts', async () => {
99
+ const receipt1 = await generateReceipt('op1', { a: 1 }, { b: 2 });
100
+ const receipt2 = await generateReceipt('op2', { c: 3 }, { d: 4 }, receipt1.hash);
101
+ const receipt3 = await generateReceipt('op3', { e: 5 }, { f: 6 }, receipt2.hash);
102
+
103
+ const { valid, errors } = await verifyReceiptChain([receipt1, receipt2, receipt3]);
104
+
105
+ expect(valid).toBe(true);
106
+ expect(errors).toHaveLength(0);
107
+ });
108
+
109
+ it('should detect broken chain linkage', async () => {
110
+ const receipt1 = await generateReceipt('op1', { a: 1 }, { b: 2 });
111
+ const receipt2 = await generateReceipt('op2', { c: 3 }, { d: 4 }, 'wrong_hash');
112
+
113
+ const { valid, errors } = await verifyReceiptChain([receipt1, receipt2]);
114
+
115
+ expect(valid).toBe(false);
116
+ expect(errors.length).toBeGreaterThan(0);
117
+ expect(errors[0]).toContain('invalid parent hash');
118
+ });
119
+ });
120
+ });
121
+
122
+ describe('ReceiptStore', () => {
123
+ let store;
124
+
125
+ beforeEach(() => {
126
+ // Clean up test directory
127
+ if (existsSync(TEST_RECEIPT_DIR)) {
128
+ rmSync(TEST_RECEIPT_DIR, { recursive: true, force: true });
129
+ }
130
+ mkdirSync(TEST_RECEIPT_DIR, { recursive: true });
131
+ store = new ReceiptStore(TEST_RECEIPT_DIR);
132
+ });
133
+
134
+ afterEach(() => {
135
+ // Clean up after tests
136
+ if (existsSync(TEST_RECEIPT_DIR)) {
137
+ rmSync(TEST_RECEIPT_DIR, { recursive: true, force: true });
138
+ }
139
+ });
140
+
141
+ describe('save and load', () => {
142
+ it('should save and load receipt', async () => {
143
+ const receipt = await generateReceipt(
144
+ 'save_test',
145
+ { input: 'test' },
146
+ { output: 'result' }
147
+ );
148
+
149
+ const savedPath = await store.save(receipt);
150
+ expect(existsSync(savedPath)).toBe(true);
151
+
152
+ const loaded = await store.load(receipt.id);
153
+ expect(loaded).toEqual(receipt);
154
+ });
155
+
156
+ it('should return null for non-existent receipt', async () => {
157
+ const loaded = await store.load('non_existent_id');
158
+ expect(loaded).toBeNull();
159
+ });
160
+
161
+ it('should update manifest on save', async () => {
162
+ const receipt = await generateReceipt(
163
+ 'manifest_test',
164
+ { a: 1 },
165
+ { b: 2 }
166
+ );
167
+
168
+ await store.save(receipt);
169
+
170
+ const manifestPath = join(TEST_RECEIPT_DIR, 'manifest.json');
171
+ expect(existsSync(manifestPath)).toBe(true);
172
+
173
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
174
+ expect(manifest.receipts).toHaveLength(1);
175
+ expect(manifest.receipts[0].id).toBe(receipt.id);
176
+ expect(manifest.receipts[0].hash).toBe(receipt.hash);
177
+ });
178
+ });
179
+
180
+ describe('list', () => {
181
+ it('should list all stored receipts', async () => {
182
+ const receipts = [
183
+ await generateReceipt('op1', { a: 1 }, { b: 2 }),
184
+ await generateReceipt('op2', { c: 3 }, { d: 4 }),
185
+ await generateReceipt('op3', { e: 5 }, { f: 6 }),
186
+ ];
187
+
188
+ for (const receipt of receipts) {
189
+ await store.save(receipt);
190
+ }
191
+
192
+ const listed = await store.list();
193
+ expect(listed).toHaveLength(3);
194
+
195
+ const ids = listed.map((r) => r.id);
196
+ expect(ids).toContain(receipts[0].id);
197
+ expect(ids).toContain(receipts[1].id);
198
+ expect(ids).toContain(receipts[2].id);
199
+ });
200
+
201
+ it('should return empty array when no receipts exist', async () => {
202
+ const listed = await store.list();
203
+ expect(listed).toEqual([]);
204
+ });
205
+ });
206
+
207
+ describe('loadChain', () => {
208
+ it('should load full receipt chain', async () => {
209
+ const r1 = await generateReceipt('op1', { a: 1 }, { b: 2 });
210
+ await store.save(r1);
211
+
212
+ const r2 = await generateReceipt('op2', { c: 3 }, { d: 4 }, r1.hash);
213
+ await store.save(r2);
214
+
215
+ const r3 = await generateReceipt('op3', { e: 5 }, { f: 6 }, r2.hash);
216
+ await store.save(r3);
217
+
218
+ const chain = await store.loadChain(r3.id);
219
+
220
+ expect(chain).toHaveLength(3);
221
+ expect(chain[0].id).toBe(r1.id);
222
+ expect(chain[1].id).toBe(r2.id);
223
+ expect(chain[2].id).toBe(r3.id);
224
+ });
225
+
226
+ it('should handle single receipt (no chain)', async () => {
227
+ const receipt = await generateReceipt('single', { x: 1 }, { y: 2 });
228
+ await store.save(receipt);
229
+
230
+ const chain = await store.loadChain(receipt.id);
231
+
232
+ expect(chain).toHaveLength(1);
233
+ expect(chain[0].id).toBe(receipt.id);
234
+ });
235
+ });
236
+
237
+ describe('delete', () => {
238
+ it('should delete receipt and update manifest', async () => {
239
+ const receipt = await generateReceipt('delete_test', { a: 1 }, { b: 2 });
240
+ await store.save(receipt);
241
+
242
+ const deleted = await store.delete(receipt.id);
243
+ expect(deleted).toBe(true);
244
+
245
+ const loaded = await store.load(receipt.id);
246
+ expect(loaded).toBeNull();
247
+
248
+ // Check manifest
249
+ const manifestPath = join(TEST_RECEIPT_DIR, 'manifest.json');
250
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
251
+ expect(manifest.receipts).toHaveLength(0);
252
+ });
253
+
254
+ it('should return false when deleting non-existent receipt', async () => {
255
+ const deleted = await store.delete('non_existent');
256
+ expect(deleted).toBe(false);
257
+ });
258
+ });
259
+
260
+ describe('integration', () => {
261
+ it('should handle multiple receipts with chains', async () => {
262
+ // Create chain 1
263
+ const c1r1 = await generateReceipt('chain1_op1', { a: 1 }, { b: 2 });
264
+ await store.save(c1r1);
265
+
266
+ const c1r2 = await generateReceipt('chain1_op2', { c: 3 }, { d: 4 }, c1r1.hash);
267
+ await store.save(c1r2);
268
+
269
+ // Create chain 2
270
+ const c2r1 = await generateReceipt('chain2_op1', { e: 5 }, { f: 6 });
271
+ await store.save(c2r1);
272
+
273
+ const c2r2 = await generateReceipt('chain2_op2', { g: 7 }, { h: 8 }, c2r1.hash);
274
+ await store.save(c2r2);
275
+
276
+ // List all
277
+ const all = await store.list();
278
+ expect(all).toHaveLength(4);
279
+
280
+ // Load chains
281
+ const chain1 = await store.loadChain(c1r2.id);
282
+ const chain2 = await store.loadChain(c2r2.id);
283
+
284
+ expect(chain1).toHaveLength(2);
285
+ expect(chain2).toHaveLength(2);
286
+
287
+ // Verify chains
288
+ const verify1 = await verifyReceiptChain(chain1);
289
+ const verify2 = await verifyReceiptChain(chain2);
290
+
291
+ expect(verify1.valid).toBe(true);
292
+ expect(verify2.valid).toBe(true);
293
+ });
294
+ });
295
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * @fileoverview Tests for Rollback Event Log System
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
6
+ import { RollbackLog } from '../src/rollback.mjs';
7
+ import { promises as fs } from 'fs';
8
+ import path from 'path';
9
+
10
+ const TEST_LOG_PATH = './var/kgc/test-undo-log.json';
11
+
12
+ describe('RollbackLog', () => {
13
+ let rollbackLog;
14
+
15
+ beforeEach(async () => {
16
+ rollbackLog = new RollbackLog(TEST_LOG_PATH);
17
+ // Clear any existing log
18
+ try {
19
+ await fs.unlink(TEST_LOG_PATH);
20
+ } catch (error) {
21
+ // File doesn't exist - that's fine
22
+ }
23
+ });
24
+
25
+ afterEach(async () => {
26
+ // Cleanup
27
+ try {
28
+ await fs.unlink(TEST_LOG_PATH);
29
+ } catch (error) {
30
+ // Ignore cleanup errors
31
+ }
32
+ });
33
+
34
+ it('should append and retrieve undo log entries', async () => {
35
+ const txId = 'tx_test_001';
36
+ const operations = [
37
+ { id: 'op1', type: 'add_capsule', data: { id: 'c1' } },
38
+ { id: 'op2', type: 'add_capsule', data: { id: 'c2' } },
39
+ ];
40
+
41
+ // Append entry
42
+ const entry = await rollbackLog.append(txId, operations, 'hash123');
43
+
44
+ expect(entry.transaction_id).toBe(txId);
45
+ expect(entry.operations).toHaveLength(2);
46
+ expect(entry.hash).toBe('hash123');
47
+ expect(entry.timestamp).toBeDefined();
48
+
49
+ // Retrieve entry
50
+ const retrieved = await rollbackLog.getByTransactionId(txId);
51
+
52
+ expect(retrieved).toBeDefined();
53
+ expect(retrieved.transaction_id).toBe(txId);
54
+ expect(retrieved.operations).toHaveLength(2);
55
+
56
+ // Verify persistence
57
+ const newLog = new RollbackLog(TEST_LOG_PATH);
58
+ const persistedEntry = await newLog.getByTransactionId(txId);
59
+
60
+ expect(persistedEntry).toBeDefined();
61
+ expect(persistedEntry.transaction_id).toBe(txId);
62
+ });
63
+
64
+ it('should replay undo operations in correct order', async () => {
65
+ const txId = 'tx_replay_001';
66
+ const operations = [
67
+ { id: 'op1', type: 'remove_capsule', data: { capsule_id: 'c1' } },
68
+ { id: 'op2', type: 'remove_capsule', data: { capsule_id: 'c2' } },
69
+ { id: 'op3', type: 'remove_capsule', data: { capsule_id: 'c3' } },
70
+ ];
71
+
72
+ await rollbackLog.append(txId, operations);
73
+
74
+ // Track applied operations
75
+ const applied = [];
76
+ const applyOp = async (op) => {
77
+ applied.push(op.id);
78
+ };
79
+
80
+ // Replay
81
+ const result = await rollbackLog.replay(txId, applyOp);
82
+
83
+ expect(result.success).toBe(true);
84
+ expect(result.operations_applied).toBe(3);
85
+ expect(result.errors).toHaveLength(0);
86
+
87
+ // Verify operations applied in reverse order
88
+ expect(applied).toEqual(['op3', 'op2', 'op1']);
89
+ });
90
+
91
+ it('should manage multiple log entries and provide statistics', async () => {
92
+ // Add multiple entries
93
+ await rollbackLog.append('tx_001', [{ id: 'op1', type: 'add_capsule', data: {} }]);
94
+
95
+ // Wait a bit to ensure different timestamps
96
+ await new Promise(resolve => setTimeout(resolve, 10));
97
+
98
+ await rollbackLog.append('tx_002', [{ id: 'op2', type: 'add_capsule', data: {} }]);
99
+
100
+ await new Promise(resolve => setTimeout(resolve, 10));
101
+
102
+ await rollbackLog.append('tx_003', [
103
+ { id: 'op3', type: 'add_capsule', data: {} },
104
+ { id: 'op4', type: 'add_capsule', data: {} },
105
+ ]);
106
+
107
+ // Get all entries
108
+ const allEntries = await rollbackLog.getAll();
109
+ expect(allEntries).toHaveLength(3);
110
+
111
+ // Get statistics
112
+ const stats = await rollbackLog.getStats();
113
+ expect(stats.total).toBe(3);
114
+ expect(stats.oldest).toBeDefined();
115
+ expect(stats.newest).toBeDefined();
116
+ expect(new Date(stats.newest).getTime()).toBeGreaterThanOrEqual(
117
+ new Date(stats.oldest).getTime()
118
+ );
119
+
120
+ // Get by time range
121
+ const start = new Date(stats.oldest);
122
+ const end = new Date(stats.newest);
123
+
124
+ const rangeEntries = await rollbackLog.getByTimeRange(start, end);
125
+ expect(rangeEntries).toHaveLength(3);
126
+
127
+ // Clear log
128
+ await rollbackLog.clear();
129
+ const clearedStats = await rollbackLog.getStats();
130
+ expect(clearedStats.total).toBe(0);
131
+ });
132
+ });
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Tests for Saga Pattern - Distributed Transactions
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach } from 'vitest';
6
+ import {
7
+ SagaOrchestrator,
8
+ createSagaStep,
9
+ createSagaBuilder,
10
+ } from '../src/saga-orchestrator.mjs';
11
+
12
+ describe('Saga Pattern - Distributed Transactions', () => {
13
+ describe('SagaOrchestrator', () => {
14
+ it('should execute saga steps sequentially', async () => {
15
+ const executionOrder = [];
16
+
17
+ const step1 = createSagaStep(
18
+ 'step1',
19
+ 'first',
20
+ async (ctx) => {
21
+ executionOrder.push('step1-execute');
22
+ return { ...ctx, step1: 'done' };
23
+ },
24
+ async () => {
25
+ executionOrder.push('step1-compensate');
26
+ }
27
+ );
28
+
29
+ const step2 = createSagaStep(
30
+ 'step2',
31
+ 'second',
32
+ async (ctx) => {
33
+ executionOrder.push('step2-execute');
34
+ return { ...ctx, step2: 'done' };
35
+ },
36
+ async () => {
37
+ executionOrder.push('step2-compensate');
38
+ }
39
+ );
40
+
41
+ const saga = new SagaOrchestrator({
42
+ id: 'test-saga',
43
+ name: 'Test Saga',
44
+ steps: [step1, step2],
45
+ });
46
+
47
+ const result = await saga.execute({ initial: 'data' });
48
+
49
+ expect(result.success).toBe(true);
50
+ expect(result.result.step1).toBe('done');
51
+ expect(result.result.step2).toBe('done');
52
+ expect(executionOrder).toEqual(['step1-execute', 'step2-execute']);
53
+ });
54
+
55
+ it('should execute saga steps in parallel', async () => {
56
+ const executionTimes = {};
57
+
58
+ const step1 = createSagaStep(
59
+ 'step1',
60
+ 'parallel1',
61
+ async () => {
62
+ const start = Date.now();
63
+ await new Promise(resolve => setTimeout(resolve, 50));
64
+ executionTimes.step1 = Date.now() - start;
65
+ return 'step1-result';
66
+ },
67
+ async () => {}
68
+ );
69
+
70
+ const step2 = createSagaStep(
71
+ 'step2',
72
+ 'parallel2',
73
+ async () => {
74
+ const start = Date.now();
75
+ await new Promise(resolve => setTimeout(resolve, 50));
76
+ executionTimes.step2 = Date.now() - start;
77
+ return 'step2-result';
78
+ },
79
+ async () => {}
80
+ );
81
+
82
+ const saga = new SagaOrchestrator({
83
+ id: 'parallel-saga',
84
+ name: 'Parallel Saga',
85
+ steps: [step1, step2],
86
+ parallel: true,
87
+ });
88
+
89
+ const startTime = Date.now();
90
+ const result = await saga.execute();
91
+ const totalTime = Date.now() - startTime;
92
+
93
+ expect(result.success).toBe(true);
94
+ expect(result.result.parallel1).toBe('step1-result');
95
+ expect(result.result.parallel2).toBe('step2-result');
96
+ // Total time should be ~50ms (parallel) not ~100ms (sequential)
97
+ expect(totalTime).toBeLessThan(100);
98
+ });
99
+
100
+ it('should compensate on failure (sequential)', async () => {
101
+ const compensated = [];
102
+
103
+ const step1 = createSagaStep(
104
+ 'step1',
105
+ 'first',
106
+ async () => 'step1-done',
107
+ async () => {
108
+ compensated.push('step1');
109
+ }
110
+ );
111
+
112
+ const step2 = createSagaStep(
113
+ 'step2',
114
+ 'second',
115
+ async () => {
116
+ throw new Error('Step 2 failed');
117
+ },
118
+ async () => {
119
+ compensated.push('step2');
120
+ }
121
+ );
122
+
123
+ const step3 = createSagaStep(
124
+ 'step3',
125
+ 'third',
126
+ async () => 'step3-done',
127
+ async () => {
128
+ compensated.push('step3');
129
+ }
130
+ );
131
+
132
+ const saga = new SagaOrchestrator({
133
+ id: 'compensate-saga',
134
+ name: 'Compensate Saga',
135
+ steps: [step1, step2, step3],
136
+ });
137
+
138
+ const result = await saga.execute();
139
+
140
+ expect(result.success).toBe(false);
141
+ expect(result.state.status).toBe('compensated');
142
+ expect(result.state.completedSteps).toHaveLength(1); // Only step1 completed
143
+ expect(compensated).toEqual(['step1']); // Compensate in reverse
144
+ });
145
+
146
+ it('should retry failed steps', async () => {
147
+ let attempts = 0;
148
+
149
+ const step = createSagaStep(
150
+ 'retry-step',
151
+ 'retry',
152
+ async () => {
153
+ attempts++;
154
+ if (attempts < 3) {
155
+ throw new Error('Not yet');
156
+ }
157
+ return 'success';
158
+ },
159
+ async () => {},
160
+ { retryable: true, maxRetries: 3 }
161
+ );
162
+
163
+ const saga = new SagaOrchestrator({
164
+ id: 'retry-saga',
165
+ name: 'Retry Saga',
166
+ steps: [step],
167
+ });
168
+
169
+ const result = await saga.execute();
170
+
171
+ expect(result.success).toBe(true);
172
+ expect(attempts).toBe(3);
173
+ });
174
+
175
+ it('should not retry non-retryable steps', async () => {
176
+ let attempts = 0;
177
+
178
+ const step = createSagaStep(
179
+ 'no-retry-step',
180
+ 'noretry',
181
+ async () => {
182
+ attempts++;
183
+ throw new Error('Fail immediately');
184
+ },
185
+ async () => {},
186
+ { retryable: false }
187
+ );
188
+
189
+ const saga = new SagaOrchestrator({
190
+ id: 'no-retry-saga',
191
+ name: 'No Retry Saga',
192
+ steps: [step],
193
+ });
194
+
195
+ const result = await saga.execute();
196
+
197
+ expect(result.success).toBe(false);
198
+ expect(attempts).toBe(1);
199
+ });
200
+
201
+ it('should track saga execution state', async () => {
202
+ const step = createSagaStep(
203
+ 'step1',
204
+ 'track',
205
+ async () => 'result',
206
+ async () => {}
207
+ );
208
+
209
+ const saga = new SagaOrchestrator({
210
+ id: 'track-saga',
211
+ name: 'Track Saga',
212
+ steps: [step],
213
+ });
214
+
215
+ const result = await saga.execute();
216
+ const states = saga.getAllStates();
217
+
218
+ expect(states).toHaveLength(1);
219
+ expect(states[0].status).toBe('completed');
220
+ expect(states[0].completedSteps).toHaveLength(1);
221
+ expect(states[0].startTime).toBeDefined();
222
+ expect(states[0].endTime).toBeDefined();
223
+ });
224
+
225
+ it('should generate receipt for saga execution', async () => {
226
+ const step = createSagaStep(
227
+ 'step1',
228
+ 'receipt',
229
+ async () => 'done',
230
+ async () => {}
231
+ );
232
+
233
+ const saga = new SagaOrchestrator({
234
+ id: 'receipt-saga',
235
+ name: 'Receipt Saga',
236
+ steps: [step],
237
+ });
238
+
239
+ const result = await saga.execute();
240
+ const states = saga.getAllStates();
241
+ const receipt = await saga.generateReceipt(states[0].sagaId);
242
+
243
+ expect(receipt.operation).toContain('saga:Receipt Saga');
244
+ expect(receipt.outputs.success).toBe(true);
245
+ expect(receipt.outputs.completedSteps).toBe(1);
246
+ });
247
+ });
248
+
249
+ describe('createSagaBuilder', () => {
250
+ it('should build saga with fluent API', () => {
251
+ const saga = createSagaBuilder('builder-saga', 'Builder Saga')
252
+ .step(createSagaStep('s1', 'step1', async () => 1, async () => {}))
253
+ .step(createSagaStep('s2', 'step2', async () => 2, async () => {}))
254
+ .build();
255
+
256
+ expect(saga).toBeInstanceOf(SagaOrchestrator);
257
+ expect(saga.config.id).toBe('builder-saga');
258
+ expect(saga.config.steps).toHaveLength(2);
259
+ });
260
+
261
+ it('should support parallel execution flag', () => {
262
+ const saga = createSagaBuilder('parallel-builder', 'Parallel')
263
+ .step(createSagaStep('s1', 'step1', async () => 1, async () => {}))
264
+ .inParallel()
265
+ .build();
266
+
267
+ expect(saga.config.parallel).toBe(true);
268
+ });
269
+
270
+ it('should support continueOnError flag', () => {
271
+ const saga = createSagaBuilder('continue-builder', 'Continue')
272
+ .step(createSagaStep('s1', 'step1', async () => 1, async () => {}))
273
+ .continueOnError()
274
+ .build();
275
+
276
+ expect(saga.config.continueOnError).toBe(true);
277
+ });
278
+ });
279
+ });