@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,340 @@
1
+ /**
2
+ * @fileoverview Receipt generation and validation for KGC operations
3
+ * All operations produce receipts with cryptographic hashes for verification
4
+ */
5
+
6
+ import { blake3 } from 'hash-wasm';
7
+ import { z } from 'zod';
8
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+
11
+ /**
12
+ * Get current timestamp in ISO format
13
+ * @returns {string} ISO timestamp
14
+ */
15
+ const now = () => new Date().toISOString();
16
+
17
+ /**
18
+ * Receipt schema for operations
19
+ */
20
+ export const ReceiptSchema = z.object({
21
+ id: z.string(),
22
+ timestamp: z.string(),
23
+ operation: z.string(),
24
+ inputs: z.record(z.any()),
25
+ outputs: z.record(z.any()),
26
+ hash: z.string(),
27
+ parentHash: z.string().optional(),
28
+ });
29
+
30
+ /**
31
+ * @typedef {z.infer<typeof ReceiptSchema>} Receipt
32
+ */
33
+
34
+ /**
35
+ * Generate a receipt for an operation
36
+ * @param {string} operation - Operation name
37
+ * @param {Record<string, any>} inputs - Operation inputs
38
+ * @param {Record<string, any>} outputs - Operation outputs
39
+ * @param {string} [parentHash] - Parent receipt hash for chaining
40
+ * @returns {Promise<Receipt>} Generated receipt
41
+ */
42
+ export async function generateReceipt(operation, inputs, outputs, parentHash) {
43
+ const timestamp = now();
44
+ const id = `receipt-${timestamp}-${operation}`;
45
+
46
+ // Create deterministic hash of operation
47
+ const data = JSON.stringify({
48
+ operation,
49
+ timestamp,
50
+ inputs,
51
+ outputs,
52
+ parentHash: parentHash || null,
53
+ }, null, 0); // No whitespace for determinism
54
+
55
+ const hash = await blake3(data);
56
+
57
+ const receipt = {
58
+ id,
59
+ timestamp,
60
+ operation,
61
+ inputs,
62
+ outputs,
63
+ hash,
64
+ ...(parentHash && { parentHash }),
65
+ };
66
+
67
+ return ReceiptSchema.parse(receipt);
68
+ }
69
+
70
+ /**
71
+ * Verify a receipt's hash
72
+ * @param {Receipt} receipt - Receipt to verify
73
+ * @returns {Promise<boolean>} True if valid
74
+ */
75
+ export async function verifyReceiptHash(receipt) {
76
+ const { hash: originalHash, ...rest } = receipt;
77
+
78
+ // Reconstruct hash from receipt data
79
+ const data = JSON.stringify({
80
+ operation: rest.operation,
81
+ timestamp: rest.timestamp,
82
+ inputs: rest.inputs,
83
+ outputs: rest.outputs,
84
+ parentHash: rest.parentHash || null,
85
+ }, null, 0);
86
+
87
+ const computedHash = await blake3(data);
88
+
89
+ return computedHash === originalHash;
90
+ }
91
+
92
+ /**
93
+ * Verify a chain of receipts
94
+ * @param {Receipt[]} receipts - Receipt chain to verify
95
+ * @returns {Promise<{valid: boolean, errors: string[]}>} Verification result
96
+ */
97
+ export async function verifyReceiptChain(receipts) {
98
+ const errors = [];
99
+
100
+ for (let i = 0; i < receipts.length; i++) {
101
+ const receipt = receipts[i];
102
+
103
+ // Verify hash
104
+ const hashValid = await verifyReceiptHash(receipt);
105
+ if (!hashValid) {
106
+ errors.push(`Receipt ${receipt.id} has invalid hash`);
107
+ }
108
+
109
+ // Verify chain linkage
110
+ if (i > 0) {
111
+ const prevReceipt = receipts[i - 1];
112
+ if (receipt.parentHash !== prevReceipt.hash) {
113
+ errors.push(`Receipt ${receipt.id} has invalid parent hash`);
114
+ }
115
+ }
116
+ }
117
+
118
+ return {
119
+ valid: errors.length === 0,
120
+ errors,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * ReceiptStore - Persistent storage for receipts
126
+ */
127
+ export class ReceiptStore {
128
+ /**
129
+ * @param {string} [baseDir='./var/kgc/receipts'] - Storage directory
130
+ */
131
+ constructor(baseDir = './var/kgc/receipts') {
132
+ this.baseDir = baseDir;
133
+ this._ensureDirectory();
134
+ }
135
+
136
+ /**
137
+ * Ensure storage directory exists
138
+ * @private
139
+ */
140
+ _ensureDirectory() {
141
+ if (!existsSync(this.baseDir)) {
142
+ mkdirSync(this.baseDir, { recursive: true });
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Save a receipt to storage
148
+ * @param {Receipt} receipt - Receipt to save
149
+ * @returns {Promise<string>} Path to saved receipt
150
+ */
151
+ async save(receipt) {
152
+ // Validate receipt
153
+ const validated = ReceiptSchema.parse(receipt);
154
+
155
+ // Ensure directory exists
156
+ this._ensureDirectory();
157
+
158
+ // Write receipt file
159
+ const receiptPath = join(this.baseDir, `${validated.id}.json`);
160
+ writeFileSync(receiptPath, JSON.stringify(validated, null, 2), 'utf-8');
161
+
162
+ // Update manifest
163
+ await this._updateManifest(validated);
164
+
165
+ return receiptPath;
166
+ }
167
+
168
+ /**
169
+ * Load a receipt from storage
170
+ * @param {string} receiptId - Receipt ID to load
171
+ * @returns {Promise<Receipt|null>} Loaded receipt or null if not found
172
+ */
173
+ async load(receiptId) {
174
+ const receiptPath = join(this.baseDir, `${receiptId}.json`);
175
+
176
+ if (!existsSync(receiptPath)) {
177
+ return null;
178
+ }
179
+
180
+ try {
181
+ const content = readFileSync(receiptPath, 'utf-8');
182
+ const data = JSON.parse(content);
183
+ return ReceiptSchema.parse(data);
184
+ } catch (error) {
185
+ throw new Error(`Failed to load receipt ${receiptId}: ${error.message}`);
186
+ }
187
+ }
188
+
189
+ /**
190
+ * List all receipts in storage
191
+ * @returns {Promise<Receipt[]>} Array of all receipts
192
+ */
193
+ async list() {
194
+ if (!existsSync(this.baseDir)) {
195
+ return [];
196
+ }
197
+
198
+ const files = readdirSync(this.baseDir).filter(
199
+ (f) => f.endsWith('.json') && f !== 'manifest.json'
200
+ );
201
+
202
+ const receipts = [];
203
+
204
+ for (const file of files) {
205
+ try {
206
+ const content = readFileSync(join(this.baseDir, file), 'utf-8');
207
+ const receipt = JSON.parse(content);
208
+ receipts.push(ReceiptSchema.parse(receipt));
209
+ } catch (error) {
210
+ // Skip corrupt files
211
+ continue;
212
+ }
213
+ }
214
+
215
+ return receipts;
216
+ }
217
+
218
+ /**
219
+ * Load a chain of receipts following parent links
220
+ * @param {string} receiptId - Starting receipt ID
221
+ * @returns {Promise<Receipt[]>} Chain of receipts from root to specified receipt
222
+ */
223
+ async loadChain(receiptId) {
224
+ const chain = [];
225
+ let currentId = receiptId;
226
+
227
+ while (currentId) {
228
+ const receipt = await this.load(currentId);
229
+
230
+ if (!receipt) {
231
+ break;
232
+ }
233
+
234
+ chain.unshift(receipt); // Add to beginning
235
+ currentId = receipt.parentHash ? this._findReceiptByHash(receipt.parentHash) : null;
236
+ }
237
+
238
+ return chain;
239
+ }
240
+
241
+ /**
242
+ * Find receipt ID by hash
243
+ * @param {string} hash - Receipt hash to find
244
+ * @returns {string|null} Receipt ID or null
245
+ * @private
246
+ */
247
+ _findReceiptByHash(hash) {
248
+ const manifestPath = join(this.baseDir, 'manifest.json');
249
+
250
+ if (!existsSync(manifestPath)) {
251
+ return null;
252
+ }
253
+
254
+ try {
255
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
256
+ const entry = manifest.receipts?.find((r) => r.hash === hash);
257
+ return entry?.id || null;
258
+ } catch (error) {
259
+ return null;
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Update manifest with receipt entry
265
+ * @param {Receipt} receipt - Receipt to add to manifest
266
+ * @private
267
+ */
268
+ async _updateManifest(receipt) {
269
+ const manifestPath = join(this.baseDir, 'manifest.json');
270
+ let manifest = { receipts: [] };
271
+
272
+ if (existsSync(manifestPath)) {
273
+ try {
274
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
275
+ } catch (error) {
276
+ // Manifest corrupted, start fresh
277
+ manifest = { receipts: [] };
278
+ }
279
+ }
280
+
281
+ // Add entry if not already present
282
+ if (!manifest.receipts.some((r) => r.id === receipt.id)) {
283
+ manifest.receipts.push({
284
+ id: receipt.id,
285
+ hash: receipt.hash,
286
+ operation: receipt.operation,
287
+ timestamp: receipt.timestamp,
288
+ parentHash: receipt.parentHash,
289
+ });
290
+ }
291
+
292
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8');
293
+ }
294
+
295
+ /**
296
+ * Delete a receipt from storage
297
+ * @param {string} receiptId - Receipt ID to delete
298
+ * @returns {Promise<boolean>} True if deleted, false if not found
299
+ */
300
+ async delete(receiptId) {
301
+ const receiptPath = join(this.baseDir, `${receiptId}.json`);
302
+
303
+ if (!existsSync(receiptPath)) {
304
+ return false;
305
+ }
306
+
307
+ try {
308
+ const { unlinkSync } = await import('node:fs');
309
+ unlinkSync(receiptPath);
310
+
311
+ // Update manifest
312
+ await this._removeFromManifest(receiptId);
313
+
314
+ return true;
315
+ } catch (error) {
316
+ throw new Error(`Failed to delete receipt ${receiptId}: ${error.message}`);
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Remove receipt from manifest
322
+ * @param {string} receiptId - Receipt ID to remove
323
+ * @private
324
+ */
325
+ async _removeFromManifest(receiptId) {
326
+ const manifestPath = join(this.baseDir, 'manifest.json');
327
+
328
+ if (!existsSync(manifestPath)) {
329
+ return;
330
+ }
331
+
332
+ try {
333
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
334
+ manifest.receipts = manifest.receipts.filter((r) => r.id !== receiptId);
335
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8');
336
+ } catch (error) {
337
+ // Ignore manifest errors
338
+ }
339
+ }
340
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * @fileoverview Rollback Event Log System for KGC Runtime
3
+ * Stores undo operations and enables deterministic replay
4
+ *
5
+ * Pattern: Event sourcing + JSON log files
6
+ * Format: {transaction_id, operations[], timestamp}
7
+ */
8
+
9
+ import { z } from 'zod';
10
+ import { promises as fs } from 'fs';
11
+ import path from 'path';
12
+
13
+ // ============================================================================
14
+ // Schemas
15
+ // ============================================================================
16
+
17
+ /**
18
+ * Undo log entry schema
19
+ */
20
+ const UndoLogEntrySchema = z.object({
21
+ transaction_id: z.string(),
22
+ operations: z.array(z.object({
23
+ id: z.string(),
24
+ type: z.string(),
25
+ data: z.any(),
26
+ })),
27
+ timestamp: z.string(),
28
+ hash: z.string().optional(),
29
+ });
30
+
31
+ /**
32
+ * @typedef {z.infer<typeof UndoLogEntrySchema>} UndoLogEntry
33
+ */
34
+
35
+ // ============================================================================
36
+ // RollbackLog Class
37
+ // ============================================================================
38
+
39
+ /**
40
+ * RollbackLog - Event log for undo operations
41
+ *
42
+ * Features:
43
+ * - Persistent JSON log storage
44
+ * - Replay transactions by ID
45
+ * - Deterministic rollback
46
+ * - Transaction history tracking
47
+ *
48
+ * @example
49
+ * const log = new RollbackLog('./var/kgc/undo-log.json');
50
+ * await log.append(transaction_id, undoOperations);
51
+ * const result = await log.replay(transaction_id);
52
+ */
53
+ export class RollbackLog {
54
+ /**
55
+ * @param {string} logPath - Path to log file
56
+ */
57
+ constructor(logPath = './var/kgc/undo-log.json') {
58
+ /** @type {string} */
59
+ this.logPath = logPath;
60
+
61
+ /** @type {UndoLogEntry[]} */
62
+ this.entries = [];
63
+
64
+ /** @type {boolean} */
65
+ this.loaded = false;
66
+ }
67
+
68
+ /**
69
+ * Ensure log directory exists
70
+ * @private
71
+ */
72
+ async _ensureDir() {
73
+ const dir = path.dirname(this.logPath);
74
+ try {
75
+ await fs.mkdir(dir, { recursive: true });
76
+ } catch (error) {
77
+ if (error.code !== 'EEXIST') {
78
+ throw error;
79
+ }
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Load log from disk
85
+ * @returns {Promise<void>}
86
+ */
87
+ async load() {
88
+ if (this.loaded) return;
89
+
90
+ await this._ensureDir();
91
+
92
+ try {
93
+ const data = await fs.readFile(this.logPath, 'utf-8');
94
+ const parsed = JSON.parse(data);
95
+ this.entries = z.array(UndoLogEntrySchema).parse(parsed);
96
+ this.loaded = true;
97
+ } catch (error) {
98
+ if (error.code === 'ENOENT') {
99
+ // File doesn't exist yet - start with empty log
100
+ this.entries = [];
101
+ this.loaded = true;
102
+ } else {
103
+ throw new Error(`Failed to load rollback log: ${error.message}`);
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Save log to disk
110
+ * @returns {Promise<void>}
111
+ */
112
+ async save() {
113
+ await this._ensureDir();
114
+
115
+ const data = JSON.stringify(this.entries, null, 2);
116
+ await fs.writeFile(this.logPath, data, 'utf-8');
117
+ }
118
+
119
+ /**
120
+ * Append transaction undo operations to log
121
+ *
122
+ * @param {string} transactionId - Transaction ID
123
+ * @param {any[]} operations - Undo operations
124
+ * @param {string} [hash] - Optional transaction hash
125
+ * @returns {Promise<UndoLogEntry>} Created log entry
126
+ */
127
+ async append(transactionId, operations, hash = null) {
128
+ await this.load();
129
+
130
+ const entry = {
131
+ transaction_id: transactionId,
132
+ operations,
133
+ timestamp: new Date().toISOString(),
134
+ ...(hash && { hash }),
135
+ };
136
+
137
+ const validated = UndoLogEntrySchema.parse(entry);
138
+ this.entries.push(validated);
139
+
140
+ await this.save();
141
+
142
+ return validated;
143
+ }
144
+
145
+ /**
146
+ * Replay (undo) a transaction by ID
147
+ *
148
+ * @param {string} transactionId - Transaction ID to replay
149
+ * @param {Function} applyOp - Function to apply undo operations
150
+ * @returns {Promise<{success: boolean, operations_applied: number, errors: string[]}>} Replay result
151
+ */
152
+ async replay(transactionId, applyOp) {
153
+ await this.load();
154
+
155
+ const entry = this.entries.find(e => e.transaction_id === transactionId);
156
+ if (!entry) {
157
+ return {
158
+ success: false,
159
+ operations_applied: 0,
160
+ errors: [`Transaction ${transactionId} not found in undo log`],
161
+ };
162
+ }
163
+
164
+ const errors = [];
165
+ let applied = 0;
166
+
167
+ // Apply operations in reverse order (newest to oldest)
168
+ for (const op of entry.operations.slice().reverse()) {
169
+ try {
170
+ await applyOp(op);
171
+ applied++;
172
+ } catch (error) {
173
+ errors.push(`Failed to apply operation ${op.id}: ${error.message}`);
174
+ }
175
+ }
176
+
177
+ return {
178
+ success: errors.length === 0,
179
+ operations_applied: applied,
180
+ errors,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Get all log entries
186
+ * @returns {Promise<UndoLogEntry[]>} All log entries
187
+ */
188
+ async getAll() {
189
+ await this.load();
190
+ return [...this.entries];
191
+ }
192
+
193
+ /**
194
+ * Get log entry by transaction ID
195
+ *
196
+ * @param {string} transactionId - Transaction ID
197
+ * @returns {Promise<UndoLogEntry|null>} Log entry or null
198
+ */
199
+ async getByTransactionId(transactionId) {
200
+ await this.load();
201
+ return this.entries.find(e => e.transaction_id === transactionId) || null;
202
+ }
203
+
204
+ /**
205
+ * Get log entries in time range
206
+ *
207
+ * @param {Date} start - Start time
208
+ * @param {Date} end - End time
209
+ * @returns {Promise<UndoLogEntry[]>} Matching entries
210
+ */
211
+ async getByTimeRange(start, end) {
212
+ await this.load();
213
+
214
+ return this.entries.filter(e => {
215
+ const timestamp = new Date(e.timestamp);
216
+ return timestamp >= start && timestamp <= end;
217
+ });
218
+ }
219
+
220
+ /**
221
+ * Clear all log entries
222
+ * @returns {Promise<void>}
223
+ */
224
+ async clear() {
225
+ this.entries = [];
226
+ await this.save();
227
+ }
228
+
229
+ /**
230
+ * Get log statistics
231
+ * @returns {Promise<{total: number, oldest: string, newest: string}>} Statistics
232
+ */
233
+ async getStats() {
234
+ await this.load();
235
+
236
+ if (this.entries.length === 0) {
237
+ return {
238
+ total: 0,
239
+ oldest: null,
240
+ newest: null,
241
+ };
242
+ }
243
+
244
+ const timestamps = this.entries.map(e => e.timestamp).sort();
245
+
246
+ return {
247
+ total: this.entries.length,
248
+ oldest: timestamps[0],
249
+ newest: timestamps[timestamps.length - 1],
250
+ };
251
+ }
252
+ }
253
+
254
+ // ============================================================================
255
+ // Exports
256
+ // ============================================================================
257
+
258
+ export default RollbackLog;