@feltdb/core 0.6.13 → 0.7.0
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/README.md +32 -0
- package/dist/agent-registry.js +1 -3
- package/dist/agent-runtime.js +8 -7
- package/dist/analytics-backend.js +3 -1
- package/dist/application-contract.js +1 -0
- package/dist/application-manifest.js +1 -0
- package/dist/artifact.js +2 -0
- package/dist/authorization.js +2 -0
- package/dist/bundle.js +2 -0
- package/dist/capability.js +1 -3
- package/dist/cell.js +9 -4
- package/dist/cli/commands.js +26 -2
- package/dist/cli/index.js +1 -1
- package/dist/collection.js +39 -31
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +613 -27
- package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +452 -0
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +208 -0
- package/dist/create/server-source/crates/feltdb/src/convergence.rs +16 -0
- package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +402 -0
- package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +784 -24
- package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +418 -0
- package/dist/create/server-source/crates/feltdb/src/lib.rs +284 -0
- package/dist/create/server-source/crates/feltdb/src/replica_acknowledgements.rs +471 -0
- package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +661 -0
- package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +72 -3
- package/dist/create/server-source/crates/feltdb/src/transaction_preconditions.rs +899 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +116 -4
- package/dist/db.d.ts +2 -2
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +48 -15
- package/dist/development-runtime-bridge.js +1 -1
- package/dist/distributed-indexing.js +7 -5
- package/dist/embedded-transaction.d.ts +9 -0
- package/dist/embedded-transaction.d.ts.map +1 -1
- package/dist/embedded-transaction.js +95 -3
- package/dist/feltdb.d.ts +16 -0
- package/dist/feltdb.d.ts.map +1 -1
- package/dist/file-db.d.ts.map +1 -1
- package/dist/file-db.js +9 -3
- package/dist/flowspec.js +2 -1
- package/dist/http-client.js +2 -0
- package/dist/http-db.d.ts +11 -0
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +32 -3
- package/dist/identity.js +1 -0
- package/dist/index-analytics.js +6 -7
- package/dist/index-backend.js +3 -3
- package/dist/index-dashboard.js +10 -13
- package/dist/index-manager.js +12 -11
- package/dist/index-monitoring.js +9 -4
- package/dist/index-store.js +2 -0
- package/dist/indexeddb-db.d.ts.map +1 -1
- package/dist/indexeddb-db.js +32 -23
- package/dist/memory-db.d.ts.map +1 -1
- package/dist/memory-db.js +8 -4
- package/dist/observe.js +2 -0
- package/dist/provider.js +2 -0
- package/dist/query-planner.js +2 -4
- package/dist/reactive-graph.js +6 -8
- package/dist/release.js +2 -0
- package/dist/sharding.js +11 -6
- package/dist/state-contract.js +3 -3
- package/dist/studio-app/assets/{feltdb_wasm-CJv3wHzi.js → feltdb_wasm-CD744e5D.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-CiIXhOLi.wasm +0 -0
- package/dist/studio-app/assets/index-DoROs8yx.js +28 -0
- package/dist/studio-app/index.html +1 -1
- package/dist/sync-contract.js +9 -2
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +32 -12
- package/dist/transaction.d.ts +127 -9
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +91 -5
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/worker.js +2 -0
- package/dist/workload.js +2 -0
- package/dist/workspace/development-node.js +11 -10
- package/dist/workspace/investigation-lifecycle-manager.js +2 -0
- package/dist/workspace/investigation-supervisor.js +5 -3
- package/dist/workspace/workspace-connection.js +14 -5
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-C8TG8r2n.wasm +0 -0
- package/dist/studio-app/assets/index-DospFFYE.js +0 -28
|
@@ -24,6 +24,7 @@ pub mod analytics;
|
|
|
24
24
|
pub mod distributed_indexing;
|
|
25
25
|
pub mod indexing;
|
|
26
26
|
pub mod sharding;
|
|
27
|
+
pub mod transaction_preconditions;
|
|
27
28
|
pub mod transactions;
|
|
28
29
|
pub mod state_hash;
|
|
29
30
|
pub mod crash_injection;
|
|
@@ -43,11 +44,16 @@ pub mod crash_atomic_boundary;
|
|
|
43
44
|
pub mod multi_operation_transaction;
|
|
44
45
|
pub mod p1_atomicity_acceptance;
|
|
45
46
|
pub mod p1_application_atomicity;
|
|
47
|
+
pub mod causal_backlog_bound;
|
|
48
|
+
pub mod dedup_bound_investigation;
|
|
49
|
+
pub mod durable_operation_identity;
|
|
46
50
|
pub mod causal_dependency_barrier;
|
|
47
51
|
pub mod partition_reconciliation;
|
|
48
52
|
pub mod consistency_contract;
|
|
49
53
|
pub mod persistence_reality;
|
|
50
54
|
pub mod adversarial_transport;
|
|
55
|
+
pub mod replica_membership;
|
|
56
|
+
pub mod replica_acknowledgements;
|
|
51
57
|
pub mod replication_manager;
|
|
52
58
|
pub mod metrics;
|
|
53
59
|
pub mod query_performance;
|
|
@@ -250,6 +256,12 @@ pub enum FlowError {
|
|
|
250
256
|
Serde(serde_json::Error),
|
|
251
257
|
CorruptLogLine(String),
|
|
252
258
|
CapabilityError(String),
|
|
259
|
+
/// A transaction precondition did not hold. Nothing was written.
|
|
260
|
+
///
|
|
261
|
+
/// Separate from `CapabilityError` because a conflict is an expected
|
|
262
|
+
/// outcome of a race and a caller is meant to branch on it, while a
|
|
263
|
+
/// capability error is not.
|
|
264
|
+
PreconditionFailed(Box<PreconditionFailure>),
|
|
253
265
|
}
|
|
254
266
|
|
|
255
267
|
impl Display for FlowError {
|
|
@@ -259,6 +271,7 @@ impl Display for FlowError {
|
|
|
259
271
|
FlowError::Serde(e) => write!(f, "serde error: {e}"),
|
|
260
272
|
FlowError::CorruptLogLine(line) => write!(f, "corrupt log line: {line}"),
|
|
261
273
|
FlowError::CapabilityError(msg) => write!(f, "capability error: {msg}"),
|
|
274
|
+
FlowError::PreconditionFailed(failure) => write!(f, "PRECONDITION_FAILED: {failure}"),
|
|
262
275
|
}
|
|
263
276
|
}
|
|
264
277
|
}
|
|
@@ -326,6 +339,109 @@ pub struct AtomicPrecondition {
|
|
|
326
339
|
pub key: String,
|
|
327
340
|
pub expected_version: Option<u64>,
|
|
328
341
|
}
|
|
342
|
+
|
|
343
|
+
/// What must be true of one record for a transaction to commit.
|
|
344
|
+
///
|
|
345
|
+
/// Distinct from `AtomicPrecondition`, and deliberately so. That one compares
|
|
346
|
+
/// the row's internal operation *sequence*, and `None` on it means "this record
|
|
347
|
+
/// must not exist". This one compares the fields a caller can actually read
|
|
348
|
+
/// back -- the document's `__version`, its authority epoch, its lease -- which
|
|
349
|
+
/// are the same fields single-record CAS compares. Plumbing a caller's
|
|
350
|
+
/// `expectedVersion` into the sequence check would have compared a document
|
|
351
|
+
/// version against a storage sequence: two different numbers that happen to
|
|
352
|
+
/// share a name.
|
|
353
|
+
///
|
|
354
|
+
/// Every predicate is optional and only a supplied one is checked. That differs
|
|
355
|
+
/// from CAS, where an absent `expected_lease_id` asserts the record is *not*
|
|
356
|
+
/// leased; here an absent predicate asserts nothing at all. The difference is
|
|
357
|
+
/// deliberate -- a transaction precondition is a fence the caller opts into,
|
|
358
|
+
/// not a full description of the record -- and it is why the fields are named
|
|
359
|
+
/// `expected_*` rather than describing a state.
|
|
360
|
+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
361
|
+
pub struct RecordPrecondition {
|
|
362
|
+
pub capability: String,
|
|
363
|
+
pub key: String,
|
|
364
|
+
/// The record must not exist. Mutually exclusive with the predicates below.
|
|
365
|
+
#[serde(default)]
|
|
366
|
+
pub require_absent: bool,
|
|
367
|
+
/// The document's `__version`, as `updateIfVersion` compares it.
|
|
368
|
+
#[serde(default)]
|
|
369
|
+
pub expected_version: Option<u64>,
|
|
370
|
+
/// The record's authority epoch, at `/authority/epoch`.
|
|
371
|
+
#[serde(default)]
|
|
372
|
+
pub expected_epoch: Option<u64>,
|
|
373
|
+
/// The id of an unexpired lease held on the record.
|
|
374
|
+
#[serde(default)]
|
|
375
|
+
pub expected_lease_id: Option<String>,
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
impl RecordPrecondition {
|
|
379
|
+
/// True when this precondition asks for nothing, which is a caller error
|
|
380
|
+
/// rather than a satisfied fence: a precondition that constrains nothing
|
|
381
|
+
/// would read as protection and provide none.
|
|
382
|
+
pub fn is_empty(&self) -> bool {
|
|
383
|
+
!self.require_absent
|
|
384
|
+
&& self.expected_version.is_none()
|
|
385
|
+
&& self.expected_epoch.is_none()
|
|
386
|
+
&& self.expected_lease_id.is_none()
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/// Which predicate failed, and what the authority actually holds.
|
|
391
|
+
///
|
|
392
|
+
/// Structured rather than a formatted string, because the caller has to tell a
|
|
393
|
+
/// lost race from a broken deployment: the first is retried with fresh state,
|
|
394
|
+
/// the second is not retried at all.
|
|
395
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
396
|
+
#[serde(rename_all = "camelCase", tag = "predicate")]
|
|
397
|
+
pub enum PreconditionFailure {
|
|
398
|
+
/// Required absent, but the record exists.
|
|
399
|
+
Present { collection: String, key: String },
|
|
400
|
+
/// A predicate was given for a record that does not exist.
|
|
401
|
+
Missing { collection: String, key: String },
|
|
402
|
+
Version { collection: String, key: String, expected: u64, actual: u64 },
|
|
403
|
+
Epoch { collection: String, key: String, expected: u64, actual: u64 },
|
|
404
|
+
Lease { collection: String, key: String, expected: String, actual: Option<String> },
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
impl PreconditionFailure {
|
|
408
|
+
pub fn collection(&self) -> &str {
|
|
409
|
+
match self {
|
|
410
|
+
Self::Present { collection, .. }
|
|
411
|
+
| Self::Missing { collection, .. }
|
|
412
|
+
| Self::Version { collection, .. }
|
|
413
|
+
| Self::Epoch { collection, .. }
|
|
414
|
+
| Self::Lease { collection, .. } => collection,
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/// The storage key, which is namespaced as `collection:id`.
|
|
418
|
+
pub fn key(&self) -> &str {
|
|
419
|
+
match self {
|
|
420
|
+
Self::Present { key, .. }
|
|
421
|
+
| Self::Missing { key, .. }
|
|
422
|
+
| Self::Version { key, .. }
|
|
423
|
+
| Self::Epoch { key, .. }
|
|
424
|
+
| Self::Lease { key, .. } => key,
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
impl Display for PreconditionFailure {
|
|
430
|
+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
431
|
+
match self {
|
|
432
|
+
Self::Present { collection, key } =>
|
|
433
|
+
write!(f, "{collection}/{key} was required to be absent but exists"),
|
|
434
|
+
Self::Missing { collection, key } =>
|
|
435
|
+
write!(f, "{collection}/{key} does not exist"),
|
|
436
|
+
Self::Version { collection, key, expected, actual } =>
|
|
437
|
+
write!(f, "{collection}/{key} is at version {actual}, expected {expected}"),
|
|
438
|
+
Self::Epoch { collection, key, expected, actual } =>
|
|
439
|
+
write!(f, "{collection}/{key} is at epoch {actual}, expected {expected}"),
|
|
440
|
+
Self::Lease { collection, key, expected, actual } =>
|
|
441
|
+
write!(f, "{collection}/{key} holds lease {actual:?}, expected {expected}"),
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
329
445
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
330
446
|
pub struct AtomicCommit {
|
|
331
447
|
pub transaction_id: String,
|
|
@@ -927,6 +1043,40 @@ impl FeltDb {
|
|
|
927
1043
|
preconditions: &[AtomicPrecondition],
|
|
928
1044
|
mutations: &[AtomicMutation],
|
|
929
1045
|
audit: Option<Value>,
|
|
1046
|
+
) -> Result<AtomicCommit> {
|
|
1047
|
+
self.apply_atomic_transaction_guarded(
|
|
1048
|
+
transaction_id,
|
|
1049
|
+
payload_hash,
|
|
1050
|
+
expected_parent,
|
|
1051
|
+
preconditions,
|
|
1052
|
+
&[],
|
|
1053
|
+
mutations,
|
|
1054
|
+
audit,
|
|
1055
|
+
)
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/// Commit a transaction only if every record precondition holds.
|
|
1059
|
+
///
|
|
1060
|
+
/// The preconditions are evaluated inside the same lock as the writes and
|
|
1061
|
+
/// before any of them, so there is no state in which one record is checked
|
|
1062
|
+
/// while the others commit. A failure returns
|
|
1063
|
+
/// `FlowError::PreconditionFailed` and applies nothing -- not a prefix, not
|
|
1064
|
+
/// the guarded record, nothing.
|
|
1065
|
+
///
|
|
1066
|
+
/// A failed precondition does **not** consume the transaction id. The id is
|
|
1067
|
+
/// recorded only after a commit, so a caller that loses a race may retry
|
|
1068
|
+
/// the same id once it has re-read state, and a caller that wants the
|
|
1069
|
+
/// retry to be a distinct decision may use a new one. Both work; the choice
|
|
1070
|
+
/// is the caller's and is asserted in the tests rather than left implied.
|
|
1071
|
+
pub fn apply_atomic_transaction_guarded(
|
|
1072
|
+
&self,
|
|
1073
|
+
transaction_id: &str,
|
|
1074
|
+
payload_hash: Option<&str>,
|
|
1075
|
+
expected_parent: Option<u64>,
|
|
1076
|
+
preconditions: &[AtomicPrecondition],
|
|
1077
|
+
record_preconditions: &[RecordPrecondition],
|
|
1078
|
+
mutations: &[AtomicMutation],
|
|
1079
|
+
audit: Option<Value>,
|
|
930
1080
|
) -> Result<AtomicCommit> {
|
|
931
1081
|
let (commit, events) = {
|
|
932
1082
|
let mut inner = self.inner.lock().expect("lock poisoned");
|
|
@@ -944,6 +1094,140 @@ impl FeltDb {
|
|
|
944
1094
|
duplicate: true,
|
|
945
1095
|
});
|
|
946
1096
|
}
|
|
1097
|
+
// Record preconditions, evaluated before anything is staged and
|
|
1098
|
+
// under the same lock the writes take. A caller reads state,
|
|
1099
|
+
// decides, and commits; this is the fence that makes the decision
|
|
1100
|
+
// still true at the moment of the write.
|
|
1101
|
+
for condition in record_preconditions {
|
|
1102
|
+
if condition.is_empty() {
|
|
1103
|
+
return Err(FlowError::CapabilityError(format!(
|
|
1104
|
+
"EMPTY_PRECONDITION:{}:{}",
|
|
1105
|
+
condition.capability, condition.key
|
|
1106
|
+
)));
|
|
1107
|
+
}
|
|
1108
|
+
let collection = condition.capability.clone();
|
|
1109
|
+
let key = condition.key.clone();
|
|
1110
|
+
let current = inner
|
|
1111
|
+
.rows
|
|
1112
|
+
.get(&condition.capability)
|
|
1113
|
+
.and_then(|bucket| bucket.get(&condition.key));
|
|
1114
|
+
|
|
1115
|
+
if condition.require_absent {
|
|
1116
|
+
if current.is_some() {
|
|
1117
|
+
return Err(FlowError::PreconditionFailed(Box::new(
|
|
1118
|
+
PreconditionFailure::Present { collection, key },
|
|
1119
|
+
)));
|
|
1120
|
+
}
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
let Some(current) = current else {
|
|
1125
|
+
return Err(FlowError::PreconditionFailed(Box::new(
|
|
1126
|
+
PreconditionFailure::Missing { collection, key },
|
|
1127
|
+
)));
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
// The same fields single-record CAS compares, read the same way.
|
|
1131
|
+
if let Some(expected) = condition.expected_version {
|
|
1132
|
+
let actual = current
|
|
1133
|
+
.value
|
|
1134
|
+
.get("__version")
|
|
1135
|
+
.and_then(Value::as_u64)
|
|
1136
|
+
.unwrap_or(1);
|
|
1137
|
+
if actual != expected {
|
|
1138
|
+
return Err(FlowError::PreconditionFailed(Box::new(
|
|
1139
|
+
PreconditionFailure::Version { collection, key, expected, actual },
|
|
1140
|
+
)));
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
if let Some(expected) = condition.expected_epoch {
|
|
1144
|
+
let actual = current
|
|
1145
|
+
.value
|
|
1146
|
+
.pointer("/authority/epoch")
|
|
1147
|
+
.and_then(Value::as_u64)
|
|
1148
|
+
.unwrap_or(0);
|
|
1149
|
+
if actual != expected {
|
|
1150
|
+
return Err(FlowError::PreconditionFailed(Box::new(
|
|
1151
|
+
PreconditionFailure::Epoch { collection, key, expected, actual },
|
|
1152
|
+
)));
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
if let Some(expected) = &condition.expected_lease_id {
|
|
1156
|
+
let lease = current.value.get("lease").filter(|lease| !lease.is_null());
|
|
1157
|
+
let held = lease.and_then(|lease| {
|
|
1158
|
+
lease.get("leaseId").and_then(Value::as_str).map(str::to_string)
|
|
1159
|
+
});
|
|
1160
|
+
// An expired lease is not held, so naming it is a conflict:
|
|
1161
|
+
// the caller believes it owns something it no longer does.
|
|
1162
|
+
let unexpired = lease
|
|
1163
|
+
.and_then(|lease| lease.get("expiresAt").and_then(Value::as_u64))
|
|
1164
|
+
.is_some_and(|expires_at| expires_at > now_ms() as u64);
|
|
1165
|
+
if held.as_deref() != Some(expected.as_str()) || !unexpired {
|
|
1166
|
+
return Err(FlowError::PreconditionFailed(Box::new(
|
|
1167
|
+
PreconditionFailure::Lease {
|
|
1168
|
+
collection,
|
|
1169
|
+
key,
|
|
1170
|
+
expected: expected.clone(),
|
|
1171
|
+
actual: held,
|
|
1172
|
+
},
|
|
1173
|
+
)));
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// Every precondition held. A write fenced by `expected_version` is a
|
|
1179
|
+
// conditional replacement and behaves as `updateIfVersion` does:
|
|
1180
|
+
// the authority validated version N above and writes N + 1 here,
|
|
1181
|
+
// inside the same lock, so the check and the advance cannot be
|
|
1182
|
+
// separated by another writer.
|
|
1183
|
+
//
|
|
1184
|
+
// Only a version-fenced write advances. An unconditional write
|
|
1185
|
+
// stores the caller's object verbatim, which is what preserves
|
|
1186
|
+
// every existing transaction caller, and a guard-only precondition
|
|
1187
|
+
// writes nothing at all. See
|
|
1188
|
+
// docs/architecture/transaction-version-contract.md.
|
|
1189
|
+
let mut advanced: Vec<AtomicMutation> = Vec::new();
|
|
1190
|
+
let fenced: HashMap<(&str, &str), u64> = record_preconditions
|
|
1191
|
+
.iter()
|
|
1192
|
+
.filter_map(|condition| {
|
|
1193
|
+
condition
|
|
1194
|
+
.expected_version
|
|
1195
|
+
.map(|version| ((condition.capability.as_str(), condition.key.as_str()), version))
|
|
1196
|
+
})
|
|
1197
|
+
.collect();
|
|
1198
|
+
let mutations: &[AtomicMutation] = if fenced.is_empty() {
|
|
1199
|
+
mutations
|
|
1200
|
+
} else {
|
|
1201
|
+
advanced = mutations
|
|
1202
|
+
.iter()
|
|
1203
|
+
.map(|mutation| {
|
|
1204
|
+
let Some(expected) =
|
|
1205
|
+
fenced.get(&(mutation.capability.as_str(), mutation.key.as_str()))
|
|
1206
|
+
else {
|
|
1207
|
+
return mutation.clone();
|
|
1208
|
+
};
|
|
1209
|
+
// A delete has no record left to carry a version, so a
|
|
1210
|
+
// fenced delete is fence-then-remove and nothing more.
|
|
1211
|
+
let Some(Value::Object(fields)) = mutation.value.clone() else {
|
|
1212
|
+
return mutation.clone();
|
|
1213
|
+
};
|
|
1214
|
+
let mut fields = fields;
|
|
1215
|
+
// The authority owns the transition. A `__version` the
|
|
1216
|
+
// caller put in the value is replaced rather than
|
|
1217
|
+
// honoured, exactly as updateIfVersion strips it,
|
|
1218
|
+
// because a caller that computes a different next
|
|
1219
|
+
// version must not be able to install it.
|
|
1220
|
+
fields.insert("__version".to_string(), Value::from(expected + 1));
|
|
1221
|
+
AtomicMutation {
|
|
1222
|
+
capability: mutation.capability.clone(),
|
|
1223
|
+
key: mutation.key.clone(),
|
|
1224
|
+
value: Some(Value::Object(fields)),
|
|
1225
|
+
}
|
|
1226
|
+
})
|
|
1227
|
+
.collect();
|
|
1228
|
+
&advanced
|
|
1229
|
+
};
|
|
1230
|
+
|
|
947
1231
|
if let Some(expected) = expected_parent {
|
|
948
1232
|
if expected != inner.sequence {
|
|
949
1233
|
return Err(FlowError::CapabilityError(format!(
|