@feltdb/core 0.13.0 → 0.15.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/dist/agent-runtime.d.ts +64 -2
- package/dist/agent-runtime.d.ts.map +1 -1
- package/dist/agent-runtime.js +121 -11
- package/dist/agent-state.d.ts +48 -0
- package/dist/agent-state.d.ts.map +1 -1
- package/dist/agent-state.js +174 -12
- package/dist/agent.d.ts +10 -1
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +10 -1
- package/dist/build-identity.json +4 -4
- package/dist/cli/commands.js +27 -6
- package/dist/collection.d.ts +3 -1
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +30 -8
- package/dist/create/build-identity.json +4 -4
- package/dist/create/cli.js +1 -1
- package/dist/create/create.js +4 -6
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +6 -6
- package/dist/create/server-source/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb/src/lib.rs +126 -35
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +162 -53
- package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +137 -1
- package/dist/create/server-source/crates/feltdb/src/workload.rs +1427 -91
- package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb-server/README.md +6 -1
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +9 -1
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +85 -25
- package/dist/create/server-source/crates/feltdb-server/src/backup.rs +4 -0
- package/dist/create/server-source/crates/feltdb-server/src/console_accounts.rs +1009 -0
- package/dist/create/server-source/crates/feltdb-server/src/control_authorization.rs +441 -0
- package/dist/create/server-source/crates/feltdb-server/src/identity.rs +68 -6
- package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +355 -23
- package/dist/create/server-source/crates/feltdb-server/src/lib.rs +3 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +879 -84
- package/dist/create/server-source/crates/feltdb-server/src/redaction.rs +208 -0
- package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +198 -19
- package/dist/create/server-source/crates/feltdb-server/tests/transaction_precondition_contract.rs +403 -0
- package/dist/db.d.ts +64 -9
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +153 -27
- package/dist/deployment.d.ts +19 -0
- package/dist/deployment.d.ts.map +1 -1
- package/dist/deployment.js +38 -2
- package/dist/embedded-query.d.ts.map +1 -1
- package/dist/embedded-query.js +5 -4
- package/dist/embedded-transaction.d.ts +8 -4
- package/dist/embedded-transaction.d.ts.map +1 -1
- package/dist/embedded-transaction.js +55 -19
- package/dist/error-codes.d.ts +31 -2
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +41 -2
- package/dist/feltdb.d.ts +7 -0
- package/dist/feltdb.d.ts.map +1 -1
- package/dist/file-db.d.ts +27 -0
- package/dist/file-db.d.ts.map +1 -1
- package/dist/file-db.js +145 -7
- package/dist/http-client.d.ts +11 -3
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +27 -14
- package/dist/http-db.d.ts +27 -1
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +252 -54
- package/dist/index.js +1 -1
- package/dist/local-state-lock.d.ts +34 -0
- package/dist/local-state-lock.d.ts.map +1 -0
- package/dist/local-state-lock.js +225 -0
- package/dist/reactive-graph.d.ts +0 -3
- package/dist/reactive-graph.d.ts.map +1 -1
- package/dist/reactive-graph.js +11 -1
- package/dist/runtime-registry.d.ts +7 -2
- package/dist/runtime-registry.d.ts.map +1 -1
- package/dist/runtime-registry.js +2 -2
- package/dist/studio/components/index.js +1 -1
- package/dist/studio/{components-BSGmHfif.js → components-CJmzntBi.js} +2 -1
- package/dist/studio/index.js +1 -1
- package/dist/studio-app/assets/{feltdb_wasm-3hDngl1h.js → feltdb_wasm-BjM1ylL3.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-TseJn2Ax.wasm +0 -0
- package/dist/studio-app/assets/index-1wd0uKua.js +30 -0
- package/dist/studio-app/index.html +1 -1
- package/dist/transaction.d.ts +27 -11
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +20 -14
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/wasm/package.json +1 -1
- package/dist/workload.d.ts +31 -3
- package/dist/workload.d.ts.map +1 -1
- package/dist/workload.js +28 -3
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-CFQT4gCy.wasm +0 -0
- package/dist/studio-app/assets/index-Dql1hS5m.js +0 -30
package/dist/create/create.js
CHANGED
|
@@ -1259,10 +1259,9 @@ main().catch(console.error);
|
|
|
1259
1259
|
# FeltDB Server URL (self-hosted or managed runtime)
|
|
1260
1260
|
VITE_FELTDB_URL=http://localhost:7700
|
|
1261
1261
|
|
|
1262
|
-
#
|
|
1263
|
-
|
|
1264
|
-
#
|
|
1265
|
-
VITE_FELTDB_API_KEY=
|
|
1262
|
+
# Browser code authenticates users with db.auth.signIn() (end-user sessions).
|
|
1263
|
+
# Never put a FeltDB service key (fdb_live_…) in a VITE_ variable: Vite
|
|
1264
|
+
# inlines every VITE_* value into the browser bundle.
|
|
1266
1265
|
|
|
1267
1266
|
# Managed example: https://runtime.your-app.feltdb.com
|
|
1268
1267
|
VITE_FELTDB_MANAGED_URL=
|
|
@@ -1508,7 +1507,7 @@ npm run feltdb:import
|
|
|
1508
1507
|
|
|
1509
1508
|
### Self-Hosted
|
|
1510
1509
|
- \`VITE_FELTDB_URL\`: Docker container URL (default: http://localhost:7700)
|
|
1511
|
-
- \`
|
|
1510
|
+
- \`FELTDB_TOKEN\`: API key for server-side code only (never a \`VITE_\` variable; browsers use \`db.auth.signIn()\`)
|
|
1512
1511
|
- \`FELTDB_IMAGE\`: Docker image to use (optional override)
|
|
1513
1512
|
- \`DOCKER_NETWORK\`: Custom Docker network (optional)
|
|
1514
1513
|
|
|
@@ -1701,7 +1700,6 @@ Configuration is in \`feltdb.config.json\`:
|
|
|
1701
1700
|
Create a \`.env.local\` file (copy from \`.env.example\`):
|
|
1702
1701
|
|
|
1703
1702
|
\`\`\`
|
|
1704
|
-
VITE_FELTDB_API_KEY=your_api_key_here
|
|
1705
1703
|
VITE_FELTDB_URL=http://localhost:7700
|
|
1706
1704
|
VITE_FELTDB_MANAGED_URL=https://api.feltdb.com
|
|
1707
1705
|
VITE_FELTDB_MANAGED_APPLICATION_ID=your_application_id
|
|
@@ -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.
|
|
3
|
+
export const FELTDB_PACKAGE_VERSION = '0.15.0';
|
|
4
4
|
export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
|
|
@@ -219,9 +219,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
|
|
219
219
|
|
|
220
220
|
[[package]]
|
|
221
221
|
name = "chacha20"
|
|
222
|
-
version = "0.10.
|
|
222
|
+
version = "0.10.2"
|
|
223
223
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
224
|
-
checksum = "
|
|
224
|
+
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
|
225
225
|
dependencies = [
|
|
226
226
|
"cfg-if",
|
|
227
227
|
"cpufeatures 0.3.0",
|
|
@@ -494,7 +494,7 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
|
|
494
494
|
|
|
495
495
|
[[package]]
|
|
496
496
|
name = "feltdb"
|
|
497
|
-
version = "0.
|
|
497
|
+
version = "0.3.0"
|
|
498
498
|
dependencies = [
|
|
499
499
|
"async-stream",
|
|
500
500
|
"async-trait",
|
|
@@ -513,7 +513,7 @@ dependencies = [
|
|
|
513
513
|
|
|
514
514
|
[[package]]
|
|
515
515
|
name = "feltdb-client"
|
|
516
|
-
version = "0.
|
|
516
|
+
version = "0.3.0"
|
|
517
517
|
dependencies = [
|
|
518
518
|
"feltdb",
|
|
519
519
|
"reqwest",
|
|
@@ -526,7 +526,7 @@ dependencies = [
|
|
|
526
526
|
|
|
527
527
|
[[package]]
|
|
528
528
|
name = "feltdb-server"
|
|
529
|
-
version = "0.
|
|
529
|
+
version = "0.3.0"
|
|
530
530
|
dependencies = [
|
|
531
531
|
"argon2",
|
|
532
532
|
"async-stream",
|
|
@@ -552,7 +552,7 @@ dependencies = [
|
|
|
552
552
|
|
|
553
553
|
[[package]]
|
|
554
554
|
name = "feltdb-wasm"
|
|
555
|
-
version = "0.
|
|
555
|
+
version = "0.3.0"
|
|
556
556
|
dependencies = [
|
|
557
557
|
"feltdb",
|
|
558
558
|
"getrandom 0.2.17",
|
|
@@ -1133,6 +1133,48 @@ impl PreconditionFailure {
|
|
|
1133
1133
|
}
|
|
1134
1134
|
}
|
|
1135
1135
|
|
|
1136
|
+
impl PreconditionFailure {
|
|
1137
|
+
/// The refusal as every transport serializes it: `predicate`, the
|
|
1138
|
+
/// application's `collection` (never a storage namespace), the record `id`,
|
|
1139
|
+
/// its `key` as `collection:id`, and `expected` / `actual` where the
|
|
1140
|
+
/// predicate has them. A fence names the first fenced collection and
|
|
1141
|
+
/// `key: "read-basis"`, with no `id` and no tokens.
|
|
1142
|
+
///
|
|
1143
|
+
/// `layout` is where the authority addressed the record: the direct route
|
|
1144
|
+
/// keys rows `collection:id` under `collection`; an application namespace
|
|
1145
|
+
/// keys them by id under `namespace:collection`.
|
|
1146
|
+
pub fn public_json(&self, layout: &ReadLayout) -> Value {
|
|
1147
|
+
let mut value = serde_json::to_value(self).unwrap_or(Value::Null);
|
|
1148
|
+
if matches!(self, Self::Fence { .. }) {
|
|
1149
|
+
return value;
|
|
1150
|
+
}
|
|
1151
|
+
let (collection, id) = match layout {
|
|
1152
|
+
ReadLayout::Direct => {
|
|
1153
|
+
let collection = self.collection().to_string();
|
|
1154
|
+
let id = self
|
|
1155
|
+
.key()
|
|
1156
|
+
.strip_prefix(&format!("{collection}:"))
|
|
1157
|
+
.unwrap_or(self.key())
|
|
1158
|
+
.to_string();
|
|
1159
|
+
(collection, id)
|
|
1160
|
+
}
|
|
1161
|
+
ReadLayout::Namespace(namespace) => (
|
|
1162
|
+
self.collection()
|
|
1163
|
+
.strip_prefix(&format!("{namespace}:"))
|
|
1164
|
+
.unwrap_or(self.collection())
|
|
1165
|
+
.to_string(),
|
|
1166
|
+
self.key().to_string(),
|
|
1167
|
+
),
|
|
1168
|
+
};
|
|
1169
|
+
if let Some(object) = value.as_object_mut() {
|
|
1170
|
+
object.insert("key".into(), Value::String(format!("{collection}:{id}")));
|
|
1171
|
+
object.insert("collection".into(), Value::String(collection));
|
|
1172
|
+
object.insert("id".into(), Value::String(id));
|
|
1173
|
+
}
|
|
1174
|
+
value
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1136
1178
|
impl Display for PreconditionFailure {
|
|
1137
1179
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
1138
1180
|
match self {
|
|
@@ -3225,6 +3267,20 @@ impl FeltDb {
|
|
|
3225
3267
|
.collect()
|
|
3226
3268
|
}
|
|
3227
3269
|
|
|
3270
|
+
/// The original commit of a transaction id, if it committed, exactly as a
|
|
3271
|
+
/// retry of it through `apply_atomic_transaction_*` would report it: the
|
|
3272
|
+
/// original revisions and positions (`duplicate: true`), or
|
|
3273
|
+
/// `TransactionIdPayloadMismatch` when `payload_hash` differs from the one
|
|
3274
|
+
/// the id was committed with. Read-only.
|
|
3275
|
+
pub fn committed_transaction(
|
|
3276
|
+
&self,
|
|
3277
|
+
transaction_id: &str,
|
|
3278
|
+
payload_hash: Option<&str>,
|
|
3279
|
+
) -> Result<Option<AtomicCommit>> {
|
|
3280
|
+
let inner = self.state();
|
|
3281
|
+
committed_transaction_locked(&inner, transaction_id, payload_hash).transpose()
|
|
3282
|
+
}
|
|
3283
|
+
|
|
3228
3284
|
pub fn has_applied_transaction(&self, transaction_id: &str) -> Result<bool> {
|
|
3229
3285
|
Ok(self
|
|
3230
3286
|
.inner
|
|
@@ -3385,41 +3441,10 @@ impl FeltDb {
|
|
|
3385
3441
|
) -> Result<AtomicCommit> {
|
|
3386
3442
|
let (commit, events) = {
|
|
3387
3443
|
let mut inner = self.state();
|
|
3388
|
-
if
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
.get(transaction_id)
|
|
3393
|
-
.map(String::as_str)
|
|
3394
|
-
!= Some(expected_hash)
|
|
3395
|
-
{
|
|
3396
|
-
return Err(FlowError::TransactionIdPayloadMismatch {
|
|
3397
|
-
transaction_id: transaction_id.to_string(),
|
|
3398
|
-
});
|
|
3399
|
-
}
|
|
3400
|
-
}
|
|
3401
|
-
let (base_revision, commit_revision) = inner
|
|
3402
|
-
.transaction_revisions
|
|
3403
|
-
.get(transaction_id)
|
|
3404
|
-
.copied()
|
|
3405
|
-
.unwrap_or((inner.authority_revision, inner.authority_revision));
|
|
3406
|
-
let (state_before, state_after) = inner
|
|
3407
|
-
.transaction_history
|
|
3408
|
-
.iter()
|
|
3409
|
-
.find(|attribution| attribution.transaction_id == transaction_id)
|
|
3410
|
-
.map(|attribution| (attribution.state_before, attribution.state_after))
|
|
3411
|
-
.unwrap_or((inner.sequence, inner.sequence));
|
|
3412
|
-
return Ok(AtomicCommit {
|
|
3413
|
-
transaction_id: transaction_id.into(),
|
|
3414
|
-
base_revision,
|
|
3415
|
-
commit_revision,
|
|
3416
|
-
status: TransactionStatus::Committed,
|
|
3417
|
-
state_before,
|
|
3418
|
-
state_after,
|
|
3419
|
-
rows: vec![],
|
|
3420
|
-
duplicate: true,
|
|
3421
|
-
payload_binding: payload_hash.map(|_| PayloadBinding::Verified),
|
|
3422
|
-
});
|
|
3444
|
+
if let Some(duplicate) =
|
|
3445
|
+
committed_transaction_locked(&inner, transaction_id, payload_hash)
|
|
3446
|
+
{
|
|
3447
|
+
return duplicate;
|
|
3423
3448
|
}
|
|
3424
3449
|
// Record preconditions, evaluated before anything is staged and
|
|
3425
3450
|
// under the same lock the writes take. A caller reads state,
|
|
@@ -4167,6 +4192,21 @@ impl FeltDb {
|
|
|
4167
4192
|
self.get::<Value>(key)
|
|
4168
4193
|
}
|
|
4169
4194
|
|
|
4195
|
+
/// One live row of a collection by its key, cloned under the state lock.
|
|
4196
|
+
///
|
|
4197
|
+
/// A keyed lookup, not a scan: callers that know the canonical key of the
|
|
4198
|
+
/// record they need (Durable Work's projection refresh, for one) use this
|
|
4199
|
+
/// rather than a predicate over the whole collection.
|
|
4200
|
+
pub(crate) fn live_row(&self, capability: &str, key: &str) -> Option<StoredRow> {
|
|
4201
|
+
let inner = self.state();
|
|
4202
|
+
inner
|
|
4203
|
+
.rows
|
|
4204
|
+
.get(capability)
|
|
4205
|
+
.and_then(|rows| rows.get(key))
|
|
4206
|
+
.filter(|row| !row.deleted)
|
|
4207
|
+
.cloned()
|
|
4208
|
+
}
|
|
4209
|
+
|
|
4170
4210
|
/// List the canonical values in one collection/capability namespace.
|
|
4171
4211
|
///
|
|
4172
4212
|
/// This clones the whole collection, so its cost is linear in collection
|
|
@@ -6021,6 +6061,57 @@ fn append_event_with(
|
|
|
6021
6061
|
Ok(())
|
|
6022
6062
|
}
|
|
6023
6063
|
|
|
6064
|
+
/// The answer to a retried transaction id, read under the commit lock.
|
|
6065
|
+
///
|
|
6066
|
+
/// `None` when the id has not committed. Otherwise the *original* commit's
|
|
6067
|
+
/// revisions and positions, never the store's current ones, or
|
|
6068
|
+
/// `TransactionIdPayloadMismatch` when a payload hash is given and differs from
|
|
6069
|
+
/// the one the id was committed with. Nothing is evaluated and nothing is
|
|
6070
|
+
/// applied: a duplicate is answered before any precondition is read.
|
|
6071
|
+
fn committed_transaction_locked(
|
|
6072
|
+
inner: &Inner,
|
|
6073
|
+
transaction_id: &str,
|
|
6074
|
+
payload_hash: Option<&str>,
|
|
6075
|
+
) -> Option<Result<AtomicCommit>> {
|
|
6076
|
+
if !inner.applied_transactions.contains(transaction_id) {
|
|
6077
|
+
return None;
|
|
6078
|
+
}
|
|
6079
|
+
if let Some(expected_hash) = payload_hash {
|
|
6080
|
+
if inner
|
|
6081
|
+
.transaction_payload_hashes
|
|
6082
|
+
.get(transaction_id)
|
|
6083
|
+
.map(String::as_str)
|
|
6084
|
+
!= Some(expected_hash)
|
|
6085
|
+
{
|
|
6086
|
+
return Some(Err(FlowError::TransactionIdPayloadMismatch {
|
|
6087
|
+
transaction_id: transaction_id.to_string(),
|
|
6088
|
+
}));
|
|
6089
|
+
}
|
|
6090
|
+
}
|
|
6091
|
+
let (base_revision, commit_revision) = inner
|
|
6092
|
+
.transaction_revisions
|
|
6093
|
+
.get(transaction_id)
|
|
6094
|
+
.copied()
|
|
6095
|
+
.unwrap_or((inner.authority_revision, inner.authority_revision));
|
|
6096
|
+
let (state_before, state_after) = inner
|
|
6097
|
+
.transaction_history
|
|
6098
|
+
.iter()
|
|
6099
|
+
.find(|attribution| attribution.transaction_id == transaction_id)
|
|
6100
|
+
.map(|attribution| (attribution.state_before, attribution.state_after))
|
|
6101
|
+
.unwrap_or((inner.sequence, inner.sequence));
|
|
6102
|
+
Some(Ok(AtomicCommit {
|
|
6103
|
+
transaction_id: transaction_id.into(),
|
|
6104
|
+
base_revision,
|
|
6105
|
+
commit_revision,
|
|
6106
|
+
status: TransactionStatus::Committed,
|
|
6107
|
+
state_before,
|
|
6108
|
+
state_after,
|
|
6109
|
+
rows: vec![],
|
|
6110
|
+
duplicate: true,
|
|
6111
|
+
payload_binding: payload_hash.map(|_| PayloadBinding::Verified),
|
|
6112
|
+
}))
|
|
6113
|
+
}
|
|
6114
|
+
|
|
6024
6115
|
fn append_transaction(path: &Path, transaction: &TransactionLogRecord) -> Result<()> {
|
|
6025
6116
|
// A transaction commit fsyncs. This span therefore includes the fsync, which
|
|
6026
6117
|
// is the point: it is the durability cost of a commit, measured rather than
|
|
@@ -1061,6 +1061,13 @@ impl StateFailure {
|
|
|
1061
1061
|
}
|
|
1062
1062
|
}
|
|
1063
1063
|
|
|
1064
|
+
fn idempotency_conflict() -> StateFailure {
|
|
1065
|
+
StateFailure::new(
|
|
1066
|
+
"IDEMPOTENCY_CONFLICT",
|
|
1067
|
+
"transaction ID was already committed with a different canonical payload",
|
|
1068
|
+
)
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1064
1071
|
fn collection<'a>(
|
|
1065
1072
|
schema: &'a StateSchema,
|
|
1066
1073
|
name: &str,
|
|
@@ -2478,53 +2485,114 @@ fn execute_transaction_with_policies(
|
|
|
2478
2485
|
transaction_id
|
|
2479
2486
|
);
|
|
2480
2487
|
let payload_hash = format!("sha256:{:x}", Sha256::digest(&canonical));
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
"transaction ID was already committed with a different canonical payload",
|
|
2494
|
-
));
|
|
2495
|
-
}
|
|
2496
|
-
let state = db.sequence().map_err(StateFailure::storage)?;
|
|
2497
|
-
let cursor = db
|
|
2498
|
-
.operation_versions()
|
|
2499
|
-
.map_err(StateFailure::storage)?
|
|
2500
|
-
.into_iter()
|
|
2501
|
-
.collect();
|
|
2502
|
-
let audit = TransactionAudit {
|
|
2503
|
-
transaction_id: transaction_id.clone(),
|
|
2504
|
-
subject: request.authorization.subject.clone(),
|
|
2505
|
-
tenant: request.tenant_id.clone(),
|
|
2506
|
-
application: request.application_id.clone(),
|
|
2507
|
-
revision: request.revision_id.clone(),
|
|
2508
|
-
schema_version: request.schema_version,
|
|
2509
|
-
state_before: state,
|
|
2510
|
-
state_after: state,
|
|
2511
|
-
operations: request.operations.len(),
|
|
2512
|
-
authorization_decisions: vec!["state:write allowed".into()],
|
|
2513
|
-
causal_parent: request.causal_parent,
|
|
2514
|
-
result: "duplicate".into(),
|
|
2515
|
-
};
|
|
2516
|
-
return Ok(TransactionResult {
|
|
2517
|
-
transaction_id,
|
|
2518
|
-
base_revision: db.current_revision().map_err(StateFailure::storage)?,
|
|
2519
|
-
commit_revision: db.current_revision().map_err(StateFailure::storage)?,
|
|
2520
|
-
status: crate::TransactionStatus::Committed,
|
|
2521
|
-
state_before: state,
|
|
2522
|
-
state_after: state,
|
|
2523
|
-
causal_cursor: cursor,
|
|
2524
|
-
duplicate: true,
|
|
2525
|
-
audit,
|
|
2526
|
-
});
|
|
2488
|
+
// A retried id is answered before anything is evaluated against current
|
|
2489
|
+
// state, with the original commit: its revisions and positions, not the
|
|
2490
|
+
// store's current ones. The same answer the commit lock gives a retry that
|
|
2491
|
+
// races past this check.
|
|
2492
|
+
if let Some(result) = committed_result(
|
|
2493
|
+
db,
|
|
2494
|
+
request,
|
|
2495
|
+
&transaction_id,
|
|
2496
|
+
&storage_transaction_id,
|
|
2497
|
+
&payload_hash,
|
|
2498
|
+
)? {
|
|
2499
|
+
return Ok(result);
|
|
2527
2500
|
}
|
|
2501
|
+
match commit_new_transaction(
|
|
2502
|
+
db,
|
|
2503
|
+
schema,
|
|
2504
|
+
request,
|
|
2505
|
+
uniform_write_policy,
|
|
2506
|
+
collection_write_policies,
|
|
2507
|
+
&transaction_id,
|
|
2508
|
+
&storage_transaction_id,
|
|
2509
|
+
&payload_hash,
|
|
2510
|
+
) {
|
|
2511
|
+
Ok(result) => Ok(result),
|
|
2512
|
+
// The request was evaluated against state read before the commit
|
|
2513
|
+
// lock. An identical request that committed in between makes this one
|
|
2514
|
+
// its duplicate, and the duplicate -- the original commit -- is the
|
|
2515
|
+
// answer, not the refusal that later state produced (a create that
|
|
2516
|
+
// now "exists", a fence that now "changed").
|
|
2517
|
+
Err(failure) => match committed_result(
|
|
2518
|
+
db,
|
|
2519
|
+
request,
|
|
2520
|
+
&transaction_id,
|
|
2521
|
+
&storage_transaction_id,
|
|
2522
|
+
&payload_hash,
|
|
2523
|
+
)? {
|
|
2524
|
+
Some(result) => Ok(result),
|
|
2525
|
+
None => Err(failure),
|
|
2526
|
+
},
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
/// A retried id, answered with the original commit: its revisions and
|
|
2531
|
+
/// positions, never the store's current ones. `IDEMPOTENCY_CONFLICT` when the id
|
|
2532
|
+
/// committed with a different payload; `None` when it has not committed.
|
|
2533
|
+
fn committed_result(
|
|
2534
|
+
db: &FeltDb,
|
|
2535
|
+
request: &TransactionRequest,
|
|
2536
|
+
transaction_id: &str,
|
|
2537
|
+
storage_transaction_id: &str,
|
|
2538
|
+
payload_hash: &str,
|
|
2539
|
+
) -> Result<Option<TransactionResult>, StateFailure> {
|
|
2540
|
+
let Some(original) = db
|
|
2541
|
+
.committed_transaction(storage_transaction_id, Some(payload_hash))
|
|
2542
|
+
.map_err(|error| match error {
|
|
2543
|
+
crate::FlowError::TransactionIdPayloadMismatch { .. } => idempotency_conflict(),
|
|
2544
|
+
other => StateFailure::storage(other),
|
|
2545
|
+
})?
|
|
2546
|
+
else {
|
|
2547
|
+
return Ok(None);
|
|
2548
|
+
};
|
|
2549
|
+
let cursor = db
|
|
2550
|
+
.operation_versions()
|
|
2551
|
+
.map_err(StateFailure::storage)?
|
|
2552
|
+
.into_iter()
|
|
2553
|
+
.collect();
|
|
2554
|
+
let audit = TransactionAudit {
|
|
2555
|
+
transaction_id: transaction_id.to_string(),
|
|
2556
|
+
subject: request.authorization.subject.clone(),
|
|
2557
|
+
tenant: request.tenant_id.clone(),
|
|
2558
|
+
application: request.application_id.clone(),
|
|
2559
|
+
revision: request.revision_id.clone(),
|
|
2560
|
+
schema_version: request.schema_version,
|
|
2561
|
+
state_before: original.state_before,
|
|
2562
|
+
state_after: original.state_after,
|
|
2563
|
+
operations: request.operations.len(),
|
|
2564
|
+
authorization_decisions: vec!["state:write allowed".into()],
|
|
2565
|
+
causal_parent: request.causal_parent,
|
|
2566
|
+
result: "duplicate".into(),
|
|
2567
|
+
};
|
|
2568
|
+
Ok(Some(TransactionResult {
|
|
2569
|
+
transaction_id: transaction_id.to_string(),
|
|
2570
|
+
base_revision: original.base_revision,
|
|
2571
|
+
commit_revision: original.commit_revision,
|
|
2572
|
+
status: original.status,
|
|
2573
|
+
state_before: original.state_before,
|
|
2574
|
+
state_after: original.state_after,
|
|
2575
|
+
causal_cursor: cursor,
|
|
2576
|
+
duplicate: true,
|
|
2577
|
+
audit,
|
|
2578
|
+
}))
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
/// Evaluate and commit a transaction id that has not committed.
|
|
2582
|
+
#[allow(clippy::too_many_arguments)]
|
|
2583
|
+
fn commit_new_transaction(
|
|
2584
|
+
db: &FeltDb,
|
|
2585
|
+
schema: &StateSchema,
|
|
2586
|
+
request: &TransactionRequest,
|
|
2587
|
+
uniform_write_policy: Option<&PolicySubject>,
|
|
2588
|
+
collection_write_policies: Option<&BTreeMap<String, PolicySubject>>,
|
|
2589
|
+
transaction_id: &str,
|
|
2590
|
+
storage_transaction_id: &str,
|
|
2591
|
+
payload_hash: &str,
|
|
2592
|
+
) -> Result<TransactionResult, StateFailure> {
|
|
2593
|
+
let transaction_id = transaction_id.to_string();
|
|
2594
|
+
let storage_transaction_id = storage_transaction_id.to_string();
|
|
2595
|
+
let payload_hash = payload_hash.to_string();
|
|
2528
2596
|
let namespace = request
|
|
2529
2597
|
.state_namespace
|
|
2530
2598
|
.as_deref()
|
|
@@ -2753,7 +2821,19 @@ fn execute_transaction_with_policies(
|
|
|
2753
2821
|
let value = match operation.kind {
|
|
2754
2822
|
TransactionOperationKind::Insert => {
|
|
2755
2823
|
if existing_value.is_some() {
|
|
2756
|
-
|
|
2824
|
+
// A create is conditional on absence: the same structured
|
|
2825
|
+
// refusal a `requireAbsent` create gets on every route.
|
|
2826
|
+
let refusal = crate::PreconditionFailure::Present {
|
|
2827
|
+
collection: capability.clone(),
|
|
2828
|
+
key: key.clone(),
|
|
2829
|
+
};
|
|
2830
|
+
let mut failure =
|
|
2831
|
+
StateFailure::new("PRECONDITION_FAILED", &refusal.to_string());
|
|
2832
|
+
failure.transaction = Some(transaction_id.clone());
|
|
2833
|
+
failure.resource = Some(format!("{}:{}", operation.collection, key));
|
|
2834
|
+
failure.failure =
|
|
2835
|
+
Some(refusal.public_json(&crate::ReadLayout::Namespace(namespace.clone())));
|
|
2836
|
+
return Err(failure);
|
|
2757
2837
|
}
|
|
2758
2838
|
let mut value = operation.value.clone();
|
|
2759
2839
|
match definition {
|
|
@@ -2924,12 +3004,18 @@ fn execute_transaction_with_policies(
|
|
|
2924
3004
|
// "this deployment is broken", so it is carried through as
|
|
2925
3005
|
// structure rather than flattened into the message.
|
|
2926
3006
|
if let FlowError::PreconditionFailed(refusal) = &error {
|
|
3007
|
+
let layout = crate::ReadLayout::Namespace(namespace.clone());
|
|
3008
|
+
let public = refusal.public_json(&layout);
|
|
2927
3009
|
let mut failure =
|
|
2928
3010
|
StateFailure::new("PRECONDITION_FAILED", &error.to_string());
|
|
2929
3011
|
failure.transaction = Some(transaction_id.clone());
|
|
2930
3012
|
failure.causal_position = db.sequence().ok();
|
|
2931
|
-
failure.resource =
|
|
2932
|
-
|
|
3013
|
+
failure.resource = public
|
|
3014
|
+
.get("key")
|
|
3015
|
+
.and_then(Value::as_str)
|
|
3016
|
+
.map(str::to_owned)
|
|
3017
|
+
.or_else(|| Some(refusal.key().to_string()));
|
|
3018
|
+
failure.failure = Some(public);
|
|
2933
3019
|
if let PreconditionFailure::Version {
|
|
2934
3020
|
expected, actual, ..
|
|
2935
3021
|
} = refusal.as_ref()
|
|
@@ -2949,6 +3035,21 @@ fn execute_transaction_with_policies(
|
|
|
2949
3035
|
failure.actual = Some(Value::from(*actual));
|
|
2950
3036
|
return failure;
|
|
2951
3037
|
}
|
|
3038
|
+
if let FlowError::TransactionIdPayloadMismatch { .. } = &error {
|
|
3039
|
+
// Two different payloads raced past the check above under one
|
|
3040
|
+
// id; the commit lock admitted the first. Same refusal.
|
|
3041
|
+
let mut failure = idempotency_conflict();
|
|
3042
|
+
failure.transaction = Some(transaction_id.clone());
|
|
3043
|
+
return failure;
|
|
3044
|
+
}
|
|
3045
|
+
if let FlowError::Io(_) | FlowError::Serde(_) = &error {
|
|
3046
|
+
// The commit could not be made durable. Never a lost race and
|
|
3047
|
+
// never a success: the outcome must be treated as unknown and
|
|
3048
|
+
// reconciled by retrying with the same transaction id.
|
|
3049
|
+
let mut failure = StateFailure::new("DURABILITY_FAILURE", &error.to_string());
|
|
3050
|
+
failure.transaction = Some(transaction_id.clone());
|
|
3051
|
+
return failure;
|
|
3052
|
+
}
|
|
2952
3053
|
let text = error.to_string();
|
|
2953
3054
|
if text.contains("INVALID_READ_SET:") {
|
|
2954
3055
|
// A malformed or foreign basis is a caller error, never a lost race.
|
|
@@ -3262,11 +3363,19 @@ mod tests {
|
|
|
3262
3363
|
let mut stale = request;
|
|
3263
3364
|
stale.transaction_id = Some("stale".into());
|
|
3264
3365
|
stale.causal_parent = Some(0);
|
|
3366
|
+
// A create of a record that exists is refused as a structured presence
|
|
3367
|
+
// precondition, as a `requireAbsent` create is on every other route.
|
|
3368
|
+
let refused = execute_transaction(&db, &schema, &stale, None).unwrap_err();
|
|
3369
|
+
assert_eq!(refused.code, "PRECONDITION_FAILED");
|
|
3370
|
+
let failure = refused.failure.expect("structured failure");
|
|
3371
|
+
assert_eq!(failure["predicate"], "present");
|
|
3372
|
+
assert_eq!(failure["collection"], "incidents");
|
|
3373
|
+
assert_eq!(failure["id"], "one");
|
|
3374
|
+
assert_eq!(failure["key"], "incidents:one");
|
|
3265
3375
|
assert_eq!(
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
"CONFLICT"
|
|
3376
|
+
db.export_snapshot().unwrap().rows.len(),
|
|
3377
|
+
2,
|
|
3378
|
+
"nothing written"
|
|
3270
3379
|
);
|
|
3271
3380
|
}
|
|
3272
3381
|
/// A conditional write through the canonical contract has to mean the same
|