@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
package/src/receipt.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/rollback.mjs
ADDED
|
@@ -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;
|