@feltdb/core 0.8.4 → 0.8.5

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 (149) hide show
  1. package/dist/create/package-versions.js +1 -1
  2. package/dist/create/server-source/Cargo.lock +165 -0
  3. package/dist/create/server-source/Cargo.toml +9 -0
  4. package/dist/create/server-source/crates/feltdb/Cargo.toml +3 -0
  5. package/dist/create/server-source/crates/feltdb/benches/gate13_baseline.rs +44 -44
  6. package/dist/create/server-source/crates/feltdb/benches/gate13_phase_7_1_release_economics.rs +12 -24
  7. package/dist/create/server-source/crates/feltdb/benches/gate_13_redux.rs +7 -13
  8. package/dist/create/server-source/crates/feltdb/benches/gate_13_regression_runner.rs +13 -10
  9. package/dist/create/server-source/crates/feltdb/benches/gate_14a_concurrent_writer_scaling.rs +12 -9
  10. package/dist/create/server-source/crates/feltdb/benches/gate_14a_production_admission_revalidation.rs +78 -25
  11. package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc2_admission_contract.rs +16 -13
  12. package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc_root_cause.rs +13 -5
  13. package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync1_queued_prototype.rs +41 -22
  14. package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync_economics.rs +33 -15
  15. package/dist/create/server-source/crates/feltdb/benches/gate_14b_causal_backlog_scaling.rs +100 -33
  16. package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_contract_test.rs +56 -20
  17. package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_scaling.rs +116 -41
  18. package/dist/create/server-source/crates/feltdb/benches/gate_14d_combined_dimension_scaling.rs +186 -55
  19. package/dist/create/server-source/crates/feltdb/benches/phase_7_1_2_optimization_benchmark.rs +64 -26
  20. package/dist/create/server-source/crates/feltdb/benches/phase_7_1_3_crossover_analysis.rs +46 -15
  21. package/dist/create/server-source/crates/feltdb/src/admission.rs +8 -15
  22. package/dist/create/server-source/crates/feltdb/src/admission_contract_tests.rs +43 -13
  23. package/dist/create/server-source/crates/feltdb/src/adversarial_transport.rs +15 -42
  24. package/dist/create/server-source/crates/feltdb/src/analytics.rs +65 -19
  25. package/dist/create/server-source/crates/feltdb/src/application.rs +113 -30
  26. package/dist/create/server-source/crates/feltdb/src/authorization_security_tests.rs +475 -140
  27. package/dist/create/server-source/crates/feltdb/src/cardinality_diagnostics.rs +17 -15
  28. package/dist/create/server-source/crates/feltdb/src/cardinality_endpoint.rs +0 -1
  29. package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +59 -15
  30. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +266 -114
  31. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier_phase_7_1.rs +25 -7
  32. package/dist/create/server-source/crates/feltdb/src/concurrency_fuzzing.rs +10 -15
  33. package/dist/create/server-source/crates/feltdb/src/consistency_contract.rs +3 -11
  34. package/dist/create/server-source/crates/feltdb/src/crash_atomic_boundary.rs +14 -5
  35. package/dist/create/server-source/crates/feltdb/src/crash_injection.rs +21 -25
  36. package/dist/create/server-source/crates/feltdb/src/crash_recovery_tests.rs +14 -11
  37. package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +103 -22
  38. package/dist/create/server-source/crates/feltdb/src/distributed_indexing.rs +18 -15
  39. package/dist/create/server-source/crates/feltdb/src/durability_guarantees.rs +12 -8
  40. package/dist/create/server-source/crates/feltdb/src/durable_dedup_set.rs +1 -5
  41. package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +87 -23
  42. package/dist/create/server-source/crates/feltdb/src/durable_operation_log.rs +3 -7
  43. package/dist/create/server-source/crates/feltdb/src/durable_sync.rs +10 -9
  44. package/dist/create/server-source/crates/feltdb/src/in_process_transport.rs +1 -6
  45. package/dist/create/server-source/crates/feltdb/src/indexing.rs +35 -38
  46. package/dist/create/server-source/crates/feltdb/src/lib.rs +507 -6
  47. package/dist/create/server-source/crates/feltdb/src/managed_cas_tests.rs +4 -1
  48. package/dist/create/server-source/crates/feltdb/src/metrics.rs +0 -1
  49. package/dist/create/server-source/crates/feltdb/src/multi_node_convergence.rs +1 -2
  50. package/dist/create/server-source/crates/feltdb/src/multi_operation_transaction.rs +107 -30
  51. package/dist/create/server-source/crates/feltdb/src/observability.rs +19 -6
  52. package/dist/create/server-source/crates/feltdb/src/operation_algebra.rs +12 -11
  53. package/dist/create/server-source/crates/feltdb/src/operation_log.rs +9 -4
  54. package/dist/create/server-source/crates/feltdb/src/p1_application_atomicity.rs +65 -18
  55. package/dist/create/server-source/crates/feltdb/src/p1_atomicity_acceptance.rs +193 -57
  56. package/dist/create/server-source/crates/feltdb/src/partition_reconciliation.rs +37 -27
  57. package/dist/create/server-source/crates/feltdb/src/permutation_scheduler.rs +38 -10
  58. package/dist/create/server-source/crates/feltdb/src/persistence_reality.rs +20 -14
  59. package/dist/create/server-source/crates/feltdb/src/phase1b_acceptance.rs +394 -229
  60. package/dist/create/server-source/crates/feltdb/src/phase1c1_acceptance.rs +8 -6
  61. package/dist/create/server-source/crates/feltdb/src/phase1c2_acceptance.rs +11 -13
  62. package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +79 -70
  63. package/dist/create/server-source/crates/feltdb/src/phase1c_atomicity_proof.rs +3 -3
  64. package/dist/create/server-source/crates/feltdb/src/phase5_integration.rs +33 -11
  65. package/dist/create/server-source/crates/feltdb/src/phase5_scenarios.rs +6 -6
  66. package/dist/create/server-source/crates/feltdb/src/phase6_adversarial_scenarios.rs +14 -56
  67. package/dist/create/server-source/crates/feltdb/src/phase6_convergence_validator.rs +29 -27
  68. package/dist/create/server-source/crates/feltdb/src/phase6_persistence.rs +35 -17
  69. package/dist/create/server-source/crates/feltdb/src/phase_1c_real_tcp.rs +8 -2
  70. package/dist/create/server-source/crates/feltdb/src/phase_2a_failures.rs +59 -15
  71. package/dist/create/server-source/crates/feltdb/src/phase_2b_network.rs +70 -17
  72. package/dist/create/server-source/crates/feltdb/src/phase_2c_cascading.rs +23 -6
  73. package/dist/create/server-source/crates/feltdb/src/phase_3_durability.rs +12 -3
  74. package/dist/create/server-source/crates/feltdb/src/phase_4_baseline.rs +41 -11
  75. package/dist/create/server-source/crates/feltdb/src/phase_5_soak.rs +56 -25
  76. package/dist/create/server-source/crates/feltdb/src/policy_evaluation.rs +701 -245
  77. package/dist/create/server-source/crates/feltdb/src/production_api.rs +31 -13
  78. package/dist/create/server-source/crates/feltdb/src/query_performance.rs +6 -8
  79. package/dist/create/server-source/crates/feltdb/src/replay_fuzzing.rs +5 -5
  80. package/dist/create/server-source/crates/feltdb/src/replica_acknowledgements.rs +48 -18
  81. package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +30 -11
  82. package/dist/create/server-source/crates/feltdb/src/replication_manager.rs +6 -3
  83. package/dist/create/server-source/crates/feltdb/src/replication_protocol.rs +4 -3
  84. package/dist/create/server-source/crates/feltdb/src/sharding.rs +36 -10
  85. package/dist/create/server-source/crates/feltdb/src/state_conflict_contract.rs +516 -0
  86. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +13 -4
  87. package/dist/create/server-source/crates/feltdb/src/state_diff_contract.rs +222 -0
  88. package/dist/create/server-source/crates/feltdb/src/state_facade.rs +82 -54
  89. package/dist/create/server-source/crates/feltdb/src/state_hash.rs +2 -2
  90. package/dist/create/server-source/crates/feltdb/src/state_model.rs +1514 -537
  91. package/dist/create/server-source/crates/feltdb/src/state_transition_store.rs +6 -3
  92. package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +672 -0
  93. package/dist/create/server-source/crates/feltdb/src/submission.rs +5 -11
  94. package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +6 -8
  95. package/dist/create/server-source/crates/feltdb/src/transaction_api.rs +24 -35
  96. package/dist/create/server-source/crates/feltdb/src/transaction_invariants.rs +24 -8
  97. package/dist/create/server-source/crates/feltdb/src/transaction_preconditions.rs +248 -59
  98. package/dist/create/server-source/crates/feltdb/src/transactions.rs +17 -20
  99. package/dist/create/server-source/crates/feltdb/src/trigger_contract.rs +749 -0
  100. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +1 -0
  101. package/dist/create/server-source/crates/feltdb/src/workload.rs +512 -4
  102. package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +3 -4
  103. package/dist/create/server-source/crates/feltdb/tests/branching_evidence.rs +299 -0
  104. package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +288 -0
  105. package/dist/create/server-source/crates/feltdb/tests/durable_format_compatibility.rs +392 -0
  106. package/dist/create/server-source/crates/feltdb/tests/feltdb_state_boundary_tests.rs +436 -220
  107. package/dist/create/server-source/crates/feltdb/tests/fixtures/state_conflict_contract_corpus.json +1916 -0
  108. package/dist/create/server-source/crates/feltdb/tests/fixtures/state_diff_contract_corpus.json +1878 -0
  109. package/dist/create/server-source/crates/feltdb/tests/fixtures/trigger_contract_corpus.json +1862 -0
  110. package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +2 -1
  111. package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +80 -25
  112. package/dist/create/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +5 -8
  113. package/dist/create/server-source/crates/feltdb/tests/pr8_vocabulary_assessment.rs +52 -44
  114. package/dist/create/server-source/crates/feltdb/tests/pr9_phase2_boundary_tests.rs +33 -16
  115. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3a_path_a_tests.rs +22 -7
  116. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_authorized_mutations.rs +41 -22
  117. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_role_based_authorization.rs +25 -8
  118. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_simple_auth_delete.rs +9 -6
  119. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_team_delete_role_authorization.rs +120 -69
  120. package/dist/create/server-source/crates/feltdb/tests/pr9_teams_role_based_access.rs +21 -10
  121. package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +1032 -0
  122. package/dist/create/server-source/crates/feltdb/tests/reconciliation_application.rs +868 -0
  123. package/dist/create/server-source/crates/feltdb/tests/reconciliation_wire_format_evidence.rs +221 -0
  124. package/dist/create/server-source/crates/feltdb/tests/resource_scoped_revisions.rs +338 -0
  125. package/dist/create/server-source/crates/feltdb/tests/revision_identity_contract.rs +1039 -0
  126. package/dist/create/server-source/crates/feltdb/tests/revision_model_decision.rs +739 -0
  127. package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +427 -0
  128. package/dist/create/server-source/crates/feltdb/tests/saas_authorization_integration.rs +3 -3
  129. package/dist/create/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +25 -22
  130. package/dist/create/server-source/crates/feltdb/tests/state_conflict_contract_conformance.rs +1799 -0
  131. package/dist/create/server-source/crates/feltdb/tests/state_diff_contract_conformance.rs +1316 -0
  132. package/dist/create/server-source/crates/feltdb/tests/state_model_integration.rs +53 -61
  133. package/dist/create/server-source/crates/feltdb/tests/state_persistence_integration.rs +156 -61
  134. package/dist/create/server-source/crates/feltdb/tests/state_store_boundary_evidence.rs +299 -0
  135. package/dist/create/server-source/crates/feltdb/tests/sync_divergence_evidence.rs +255 -0
  136. package/dist/create/server-source/crates/feltdb/tests/three_way_input_boundary_evidence.rs +249 -0
  137. package/dist/create/server-source/crates/feltdb/tests/trigger_contract_conformance.rs +994 -0
  138. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +2 -1
  139. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +164 -13
  140. package/dist/create/server-source/crates/feltdb-server/src/main.rs +326 -6
  141. package/dist/studio-app/assets/{feltdb_wasm-CVQWgXO-.js → feltdb_wasm-DaNwCLRX.js} +1 -1
  142. package/dist/studio-app/assets/feltdb_wasm_bg-DnsHNv6g.wasm +0 -0
  143. package/dist/studio-app/assets/{index-DwgNAIIX.js → index-j8IlhNqJ.js} +1 -1
  144. package/dist/studio-app/index.html +1 -1
  145. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  146. package/dist/workload.d.ts +2 -1
  147. package/dist/workload.d.ts.map +1 -1
  148. package/package.json +1 -1
  149. package/dist/studio-app/assets/feltdb_wasm_bg-CNVpvaZV.wasm +0 -0
@@ -109,11 +109,14 @@ pub mod replication_manager;
109
109
  pub mod replication_protocol;
110
110
  mod routing;
111
111
  pub mod sharding;
112
+ pub mod state_conflict_contract;
112
113
  pub mod state_contract;
114
+ pub mod state_diff_contract;
113
115
  pub mod state_facade;
114
116
  pub mod state_hash;
115
117
  pub mod state_model;
116
118
  pub mod state_transition_store;
119
+ pub mod state_trigger;
117
120
  mod storage;
118
121
  pub mod submission;
119
122
  mod sync;
@@ -126,6 +129,7 @@ mod transaction_invariants;
126
129
  pub mod transaction_preconditions;
127
130
  pub mod transactions;
128
131
  mod trigger;
132
+ pub mod trigger_contract;
129
133
  pub mod worker_mesh;
130
134
  mod workflow;
131
135
  #[cfg(test)]
@@ -222,14 +226,27 @@ pub use sharding::{
222
226
  HotspotAlert, RebalanceOperation, ShardDistributionSummary, ShardId, ShardKey, ShardManager,
223
227
  ShardMetrics, ShardRange, ShardingStrategy,
224
228
  };
229
+ pub use state_conflict_contract::{
230
+ classify_states, StateConflictInput, StateConflictOutput, STATE_CONFLICT_CONTRACT_ID,
231
+ STATE_CONFLICT_CONTRACT_VERSION,
232
+ };
233
+ pub use state_diff_contract::{
234
+ diff_states, StateDiffInput, StateDiffOutput, STATE_DIFF_CONTRACT_ID,
235
+ STATE_DIFF_CONTRACT_VERSION,
236
+ };
225
237
  pub use state_facade::FeltDBStateSystem;
226
238
  pub use state_hash::{CanonicalState, StateHash};
227
239
  pub use state_model::{
228
- ChangeKind, ConflictClass, ConflictClassification, PathComponent, PathConflict,
240
+ apply_reconciliation_plan, path_relation, paths_overlap, reconcile, resolve_path, ChangeKind,
241
+ ConflictClass, ConflictClassification, PathComponent, PathConflict, PathRelation,
229
242
  ReconciliationPlan, Relationship, SemanticChange, SemanticDiff, StateId,
230
243
  StateReconciliationResult, StateRevision, StateStore, StateTopology, STATE_MODEL_VERSION,
231
244
  };
232
245
  pub use state_transition_store::{MemoryStateTransitionStore, StateTransitionRecord};
246
+ pub use state_trigger::{
247
+ StateTrigger, StateTriggerChangeKind, StateTriggerError, StateTriggerPredicate,
248
+ StateTriggerStore, TriggeredWorkload,
249
+ };
233
250
  pub use storage::{CheckpointData, FileStorage, MemoryStorage, Storage};
234
251
  pub use submission::{SubmissionManager, SubmissionMetrics};
235
252
  pub use sync::{ChangeLog, Conflict, ConflictDetector, PeerState, SyncMessage, SyncState};
@@ -239,6 +256,12 @@ pub use transactions::{
239
256
  StateTransition, TransactionExecutor, TransitionResult,
240
257
  };
241
258
  pub use trigger::{Trigger, TriggerFilter, TriggerRegistry};
259
+ pub use trigger_contract::{
260
+ canonical_input_bytes, canonical_output_bytes, evaluate_canonical, evaluate_trigger,
261
+ ContractChange, ContractChangeKind, ContractErrorBody, ContractErrorCode, ContractPredicate,
262
+ ContractRuntime, ContractTrigger, MatchOutcome, MatchReason, TriggerMatchInput,
263
+ TriggerMatchOutput, TRIGGER_MATCH_CONTRACT_ID, TRIGGER_MATCH_CONTRACT_VERSION,
264
+ };
242
265
  pub use workflow::{
243
266
  BlockedReason, WorkflowGraph, WorkflowInstance, WorkflowNode, WorkflowOperation, WorkflowRef,
244
267
  WorkflowState, WorkflowStepRef, WorkflowStepState,
@@ -285,6 +308,12 @@ pub enum FlowError {
285
308
  expected: u64,
286
309
  actual: u64,
287
310
  },
311
+ /// The durable database is in a format this build does not understand.
312
+ ///
313
+ /// Returned by [`FeltDb::open`] **before any record is interpreted**, and
314
+ /// before anything is written. A database that returns this error is
315
+ /// untouched.
316
+ IncompatibleFormat(Box<FormatIncompatibility>),
288
317
  }
289
318
 
290
319
  impl Display for FlowError {
@@ -299,6 +328,9 @@ impl Display for FlowError {
299
328
  f,
300
329
  "REVISION_CONFLICT: expected authority revision {expected}, current revision is {actual}"
301
330
  ),
331
+ FlowError::IncompatibleFormat(incompatibility) => {
332
+ write!(f, "INCOMPATIBLE_DURABLE_FORMAT: {incompatibility}")
333
+ }
302
334
  }
303
335
  }
304
336
  }
@@ -361,6 +393,13 @@ struct Inner {
361
393
  /// inside the same state mutation boundary as `rows` and rebuilt from
362
394
  /// `rows` after recovery.
363
395
  equality_index: EqualityIndex,
396
+ /// Each resource's newest revision, as `(id, sequence)`.
397
+ ///
398
+ /// Derived from the `state` rows exactly like `equality_index` is derived
399
+ /// from `rows`: never durable, rebuilt after recovery. It exists so that
400
+ /// minting a revision does not have to scan a resource's history to find
401
+ /// the parent it descends from.
402
+ revision_heads: HashMap<String, (state_model::StateId, u64)>,
364
403
  }
365
404
 
366
405
  /// The state lock, held with its wait and hold time attributable.
@@ -732,11 +771,107 @@ pub struct StoredRow {
732
771
  pub operation: Option<Operation>,
733
772
  }
734
773
 
774
+ /// The durable format this build writes and understands.
775
+ ///
776
+ /// Independent of the package version on purpose: an application release that
777
+ /// changes nothing about persistence must not appear to change the database.
778
+ ///
779
+ /// - **1** — everything before revisions became resource-scoped. Never written
780
+ /// by any build; it names the era that predates format versioning.
781
+ /// - **2** — resource-scoped revisions: a `StateRevision` carries `resource`,
782
+ /// `content_id` and `sequence`.
783
+ pub const DURABLE_FORMAT_VERSION: u32 = 2;
784
+
785
+ /// The log record that carries the format version.
786
+ const FORMAT_RECORD_TYPE: &str = "feltdb.format.v1";
787
+
788
+ #[derive(Debug, Serialize, Deserialize)]
789
+ struct FormatRecord {
790
+ record_type: String,
791
+ format_version: u32,
792
+ }
793
+
794
+ /// What format a durable database is in.
795
+ #[derive(Debug, Clone, PartialEq, Eq)]
796
+ pub enum DurableFormat {
797
+ /// A format record states the version.
798
+ Versioned(u32),
799
+ /// No format record: the database predates format versioning.
800
+ ///
801
+ /// This is a version, not an absence of one. It is the only case where the
802
+ /// records themselves are examined to decide compatibility, and that
803
+ /// examination is a deliberate one-time probe rather than a deserialization
804
+ /// attempt whose failure is discarded.
805
+ Unversioned,
806
+ }
807
+
808
+ impl Display for DurableFormat {
809
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
810
+ match self {
811
+ DurableFormat::Versioned(version) => write!(f, "version {version}"),
812
+ DurableFormat::Unversioned => write!(f, "unversioned (predates format versioning)"),
813
+ }
814
+ }
815
+ }
816
+
817
+ /// Why a durable database cannot be opened, and what to do about it.
818
+ #[derive(Debug, Clone, PartialEq, Eq)]
819
+ pub struct FormatIncompatibility {
820
+ /// What the database is.
821
+ pub found: DurableFormat,
822
+ /// What this build requires.
823
+ pub required: u32,
824
+ /// What is wrong, in terms of the data rather than the code.
825
+ pub reason: String,
826
+ /// What the operator can do.
827
+ pub action: String,
828
+ }
829
+
830
+ impl Display for FormatIncompatibility {
831
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
832
+ write!(
833
+ f,
834
+ "durable format {} is incompatible with this FeltDB, which requires \
835
+ format version {}; {}. No data was read or modified. {}",
836
+ self.found, self.required, self.reason, self.action
837
+ )
838
+ }
839
+ }
840
+
841
+ /// The result of examining a durable database without opening it.
842
+ #[derive(Debug, Clone, PartialEq, Eq)]
843
+ pub enum FormatCompatibility {
844
+ /// Safe to open.
845
+ Compatible(DurableFormat),
846
+ /// Must not be opened.
847
+ Incompatible(FormatIncompatibility),
848
+ }
849
+
850
+ impl FormatCompatibility {
851
+ /// Whether opening this database is safe.
852
+ pub fn is_compatible(&self) -> bool {
853
+ matches!(self, FormatCompatibility::Compatible(_))
854
+ }
855
+ }
856
+
735
857
  #[derive(Debug, Clone, Serialize, Deserialize)]
736
858
  pub struct DatabaseSnapshot {
737
859
  pub rows: Vec<StoredRow>,
738
860
  pub versions: HashMap<String, u64>,
739
861
  pub content_hash: String,
862
+ /// The durable format the rows are in.
863
+ ///
864
+ /// A snapshot carries whole `StoredRow`s, revisions included, so it is a
865
+ /// durable interchange artifact and not merely a protocol value: installing
866
+ /// one from a different format would reintroduce exactly the silent
867
+ /// misinterpretation the on-disk version exists to prevent.
868
+ ///
869
+ /// Defaults to 0 so a snapshot serialized before versioning still
870
+ /// deserializes; 0 is treated as unversioned and refused rather than
871
+ /// assumed current. `content_hash` deliberately does not cover this field,
872
+ /// so hashes computed before it existed remain valid.
873
+ #[serde(default)]
874
+ pub format_version: u32,
740
875
  }
741
876
 
742
877
  impl DatabaseSnapshot {
@@ -764,6 +899,10 @@ struct LogHeader {
764
899
  record_type: String,
765
900
  local_sequence: u64,
766
901
  observed_versions: HashMap<String, u64>,
902
+ /// Present since format versioning. Absent in headers written before it,
903
+ /// which deserialize as 0 and are classified as unversioned.
904
+ #[serde(default)]
905
+ format_version: u32,
767
906
  }
768
907
 
769
908
  #[derive(Debug, Default, Serialize, Deserialize)]
@@ -777,14 +916,222 @@ pub fn open<P: AsRef<Path>>(path: P) -> Result<FeltDb> {
777
916
  FeltDb::open(path)
778
917
  }
779
918
 
919
+ /// Capabilities whose rows are not themselves versioned.
920
+ ///
921
+ /// `state` holds the revisions, and `_retention` holds the watermark that says
922
+ /// how much of each resource's history survives. Minting a revision of either
923
+ /// would mint a revision of the mint.
924
+ const UNVERSIONED_CAPABILITIES: &[&str] = &["state", "_retention"];
925
+
926
+ /// Whether a write to this capability creates a revision.
927
+ fn is_versioned(capability: &str) -> bool {
928
+ !UNVERSIONED_CAPABILITIES.contains(&capability)
929
+ }
930
+
931
+ /// Rebuild the derived head index from the durable revisions.
932
+ ///
933
+ /// Called after recovery, for the same reason `equality_index` is: the index is
934
+ /// a convenience over `rows` and must never be the authority for what `rows`
935
+ /// contains.
936
+ fn rebuild_revision_heads(inner: &mut Inner) {
937
+ let mut heads: HashMap<String, (state_model::StateId, u64)> = HashMap::new();
938
+ if let Some(bucket) = inner.rows.get("state") {
939
+ for row in bucket.values() {
940
+ if row.deleted || !row.key.starts_with("state:revision:") {
941
+ continue;
942
+ }
943
+ let Ok(revision) =
944
+ serde_json::from_value::<state_model::StateRevision>(row.value.clone())
945
+ else {
946
+ continue;
947
+ };
948
+ let entry = heads
949
+ .entry(revision.resource.clone())
950
+ .or_insert_with(|| (revision.id.clone(), revision.sequence));
951
+ if revision.sequence >= entry.1 {
952
+ *entry = (revision.id.clone(), revision.sequence);
953
+ }
954
+ }
955
+ }
956
+ inner.revision_heads = heads;
957
+ }
958
+
959
+ /// Write one derived row inside an already-held state lock.
960
+ ///
961
+ /// Deliberately **not** a mutation: no sequence number, no vector clock tick,
962
+ /// no entry in the change log. A revision is a *derived* record of a mutation
963
+ /// that already has all three, and giving it its own would make every write
964
+ /// advance replication twice and double the operation stream.
965
+ ///
966
+ /// It is still durable — the row is appended to the log and enters `rows`
967
+ /// inside the same lock as the state it records, so a reader never observes a
968
+ /// mutation whose revision is missing, and a reopened database has both.
969
+ ///
970
+ /// The consequence is that revisions are **local and derived**, not replicated.
971
+ /// That is sound in principle, because a revision's identity is a function of
972
+ /// the resource, the content, the parent and the sequence — all of which a peer
973
+ /// that applies the same operation stream has. Whether the sync-apply path
974
+ /// actually mints is a separate question this change does not answer.
975
+ fn append_derived_row_locked(
976
+ inner: &mut Inner,
977
+ capability: &str,
978
+ key: &str,
979
+ value: Value,
980
+ ) -> Result<()> {
981
+ let row = StoredRow {
982
+ capability: capability.to_string(),
983
+ key: key.to_string(),
984
+ rust_type: "feltdb::state_model::StateRevision".to_string(),
985
+ value,
986
+ unix_ms: now_ms(),
987
+ content_hash: None,
988
+ flow_ref: None,
989
+ deleted: false,
990
+ operation: None,
991
+ };
992
+ append_event(&inner.path, &row)?;
993
+ inner.put_row(row);
994
+ Ok(())
995
+ }
996
+
997
+ /// Mint a revision for an authoritative write, under the caller's lock.
998
+ ///
999
+ /// This is where the resource-scoped model meets the write path. The resource
1000
+ /// is the record's key, the parent is that resource's current head, and the
1001
+ /// sequence is allocated from the parent — so a resource that returns to a value
1002
+ /// it previously held records a *new* revision rather than resurrecting the old
1003
+ /// one.
1004
+ ///
1005
+ /// Retention is applied here too, by the same decision function the explicit
1006
+ /// store API uses. Bounding history at the moment history is created is what
1007
+ /// keeps automatic minting from being an unbounded-history architecture.
1008
+ fn mint_revision_locked(inner: &mut Inner, resource: &str, content: String) -> Result<()> {
1009
+ let parent = inner.revision_heads.get(resource).cloned();
1010
+ let (parent_id, sequence) = match &parent {
1011
+ Some((id, sequence)) => (Some(id.clone()), sequence + 1),
1012
+ None => (None, 0),
1013
+ };
1014
+ let authority = inner.instance_id.clone();
1015
+ let revision = state_model::StateRevision::at(
1016
+ resource.to_string(),
1017
+ content,
1018
+ parent_id,
1019
+ sequence,
1020
+ authority,
1021
+ );
1022
+ let id = revision.id.clone();
1023
+ let key = format!("state:revision:{}", id.as_hex());
1024
+ let value = serde_json::to_value(&revision)?;
1025
+ append_derived_row_locked(inner, "state", &key, value)?;
1026
+ inner
1027
+ .revision_heads
1028
+ .insert(resource.to_string(), (id, sequence));
1029
+ apply_retention_locked(inner, resource)
1030
+ }
1031
+
1032
+ /// Expire whatever the resource's configured policy no longer retains.
1033
+ ///
1034
+ /// The common path is one map lookup: with no policy configured there is
1035
+ /// nothing to scan. A resource's history is only walked once a policy exists to
1036
+ /// bound it.
1037
+ fn apply_retention_locked(inner: &mut Inner, resource: &str) -> Result<()> {
1038
+ let retention_key = format!("_retention:{resource}");
1039
+ let Some(state_row) = inner
1040
+ .rows
1041
+ .get("_retention")
1042
+ .and_then(|bucket| bucket.get(&retention_key))
1043
+ else {
1044
+ return Ok(());
1045
+ };
1046
+ let Ok(mut state) = serde_json::from_value::<RetentionRecord>(state_row.value.clone()) else {
1047
+ return Ok(());
1048
+ };
1049
+ if state.policy.keep_last.is_none() {
1050
+ return Ok(());
1051
+ }
1052
+
1053
+ let mut history: Vec<(state_model::StateId, u64)> = Vec::new();
1054
+ if let Some(bucket) = inner.rows.get("state") {
1055
+ for row in bucket.values() {
1056
+ if row.deleted || !row.key.starts_with("state:revision:") {
1057
+ continue;
1058
+ }
1059
+ if let Ok(revision) =
1060
+ serde_json::from_value::<state_model::StateRevision>(row.value.clone())
1061
+ {
1062
+ if revision.resource == resource {
1063
+ history.push((revision.id, revision.sequence));
1064
+ }
1065
+ }
1066
+ }
1067
+ }
1068
+ history.sort_by_key(|(_, sequence)| *sequence);
1069
+
1070
+ let (expire, horizon) = state_model::revisions_to_expire(&history, state.policy.keep_last);
1071
+ if expire.is_empty() {
1072
+ return Ok(());
1073
+ }
1074
+ for id in &expire {
1075
+ let key = format!("state:revision:{}", id.as_hex());
1076
+ if let Some(bucket) = inner.rows.get_mut("state") {
1077
+ bucket.remove(&key);
1078
+ }
1079
+ let tombstone = StoredRow {
1080
+ capability: "state".to_string(),
1081
+ key,
1082
+ rust_type: "feltdb::state_model::StateRevision".to_string(),
1083
+ value: Value::Null,
1084
+ unix_ms: now_ms(),
1085
+ content_hash: None,
1086
+ flow_ref: None,
1087
+ deleted: true,
1088
+ operation: None,
1089
+ };
1090
+ append_event(&inner.path, &tombstone)?;
1091
+ }
1092
+ if let Some(horizon) = horizon {
1093
+ state.horizon = horizon;
1094
+ }
1095
+ let value = serde_json::to_value(&state)?;
1096
+ append_derived_row_locked(inner, "_retention", &retention_key, value)
1097
+ }
1098
+
1099
+ /// The durable shape of a resource's retention state.
1100
+ ///
1101
+ /// Mirrors `state_model`'s private record so the write boundary can read and
1102
+ /// advance the horizon without the store type.
1103
+ #[derive(Clone, Debug, Default, Serialize, Deserialize)]
1104
+ struct RetentionRecord {
1105
+ policy: state_model::RetentionPolicy,
1106
+ horizon: u64,
1107
+ }
1108
+
780
1109
  impl FeltDb {
781
1110
  pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
782
1111
  let path = path.as_ref().to_path_buf();
1112
+
1113
+ // Fail closed. The format is decided before any record is interpreted
1114
+ // and before anything is written, so a database this build does not
1115
+ // understand is refused with its bytes untouched rather than opened
1116
+ // with whatever happened to deserialize.
1117
+ if let FormatCompatibility::Incompatible(incompatibility) = inspect_durable_format(&path)? {
1118
+ return Err(FlowError::IncompatibleFormat(Box::new(incompatibility)));
1119
+ }
1120
+
783
1121
  if let Some(parent) = path.parent() {
784
1122
  fs::create_dir_all(parent)?;
785
1123
  }
786
1124
  if !path.exists() {
787
- OpenOptions::new().create(true).append(true).open(&path)?;
1125
+ let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
1126
+ // A new database states its format in its first record, so it never
1127
+ // has to be inferred again.
1128
+ write_json_line(
1129
+ &mut file,
1130
+ &FormatRecord {
1131
+ record_type: FORMAT_RECORD_TYPE.to_string(),
1132
+ format_version: DURABLE_FORMAT_VERSION,
1133
+ },
1134
+ )?;
788
1135
  }
789
1136
 
790
1137
  // Generate instance ID from path hash
@@ -804,6 +1151,7 @@ impl FeltDb {
804
1151
  inner.sync_state = SyncState::new(instance_id);
805
1152
  replay_log(&mut inner)?;
806
1153
  load_sync_metadata(&mut inner)?;
1154
+ rebuild_revision_heads(&mut inner);
807
1155
 
808
1156
  let (event_tx, _) = broadcast::channel(1024);
809
1157
 
@@ -954,6 +1302,9 @@ impl FeltDb {
954
1302
  inner.authority_revision += 1;
955
1303
  inner.change_log.add_operation(operation);
956
1304
  inner.put_row(row.clone());
1305
+ if is_versioned(&capability) {
1306
+ mint_revision_locked(&mut inner, key, value.to_string())?;
1307
+ }
957
1308
  let event = ChangeEvent {
958
1309
  capability,
959
1310
  key: key.to_string(),
@@ -1740,6 +2091,9 @@ impl FeltDb {
1740
2091
  ) -> Result<()> {
1741
2092
  let rust_type = type_name::<T>().to_string();
1742
2093
  let value_json = serde_json::to_value(value)?;
2094
+ // Canonical because `serde_json` is built without `preserve_order`, so
2095
+ // `Map` is a `BTreeMap` and `to_string` emits keys in sorted order.
2096
+ let canonical = value_json.to_string();
1743
2097
  let mut row = StoredRow {
1744
2098
  capability: capability.clone(),
1745
2099
  key: key.clone(),
@@ -1783,6 +2137,9 @@ impl FeltDb {
1783
2137
  append_event(&inner.path, &row)?;
1784
2138
  inner.authority_revision += 1;
1785
2139
  inner.put_row(row);
2140
+ if is_versioned(&capability) {
2141
+ mint_revision_locked(&mut inner, &key, canonical)?;
2142
+ }
1786
2143
  let suffix = key.rsplit_once(':').map(|(_, right)| right);
1787
2144
  if let Some(id) = suffix.and_then(|s| s.parse::<u64>().ok()) {
1788
2145
  let entry = inner.key_counters.entry(capability.clone()).or_default();
@@ -1809,6 +2166,9 @@ impl FeltDb {
1809
2166
  ) -> Result<()> {
1810
2167
  let rust_type = type_name::<T>().to_string();
1811
2168
  let value_json = serde_json::to_value(value)?;
2169
+ // Canonical because `serde_json` is built without `preserve_order`, so
2170
+ // `Map` is a `BTreeMap` and `to_string` emits keys in sorted order.
2171
+ let canonical = value_json.to_string();
1812
2172
  let mut row = StoredRow {
1813
2173
  capability: capability.clone(),
1814
2174
  key: key.clone(),
@@ -1852,6 +2212,9 @@ impl FeltDb {
1852
2212
  append_event(&inner.path, &row)?;
1853
2213
  inner.authority_revision += 1;
1854
2214
  inner.put_row(row);
2215
+ if is_versioned(&capability) {
2216
+ mint_revision_locked(&mut inner, &key, canonical)?;
2217
+ }
1855
2218
  }
1856
2219
 
1857
2220
  let event = ChangeEvent {
@@ -2062,8 +2425,9 @@ impl FeltDb {
2062
2425
  // The same predicate phase the indexed execution reports to,
2063
2426
  // so the two executions' predicate cost is directly
2064
2427
  // comparable rather than being two different measurements.
2065
- let _predicate =
2066
- workload_diagnostics::span(workload_diagnostics::Phase::PredicateEvaluation);
2428
+ let _predicate = workload_diagnostics::span(
2429
+ workload_diagnostics::Phase::PredicateEvaluation,
2430
+ );
2067
2431
  if predicate(row) {
2068
2432
  drop(_predicate);
2069
2433
  matched.push(row.clone());
@@ -2102,7 +2466,10 @@ impl FeltDb {
2102
2466
  let acquired = std::time::Instant::now();
2103
2467
  workload_diagnostics::record(
2104
2468
  workload_diagnostics::Phase::StateLockWait,
2105
- acquired.duration_since(requested).as_nanos().min(u64::MAX as u128) as u64,
2469
+ acquired
2470
+ .duration_since(requested)
2471
+ .as_nanos()
2472
+ .min(u64::MAX as u128) as u64,
2106
2473
  );
2107
2474
  StateGuard {
2108
2475
  inner,
@@ -2216,7 +2583,7 @@ impl FeltDb {
2216
2583
  }
2217
2584
  }
2218
2585
  Ok(Err(
2219
- "the live index holds entries no authoritative record derives".to_string()
2586
+ "the live index holds entries no authoritative record derives".to_string(),
2220
2587
  ))
2221
2588
  }
2222
2589
 
@@ -2432,6 +2799,7 @@ impl FeltDb {
2432
2799
  rows,
2433
2800
  versions,
2434
2801
  content_hash,
2802
+ format_version: DURABLE_FORMAT_VERSION,
2435
2803
  })
2436
2804
  }
2437
2805
  /// Current materialized rows including their causal operation envelopes.
@@ -2470,6 +2838,24 @@ impl FeltDb {
2470
2838
  /// Install a verified bootstrap snapshot into a pristine node. A node with
2471
2839
  /// local history must converge through operations rather than overwrite it.
2472
2840
  pub fn install_snapshot(&self, snapshot: DatabaseSnapshot) -> Result<()> {
2841
+ // A snapshot carries whole rows, revisions included, so installing one
2842
+ // from another format would reintroduce exactly the silent
2843
+ // misinterpretation the on-disk version prevents.
2844
+ if snapshot.format_version != DURABLE_FORMAT_VERSION {
2845
+ return Err(FlowError::IncompatibleFormat(Box::new(
2846
+ FormatIncompatibility {
2847
+ found: if snapshot.format_version == 0 {
2848
+ DurableFormat::Unversioned
2849
+ } else {
2850
+ DurableFormat::Versioned(snapshot.format_version)
2851
+ },
2852
+ required: DURABLE_FORMAT_VERSION,
2853
+ reason: "the snapshot was produced in a different durable format".to_string(),
2854
+ action: "Take a fresh snapshot from a FeltDB build matching this one."
2855
+ .to_string(),
2856
+ },
2857
+ )));
2858
+ }
2473
2859
  snapshot.verify()?;
2474
2860
  let mut inner = self.state();
2475
2861
  if !inner.rows.is_empty() || !inner.change_log.pending.is_empty() {
@@ -2498,6 +2884,7 @@ impl FeltDb {
2498
2884
  &mut file,
2499
2885
  &LogHeader {
2500
2886
  record_type: "feltdb.snapshot.v1".to_string(),
2887
+ format_version: DURABLE_FORMAT_VERSION,
2501
2888
  local_sequence: inner.sequence,
2502
2889
  observed_versions: snapshot.versions.clone(),
2503
2890
  },
@@ -2585,6 +2972,7 @@ impl FeltDb {
2585
2972
  &mut file,
2586
2973
  &LogHeader {
2587
2974
  record_type: "feltdb.snapshot.v1".to_string(),
2975
+ format_version: DURABLE_FORMAT_VERSION,
2588
2976
  local_sequence: inner.sequence,
2589
2977
  observed_versions: inner.change_log.versions(),
2590
2978
  },
@@ -3113,6 +3501,115 @@ where
3113
3501
  ///
3114
3502
  /// Damage anywhere earlier is not an interrupted write. Recovery refuses it
3115
3503
  /// rather than silently dropping committed history.
3504
+ /// Examine a durable database's format **without opening or modifying it**.
3505
+ ///
3506
+ /// This runs before any record is interpreted, and reads the file only. A
3507
+ /// database this reports as incompatible is left exactly as it was found.
3508
+ ///
3509
+ /// The order matters: an explicit format record decides, and the records
3510
+ /// themselves are examined *only* when no format record exists — the
3511
+ /// unversioned era. Compatibility is never inferred from whether a
3512
+ /// deserialization happened to succeed.
3513
+ pub fn inspect_durable_format(path: &Path) -> Result<FormatCompatibility> {
3514
+ if !path.exists() {
3515
+ // A database that does not exist yet will be created in the current
3516
+ // format.
3517
+ return Ok(FormatCompatibility::Compatible(DurableFormat::Versioned(
3518
+ DURABLE_FORMAT_VERSION,
3519
+ )));
3520
+ }
3521
+
3522
+ let lines = read_log_lines(path)?;
3523
+ let mut declared: Option<u32> = None;
3524
+ let mut legacy_revision = false;
3525
+
3526
+ for line in &lines {
3527
+ if line.trim().is_empty() {
3528
+ continue;
3529
+ }
3530
+ let Ok(value) = serde_json::from_str::<Value>(line) else {
3531
+ continue;
3532
+ };
3533
+ match value.get("record_type").and_then(Value::as_str) {
3534
+ Some(FORMAT_RECORD_TYPE) => {
3535
+ if let Some(version) = value.get("format_version").and_then(Value::as_u64) {
3536
+ declared = Some(version as u32);
3537
+ }
3538
+ }
3539
+ Some("feltdb.snapshot.v1" | "flowdb.snapshot.v1") => {
3540
+ match value.get("format_version").and_then(Value::as_u64) {
3541
+ Some(version) if version > 0 => declared = Some(version as u32),
3542
+ _ => {}
3543
+ }
3544
+ }
3545
+ _ => {
3546
+ // The unversioned-era probe: a revision record that predates
3547
+ // resource-scoped identity. Deliberate and specific — it looks
3548
+ // for a named absence rather than trying to parse and shrugging
3549
+ // at failure.
3550
+ let is_revision = value
3551
+ .get("key")
3552
+ .and_then(Value::as_str)
3553
+ .is_some_and(|key| key.starts_with("state:revision:"));
3554
+ if is_revision
3555
+ && value
3556
+ .get("value")
3557
+ .and_then(Value::as_object)
3558
+ .is_some_and(|revision| !revision.contains_key("resource"))
3559
+ {
3560
+ legacy_revision = true;
3561
+ }
3562
+ }
3563
+ }
3564
+ }
3565
+
3566
+ if let Some(version) = declared {
3567
+ return Ok(match version.cmp(&DURABLE_FORMAT_VERSION) {
3568
+ std::cmp::Ordering::Equal => {
3569
+ FormatCompatibility::Compatible(DurableFormat::Versioned(version))
3570
+ }
3571
+ std::cmp::Ordering::Greater => {
3572
+ FormatCompatibility::Incompatible(FormatIncompatibility {
3573
+ found: DurableFormat::Versioned(version),
3574
+ required: DURABLE_FORMAT_VERSION,
3575
+ reason: "the database was written by a newer FeltDB".to_string(),
3576
+ action: "Run a FeltDB build that understands this format, or restore \
3577
+ a backup taken in an older format."
3578
+ .to_string(),
3579
+ })
3580
+ }
3581
+ std::cmp::Ordering::Less => FormatCompatibility::Incompatible(FormatIncompatibility {
3582
+ found: DurableFormat::Versioned(version),
3583
+ required: DURABLE_FORMAT_VERSION,
3584
+ reason: "the database was written in an older durable format".to_string(),
3585
+ action: "Migrate the database with a FeltDB build that supports both \
3586
+ formats, or restore a backup."
3587
+ .to_string(),
3588
+ }),
3589
+ });
3590
+ }
3591
+
3592
+ if legacy_revision {
3593
+ return Ok(FormatCompatibility::Incompatible(FormatIncompatibility {
3594
+ found: DurableFormat::Unversioned,
3595
+ required: DURABLE_FORMAT_VERSION,
3596
+ reason: "it holds revision records written before revisions belonged to a \
3597
+ resource, and those records cannot be migrated without inventing \
3598
+ information they never contained: a revision recorded no resource, \
3599
+ so which history each one belongs to is not recoverable from the data"
3600
+ .to_string(),
3601
+ action: "Export the application state you need from a FeltDB build that \
3602
+ understands the old format, then load it into a new database. The \
3603
+ existing file has not been modified."
3604
+ .to_string(),
3605
+ }));
3606
+ }
3607
+
3608
+ // No format record and no legacy revision: nothing in this database depends
3609
+ // on the difference.
3610
+ Ok(FormatCompatibility::Compatible(DurableFormat::Unversioned))
3611
+ }
3612
+
3116
3613
  fn read_log_lines(path: &Path) -> Result<Vec<String>> {
3117
3614
  let mut file = BufReader::new(OpenOptions::new().read(true).open(path)?);
3118
3615
  let mut lines: Vec<(String, bool)> = Vec::new();
@@ -3169,6 +3666,10 @@ fn replay_log(inner: &mut Inner) -> Result<()> {
3169
3666
  snapshot_log = true;
3170
3667
  continue;
3171
3668
  }
3669
+ if value.get("record_type").and_then(Value::as_str) == Some(FORMAT_RECORD_TYPE) {
3670
+ // Already honoured by `inspect_durable_format` before the open.
3671
+ continue;
3672
+ }
3172
3673
  if value.get("record_type").and_then(Value::as_str) == Some("feltdb.transaction.v1") {
3173
3674
  let transaction: TransactionLogRecord = serde_json::from_value(value)?;
3174
3675
  // Older records did not carry a commit revision. Their durable