@feltdb/core 0.5.1 → 0.5.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/cell.d.ts +235 -0
- package/dist/cell.d.ts.map +1 -0
- package/dist/cell.js +1090 -0
- package/dist/cli/index.js +1 -1
- package/dist/collection.d.ts +4 -2
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +28 -25
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/crates/feltdb/src/lib.rs +173 -0
- package/dist/create/server-source/crates/feltdb/src/managed_cas_tests.rs +231 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +108 -1
- package/dist/create/server-source/crates/feltdb-server/src/request_telemetry.rs +1 -1
- package/dist/create/server-source/crates/feltdb-server/tests/revision_recovery_integration_test.rs +219 -0
- package/dist/db.d.ts +16 -0
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +21 -0
- package/dist/feltdb.d.ts +7 -1
- package/dist/feltdb.d.ts.map +1 -1
- package/dist/file-db.d.ts +5 -0
- package/dist/file-db.d.ts.map +1 -1
- package/dist/file-db.js +72 -9
- package/dist/http-db.d.ts +13 -0
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +41 -0
- package/dist/http-server.d.ts +42 -0
- package/dist/http-server.d.ts.map +1 -0
- package/dist/http-server.js +182 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/indexeddb-conformance.spec.d.ts +21 -0
- package/dist/indexeddb-conformance.spec.d.ts.map +1 -0
- package/dist/indexeddb-conformance.spec.js +103 -0
- package/dist/indexeddb-db.d.ts +8 -0
- package/dist/indexeddb-db.d.ts.map +1 -1
- package/dist/indexeddb-db.js +69 -0
- package/dist/memory-db.d.ts +2 -0
- package/dist/memory-db.d.ts.map +1 -1
- package/dist/memory-db.js +9 -3
- package/dist/studio-app/assets/{feltdb_wasm-DYPuS6Ky.js → feltdb_wasm-h9mxesnH.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-B8U4A1n1.wasm +0 -0
- package/dist/studio-app/assets/index-Z92yFC4z.js +28 -0
- package/dist/studio-app/index.html +1 -1
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-DEwA82pF.wasm +0 -0
- package/dist/studio-app/assets/index-_qqVLfw1.js +0 -28
package/dist/cli/index.js
CHANGED
|
@@ -23,7 +23,7 @@ import * as path from 'path';
|
|
|
23
23
|
import * as readline from 'readline';
|
|
24
24
|
import { getClient } from './api-client.js';
|
|
25
25
|
import { loadFeltDBConfig, createDefaultConfig, validateModel, } from './config.js';
|
|
26
|
-
const VERSION = '0.5.
|
|
26
|
+
const VERSION = '0.5.3';
|
|
27
27
|
function prompt(question) {
|
|
28
28
|
const rl = readline.createInterface({
|
|
29
29
|
input: process.stdin,
|
package/dist/collection.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface UpdateIfVersionResult<T> {
|
|
|
24
24
|
};
|
|
25
25
|
/** Current version if update failed due to version mismatch */
|
|
26
26
|
currentVersion?: number;
|
|
27
|
+
/** Structured backend conflict code, when available. */
|
|
28
|
+
conflictCode?: string;
|
|
27
29
|
}
|
|
28
30
|
/**
|
|
29
31
|
* A live collection that automatically updates when underlying data changes.
|
|
@@ -47,7 +49,7 @@ export declare class Collection<T> {
|
|
|
47
49
|
private indexBackend;
|
|
48
50
|
private indexStore;
|
|
49
51
|
private indexesLoaded;
|
|
50
|
-
constructor(db: JsDb, name: string, predicate?: Predicate<T>, parent?: Collection<T
|
|
52
|
+
constructor(db: JsDb, name: string, predicate?: Predicate<T>, parent?: Collection<T>, loadIndexes?: boolean);
|
|
51
53
|
/**
|
|
52
54
|
* Load indexes that were persisted from a previous session.
|
|
53
55
|
*/
|
|
@@ -126,7 +128,7 @@ export declare class Collection<T> {
|
|
|
126
128
|
* console.log('Conflict - another writer won at version', result.currentVersion);
|
|
127
129
|
* }
|
|
128
130
|
*/
|
|
129
|
-
updateIfVersion(id: string | number, expectedVersion: number, updates: Partial<T
|
|
131
|
+
updateIfVersion(id: string | number, expectedVersion: number, updates: Partial<T>, expectedEpoch?: number, expectedLeaseId?: string, rejectIfDiverged?: boolean, knownCurrent?: T): Promise<UpdateIfVersionResult<T>>;
|
|
130
132
|
/**
|
|
131
133
|
* Delete a record from this collection.
|
|
132
134
|
*/
|
package/dist/collection.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"collection.d.ts","sourceRoot":"","sources":["../src/collection.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AAIxC,OAAO,KAAK,EAAE,WAAW,EAAc,MAAM,kBAAkB,CAAC;AAEhE,MAAM,MAAM,SAAS,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC;AAChD,MAAM,MAAM,UAAU,CAAC,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,EAAE,KAAK,IAAI,CAAC;AAEjD;;;GAGG;AACH,MAAM,WAAW,qBAAqB,CAAC,CAAC;IACtC,mCAAmC;IACnC,OAAO,EAAE,OAAO,CAAC;IACjB,iEAAiE;IACjE,IAAI,CAAC,EAAE,CAAC,GAAG;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;IACjC,+DAA+D;IAC/D,cAAc,CAAC,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"collection.d.ts","sourceRoot":"","sources":["../src/collection.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AAIxC,OAAO,KAAK,EAAE,WAAW,EAAc,MAAM,kBAAkB,CAAC;AAEhE,MAAM,MAAM,SAAS,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC;AAChD,MAAM,MAAM,UAAU,CAAC,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,EAAE,KAAK,IAAI,CAAC;AAEjD;;;GAGG;AACH,MAAM,WAAW,qBAAqB,CAAC,CAAC;IACtC,mCAAmC;IACnC,OAAO,EAAE,OAAO,CAAC;IACjB,iEAAiE;IACjE,IAAI,CAAC,EAAE,CAAC,GAAG;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;IACjC,+DAA+D;IAC/D,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,wDAAwD;IACxD,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;GAKG;AACH,qBAAa,UAAU,CAAC,CAAC;IACvB,OAAO,CAAC,EAAE,CAAO;IACjB,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,SAAS,CAA6B;IAC9C,OAAO,CAAC,KAAK,CAAW;IACxB,OAAO,CAAC,WAAW,CAAiC;IACpD,OAAO,CAAC,MAAM,CAA8B;IAC5C,OAAO,CAAC,QAAQ,CAAuB;IACvC,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,oBAAoB,CAA8B;IAC1D,OAAO,CAAC,gBAAgB,CAA6B;IACrD,OAAO,CAAC,kBAAkB,CAA6B;IACvD,OAAO,CAAC,YAAY,CAAoC;IACxD,OAAO,CAAC,UAAU,CAAa;IAC/B,OAAO,CAAC,aAAa,CAAS;gBAElB,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC,EAAE,WAAW,UAAO;IAoBxG;;OAEG;YACW,oBAAoB;IAalC;;;OAGG;IACG,GAAG,IAAI,OAAO,CAAC,CAAC,EAAE,CAAC;IAKzB,0DAA0D;IACpD,IAAI,CAAC,KAAK,GAAE,OAAO,CAAC,CAAC,CAAM,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC;IAQhD;;;;;OAKG;IACH,WAAW,CAAC,MAAM,EAAE,WAAW,GAAG,IAAI;IAWtC;;OAEG;IACH,WAAW,IAAI,WAAW,EAAE;IAI5B;;;OAGG;IACG,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;IAYrC;;OAEG;IACG,eAAe,IAAI,OAAO,CAAC,OAAO,CAAC;IAazC;;OAEG;IACG,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;IAYjD;;;OAGG;IACH,KAAK,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC;IAO7C;;;OAGG;IACG,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IA4BrE;;OAEG;IACG,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IA8BrE;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;IACG,eAAe,CACnB,EAAE,EAAE,MAAM,GAAG,MAAM,EACnB,eAAe,EAAE,MAAM,EACvB,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,aAAa,CAAC,EAAE,MAAM,EACtB,eAAe,CAAC,EAAE,MAAM,EACxB,gBAAgB,CAAC,EAAE,OAAO,EAC1B,YAAY,CAAC,EAAE,CAAC,GACf,OAAO,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAC;IAmEpC;;OAEG;IACG,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA2BhD;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,MAAM,CAAC;IAK9B;;OAEG;IACG,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAKnD;;;;;;OAMG;IACG,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,EAAE,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,OAAO,CAAC;QAAC,KAAK,EAAE,CAAC,CAAA;KAAE,CAAC;IAmClG;;;OAGG;IACH,SAAS,CAAC,UAAU,EAAE,UAAU,CAAC,CAAC,CAAC,EAAE,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,IAAI;IA6CxE,mEAAmE;IACnE,KAAK,IAAI,IAAI;IASb;;OAEG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;CAgC/B;AAED;;GAEG;AACH,qBAAa,YAAY,CAAC,MAAM,EAAE,KAAK;IACrC,OAAO,CAAC,QAAQ,CAAO;IACvB,OAAO,CAAC,OAAO,CAAO;IACtB,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,UAAU,CAAoC;gBAGpD,QAAQ,EAAE,IAAI,EACd,OAAO,EAAE,IAAI,EACb,eAAe,EAAE,MAAM,EACvB,UAAU,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,MAAM,GAAG,MAAM;IAQ/C;;OAEG;IACG,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,KAAK,EAAE,CAAC;CAoBxD"}
|
package/dist/collection.js
CHANGED
|
@@ -17,7 +17,7 @@ import { IndexStore } from './index-store.js';
|
|
|
17
17
|
* Uses reactive dependency graph instead of polling for efficient updates.
|
|
18
18
|
*/
|
|
19
19
|
export class Collection {
|
|
20
|
-
constructor(db, name, predicate, parent) {
|
|
20
|
+
constructor(db, name, predicate, parent, loadIndexes = true) {
|
|
21
21
|
this.predicate = null;
|
|
22
22
|
this.cache = [];
|
|
23
23
|
this.subscribers = new Set();
|
|
@@ -42,7 +42,10 @@ export class Collection {
|
|
|
42
42
|
graph.registerDependency(parent.collectionId, this.collectionId);
|
|
43
43
|
}
|
|
44
44
|
// Load persisted indexes asynchronously
|
|
45
|
-
|
|
45
|
+
if (loadIndexes)
|
|
46
|
+
this.loadPersistedIndexes();
|
|
47
|
+
else
|
|
48
|
+
this.indexesLoaded = true;
|
|
46
49
|
}
|
|
47
50
|
/**
|
|
48
51
|
* Load indexes that were persisted from a previous session.
|
|
@@ -131,13 +134,11 @@ export class Collection {
|
|
|
131
134
|
async get(id) {
|
|
132
135
|
const key = `${this.name}:${id}`;
|
|
133
136
|
const result = await this.db.get(key);
|
|
134
|
-
if (result.success
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return null;
|
|
140
|
-
}
|
|
137
|
+
if (!result.success) {
|
|
138
|
+
throw new Error(result.error || `Failed to read record ${id}`);
|
|
139
|
+
}
|
|
140
|
+
if (result.data) {
|
|
141
|
+
return JSON.parse(result.data);
|
|
141
142
|
}
|
|
142
143
|
return null;
|
|
143
144
|
}
|
|
@@ -235,33 +236,30 @@ export class Collection {
|
|
|
235
236
|
* console.log('Conflict - another writer won at version', result.currentVersion);
|
|
236
237
|
* }
|
|
237
238
|
*/
|
|
238
|
-
async updateIfVersion(id, expectedVersion, updates) {
|
|
239
|
-
const current = await this.get(id);
|
|
239
|
+
async updateIfVersion(id, expectedVersion, updates, expectedEpoch, expectedLeaseId, rejectIfDiverged, knownCurrent) {
|
|
240
|
+
const current = knownCurrent ?? await this.get(id);
|
|
240
241
|
if (!current) {
|
|
241
242
|
throw new Error(`Record ${id} not found`);
|
|
242
243
|
}
|
|
243
|
-
|
|
244
|
-
if (currentVersion !== expectedVersion) {
|
|
245
|
-
return {
|
|
246
|
-
updated: false,
|
|
247
|
-
currentVersion,
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
// Prepare updated record with incremented version
|
|
244
|
+
// Prepare updated record (no version increment - backend will handle it)
|
|
251
245
|
// User updates should not include __version
|
|
252
246
|
const cleanUpdates = { ...updates };
|
|
253
247
|
delete cleanUpdates.__version;
|
|
254
|
-
const
|
|
255
|
-
const updated = { ...current, ...cleanUpdates, __version: newVersion };
|
|
248
|
+
const updated = { ...current, ...cleanUpdates };
|
|
256
249
|
const key = `${this.name}:${id}`;
|
|
257
250
|
// Use CAS for atomic version-checked update
|
|
258
251
|
if (!this.db.cas) {
|
|
259
252
|
throw new Error(`updateIfVersion is not supported by this FeltDB runtime. ` +
|
|
260
253
|
`Only FileJsDb (Node.js) and HTTP/Server backends support atomic version-checked updates.`);
|
|
261
254
|
}
|
|
255
|
+
// Delegate authoritative version and epoch check to the backend
|
|
256
|
+
// The backend will atomically verify both predicates
|
|
262
257
|
const casResult = await this.db.cas({
|
|
263
258
|
key,
|
|
264
|
-
expectedVersion
|
|
259
|
+
expectedVersion,
|
|
260
|
+
expectedEpoch,
|
|
261
|
+
expectedLeaseId,
|
|
262
|
+
rejectIfDiverged,
|
|
265
263
|
value: JSON.stringify(updated),
|
|
266
264
|
});
|
|
267
265
|
if (!casResult.updated) {
|
|
@@ -269,22 +267,27 @@ export class Collection {
|
|
|
269
267
|
return {
|
|
270
268
|
updated: false,
|
|
271
269
|
currentVersion: casResult.currentVersion,
|
|
270
|
+
conflictCode: casResult.conflictCode,
|
|
272
271
|
};
|
|
273
272
|
}
|
|
273
|
+
// Use the item returned by cas() which has the correct incremented version
|
|
274
|
+
const finalItem = (casResult.item || updated);
|
|
275
|
+
// CAS operation on backend increments version atomically - update finalItem to match
|
|
276
|
+
finalItem.__version = casResult.currentVersion;
|
|
274
277
|
// Update indexes
|
|
275
|
-
this.indexBackend.updateRecord(String(id), current,
|
|
278
|
+
this.indexBackend.updateRecord(String(id), current, finalItem);
|
|
276
279
|
// Emit change through reactive dependency graph
|
|
277
280
|
const change = {
|
|
278
281
|
type: 'update',
|
|
279
282
|
key,
|
|
280
|
-
value:
|
|
283
|
+
value: finalItem,
|
|
281
284
|
timestamp: Date.now(),
|
|
282
285
|
};
|
|
283
286
|
const graph = getReactiveDependencyGraph();
|
|
284
287
|
await graph.emitChange(this.name, change);
|
|
285
288
|
return {
|
|
286
289
|
updated: true,
|
|
287
|
-
item:
|
|
290
|
+
item: finalItem,
|
|
288
291
|
};
|
|
289
292
|
}
|
|
290
293
|
/**
|
|
@@ -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.5.
|
|
3
|
+
export const FELTDB_PACKAGE_VERSION = '0.5.3';
|
|
4
4
|
export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
mod acceptance_tests;
|
|
3
3
|
#[cfg(test)]
|
|
4
4
|
mod admission_contract_tests;
|
|
5
|
+
#[cfg(test)]
|
|
6
|
+
mod managed_cas_tests;
|
|
5
7
|
mod acquisition;
|
|
6
8
|
pub mod admission;
|
|
7
9
|
pub mod application;
|
|
@@ -329,6 +331,32 @@ pub struct AtomicCommit {
|
|
|
329
331
|
pub rows: Vec<StoredRow>,
|
|
330
332
|
pub duplicate: bool,
|
|
331
333
|
}
|
|
334
|
+
|
|
335
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
336
|
+
pub enum JsonCasResult {
|
|
337
|
+
Updated {
|
|
338
|
+
current_version: u64,
|
|
339
|
+
current_epoch: u64,
|
|
340
|
+
value: Value,
|
|
341
|
+
},
|
|
342
|
+
VersionConflict {
|
|
343
|
+
current_version: u64,
|
|
344
|
+
current_epoch: u64,
|
|
345
|
+
},
|
|
346
|
+
AuthorityConflict {
|
|
347
|
+
current_version: u64,
|
|
348
|
+
current_epoch: u64,
|
|
349
|
+
},
|
|
350
|
+
LeaseConflict {
|
|
351
|
+
current_version: u64,
|
|
352
|
+
current_epoch: u64,
|
|
353
|
+
},
|
|
354
|
+
ReplicationConflict {
|
|
355
|
+
current_version: u64,
|
|
356
|
+
current_epoch: u64,
|
|
357
|
+
},
|
|
358
|
+
NotFound,
|
|
359
|
+
}
|
|
332
360
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
333
361
|
struct TransactionLogRecord {
|
|
334
362
|
record_type: String,
|
|
@@ -478,6 +506,151 @@ impl FeltDb {
|
|
|
478
506
|
Ok(())
|
|
479
507
|
}
|
|
480
508
|
|
|
509
|
+
/// Atomically compare the logical record version and authority epoch before
|
|
510
|
+
/// durably replacing a JSON object. Failed predicates append no operation
|
|
511
|
+
/// and emit no change event.
|
|
512
|
+
pub fn compare_and_set_json(
|
|
513
|
+
&self,
|
|
514
|
+
key: &str,
|
|
515
|
+
expected_version: u64,
|
|
516
|
+
expected_epoch: Option<u64>,
|
|
517
|
+
expected_lease_id: Option<&str>,
|
|
518
|
+
reject_if_diverged: bool,
|
|
519
|
+
mut value: Value,
|
|
520
|
+
) -> Result<JsonCasResult> {
|
|
521
|
+
let capability = key
|
|
522
|
+
.split_once(':')
|
|
523
|
+
.map(|(cap, _)| cap)
|
|
524
|
+
.unwrap_or("default")
|
|
525
|
+
.to_string();
|
|
526
|
+
let rust_type = type_name::<Value>().to_string();
|
|
527
|
+
let (result, event) = {
|
|
528
|
+
let mut inner = self.inner.lock().expect("lock poisoned");
|
|
529
|
+
let Some(current) = inner.rows.get(&capability).and_then(|rows| rows.get(key)) else {
|
|
530
|
+
return Ok(JsonCasResult::NotFound);
|
|
531
|
+
};
|
|
532
|
+
let current_version = current
|
|
533
|
+
.value
|
|
534
|
+
.get("__version")
|
|
535
|
+
.and_then(Value::as_u64)
|
|
536
|
+
.unwrap_or(1);
|
|
537
|
+
let current_epoch = current
|
|
538
|
+
.value
|
|
539
|
+
.pointer("/authority/epoch")
|
|
540
|
+
.and_then(Value::as_u64)
|
|
541
|
+
.unwrap_or(0);
|
|
542
|
+
if current_version != expected_version {
|
|
543
|
+
return Ok(JsonCasResult::VersionConflict {
|
|
544
|
+
current_version,
|
|
545
|
+
current_epoch,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
if expected_epoch.is_some_and(|expected| expected != current_epoch) {
|
|
549
|
+
return Ok(JsonCasResult::AuthorityConflict {
|
|
550
|
+
current_version,
|
|
551
|
+
current_epoch,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
let persisted_lease = current.value.get("lease").filter(|lease| !lease.is_null());
|
|
555
|
+
let lease_matches = match (persisted_lease, expected_lease_id) {
|
|
556
|
+
(None, None) => true,
|
|
557
|
+
(None, Some(_)) => false,
|
|
558
|
+
(Some(lease), Some(expected_id)) => {
|
|
559
|
+
lease.get("leaseId").and_then(Value::as_str) == Some(expected_id)
|
|
560
|
+
&& lease
|
|
561
|
+
.get("expiresAt")
|
|
562
|
+
.and_then(Value::as_u64)
|
|
563
|
+
.is_some_and(|expires_at| expires_at > now_ms() as u64)
|
|
564
|
+
}
|
|
565
|
+
(Some(lease), None) => lease
|
|
566
|
+
.get("expiresAt")
|
|
567
|
+
.and_then(Value::as_u64)
|
|
568
|
+
.is_some_and(|expires_at| expires_at <= now_ms() as u64),
|
|
569
|
+
};
|
|
570
|
+
if !lease_matches {
|
|
571
|
+
return Ok(JsonCasResult::LeaseConflict {
|
|
572
|
+
current_version,
|
|
573
|
+
current_epoch,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
if reject_if_diverged && capability == "_cells" {
|
|
577
|
+
let replica_key = key
|
|
578
|
+
.strip_prefix("_cells:")
|
|
579
|
+
.map(|suffix| format!("_replicas:{suffix}"));
|
|
580
|
+
let divergent = replica_key.as_ref().and_then(|replica_key| {
|
|
581
|
+
inner
|
|
582
|
+
.rows
|
|
583
|
+
.get("_replicas")
|
|
584
|
+
.and_then(|rows| rows.get(replica_key))
|
|
585
|
+
.and_then(|row| row.value.get("divergent"))
|
|
586
|
+
.and_then(Value::as_bool)
|
|
587
|
+
}) == Some(true);
|
|
588
|
+
if divergent {
|
|
589
|
+
return Ok(JsonCasResult::ReplicationConflict {
|
|
590
|
+
current_version,
|
|
591
|
+
current_epoch,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
let object = value.as_object_mut().ok_or_else(|| {
|
|
597
|
+
FlowError::CapabilityError("CAS value must be a JSON object".to_string())
|
|
598
|
+
})?;
|
|
599
|
+
object.insert("__version".to_string(), Value::from(expected_version + 1));
|
|
600
|
+
|
|
601
|
+
inner.sequence += 1;
|
|
602
|
+
inner.sync_state.increment_vector_clock();
|
|
603
|
+
let vector_clock = inner.sync_state.get_or_init_vector_clock().clone();
|
|
604
|
+
let operation = Operation::update(
|
|
605
|
+
inner.sequence,
|
|
606
|
+
inner.instance_id.clone(),
|
|
607
|
+
inner.sequence,
|
|
608
|
+
key.to_string(),
|
|
609
|
+
value.clone(),
|
|
610
|
+
rust_type.clone(),
|
|
611
|
+
capability.clone(),
|
|
612
|
+
)
|
|
613
|
+
.with_vector_clock(vector_clock);
|
|
614
|
+
let row = StoredRow {
|
|
615
|
+
capability: capability.clone(),
|
|
616
|
+
key: key.to_string(),
|
|
617
|
+
rust_type: rust_type.clone(),
|
|
618
|
+
value: value.clone(),
|
|
619
|
+
unix_ms: now_ms(),
|
|
620
|
+
content_hash: Some(operation.content_hash.clone()),
|
|
621
|
+
flow_ref: None,
|
|
622
|
+
deleted: false,
|
|
623
|
+
operation: Some(operation.clone()),
|
|
624
|
+
};
|
|
625
|
+
append_event(&inner.path, &row)?;
|
|
626
|
+
inner.change_log.add_operation(operation);
|
|
627
|
+
inner
|
|
628
|
+
.rows
|
|
629
|
+
.entry(capability.clone())
|
|
630
|
+
.or_default()
|
|
631
|
+
.insert(key.to_string(), row.clone());
|
|
632
|
+
let event = ChangeEvent {
|
|
633
|
+
capability,
|
|
634
|
+
key: key.to_string(),
|
|
635
|
+
rust_type,
|
|
636
|
+
unix_ms: row.unix_ms,
|
|
637
|
+
};
|
|
638
|
+
(
|
|
639
|
+
JsonCasResult::Updated {
|
|
640
|
+
current_version: expected_version + 1,
|
|
641
|
+
current_epoch: value
|
|
642
|
+
.pointer("/authority/epoch")
|
|
643
|
+
.and_then(Value::as_u64)
|
|
644
|
+
.unwrap_or(current_epoch),
|
|
645
|
+
value,
|
|
646
|
+
},
|
|
647
|
+
event,
|
|
648
|
+
)
|
|
649
|
+
};
|
|
650
|
+
let _ = self.event_tx.send(event);
|
|
651
|
+
Ok(result)
|
|
652
|
+
}
|
|
653
|
+
|
|
481
654
|
/// Delete a record with vector clock tracking
|
|
482
655
|
pub fn delete(&self, key: &str) -> Result<()> {
|
|
483
656
|
let capability = key
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
use std::{fs, path::PathBuf};
|
|
2
|
+
|
|
3
|
+
use serde_json::json;
|
|
4
|
+
|
|
5
|
+
use crate::{FeltDb, JsonCasResult};
|
|
6
|
+
|
|
7
|
+
fn database(name: &str) -> (FeltDb, PathBuf) {
|
|
8
|
+
let path = std::env::temp_dir().join(format!(
|
|
9
|
+
"feltdb-managed-cas-{name}-{}-{}.log",
|
|
10
|
+
std::process::id(),
|
|
11
|
+
std::time::SystemTime::now()
|
|
12
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
13
|
+
.unwrap()
|
|
14
|
+
.as_nanos()
|
|
15
|
+
));
|
|
16
|
+
(FeltDb::open(&path).unwrap(), path)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
#[test]
|
|
20
|
+
fn version_and_epoch_are_one_atomic_predicate() {
|
|
21
|
+
let (db, path) = database("predicate");
|
|
22
|
+
let key = "_cells:cell-1";
|
|
23
|
+
db.insert(
|
|
24
|
+
key,
|
|
25
|
+
json!({
|
|
26
|
+
"id": "cell-1",
|
|
27
|
+
"version": 42,
|
|
28
|
+
"value": "before",
|
|
29
|
+
"authority": { "owner": "A", "epoch": 7, "lifecycle": "active" },
|
|
30
|
+
"__version": 1
|
|
31
|
+
}),
|
|
32
|
+
)
|
|
33
|
+
.unwrap();
|
|
34
|
+
|
|
35
|
+
let stale_epoch = db
|
|
36
|
+
.compare_and_set_json(
|
|
37
|
+
key,
|
|
38
|
+
1,
|
|
39
|
+
Some(6),
|
|
40
|
+
None,
|
|
41
|
+
false,
|
|
42
|
+
json!({
|
|
43
|
+
"id": "cell-1",
|
|
44
|
+
"version": 42,
|
|
45
|
+
"value": "stale",
|
|
46
|
+
"authority": { "owner": "stale", "epoch": 7, "lifecycle": "active" }
|
|
47
|
+
}),
|
|
48
|
+
)
|
|
49
|
+
.unwrap();
|
|
50
|
+
assert_eq!(
|
|
51
|
+
stale_epoch,
|
|
52
|
+
JsonCasResult::AuthorityConflict {
|
|
53
|
+
current_version: 1,
|
|
54
|
+
current_epoch: 7,
|
|
55
|
+
}
|
|
56
|
+
);
|
|
57
|
+
assert_eq!(db.get_value(key).unwrap().unwrap()["value"], "before");
|
|
58
|
+
|
|
59
|
+
let updated = db
|
|
60
|
+
.compare_and_set_json(
|
|
61
|
+
key,
|
|
62
|
+
1,
|
|
63
|
+
Some(7),
|
|
64
|
+
None,
|
|
65
|
+
false,
|
|
66
|
+
json!({
|
|
67
|
+
"id": "cell-1",
|
|
68
|
+
"version": 42,
|
|
69
|
+
"value": "after",
|
|
70
|
+
"authority": { "owner": "B", "epoch": 8, "lifecycle": "active" }
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
.unwrap();
|
|
74
|
+
assert!(matches!(
|
|
75
|
+
updated,
|
|
76
|
+
JsonCasResult::Updated {
|
|
77
|
+
current_version: 2,
|
|
78
|
+
current_epoch: 8,
|
|
79
|
+
..
|
|
80
|
+
}
|
|
81
|
+
));
|
|
82
|
+
|
|
83
|
+
let stale_version = db
|
|
84
|
+
.compare_and_set_json(key, 1, Some(8), None, false, json!({ "value": "lost" }))
|
|
85
|
+
.unwrap();
|
|
86
|
+
assert_eq!(
|
|
87
|
+
stale_version,
|
|
88
|
+
JsonCasResult::VersionConflict {
|
|
89
|
+
current_version: 2,
|
|
90
|
+
current_epoch: 8,
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
let final_value = db.get_value(key).unwrap().unwrap();
|
|
94
|
+
assert_eq!(final_value["value"], "after");
|
|
95
|
+
assert_eq!(final_value["__version"], 2);
|
|
96
|
+
assert_eq!(final_value["authority"]["epoch"], 8);
|
|
97
|
+
|
|
98
|
+
drop(db);
|
|
99
|
+
let _ = fs::remove_file(path);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#[test]
|
|
103
|
+
fn lease_is_checked_with_the_server_clock_inside_cas() {
|
|
104
|
+
let (db, path) = database("lease");
|
|
105
|
+
let key = "_cells:leased";
|
|
106
|
+
let future = crate::now_ms() as u64 + 60_000;
|
|
107
|
+
db.insert(
|
|
108
|
+
key,
|
|
109
|
+
json!({
|
|
110
|
+
"value": "before",
|
|
111
|
+
"authority": { "epoch": 3 },
|
|
112
|
+
"lease": { "leaseId": "lease-good", "expiresAt": future },
|
|
113
|
+
"__version": 1
|
|
114
|
+
}),
|
|
115
|
+
)
|
|
116
|
+
.unwrap();
|
|
117
|
+
|
|
118
|
+
for expected_lease_id in [None, Some("lease-wrong")] {
|
|
119
|
+
assert_eq!(
|
|
120
|
+
db.compare_and_set_json(
|
|
121
|
+
key,
|
|
122
|
+
1,
|
|
123
|
+
Some(3),
|
|
124
|
+
expected_lease_id,
|
|
125
|
+
false,
|
|
126
|
+
json!({ "value": "rejected", "authority": { "epoch": 3 } }),
|
|
127
|
+
)
|
|
128
|
+
.unwrap(),
|
|
129
|
+
JsonCasResult::LeaseConflict {
|
|
130
|
+
current_version: 1,
|
|
131
|
+
current_epoch: 3,
|
|
132
|
+
}
|
|
133
|
+
);
|
|
134
|
+
assert_eq!(db.get_value(key).unwrap().unwrap()["value"], "before");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
assert!(matches!(
|
|
138
|
+
db.compare_and_set_json(
|
|
139
|
+
key,
|
|
140
|
+
1,
|
|
141
|
+
Some(3),
|
|
142
|
+
Some("lease-good"),
|
|
143
|
+
false,
|
|
144
|
+
json!({ "value": "accepted", "authority": { "epoch": 3 }, "lease": { "leaseId": "lease-good", "expiresAt": future } }),
|
|
145
|
+
)
|
|
146
|
+
.unwrap(),
|
|
147
|
+
JsonCasResult::Updated { .. }
|
|
148
|
+
));
|
|
149
|
+
|
|
150
|
+
let expired_key = "_cells:expired";
|
|
151
|
+
db.insert(
|
|
152
|
+
expired_key,
|
|
153
|
+
json!({
|
|
154
|
+
"value": "before",
|
|
155
|
+
"authority": { "epoch": 3 },
|
|
156
|
+
"lease": { "leaseId": "lease-expired", "expiresAt": 1 },
|
|
157
|
+
"__version": 1
|
|
158
|
+
}),
|
|
159
|
+
)
|
|
160
|
+
.unwrap();
|
|
161
|
+
assert_eq!(
|
|
162
|
+
db.compare_and_set_json(
|
|
163
|
+
expired_key,
|
|
164
|
+
1,
|
|
165
|
+
Some(3),
|
|
166
|
+
Some("lease-expired"),
|
|
167
|
+
false,
|
|
168
|
+
json!({ "value": "rejected", "authority": { "epoch": 3 } }),
|
|
169
|
+
)
|
|
170
|
+
.unwrap(),
|
|
171
|
+
JsonCasResult::LeaseConflict {
|
|
172
|
+
current_version: 1,
|
|
173
|
+
current_epoch: 3,
|
|
174
|
+
}
|
|
175
|
+
);
|
|
176
|
+
assert_eq!(db.get_value(expired_key).unwrap().unwrap()["value"], "before");
|
|
177
|
+
|
|
178
|
+
drop(db);
|
|
179
|
+
let _ = fs::remove_file(path);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#[test]
|
|
183
|
+
fn divergent_replica_blocks_promotion_inside_cas() {
|
|
184
|
+
let (db, path) = database("divergence");
|
|
185
|
+
let cell_key = "_cells:replica";
|
|
186
|
+
let replica_key = "_replicas:replica";
|
|
187
|
+
db.insert(
|
|
188
|
+
cell_key,
|
|
189
|
+
json!({
|
|
190
|
+
"version": 42,
|
|
191
|
+
"value": "A",
|
|
192
|
+
"authority": { "owner": "A", "epoch": 7 },
|
|
193
|
+
"lease": null,
|
|
194
|
+
"__version": 42
|
|
195
|
+
}),
|
|
196
|
+
)
|
|
197
|
+
.unwrap();
|
|
198
|
+
db.insert(
|
|
199
|
+
replica_key,
|
|
200
|
+
json!({ "version": 42, "value": "B", "divergent": true }),
|
|
201
|
+
)
|
|
202
|
+
.unwrap();
|
|
203
|
+
let sequence_before = db.sequence().unwrap();
|
|
204
|
+
let value_before = db.get_value(cell_key).unwrap().unwrap();
|
|
205
|
+
|
|
206
|
+
assert_eq!(
|
|
207
|
+
db.compare_and_set_json(
|
|
208
|
+
cell_key,
|
|
209
|
+
42,
|
|
210
|
+
Some(7),
|
|
211
|
+
None,
|
|
212
|
+
true,
|
|
213
|
+
json!({
|
|
214
|
+
"version": 43,
|
|
215
|
+
"value": "A",
|
|
216
|
+
"authority": { "owner": "B", "epoch": 8 },
|
|
217
|
+
"lease": null
|
|
218
|
+
}),
|
|
219
|
+
)
|
|
220
|
+
.unwrap(),
|
|
221
|
+
JsonCasResult::ReplicationConflict {
|
|
222
|
+
current_version: 42,
|
|
223
|
+
current_epoch: 7,
|
|
224
|
+
}
|
|
225
|
+
);
|
|
226
|
+
assert_eq!(db.sequence().unwrap(), sequence_before);
|
|
227
|
+
assert_eq!(db.get_value(cell_key).unwrap().unwrap(), value_before);
|
|
228
|
+
|
|
229
|
+
drop(db);
|
|
230
|
+
let _ = fs::remove_file(path);
|
|
231
|
+
}
|