@feltdb/core 0.8.2 → 0.8.3
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/dist/create/package-versions.js +1 -1
- package/dist/file-db.d.ts +8 -15
- package/dist/file-db.d.ts.map +1 -1
- package/dist/file-db.js +234 -130
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// One release train keeps generated applications installable. The repository
|
|
2
2
|
// validation script checks these values against every workspace manifest.
|
|
3
|
-
export const FELTDB_PACKAGE_VERSION = '0.8.
|
|
3
|
+
export const FELTDB_PACKAGE_VERSION = '0.8.3';
|
|
4
4
|
export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
|
package/dist/file-db.d.ts
CHANGED
|
@@ -25,6 +25,7 @@ export declare class FileJsDb implements JsDb {
|
|
|
25
25
|
private readonly origin;
|
|
26
26
|
private readonly peers;
|
|
27
27
|
private lockFd;
|
|
28
|
+
private lockToken;
|
|
28
29
|
private readonly applied;
|
|
29
30
|
constructor(path: string);
|
|
30
31
|
private loadState;
|
|
@@ -34,6 +35,10 @@ export declare class FileJsDb implements JsDb {
|
|
|
34
35
|
private cleanupStaleLock;
|
|
35
36
|
private acquireLockAsync;
|
|
36
37
|
private releaseLockAsync;
|
|
38
|
+
private ownsLock;
|
|
39
|
+
private acquireLockSync;
|
|
40
|
+
private withMutationLock;
|
|
41
|
+
private refresh;
|
|
37
42
|
private saveState;
|
|
38
43
|
private event;
|
|
39
44
|
/**
|
|
@@ -64,21 +69,9 @@ export declare class FileJsDb implements JsDb {
|
|
|
64
69
|
instance_id(): string;
|
|
65
70
|
get_sequence(): number;
|
|
66
71
|
/**
|
|
67
|
-
* The file runtime
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* A shared directory has no serialization authority over its writers.
|
|
71
|
-
* `insert`, `update`, `delete`, and `commit_transaction` do not take the
|
|
72
|
-
* cross-process lock, and publication is a whole-snapshot rename, so two
|
|
73
|
-
* processes writing the same directory lose data. `state.sequence` therefore
|
|
74
|
-
* cannot distinguish "one writer committed 50" from "two writers committed 50
|
|
75
|
-
* each and one snapshot won" — it produces a plausible but false answer,
|
|
76
|
-
* which is worse than producing none.
|
|
77
|
-
*
|
|
78
|
-
* Measured in tools/cli/test/file-runtime-concurrency.test.mjs and analysed in
|
|
79
|
-
* docs/architecture/freshness-revision-investigation.md. Making this runtime
|
|
80
|
-
* revision-capable requires fixing lock discipline and publication together;
|
|
81
|
-
* advertising `refresh` is correct until then, not a placeholder.
|
|
72
|
+
* The file runtime uses explicit refresh validation. Cross-process mutations
|
|
73
|
+
* are serialized and reads reload the committed snapshot, but the public read
|
|
74
|
+
* contract does not yet return a scoped revision alongside its value.
|
|
82
75
|
*/
|
|
83
76
|
freshness(): FreshnessCapability;
|
|
84
77
|
audit_events(): PortableEvent[];
|
package/dist/file-db.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file-db.d.ts","sourceRoot":"","sources":["../src/file-db.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AACxC,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAC1D,OAAO,KAAK,EACV,uBAAuB,EACvB,wBAAwB,EACxB,wBAAwB,EACxB,yBAAyB,EAG1B,MAAM,0BAA0B,CAAC;AAYlC,OAAO,KAAK,EAAE,wBAAwB,EAAE,uBAAuB,EAAE,MAAM,kBAAkB,CAAC;AAkB1F,UAAU,QAAQ;IAChB,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,UAAU,aAAa;IAAG,QAAQ,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,KAAK,GAAG,QAAQ,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE;AA4BrK,qBAAa,QAAS,YAAW,IAAI;IACnC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,KAAK,CAAc;IAC3B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAqB;IAC3C,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAqB;gBAEjC,IAAI,EAAE,MAAM;IAmCxB,OAAO,CAAC,SAAS;IAkCjB,OAAO,CAAC,eAAe;IAavB,OAAO,CAAC,YAAY;IAMpB,OAAO,CAAC,WAAW;
|
|
1
|
+
{"version":3,"file":"file-db.d.ts","sourceRoot":"","sources":["../src/file-db.ts"],"names":[],"mappings":"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"}
|
package/dist/file-db.js
CHANGED
|
@@ -21,6 +21,7 @@ export class FileJsDb {
|
|
|
21
21
|
origin;
|
|
22
22
|
peers = new Set();
|
|
23
23
|
lockFd = null;
|
|
24
|
+
lockToken = null;
|
|
24
25
|
applied = new Set();
|
|
25
26
|
constructor(path) {
|
|
26
27
|
const nodePath = getPathSync();
|
|
@@ -105,11 +106,25 @@ export class FileJsDb {
|
|
|
105
106
|
}
|
|
106
107
|
const stat = nodeFs.statSync(this.lockPath);
|
|
107
108
|
const ageMs = Date.now() - stat.mtimeMs;
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
if (ageMs <= STALE_LOCK_THRESHOLD_MS)
|
|
110
|
+
return false;
|
|
111
|
+
try {
|
|
112
|
+
const owner = JSON.parse(nodeFs.readFileSync(this.lockPath, 'utf8'));
|
|
113
|
+
if (typeof owner.pid === 'number') {
|
|
114
|
+
try {
|
|
115
|
+
process.kill(owner.pid, 0);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
return error?.code === 'ESRCH';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
111
122
|
}
|
|
112
|
-
|
|
123
|
+
catch {
|
|
124
|
+
// Legacy locks did not contain ownership metadata. Their age is the
|
|
125
|
+
// only recovery signal available.
|
|
126
|
+
}
|
|
127
|
+
return true;
|
|
113
128
|
}
|
|
114
129
|
catch {
|
|
115
130
|
return false;
|
|
@@ -134,9 +149,13 @@ export class FileJsDb {
|
|
|
134
149
|
this.cleanupStaleLock();
|
|
135
150
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
136
151
|
try {
|
|
152
|
+
const token = `${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
|
137
153
|
const fd = nodeFs.openSync(this.lockPath, 'wx', 0o600);
|
|
154
|
+
nodeFs.writeFileSync(fd, JSON.stringify({ pid: process.pid, token }));
|
|
155
|
+
nodeFs.fsyncSync(fd);
|
|
138
156
|
nodeFs.closeSync(fd);
|
|
139
157
|
this.lockFd = 1;
|
|
158
|
+
this.lockToken = token;
|
|
140
159
|
return;
|
|
141
160
|
}
|
|
142
161
|
catch (err) {
|
|
@@ -156,7 +175,7 @@ export class FileJsDb {
|
|
|
156
175
|
releaseLockAsync() {
|
|
157
176
|
const nodeFs = getFsSync();
|
|
158
177
|
try {
|
|
159
|
-
if (nodeFs.existsSync(this.lockPath)) {
|
|
178
|
+
if (nodeFs.existsSync(this.lockPath) && this.ownsLock()) {
|
|
160
179
|
nodeFs.unlinkSync(this.lockPath);
|
|
161
180
|
}
|
|
162
181
|
}
|
|
@@ -164,6 +183,59 @@ export class FileJsDb {
|
|
|
164
183
|
console.warn('Error releasing lock:', err);
|
|
165
184
|
}
|
|
166
185
|
this.lockFd = null;
|
|
186
|
+
this.lockToken = null;
|
|
187
|
+
}
|
|
188
|
+
ownsLock() {
|
|
189
|
+
if (!this.lockToken)
|
|
190
|
+
return false;
|
|
191
|
+
try {
|
|
192
|
+
const owner = JSON.parse(getFsSync().readFileSync(this.lockPath, 'utf8'));
|
|
193
|
+
return owner.token === this.lockToken;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
acquireLockSync() {
|
|
200
|
+
const nodeFs = getFsSync();
|
|
201
|
+
this.cleanupStaleLock();
|
|
202
|
+
for (let attempt = 0; attempt < 1000; attempt += 1) {
|
|
203
|
+
try {
|
|
204
|
+
const token = `${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
|
205
|
+
const fd = nodeFs.openSync(this.lockPath, 'wx', 0o600);
|
|
206
|
+
nodeFs.writeFileSync(fd, JSON.stringify({ pid: process.pid, token }));
|
|
207
|
+
nodeFs.fsyncSync(fd);
|
|
208
|
+
nodeFs.closeSync(fd);
|
|
209
|
+
this.lockFd = 1;
|
|
210
|
+
this.lockToken = token;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
if (error?.code !== 'EEXIST' || attempt === 999)
|
|
215
|
+
throw error;
|
|
216
|
+
if (attempt % 10 === 0)
|
|
217
|
+
this.cleanupStaleLock();
|
|
218
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
withMutationLock(operation) {
|
|
223
|
+
this.acquireLockSync();
|
|
224
|
+
try {
|
|
225
|
+
this.refresh();
|
|
226
|
+
return operation();
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
this.releaseLockAsync();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
refresh() {
|
|
233
|
+
this.state = this.loadState();
|
|
234
|
+
this.peers.clear();
|
|
235
|
+
this.restorePeers();
|
|
236
|
+
this.applied.clear();
|
|
237
|
+
for (const transactionId of this.state.applied_transactions ?? [])
|
|
238
|
+
this.applied.add(transactionId);
|
|
167
239
|
}
|
|
168
240
|
saveState() {
|
|
169
241
|
const nodeFs = getFsSync();
|
|
@@ -187,7 +259,7 @@ export class FileJsDb {
|
|
|
187
259
|
};
|
|
188
260
|
const content = JSON.stringify(snapshot, null, 2);
|
|
189
261
|
// Write to temporary file
|
|
190
|
-
const tempPath = this.statePath
|
|
262
|
+
const tempPath = `${this.statePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
191
263
|
const handle = nodeFs.openSync(tempPath, 'w');
|
|
192
264
|
try {
|
|
193
265
|
nodeFs.writeFileSync(handle, content, 'utf-8');
|
|
@@ -246,96 +318,120 @@ export class FileJsDb {
|
|
|
246
318
|
* adopted in memory, so a failed write cannot leave memory ahead of disk.
|
|
247
319
|
*/
|
|
248
320
|
async commit_transaction(request) {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
321
|
+
await this.acquireLockAsync();
|
|
322
|
+
try {
|
|
323
|
+
this.refresh();
|
|
324
|
+
const before = this.state.sequence;
|
|
325
|
+
const plan = decideTransaction(request, {
|
|
326
|
+
exists: key => key in this.state.rows,
|
|
327
|
+
hasApplied: transactionId => this.applied.has(transactionId),
|
|
328
|
+
read: key => this.state.rows[key],
|
|
329
|
+
});
|
|
330
|
+
if (plan.kind === 'duplicate')
|
|
331
|
+
return embeddedResult(request, true, before, this.state.sequence);
|
|
332
|
+
if (plan.kind === 'reject')
|
|
333
|
+
throw new EmbeddedTransactionRejected(plan.reason);
|
|
334
|
+
// Build the post-transaction state without touching the live one.
|
|
335
|
+
const rows = { ...this.state.rows };
|
|
336
|
+
const events = [...this.state.events];
|
|
337
|
+
let sequence = this.state.sequence;
|
|
338
|
+
const timestamp = Date.now();
|
|
339
|
+
for (const mutation of plan.mutations) {
|
|
340
|
+
sequence += 1;
|
|
341
|
+
if (mutation.value === undefined) {
|
|
342
|
+
delete rows[mutation.key];
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
rows[mutation.key] = mutation.value;
|
|
346
|
+
}
|
|
347
|
+
events.push({
|
|
348
|
+
sequence,
|
|
349
|
+
collection: mutation.collection,
|
|
350
|
+
key: mutation.key,
|
|
351
|
+
type: mutation.value === undefined ? 'delete' : 'put',
|
|
352
|
+
value: mutation.value,
|
|
353
|
+
timestamp,
|
|
354
|
+
origin: this.origin,
|
|
355
|
+
id: `${this.origin}:${timestamp}:${sequence}`,
|
|
356
|
+
});
|
|
270
357
|
}
|
|
271
|
-
|
|
272
|
-
|
|
358
|
+
const previous = this.state;
|
|
359
|
+
this.state = { ...this.state, rows, events, sequence };
|
|
360
|
+
// The transaction id has to be part of the snapshot being written, not
|
|
361
|
+
// added after it. Recording it afterwards would leave a committed
|
|
362
|
+
// transaction whose identity did not survive a restart, and redelivery
|
|
363
|
+
// would then apply it a second time.
|
|
364
|
+
this.applied.add(request.transactionId);
|
|
365
|
+
try {
|
|
366
|
+
this.saveState();
|
|
273
367
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
origin: this.origin,
|
|
282
|
-
id: `${this.origin}:${timestamp}:${sequence}`,
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
const previous = this.state;
|
|
286
|
-
this.state = { ...this.state, rows, events, sequence };
|
|
287
|
-
// The transaction id has to be part of the snapshot being written, not
|
|
288
|
-
// added after it. Recording it afterwards would leave a committed
|
|
289
|
-
// transaction whose identity did not survive a restart, and redelivery
|
|
290
|
-
// would then apply it a second time.
|
|
291
|
-
this.applied.add(request.transactionId);
|
|
292
|
-
try {
|
|
293
|
-
this.saveState();
|
|
368
|
+
catch (error) {
|
|
369
|
+
// The commit did not become durable, so it must not be visible either.
|
|
370
|
+
this.state = previous;
|
|
371
|
+
this.applied.delete(request.transactionId);
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
374
|
+
return embeddedResult(request, false, before, sequence);
|
|
294
375
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
this.state = previous;
|
|
298
|
-
this.applied.delete(request.transactionId);
|
|
299
|
-
throw error;
|
|
376
|
+
finally {
|
|
377
|
+
this.releaseLockAsync();
|
|
300
378
|
}
|
|
301
|
-
return embeddedResult(request, false, before, sequence);
|
|
302
379
|
}
|
|
303
380
|
insert(key, value) {
|
|
304
381
|
try {
|
|
305
382
|
const parsed = JSON.parse(value);
|
|
306
|
-
this.
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
383
|
+
return this.withMutationLock(() => {
|
|
384
|
+
this.state.rows[key] = parsed;
|
|
385
|
+
const collection = key.split(':')[0];
|
|
386
|
+
this.event(collection, key, 'put', parsed);
|
|
387
|
+
this.saveState();
|
|
388
|
+
return { success: true, data: key };
|
|
389
|
+
});
|
|
311
390
|
}
|
|
312
391
|
catch (error) {
|
|
313
392
|
return { success: false, error: String(error) };
|
|
314
393
|
}
|
|
315
394
|
}
|
|
316
395
|
update(key, value) {
|
|
317
|
-
|
|
318
|
-
|
|
396
|
+
try {
|
|
397
|
+
const parsed = JSON.parse(value);
|
|
398
|
+
return this.withMutationLock(() => {
|
|
399
|
+
if (!(key in this.state.rows))
|
|
400
|
+
return { success: false, error: `Record ${key} not found` };
|
|
401
|
+
this.state.rows[key] = parsed;
|
|
402
|
+
this.event(key.split(':')[0], key, 'put', parsed);
|
|
403
|
+
this.saveState();
|
|
404
|
+
return { success: true, data: key };
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
return { success: false, error: String(error) };
|
|
319
409
|
}
|
|
320
|
-
return this.insert(key, value);
|
|
321
410
|
}
|
|
322
411
|
get(key) {
|
|
412
|
+
this.refresh();
|
|
323
413
|
const value = this.state.rows[key];
|
|
324
414
|
return value === undefined
|
|
325
415
|
? { success: true }
|
|
326
416
|
: { success: true, data: JSON.stringify(value) };
|
|
327
417
|
}
|
|
328
418
|
delete(key) {
|
|
329
|
-
|
|
330
|
-
return
|
|
419
|
+
try {
|
|
420
|
+
return this.withMutationLock(() => {
|
|
421
|
+
if (!(key in this.state.rows))
|
|
422
|
+
return { success: false, error: `Record ${key} not found` };
|
|
423
|
+
delete this.state.rows[key];
|
|
424
|
+
this.event(key.split(':')[0], key, 'delete');
|
|
425
|
+
this.saveState();
|
|
426
|
+
return { success: true, data: key };
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
return { success: false, error: String(error) };
|
|
331
431
|
}
|
|
332
|
-
delete this.state.rows[key];
|
|
333
|
-
const collection = key.split(':')[0];
|
|
334
|
-
this.event(collection, key, 'delete');
|
|
335
|
-
this.saveState();
|
|
336
|
-
return { success: true, data: key };
|
|
337
432
|
}
|
|
338
433
|
query(capability) {
|
|
434
|
+
this.refresh();
|
|
339
435
|
const prefix = `${capability}:`;
|
|
340
436
|
const values = Object.entries(this.state.rows)
|
|
341
437
|
.filter(([key]) => key.startsWith(prefix))
|
|
@@ -349,6 +445,7 @@ export class FileJsDb {
|
|
|
349
445
|
return { success: false, error: 'Raw operations are unavailable in the file runtime' };
|
|
350
446
|
}
|
|
351
447
|
sync_info() {
|
|
448
|
+
this.refresh();
|
|
352
449
|
return {
|
|
353
450
|
success: true,
|
|
354
451
|
data: JSON.stringify({
|
|
@@ -368,12 +465,10 @@ export class FileJsDb {
|
|
|
368
465
|
return { success: true };
|
|
369
466
|
}
|
|
370
467
|
add_sync_peer(peerId) {
|
|
371
|
-
this.peers.add(peerId);
|
|
372
|
-
return { success: true };
|
|
468
|
+
return this.withMutationLock(() => { this.peers.add(peerId); this.saveState(); return { success: true }; });
|
|
373
469
|
}
|
|
374
470
|
remove_sync_peer(peerId) {
|
|
375
|
-
this.peers.delete(peerId);
|
|
376
|
-
return { success: true };
|
|
471
|
+
return this.withMutationLock(() => { this.peers.delete(peerId); this.saveState(); return { success: true }; });
|
|
377
472
|
}
|
|
378
473
|
acknowledge_peer_operations() {
|
|
379
474
|
return { success: true };
|
|
@@ -385,83 +480,82 @@ export class FileJsDb {
|
|
|
385
480
|
return this.origin;
|
|
386
481
|
}
|
|
387
482
|
get_sequence() {
|
|
483
|
+
this.refresh();
|
|
388
484
|
return this.state.sequence;
|
|
389
485
|
}
|
|
390
486
|
/**
|
|
391
|
-
* The file runtime
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
* A shared directory has no serialization authority over its writers.
|
|
395
|
-
* `insert`, `update`, `delete`, and `commit_transaction` do not take the
|
|
396
|
-
* cross-process lock, and publication is a whole-snapshot rename, so two
|
|
397
|
-
* processes writing the same directory lose data. `state.sequence` therefore
|
|
398
|
-
* cannot distinguish "one writer committed 50" from "two writers committed 50
|
|
399
|
-
* each and one snapshot won" — it produces a plausible but false answer,
|
|
400
|
-
* which is worse than producing none.
|
|
401
|
-
*
|
|
402
|
-
* Measured in tools/cli/test/file-runtime-concurrency.test.mjs and analysed in
|
|
403
|
-
* docs/architecture/freshness-revision-investigation.md. Making this runtime
|
|
404
|
-
* revision-capable requires fixing lock discipline and publication together;
|
|
405
|
-
* advertising `refresh` is correct until then, not a placeholder.
|
|
487
|
+
* The file runtime uses explicit refresh validation. Cross-process mutations
|
|
488
|
+
* are serialized and reads reload the committed snapshot, but the public read
|
|
489
|
+
* contract does not yet return a scoped revision alongside its value.
|
|
406
490
|
*/
|
|
407
491
|
freshness() {
|
|
408
492
|
return {
|
|
409
493
|
domain: 'multi-process-file',
|
|
410
494
|
validation: 'refresh',
|
|
411
495
|
invalidation: false,
|
|
412
|
-
reason: '
|
|
413
|
-
+ '
|
|
496
|
+
reason: 'reads refresh from the serialized file authority; the public read '
|
|
497
|
+
+ 'contract does not yet expose a scoped revision token',
|
|
414
498
|
};
|
|
415
499
|
}
|
|
416
500
|
audit_events() {
|
|
501
|
+
this.refresh();
|
|
417
502
|
return [...this.state.events];
|
|
418
503
|
}
|
|
419
504
|
export_operations(sinceSequence) {
|
|
505
|
+
this.refresh();
|
|
420
506
|
return this.state.events.filter(event => event.sequence > sinceSequence);
|
|
421
507
|
}
|
|
422
508
|
apply_remote_operations(operations) {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
if (
|
|
441
|
-
|
|
509
|
+
return this.withMutationLock(() => {
|
|
510
|
+
let applied = 0;
|
|
511
|
+
let ignored = 0;
|
|
512
|
+
for (const operation of operations) {
|
|
513
|
+
// Skip if we've already applied this operation
|
|
514
|
+
if (this.state.events.some(event => event.id === operation.id)) {
|
|
515
|
+
ignored++;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
// Find the current state of this key
|
|
519
|
+
const current = [...this.state.events].reverse().find(event => event.key === operation.key);
|
|
520
|
+
// Use lamport-clock-style ordering: timestamp, then origin, then id
|
|
521
|
+
const incomingOrder = `${String(operation.timestamp).padStart(16, '0')}:${operation.origin}:${operation.id}`;
|
|
522
|
+
const currentOrder = current ? `${String(current.timestamp).padStart(16, '0')}:${current.origin}:${current.id}` : '';
|
|
523
|
+
this.state.sequence += 1;
|
|
524
|
+
this.state.events.push({ ...operation, sequence: this.state.sequence });
|
|
525
|
+
// Apply if incoming is newer
|
|
526
|
+
if (incomingOrder >= currentOrder) {
|
|
527
|
+
if (operation.type === 'delete') {
|
|
528
|
+
delete this.state.rows[operation.key];
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
this.state.rows[operation.key] = operation.value;
|
|
532
|
+
}
|
|
533
|
+
applied++;
|
|
442
534
|
}
|
|
443
535
|
else {
|
|
444
|
-
|
|
536
|
+
ignored++;
|
|
445
537
|
}
|
|
446
|
-
applied++;
|
|
447
538
|
}
|
|
448
|
-
|
|
449
|
-
|
|
539
|
+
// Save state after applying remote operations
|
|
540
|
+
if (applied > 0) {
|
|
541
|
+
this.saveState();
|
|
450
542
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
if (applied > 0) {
|
|
454
|
-
this.saveState();
|
|
455
|
-
}
|
|
456
|
-
return { applied, ignored };
|
|
543
|
+
return { applied, ignored };
|
|
544
|
+
});
|
|
457
545
|
}
|
|
458
546
|
async allocateSequence(params) {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
547
|
+
await this.acquireLockAsync();
|
|
548
|
+
try {
|
|
549
|
+
this.refresh();
|
|
550
|
+
const key = `${params.scope}:${params.scopeId}`;
|
|
551
|
+
const next = (this.state.sequences[key] ?? 0) + 1;
|
|
552
|
+
this.state.sequences[key] = next;
|
|
553
|
+
this.saveState();
|
|
554
|
+
return next;
|
|
555
|
+
}
|
|
556
|
+
finally {
|
|
557
|
+
this.releaseLockAsync();
|
|
558
|
+
}
|
|
465
559
|
}
|
|
466
560
|
async putIfAbsent(key, value) {
|
|
467
561
|
const nodeFs = getFsSync();
|
|
@@ -524,10 +618,17 @@ export class FileJsDb {
|
|
|
524
618
|
const operationIdKey = `op-id:${operationId}`;
|
|
525
619
|
const operationValue = JSON.stringify(durableOperation);
|
|
526
620
|
try {
|
|
527
|
-
|
|
528
|
-
|
|
621
|
+
await this.acquireLockAsync();
|
|
622
|
+
this.refresh();
|
|
623
|
+
const present = this.state.rows[idempotencyKey];
|
|
624
|
+
const result = present === undefined
|
|
625
|
+
? { inserted: true, value: operationValue }
|
|
626
|
+
: { inserted: false, value: JSON.stringify(present) };
|
|
529
627
|
if (result.inserted) {
|
|
628
|
+
this.state.rows[idempotencyKey] = durableOperation;
|
|
530
629
|
this.state.rows[operationIdKey] = durableOperation;
|
|
630
|
+
this.event('operation', idempotencyKey, 'put', durableOperation);
|
|
631
|
+
this.event('op-id', operationIdKey, 'put', durableOperation);
|
|
531
632
|
this.saveState();
|
|
532
633
|
}
|
|
533
634
|
// Parse the existing or newly inserted operation
|
|
@@ -548,6 +649,9 @@ export class FileJsDb {
|
|
|
548
649
|
catch (error) {
|
|
549
650
|
throw new Error(`Operation admission failed: ${error}`);
|
|
550
651
|
}
|
|
652
|
+
finally {
|
|
653
|
+
this.releaseLockAsync();
|
|
654
|
+
}
|
|
551
655
|
}
|
|
552
656
|
async transitionOperation(input) {
|
|
553
657
|
// Validate input
|
|
@@ -722,7 +826,7 @@ export class FileJsDb {
|
|
|
722
826
|
}
|
|
723
827
|
}
|
|
724
828
|
close() {
|
|
725
|
-
//
|
|
726
|
-
|
|
829
|
+
// Mutations are durable before they return. Closing a stale handle must
|
|
830
|
+
// never rewrite authority state produced by another process.
|
|
727
831
|
}
|
|
728
832
|
}
|
package/package.json
CHANGED