@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.
Files changed (92) hide show
  1. package/dist/agent-runtime.d.ts +64 -2
  2. package/dist/agent-runtime.d.ts.map +1 -1
  3. package/dist/agent-runtime.js +121 -11
  4. package/dist/agent-state.d.ts +48 -0
  5. package/dist/agent-state.d.ts.map +1 -1
  6. package/dist/agent-state.js +174 -12
  7. package/dist/agent.d.ts +10 -1
  8. package/dist/agent.d.ts.map +1 -1
  9. package/dist/agent.js +10 -1
  10. package/dist/build-identity.json +4 -4
  11. package/dist/cli/commands.js +27 -6
  12. package/dist/collection.d.ts +3 -1
  13. package/dist/collection.d.ts.map +1 -1
  14. package/dist/collection.js +30 -8
  15. package/dist/create/build-identity.json +4 -4
  16. package/dist/create/cli.js +1 -1
  17. package/dist/create/create.js +4 -6
  18. package/dist/create/package-versions.js +1 -1
  19. package/dist/create/server-source/Cargo.lock +6 -6
  20. package/dist/create/server-source/Cargo.toml +1 -1
  21. package/dist/create/server-source/crates/feltdb/src/lib.rs +126 -35
  22. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +162 -53
  23. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +137 -1
  24. package/dist/create/server-source/crates/feltdb/src/workload.rs +1427 -91
  25. package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
  26. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
  27. package/dist/create/server-source/crates/feltdb-server/README.md +6 -1
  28. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +9 -1
  29. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +85 -25
  30. package/dist/create/server-source/crates/feltdb-server/src/backup.rs +4 -0
  31. package/dist/create/server-source/crates/feltdb-server/src/console_accounts.rs +1009 -0
  32. package/dist/create/server-source/crates/feltdb-server/src/control_authorization.rs +441 -0
  33. package/dist/create/server-source/crates/feltdb-server/src/identity.rs +68 -6
  34. package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +355 -23
  35. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +3 -0
  36. package/dist/create/server-source/crates/feltdb-server/src/main.rs +879 -84
  37. package/dist/create/server-source/crates/feltdb-server/src/redaction.rs +208 -0
  38. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +198 -19
  39. package/dist/create/server-source/crates/feltdb-server/tests/transaction_precondition_contract.rs +403 -0
  40. package/dist/db.d.ts +64 -9
  41. package/dist/db.d.ts.map +1 -1
  42. package/dist/db.js +153 -27
  43. package/dist/deployment.d.ts +19 -0
  44. package/dist/deployment.d.ts.map +1 -1
  45. package/dist/deployment.js +38 -2
  46. package/dist/embedded-query.d.ts.map +1 -1
  47. package/dist/embedded-query.js +5 -4
  48. package/dist/embedded-transaction.d.ts +8 -4
  49. package/dist/embedded-transaction.d.ts.map +1 -1
  50. package/dist/embedded-transaction.js +55 -19
  51. package/dist/error-codes.d.ts +31 -2
  52. package/dist/error-codes.d.ts.map +1 -1
  53. package/dist/error-codes.js +41 -2
  54. package/dist/feltdb.d.ts +7 -0
  55. package/dist/feltdb.d.ts.map +1 -1
  56. package/dist/file-db.d.ts +27 -0
  57. package/dist/file-db.d.ts.map +1 -1
  58. package/dist/file-db.js +145 -7
  59. package/dist/http-client.d.ts +11 -3
  60. package/dist/http-client.d.ts.map +1 -1
  61. package/dist/http-client.js +27 -14
  62. package/dist/http-db.d.ts +27 -1
  63. package/dist/http-db.d.ts.map +1 -1
  64. package/dist/http-db.js +252 -54
  65. package/dist/index.js +1 -1
  66. package/dist/local-state-lock.d.ts +34 -0
  67. package/dist/local-state-lock.d.ts.map +1 -0
  68. package/dist/local-state-lock.js +225 -0
  69. package/dist/reactive-graph.d.ts +0 -3
  70. package/dist/reactive-graph.d.ts.map +1 -1
  71. package/dist/reactive-graph.js +11 -1
  72. package/dist/runtime-registry.d.ts +7 -2
  73. package/dist/runtime-registry.d.ts.map +1 -1
  74. package/dist/runtime-registry.js +2 -2
  75. package/dist/studio/components/index.js +1 -1
  76. package/dist/studio/{components-BSGmHfif.js → components-CJmzntBi.js} +2 -1
  77. package/dist/studio/index.js +1 -1
  78. package/dist/studio-app/assets/{feltdb_wasm-3hDngl1h.js → feltdb_wasm-BjM1ylL3.js} +1 -1
  79. package/dist/studio-app/assets/feltdb_wasm_bg-TseJn2Ax.wasm +0 -0
  80. package/dist/studio-app/assets/index-1wd0uKua.js +30 -0
  81. package/dist/studio-app/index.html +1 -1
  82. package/dist/transaction.d.ts +27 -11
  83. package/dist/transaction.d.ts.map +1 -1
  84. package/dist/transaction.js +20 -14
  85. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  86. package/dist/wasm/package.json +1 -1
  87. package/dist/workload.d.ts +31 -3
  88. package/dist/workload.d.ts.map +1 -1
  89. package/dist/workload.js +28 -3
  90. package/package.json +1 -1
  91. package/dist/studio-app/assets/feltdb_wasm_bg-CFQT4gCy.wasm +0 -0
  92. package/dist/studio-app/assets/index-Dql1hS5m.js +0 -30
@@ -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
- # Optional credential for authenticated remote browser access
1263
- VITE_FELTDB_TOKEN=
1264
- # Legacy alias accepted by older tooling
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
- - \`VITE_FELTDB_API_KEY\`: API token for authentication
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.13.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.1"
222
+ version = "0.10.2"
223
223
  source = "registry+https://github.com/rust-lang/crates.io-index"
224
- checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
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.2.2"
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.2.2"
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.2.2"
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.2.2"
555
+ version = "0.3.0"
556
556
  dependencies = [
557
557
  "feltdb",
558
558
  "getrandom 0.2.17",
@@ -14,5 +14,5 @@ exclude = [
14
14
  resolver = "2"
15
15
 
16
16
  [workspace.package]
17
- version = "0.2.2"
17
+ version = "0.3.0"
18
18
  edition = "2021"
@@ -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 inner.applied_transactions.contains(transaction_id) {
3389
- if let Some(expected_hash) = payload_hash {
3390
- if inner
3391
- .transaction_payload_hashes
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
- if db
2482
- .has_applied_transaction(&storage_transaction_id)
2483
- .map_err(StateFailure::storage)?
2484
- {
2485
- if db
2486
- .applied_transaction_payload_hash(&storage_transaction_id)
2487
- .map_err(StateFailure::storage)?
2488
- .as_deref()
2489
- != Some(payload_hash.as_str())
2490
- {
2491
- return Err(StateFailure::new(
2492
- "IDEMPOTENCY_CONFLICT",
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
- return Err(StateFailure::new("CONFLICT", "record already exists"));
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 = Some(refusal.key().to_string());
2932
- failure.failure = serde_json::to_value(refusal.as_ref()).ok();
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
- execute_transaction(&db, &schema, &stale, None)
3267
- .unwrap_err()
3268
- .code,
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