@feltdb/core 0.8.3 → 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 (193) hide show
  1. package/dist/cli/commands.js +4 -1
  2. package/dist/cli/provisioning-neutrality.js +79 -0
  3. package/dist/collection.d.ts +43 -1
  4. package/dist/collection.d.ts.map +1 -1
  5. package/dist/collection.js +192 -22
  6. package/dist/create/create.js +25 -21
  7. package/dist/create/managed-account.js +11 -0
  8. package/dist/create/package-versions.js +1 -1
  9. package/dist/create/server-source/Cargo.lock +165 -0
  10. package/dist/create/server-source/Cargo.toml +9 -0
  11. package/dist/create/server-source/crates/feltdb/Cargo.toml +3 -0
  12. package/dist/create/server-source/crates/feltdb/benches/gate13_baseline.rs +44 -44
  13. package/dist/create/server-source/crates/feltdb/benches/gate13_phase_7_1_release_economics.rs +12 -24
  14. package/dist/create/server-source/crates/feltdb/benches/gate_13_redux.rs +7 -13
  15. package/dist/create/server-source/crates/feltdb/benches/gate_13_regression_runner.rs +13 -10
  16. package/dist/create/server-source/crates/feltdb/benches/gate_14a_concurrent_writer_scaling.rs +12 -9
  17. package/dist/create/server-source/crates/feltdb/benches/gate_14a_production_admission_revalidation.rs +78 -25
  18. package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc2_admission_contract.rs +16 -13
  19. package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc_root_cause.rs +13 -5
  20. package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync1_queued_prototype.rs +41 -22
  21. package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync_economics.rs +33 -15
  22. package/dist/create/server-source/crates/feltdb/benches/gate_14b_causal_backlog_scaling.rs +100 -33
  23. package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_contract_test.rs +56 -20
  24. package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_scaling.rs +116 -41
  25. package/dist/create/server-source/crates/feltdb/benches/gate_14d_combined_dimension_scaling.rs +186 -55
  26. package/dist/create/server-source/crates/feltdb/benches/phase_7_1_2_optimization_benchmark.rs +64 -26
  27. package/dist/create/server-source/crates/feltdb/benches/phase_7_1_3_crossover_analysis.rs +46 -15
  28. package/dist/create/server-source/crates/feltdb/src/admission.rs +8 -15
  29. package/dist/create/server-source/crates/feltdb/src/admission_contract_tests.rs +43 -13
  30. package/dist/create/server-source/crates/feltdb/src/adversarial_transport.rs +15 -42
  31. package/dist/create/server-source/crates/feltdb/src/analytics.rs +65 -19
  32. package/dist/create/server-source/crates/feltdb/src/application.rs +113 -30
  33. package/dist/create/server-source/crates/feltdb/src/authorization_security_tests.rs +475 -140
  34. package/dist/create/server-source/crates/feltdb/src/cardinality_diagnostics.rs +17 -15
  35. package/dist/create/server-source/crates/feltdb/src/cardinality_endpoint.rs +0 -1
  36. package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +59 -15
  37. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +266 -114
  38. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier_phase_7_1.rs +25 -7
  39. package/dist/create/server-source/crates/feltdb/src/concurrency_fuzzing.rs +10 -15
  40. package/dist/create/server-source/crates/feltdb/src/consistency_contract.rs +3 -11
  41. package/dist/create/server-source/crates/feltdb/src/crash_atomic_boundary.rs +14 -5
  42. package/dist/create/server-source/crates/feltdb/src/crash_injection.rs +21 -25
  43. package/dist/create/server-source/crates/feltdb/src/crash_recovery_tests.rs +14 -11
  44. package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +103 -22
  45. package/dist/create/server-source/crates/feltdb/src/distributed_indexing.rs +18 -15
  46. package/dist/create/server-source/crates/feltdb/src/durability_guarantees.rs +12 -8
  47. package/dist/create/server-source/crates/feltdb/src/durable_dedup_set.rs +1 -5
  48. package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +87 -23
  49. package/dist/create/server-source/crates/feltdb/src/durable_operation_log.rs +3 -7
  50. package/dist/create/server-source/crates/feltdb/src/durable_sync.rs +10 -9
  51. package/dist/create/server-source/crates/feltdb/src/equality_index.rs +595 -0
  52. package/dist/create/server-source/crates/feltdb/src/in_process_transport.rs +1 -6
  53. package/dist/create/server-source/crates/feltdb/src/indexing.rs +35 -38
  54. package/dist/create/server-source/crates/feltdb/src/lib.rs +1050 -117
  55. package/dist/create/server-source/crates/feltdb/src/managed_cas_tests.rs +4 -1
  56. package/dist/create/server-source/crates/feltdb/src/metrics.rs +0 -1
  57. package/dist/create/server-source/crates/feltdb/src/multi_node_convergence.rs +1 -2
  58. package/dist/create/server-source/crates/feltdb/src/multi_operation_transaction.rs +107 -30
  59. package/dist/create/server-source/crates/feltdb/src/observability.rs +19 -6
  60. package/dist/create/server-source/crates/feltdb/src/operation_algebra.rs +12 -11
  61. package/dist/create/server-source/crates/feltdb/src/operation_log.rs +9 -4
  62. package/dist/create/server-source/crates/feltdb/src/p1_application_atomicity.rs +65 -18
  63. package/dist/create/server-source/crates/feltdb/src/p1_atomicity_acceptance.rs +193 -57
  64. package/dist/create/server-source/crates/feltdb/src/partition_reconciliation.rs +37 -27
  65. package/dist/create/server-source/crates/feltdb/src/permutation_scheduler.rs +38 -10
  66. package/dist/create/server-source/crates/feltdb/src/persistence_reality.rs +20 -14
  67. package/dist/create/server-source/crates/feltdb/src/phase1b_acceptance.rs +394 -229
  68. package/dist/create/server-source/crates/feltdb/src/phase1c1_acceptance.rs +8 -6
  69. package/dist/create/server-source/crates/feltdb/src/phase1c2_acceptance.rs +11 -13
  70. package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +90 -72
  71. package/dist/create/server-source/crates/feltdb/src/phase1c_atomicity_proof.rs +3 -3
  72. package/dist/create/server-source/crates/feltdb/src/phase5_integration.rs +33 -11
  73. package/dist/create/server-source/crates/feltdb/src/phase5_scenarios.rs +6 -6
  74. package/dist/create/server-source/crates/feltdb/src/phase6_adversarial_scenarios.rs +14 -56
  75. package/dist/create/server-source/crates/feltdb/src/phase6_convergence_validator.rs +29 -27
  76. package/dist/create/server-source/crates/feltdb/src/phase6_persistence.rs +35 -17
  77. package/dist/create/server-source/crates/feltdb/src/phase_1c_real_tcp.rs +8 -2
  78. package/dist/create/server-source/crates/feltdb/src/phase_2a_failures.rs +59 -15
  79. package/dist/create/server-source/crates/feltdb/src/phase_2b_network.rs +70 -17
  80. package/dist/create/server-source/crates/feltdb/src/phase_2c_cascading.rs +23 -6
  81. package/dist/create/server-source/crates/feltdb/src/phase_3_durability.rs +12 -3
  82. package/dist/create/server-source/crates/feltdb/src/phase_4_baseline.rs +41 -11
  83. package/dist/create/server-source/crates/feltdb/src/phase_5_soak.rs +56 -25
  84. package/dist/create/server-source/crates/feltdb/src/policy_evaluation.rs +701 -245
  85. package/dist/create/server-source/crates/feltdb/src/production_api.rs +31 -13
  86. package/dist/create/server-source/crates/feltdb/src/query_execution_diagnostics.rs +126 -0
  87. package/dist/create/server-source/crates/feltdb/src/query_performance.rs +6 -8
  88. package/dist/create/server-source/crates/feltdb/src/replay_fuzzing.rs +5 -5
  89. package/dist/create/server-source/crates/feltdb/src/replica_acknowledgements.rs +48 -18
  90. package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +30 -11
  91. package/dist/create/server-source/crates/feltdb/src/replication_manager.rs +6 -3
  92. package/dist/create/server-source/crates/feltdb/src/replication_protocol.rs +4 -3
  93. package/dist/create/server-source/crates/feltdb/src/sharding.rs +36 -10
  94. package/dist/create/server-source/crates/feltdb/src/state_conflict_contract.rs +516 -0
  95. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +305 -6
  96. package/dist/create/server-source/crates/feltdb/src/state_diff_contract.rs +222 -0
  97. package/dist/create/server-source/crates/feltdb/src/state_facade.rs +82 -54
  98. package/dist/create/server-source/crates/feltdb/src/state_hash.rs +2 -2
  99. package/dist/create/server-source/crates/feltdb/src/state_model.rs +1514 -537
  100. package/dist/create/server-source/crates/feltdb/src/state_transition_store.rs +6 -3
  101. package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +672 -0
  102. package/dist/create/server-source/crates/feltdb/src/submission.rs +5 -11
  103. package/dist/create/server-source/crates/feltdb/src/sync.rs +12 -0
  104. package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +6 -8
  105. package/dist/create/server-source/crates/feltdb/src/transaction_api.rs +24 -35
  106. package/dist/create/server-source/crates/feltdb/src/transaction_invariants.rs +24 -8
  107. package/dist/create/server-source/crates/feltdb/src/transaction_preconditions.rs +248 -59
  108. package/dist/create/server-source/crates/feltdb/src/transactions.rs +17 -20
  109. package/dist/create/server-source/crates/feltdb/src/trigger_contract.rs +749 -0
  110. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +1 -0
  111. package/dist/create/server-source/crates/feltdb/src/workload.rs +512 -4
  112. package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +442 -0
  113. package/dist/create/server-source/crates/feltdb/tests/branching_evidence.rs +299 -0
  114. package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +288 -0
  115. package/dist/create/server-source/crates/feltdb/tests/durable_format_compatibility.rs +392 -0
  116. package/dist/create/server-source/crates/feltdb/tests/feltdb_state_boundary_tests.rs +436 -220
  117. package/dist/create/server-source/crates/feltdb/tests/fixtures/state_conflict_contract_corpus.json +1916 -0
  118. package/dist/create/server-source/crates/feltdb/tests/fixtures/state_diff_contract_corpus.json +1878 -0
  119. package/dist/create/server-source/crates/feltdb/tests/fixtures/trigger_contract_corpus.json +1862 -0
  120. package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +234 -0
  121. package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +947 -0
  122. package/dist/create/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +5 -8
  123. package/dist/create/server-source/crates/feltdb/tests/pr8_vocabulary_assessment.rs +52 -44
  124. package/dist/create/server-source/crates/feltdb/tests/pr9_phase2_boundary_tests.rs +33 -16
  125. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3a_path_a_tests.rs +22 -7
  126. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_authorized_mutations.rs +41 -22
  127. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_role_based_authorization.rs +25 -8
  128. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_simple_auth_delete.rs +9 -6
  129. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_team_delete_role_authorization.rs +120 -69
  130. package/dist/create/server-source/crates/feltdb/tests/pr9_teams_role_based_access.rs +21 -10
  131. package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +1032 -0
  132. package/dist/create/server-source/crates/feltdb/tests/reconciliation_application.rs +868 -0
  133. package/dist/create/server-source/crates/feltdb/tests/reconciliation_wire_format_evidence.rs +221 -0
  134. package/dist/create/server-source/crates/feltdb/tests/resource_scoped_revisions.rs +338 -0
  135. package/dist/create/server-source/crates/feltdb/tests/revision_identity_contract.rs +1039 -0
  136. package/dist/create/server-source/crates/feltdb/tests/revision_model_decision.rs +739 -0
  137. package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +427 -0
  138. package/dist/create/server-source/crates/feltdb/tests/saas_authorization_integration.rs +3 -3
  139. package/dist/create/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +25 -22
  140. package/dist/create/server-source/crates/feltdb/tests/state_conflict_contract_conformance.rs +1799 -0
  141. package/dist/create/server-source/crates/feltdb/tests/state_diff_contract_conformance.rs +1316 -0
  142. package/dist/create/server-source/crates/feltdb/tests/state_model_integration.rs +53 -61
  143. package/dist/create/server-source/crates/feltdb/tests/state_persistence_integration.rs +156 -61
  144. package/dist/create/server-source/crates/feltdb/tests/state_store_boundary_evidence.rs +299 -0
  145. package/dist/create/server-source/crates/feltdb/tests/sync_divergence_evidence.rs +255 -0
  146. package/dist/create/server-source/crates/feltdb/tests/three_way_input_boundary_evidence.rs +249 -0
  147. package/dist/create/server-source/crates/feltdb/tests/trigger_contract_conformance.rs +994 -0
  148. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +2 -1
  149. package/dist/create/server-source/crates/feltdb-server/src/audit.rs +1137 -29
  150. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +164 -13
  151. package/dist/create/server-source/crates/feltdb-server/src/main.rs +800 -34
  152. package/dist/db.d.ts +33 -34
  153. package/dist/db.d.ts.map +1 -1
  154. package/dist/db.js +74 -20
  155. package/dist/deployment.d.ts +30 -0
  156. package/dist/deployment.d.ts.map +1 -0
  157. package/dist/deployment.js +130 -0
  158. package/dist/embedded-transaction.d.ts +22 -4
  159. package/dist/embedded-transaction.d.ts.map +1 -1
  160. package/dist/embedded-transaction.js +51 -5
  161. package/dist/feltdb.d.ts +14 -2
  162. package/dist/feltdb.d.ts.map +1 -1
  163. package/dist/file-db.js +1 -1
  164. package/dist/http-client.d.ts +14 -0
  165. package/dist/http-client.d.ts.map +1 -1
  166. package/dist/http-client.js +23 -5
  167. package/dist/http-db.d.ts +119 -1
  168. package/dist/http-db.d.ts.map +1 -1
  169. package/dist/http-db.js +346 -31
  170. package/dist/index-core.d.ts +2 -0
  171. package/dist/index-core.d.ts.map +1 -1
  172. package/dist/index-core.js +2 -0
  173. package/dist/index.d.ts.map +1 -1
  174. package/dist/index.js +9 -0
  175. package/dist/indexeddb-db.d.ts.map +1 -1
  176. package/dist/indexeddb-db.js +35 -21
  177. package/dist/managed-recovery.d.ts +192 -0
  178. package/dist/managed-recovery.d.ts.map +1 -0
  179. package/dist/managed-recovery.js +242 -0
  180. package/dist/memory-db.js +1 -1
  181. package/dist/studio-app/assets/{feltdb_wasm-DB8cX151.js → feltdb_wasm-DaNwCLRX.js} +1 -1
  182. package/dist/studio-app/assets/feltdb_wasm_bg-DnsHNv6g.wasm +0 -0
  183. package/dist/studio-app/assets/index-j8IlhNqJ.js +29 -0
  184. package/dist/studio-app/index.html +1 -1
  185. package/dist/transaction.d.ts +30 -0
  186. package/dist/transaction.d.ts.map +1 -1
  187. package/dist/transaction.js +41 -0
  188. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  189. package/dist/workload.d.ts +2 -1
  190. package/dist/workload.d.ts.map +1 -1
  191. package/package.json +1 -1
  192. package/dist/studio-app/assets/feltdb_wasm_bg-ClhDHp0S.wasm +0 -0
  193. package/dist/studio-app/assets/index-B0k4UAlI.js +0 -29
@@ -3,7 +3,6 @@
3
3
  /// Persistent storage of executed state transitions.
4
4
  /// Transitions are immutable once committed; this is the authoritative record of
5
5
  /// what state changes have occurred and in what order.
6
-
7
6
  use crate::transactions::{StateTransition, StateVersion};
8
7
  use std::collections::HashMap;
9
8
  use std::sync::{Arc, Mutex};
@@ -195,10 +194,14 @@ impl Default for MemoryStateTransitionStore {
195
194
  mod tests {
196
195
  use super::*;
197
196
  use crate::convergence::VectorClock;
198
- use crate::transactions::{ConsistencyContract, OperationCommand, OperationId, Operation};
197
+ use crate::transactions::{ConsistencyContract, Operation, OperationCommand, OperationId};
199
198
  use std::collections::HashMap;
200
199
 
201
- fn create_test_transition(from_hash: &str, to_hash: &str, txn_id: Option<&str>) -> StateTransition {
200
+ fn create_test_transition(
201
+ from_hash: &str,
202
+ to_hash: &str,
203
+ txn_id: Option<&str>,
204
+ ) -> StateTransition {
202
205
  let mut from_vc = VectorClock::new();
203
206
  from_vc.increment("node1");
204
207
  let from_version = StateVersion::new(from_vc, from_hash.to_string());
@@ -0,0 +1,672 @@
1
+ //! Durable evaluation of committed state changes into workloads.
2
+ //!
3
+ //! This module is intentionally only the causal bridge from an already committed
4
+ //! operation to `WorkloadStore::create`. It is not a registry, queue, worker, or
5
+ //! execution runtime.
6
+
7
+ use crate::authorization::{SignedGrant, Subject};
8
+ use crate::trigger_contract;
9
+ use crate::workload::{
10
+ CreateWorkload, WorkloadError, WorkloadRetryPolicy, WorkloadStore, WorkloadTimeouts,
11
+ };
12
+ use crate::{FeltDb, FlowError, Operation};
13
+ use serde::{Deserialize, Serialize};
14
+ use serde_json::{json, Value};
15
+ use std::collections::{BTreeMap, BTreeSet, HashMap};
16
+ use std::fs;
17
+ use std::path::{Path, PathBuf};
18
+
19
+ pub const STATE_TRIGGER_CONTRACT_VERSION: u32 = 1;
20
+
21
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
22
+ #[serde(rename_all = "snake_case")]
23
+ pub enum StateTriggerChangeKind {
24
+ Insert,
25
+ Update,
26
+ Delete,
27
+ }
28
+
29
+ impl StateTriggerChangeKind {
30
+ fn as_str(self) -> &'static str {
31
+ match self {
32
+ Self::Insert => "insert",
33
+ Self::Update => "update",
34
+ Self::Delete => "delete",
35
+ }
36
+ }
37
+ }
38
+
39
+ #[derive(Debug, Clone, Serialize, Deserialize)]
40
+ pub struct StateTriggerPredicate {
41
+ pub field: String,
42
+ pub equals: Value,
43
+ }
44
+
45
+ #[derive(Debug, Clone, Serialize, Deserialize)]
46
+ pub struct StateTrigger {
47
+ pub trigger_id: String,
48
+ pub source_collection: String,
49
+ pub change_kind: StateTriggerChangeKind,
50
+ #[serde(default)]
51
+ pub predicate: Option<StateTriggerPredicate>,
52
+ pub target_capability: String,
53
+ pub tenant_id: String,
54
+ pub application_id: String,
55
+ #[serde(default = "default_environment")]
56
+ pub environment: String,
57
+ #[serde(default = "default_revision")]
58
+ pub definition_revision: String,
59
+ pub requested_by: Subject,
60
+ pub authorization_snapshot: SignedGrant,
61
+ #[serde(default)]
62
+ pub capability_snapshot: BTreeSet<String>,
63
+ #[serde(default)]
64
+ pub connection_scopes: BTreeSet<String>,
65
+ #[serde(default)]
66
+ pub priority: i32,
67
+ #[serde(default)]
68
+ pub retry_policy: WorkloadRetryPolicy,
69
+ #[serde(default)]
70
+ pub timeouts: WorkloadTimeouts,
71
+ #[serde(default)]
72
+ pub not_before: Option<i64>,
73
+ #[serde(default = "enabled")]
74
+ pub enabled: bool,
75
+ }
76
+
77
+ fn default_environment() -> String {
78
+ "production".into()
79
+ }
80
+
81
+ fn default_revision() -> String {
82
+ "trigger".into()
83
+ }
84
+
85
+ fn enabled() -> bool {
86
+ true
87
+ }
88
+
89
+ impl StateTrigger {
90
+ /// Whether this trigger matches a committed operation.
91
+ ///
92
+ /// The decision itself lives in [`crate::trigger_contract`], the
93
+ /// runtime-independent deterministic contract. This method is the
94
+ /// projection from FeltDB's durable types onto that contract's input; it
95
+ /// holds no matching logic of its own, so the native and WASM contract
96
+ /// implementations decide production matching identically by construction.
97
+ pub fn matches(&self, operation: &Operation) -> bool {
98
+ trigger_contract::evaluate_trigger(&trigger_contract::TriggerMatchInput::for_operation(
99
+ self, operation,
100
+ ))
101
+ .matched()
102
+ }
103
+
104
+ /// The contract input this trigger and operation produce, for provenance.
105
+ pub fn contract_input(&self, operation: &Operation) -> trigger_contract::TriggerMatchInput {
106
+ trigger_contract::TriggerMatchInput::for_operation(self, operation)
107
+ }
108
+ }
109
+
110
+ impl StateTriggerPredicate {
111
+ pub fn matches(&self, value: Option<&Value>) -> bool {
112
+ let Some(value) = value else { return false };
113
+ get_path(value, &self.field) == Some(&self.equals)
114
+ }
115
+ }
116
+
117
+ #[derive(Debug, Clone, Serialize, Deserialize)]
118
+ pub struct TriggeredWorkload {
119
+ pub trigger_id: String,
120
+ pub source_change_id: String,
121
+ pub workload_id: String,
122
+ pub duplicate: bool,
123
+ }
124
+
125
+ #[derive(Debug, Clone, Serialize, Deserialize)]
126
+ pub struct StateTriggerStore {
127
+ #[serde(skip)]
128
+ path: PathBuf,
129
+ #[serde(default = "contract_version")]
130
+ pub contract_version: u32,
131
+ #[serde(default)]
132
+ pub triggers: BTreeMap<String, StateTrigger>,
133
+ #[serde(default)]
134
+ pub cursors: HashMap<String, u64>,
135
+ }
136
+
137
+ fn contract_version() -> u32 {
138
+ STATE_TRIGGER_CONTRACT_VERSION
139
+ }
140
+
141
+ impl StateTriggerStore {
142
+ pub fn load(path: impl AsRef<Path>) -> Result<Self, StateTriggerError> {
143
+ let path = path.as_ref().to_path_buf();
144
+ if !path.exists() {
145
+ return Ok(Self {
146
+ path,
147
+ contract_version: STATE_TRIGGER_CONTRACT_VERSION,
148
+ triggers: BTreeMap::new(),
149
+ cursors: HashMap::new(),
150
+ });
151
+ }
152
+ let mut value: Self = serde_json::from_slice(&fs::read(&path).map_err(io)?).map_err(io)?;
153
+ value.path = path;
154
+ if value.contract_version == 0 {
155
+ value.contract_version = STATE_TRIGGER_CONTRACT_VERSION;
156
+ }
157
+ Ok(value)
158
+ }
159
+
160
+ pub fn define(&mut self, trigger: StateTrigger) -> Result<(), StateTriggerError> {
161
+ validate_trigger(&trigger)?;
162
+ self.triggers.insert(trigger.trigger_id.clone(), trigger);
163
+ self.persist()
164
+ }
165
+
166
+ pub fn list(&self) -> Vec<StateTrigger> {
167
+ self.triggers.values().cloned().collect()
168
+ }
169
+
170
+ pub fn evaluate(
171
+ &mut self,
172
+ db: &FeltDb,
173
+ workloads: &mut WorkloadStore,
174
+ now: i64,
175
+ ) -> Result<Vec<TriggeredWorkload>, StateTriggerError> {
176
+ let mut produced = Vec::new();
177
+ let mut operations = db
178
+ .operations_since(&self.cursors)
179
+ .map_err(StateTriggerError::from)?;
180
+ operations.sort_by(|left, right| {
181
+ left.instance_id
182
+ .cmp(&right.instance_id)
183
+ .then_with(|| left.sequence.cmp(&right.sequence))
184
+ });
185
+
186
+ for operation in operations {
187
+ let matches = self
188
+ .triggers
189
+ .values()
190
+ .filter(|trigger| trigger.matches(&operation))
191
+ .cloned()
192
+ .collect::<Vec<_>>();
193
+ for trigger in matches {
194
+ let before = workloads.workloads.len();
195
+ let workload = workloads
196
+ .create(create_workload(&trigger, &operation, now))
197
+ .map_err(StateTriggerError::from)?;
198
+ produced.push(TriggeredWorkload {
199
+ trigger_id: trigger.trigger_id,
200
+ source_change_id: source_change_id(&operation),
201
+ workload_id: workload.workload_id,
202
+ duplicate: workloads.workloads.len() == before,
203
+ });
204
+ }
205
+ self.cursors
206
+ .entry(operation.instance_id.clone())
207
+ .and_modify(|cursor| *cursor = (*cursor).max(operation.sequence))
208
+ .or_insert(operation.sequence);
209
+ self.persist()?;
210
+ }
211
+ Ok(produced)
212
+ }
213
+
214
+ pub fn cursor_versions(&self) -> HashMap<String, u64> {
215
+ self.cursors.clone()
216
+ }
217
+ }
218
+
219
+ fn validate_trigger(trigger: &StateTrigger) -> Result<(), StateTriggerError> {
220
+ for (field, value) in [
221
+ ("trigger_id", &trigger.trigger_id),
222
+ ("source_collection", &trigger.source_collection),
223
+ ("target_capability", &trigger.target_capability),
224
+ ("tenant_id", &trigger.tenant_id),
225
+ ("application_id", &trigger.application_id),
226
+ ] {
227
+ if value.trim().is_empty() {
228
+ return Err(StateTriggerError::new(
229
+ "TRIGGER_INVALID",
230
+ format!("{field} is required"),
231
+ ));
232
+ }
233
+ }
234
+ if trigger.authorization_snapshot.grant.subject != trigger.requested_by
235
+ || trigger.authorization_snapshot.grant.tenant_id != trigger.tenant_id
236
+ || trigger.authorization_snapshot.grant.application_id != trigger.application_id
237
+ {
238
+ return Err(StateTriggerError::new(
239
+ "TRIGGER_UNAUTHORIZED",
240
+ "authorization snapshot does not bind the trigger requester",
241
+ ));
242
+ }
243
+ Ok(())
244
+ }
245
+
246
+ fn create_workload(trigger: &StateTrigger, operation: &Operation, now: i64) -> CreateWorkload {
247
+ let source_change_id = source_change_id(operation);
248
+ let kind = trigger.change_kind.as_str();
249
+ let contract_input = trigger.contract_input(operation);
250
+ let capability_snapshot = if trigger.capability_snapshot.is_empty() {
251
+ [trigger.target_capability.clone()].into()
252
+ } else {
253
+ trigger.capability_snapshot.clone()
254
+ };
255
+ CreateWorkload {
256
+ tenant_id: trigger.tenant_id.clone(),
257
+ application_id: trigger.application_id.clone(),
258
+ environment: trigger.environment.clone(),
259
+ definition_id: trigger.target_capability.clone(),
260
+ definition_revision: trigger.definition_revision.clone(),
261
+ trigger: format!("state_change:{}", trigger.trigger_id),
262
+ input: json!({
263
+ "provenance": {
264
+ "trigger_id": trigger.trigger_id,
265
+ "source_change_id": source_change_id,
266
+ "source_collection": operation.capability,
267
+ "source_key": operation.key,
268
+ "change_kind": kind,
269
+ // Which deterministic contract decided this match, at which
270
+ // version, on which runtime, over which exact input. This is a
271
+ // description of the existing decision, not a second record of
272
+ // it: the digest identifies the same committed change the
273
+ // surrounding provenance already names.
274
+ "contract": {
275
+ "id": trigger_contract::TRIGGER_MATCH_CONTRACT_ID,
276
+ "version": trigger_contract::TRIGGER_MATCH_CONTRACT_VERSION,
277
+ "runtime": trigger_contract::ContractRuntime::Native.as_str(),
278
+ "input_digest": trigger_contract::input_digest(&contract_input),
279
+ },
280
+ },
281
+ "source": {
282
+ "origin": operation.instance_id,
283
+ "sequence": operation.sequence,
284
+ "key": operation.key,
285
+ "collection": operation.capability,
286
+ "content_hash": operation.content_hash,
287
+ },
288
+ "record": operation.value,
289
+ }),
290
+ requested_by: trigger.requested_by.clone(),
291
+ authorization_snapshot: trigger.authorization_snapshot.clone(),
292
+ capability_snapshot,
293
+ connection_scopes: trigger.connection_scopes.clone(),
294
+ priority: trigger.priority,
295
+ idempotency_key: format!("state-trigger:{}:{}", trigger.trigger_id, source_change_id),
296
+ retry_policy: trigger.retry_policy.clone(),
297
+ timeouts: trigger.timeouts.clone(),
298
+ dependencies: vec![],
299
+ not_before: trigger.not_before,
300
+ created_at: now,
301
+ }
302
+ }
303
+
304
+ fn source_change_id(operation: &Operation) -> String {
305
+ format!("{}:{}", operation.instance_id, operation.sequence)
306
+ }
307
+
308
+ fn get_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
309
+ if path.is_empty() {
310
+ return Some(value);
311
+ }
312
+ let mut current = value;
313
+ for part in path.split('.') {
314
+ current = current.get(part)?;
315
+ }
316
+ Some(current)
317
+ }
318
+
319
+ impl StateTriggerStore {
320
+ fn persist(&self) -> Result<(), StateTriggerError> {
321
+ if let Some(parent) = self.path.parent() {
322
+ fs::create_dir_all(parent).map_err(io)?;
323
+ }
324
+ let temp = self.path.with_extension("tmp");
325
+ fs::write(&temp, serde_json::to_vec_pretty(self).map_err(io)?).map_err(io)?;
326
+ fs::rename(temp, &self.path).map_err(io)
327
+ }
328
+ }
329
+
330
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
331
+ pub struct StateTriggerError {
332
+ pub code: String,
333
+ pub message: String,
334
+ }
335
+
336
+ impl StateTriggerError {
337
+ fn new(code: &str, message: impl Into<String>) -> Self {
338
+ Self {
339
+ code: code.into(),
340
+ message: message.into(),
341
+ }
342
+ }
343
+ }
344
+
345
+ impl std::fmt::Display for StateTriggerError {
346
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
347
+ write!(f, "{}: {}", self.code, self.message)
348
+ }
349
+ }
350
+
351
+ impl std::error::Error for StateTriggerError {}
352
+
353
+ impl From<FlowError> for StateTriggerError {
354
+ fn from(value: FlowError) -> Self {
355
+ Self::new("TRIGGER_STATE_REPLAY_FAILED", value.to_string())
356
+ }
357
+ }
358
+
359
+ impl From<WorkloadError> for StateTriggerError {
360
+ fn from(value: WorkloadError) -> Self {
361
+ Self::new(&value.code, value.message)
362
+ }
363
+ }
364
+
365
+ fn io(error: impl std::fmt::Display) -> StateTriggerError {
366
+ StateTriggerError::new("TRIGGER_STORAGE_FAILURE", error.to_string())
367
+ }
368
+
369
+ #[cfg(test)]
370
+ mod tests {
371
+ use super::*;
372
+ use crate::authorization::{
373
+ Grant, GrantConstraints, GrantSigner, GrantStatus, ResourceScope, ResourceScopeKind,
374
+ };
375
+ use crate::workload::WorkloadState;
376
+ use crate::AtomicMutation;
377
+ use tempfile::tempdir;
378
+
379
+ fn grant(subject: &Subject) -> SignedGrant {
380
+ let signer = GrantSigner::new("k", vec![7; 32]).unwrap();
381
+ signer
382
+ .sign(Grant {
383
+ contract_version: 1,
384
+ id: "trigger-grant".into(),
385
+ issuer: Subject::Human("owner".into()),
386
+ subject: subject.clone(),
387
+ tenant_id: "t".into(),
388
+ application_id: "app".into(),
389
+ capabilities: ["workload:execute".into()].into(),
390
+ resources: vec![ResourceScope {
391
+ kind: ResourceScopeKind::Workload,
392
+ uri: "flow://app/workloads/*".into(),
393
+ }],
394
+ constraints: GrantConstraints::default(),
395
+ issued_at: 1,
396
+ expires_at: 9999,
397
+ parent_grant: None,
398
+ revision: 1,
399
+ status: GrantStatus::Active,
400
+ })
401
+ .unwrap()
402
+ }
403
+
404
+ fn trigger(id: &str, collection: &str, kind: StateTriggerChangeKind) -> StateTrigger {
405
+ let subject = Subject::Agent("trigger-agent".into());
406
+ StateTrigger {
407
+ trigger_id: id.into(),
408
+ source_collection: collection.into(),
409
+ change_kind: kind,
410
+ predicate: None,
411
+ target_capability: "identity".into(),
412
+ tenant_id: "t".into(),
413
+ application_id: "app".into(),
414
+ environment: "production".into(),
415
+ definition_revision: "r1".into(),
416
+ requested_by: subject.clone(),
417
+ authorization_snapshot: grant(&subject),
418
+ capability_snapshot: ["identity".into()].into(),
419
+ connection_scopes: BTreeSet::new(),
420
+ priority: 0,
421
+ retry_policy: Default::default(),
422
+ timeouts: Default::default(),
423
+ not_before: None,
424
+ enabled: true,
425
+ }
426
+ }
427
+
428
+ fn stores(name: &str) -> (tempfile::TempDir, FeltDb, StateTriggerStore, WorkloadStore) {
429
+ let dir = tempdir().unwrap();
430
+ let db = FeltDb::open(dir.path().join(format!("{name}.log"))).unwrap();
431
+ let triggers =
432
+ StateTriggerStore::load(dir.path().join(format!("{name}.triggers.json"))).unwrap();
433
+ let workloads =
434
+ WorkloadStore::load(dir.path().join(format!("{name}.workloads.json"))).unwrap();
435
+ (dir, db, triggers, workloads)
436
+ }
437
+
438
+ #[test]
439
+ fn matching_change_creates_workload_with_provenance() {
440
+ let (_dir, db, mut triggers, mut workloads) = stores("matching");
441
+ triggers
442
+ .define(trigger(
443
+ "on-order",
444
+ "orders",
445
+ StateTriggerChangeKind::Insert,
446
+ ))
447
+ .unwrap();
448
+ db.insert("orders:1", json!({"status":"new"})).unwrap();
449
+
450
+ let created = triggers.evaluate(&db, &mut workloads, 10).unwrap();
451
+
452
+ assert_eq!(created.len(), 1);
453
+ let workload = workloads.get(&created[0].workload_id).unwrap();
454
+ assert_eq!(workload.state, WorkloadState::Ready);
455
+ assert_eq!(workload.trigger, "state_change:on-order");
456
+ assert_eq!(
457
+ workload.idempotency_key,
458
+ format!("state-trigger:on-order:{}", created[0].source_change_id)
459
+ );
460
+ assert_eq!(workload.input["provenance"]["trigger_id"], "on-order");
461
+ assert_eq!(workload.input["provenance"]["source_collection"], "orders");
462
+ assert_eq!(workload.input["record"]["status"], "new");
463
+ }
464
+
465
+ #[test]
466
+ fn nonmatching_change_creates_nothing() {
467
+ let (_dir, db, mut triggers, mut workloads) = stores("nonmatching");
468
+ triggers
469
+ .define(trigger(
470
+ "on-order",
471
+ "orders",
472
+ StateTriggerChangeKind::Insert,
473
+ ))
474
+ .unwrap();
475
+ db.insert("customers:1", json!({"name":"Ada"})).unwrap();
476
+
477
+ assert!(triggers
478
+ .evaluate(&db, &mut workloads, 10)
479
+ .unwrap()
480
+ .is_empty());
481
+ assert!(workloads.list("t", "app").is_empty());
482
+ }
483
+
484
+ #[test]
485
+ fn predicate_accepts_and_rejects_deterministically() {
486
+ let (_dir, db, mut triggers, mut workloads) = stores("predicate");
487
+ let mut accepted = trigger("paid", "orders", StateTriggerChangeKind::Insert);
488
+ accepted.predicate = Some(StateTriggerPredicate {
489
+ field: "status".into(),
490
+ equals: json!("paid"),
491
+ });
492
+ triggers.define(accepted).unwrap();
493
+ db.insert("orders:1", json!({"status":"draft"})).unwrap();
494
+ db.insert("orders:2", json!({"status":"paid"})).unwrap();
495
+
496
+ let created = triggers.evaluate(&db, &mut workloads, 10).unwrap();
497
+
498
+ assert_eq!(created.len(), 1);
499
+ assert_eq!(
500
+ workloads.get(&created[0].workload_id).unwrap().input["record"]["status"],
501
+ "paid"
502
+ );
503
+ }
504
+
505
+ #[test]
506
+ fn multiple_triggers_create_independent_workloads() {
507
+ let (_dir, db, mut triggers, mut workloads) = stores("multiple");
508
+ triggers
509
+ .define(trigger("one", "orders", StateTriggerChangeKind::Insert))
510
+ .unwrap();
511
+ triggers
512
+ .define(trigger("two", "orders", StateTriggerChangeKind::Insert))
513
+ .unwrap();
514
+ db.insert("orders:1", json!({"status":"paid"})).unwrap();
515
+
516
+ let created = triggers.evaluate(&db, &mut workloads, 10).unwrap();
517
+
518
+ assert_eq!(created.len(), 2);
519
+ assert_ne!(created[0].workload_id, created[1].workload_id);
520
+ assert_eq!(workloads.list("t", "app").len(), 2);
521
+ }
522
+
523
+ #[test]
524
+ fn duplicate_and_replayed_change_create_exactly_one_workload() {
525
+ let (dir, db, mut triggers, mut workloads) = stores("replay");
526
+ let trigger_path = dir.path().join("replay.triggers.json");
527
+ let workload_path = dir.path().join("replay.workloads.json");
528
+ triggers
529
+ .define(trigger("once", "orders", StateTriggerChangeKind::Insert))
530
+ .unwrap();
531
+ db.insert("orders:1", json!({"status":"paid"})).unwrap();
532
+
533
+ let first = triggers.evaluate(&db, &mut workloads, 10).unwrap();
534
+ let again = triggers.evaluate(&db, &mut workloads, 11).unwrap();
535
+ assert_eq!(first.len(), 1);
536
+ assert!(
537
+ again.is_empty(),
538
+ "durable cursor suppresses already evaluated changes"
539
+ );
540
+
541
+ let mut cursor_lost = StateTriggerStore::load(&trigger_path).unwrap();
542
+ cursor_lost.cursors.clear();
543
+ let mut reopened_workloads = WorkloadStore::load(&workload_path).unwrap();
544
+ let replayed = cursor_lost
545
+ .evaluate(&db, &mut reopened_workloads, 12)
546
+ .unwrap();
547
+ assert_eq!(replayed.len(), 1, "lost cursor replays the change");
548
+ assert!(
549
+ replayed[0].duplicate,
550
+ "workload idempotency suppresses the duplicate"
551
+ );
552
+ assert_eq!(reopened_workloads.list("t", "app").len(), 1);
553
+ }
554
+
555
+ #[test]
556
+ fn restart_after_observing_before_workload_creation_replays() {
557
+ let (dir, db, mut triggers, mut workloads) = stores("restart-before-create");
558
+ let trigger_path = dir.path().join("restart-before-create.triggers.json");
559
+ let workload_path = dir.path().join("restart-before-create.workloads.json");
560
+ triggers
561
+ .define(trigger("recover", "orders", StateTriggerChangeKind::Insert))
562
+ .unwrap();
563
+ db.insert("orders:1", json!({"status":"paid"})).unwrap();
564
+ drop(triggers);
565
+ drop(workloads);
566
+
567
+ let mut restarted_triggers = StateTriggerStore::load(trigger_path).unwrap();
568
+ let mut restarted_workloads = WorkloadStore::load(workload_path).unwrap();
569
+ let created = restarted_triggers
570
+ .evaluate(&db, &mut restarted_workloads, 10)
571
+ .unwrap();
572
+
573
+ assert_eq!(created.len(), 1);
574
+ assert_eq!(restarted_workloads.list("t", "app").len(), 1);
575
+ }
576
+
577
+ #[test]
578
+ fn future_eligibility_and_unsupported_capability_are_preserved() {
579
+ let (_dir, db, mut triggers, mut workloads) = stores("eligibility");
580
+ let mut future = trigger("future", "orders", StateTriggerChangeKind::Insert);
581
+ future.target_capability = "missing-capability".into();
582
+ future.capability_snapshot = ["missing-capability".into()].into();
583
+ future.not_before = Some(1_000);
584
+ triggers.define(future).unwrap();
585
+ db.insert("orders:1", json!({"status":"paid"})).unwrap();
586
+
587
+ let created = triggers.evaluate(&db, &mut workloads, 10).unwrap();
588
+ let workload = workloads.get(&created[0].workload_id).unwrap();
589
+ let workload_id = workload.workload_id.clone();
590
+ assert_eq!(workload.next_attempt_at, Some(1_000));
591
+ let caps: BTreeSet<String> = ["identity".into()].into();
592
+ assert_eq!(
593
+ workloads
594
+ .claim(&workload_id, "worker", &caps, 1_001, 30_000)
595
+ .unwrap_err()
596
+ .code,
597
+ "WORKLOAD_CAPABILITY_DENIED"
598
+ );
599
+ }
600
+
601
+ #[test]
602
+ fn cancellation_and_retry_use_normal_workload_semantics() {
603
+ let (_dir, db, mut triggers, mut workloads) = stores("lifecycle");
604
+ let mut lifecycle = trigger("life", "orders", StateTriggerChangeKind::Insert);
605
+ lifecycle.retry_policy.max_attempts = 1;
606
+ triggers.define(lifecycle).unwrap();
607
+ db.insert("orders:1", json!({"status":"paid"})).unwrap();
608
+ db.insert("orders:2", json!({"status":"paid"})).unwrap();
609
+ let created = triggers.evaluate(&db, &mut workloads, 10).unwrap();
610
+ assert_eq!(created.len(), 2);
611
+
612
+ let cancelled = created[0].workload_id.clone();
613
+ assert_eq!(
614
+ workloads.cancel(&cancelled, "operator", 11).unwrap(),
615
+ WorkloadState::Cancelled
616
+ );
617
+ assert_eq!(
618
+ workloads
619
+ .retry(&cancelled, "operator", 12)
620
+ .unwrap_err()
621
+ .code,
622
+ "WORKLOAD_TRANSITION_DENIED"
623
+ );
624
+
625
+ let failed = created[1].workload_id.clone();
626
+ let caps: BTreeSet<String> = ["identity".into()].into();
627
+ let claim = workloads
628
+ .claim(&failed, "worker", &caps, 12, 30_000)
629
+ .unwrap();
630
+ workloads
631
+ .start(&failed, &claim.claim_id, "worker", claim.fencing_token, 13)
632
+ .unwrap();
633
+ assert_eq!(
634
+ workloads
635
+ .fail(
636
+ &failed,
637
+ &claim.claim_id,
638
+ "worker",
639
+ claim.fencing_token,
640
+ "worker_failure",
641
+ "crashed",
642
+ 14
643
+ )
644
+ .unwrap(),
645
+ WorkloadState::DeadLetter
646
+ );
647
+ workloads.retry(&failed, "operator", 15).unwrap();
648
+ assert_eq!(workloads.get(&failed).unwrap().state, WorkloadState::Ready);
649
+ }
650
+
651
+ #[test]
652
+ fn atomic_transaction_changes_are_replayed_into_triggers() {
653
+ let (_dir, db, mut triggers, mut workloads) = stores("transaction");
654
+ triggers
655
+ .define(trigger("tx", "orders", StateTriggerChangeKind::Insert))
656
+ .unwrap();
657
+ db.apply_atomic_transaction(
658
+ "tx-1",
659
+ None,
660
+ &[],
661
+ &[AtomicMutation {
662
+ capability: "orders".into(),
663
+ key: "orders:1".into(),
664
+ value: Some(json!({"status":"paid"})),
665
+ }],
666
+ None,
667
+ )
668
+ .unwrap();
669
+
670
+ assert_eq!(triggers.evaluate(&db, &mut workloads, 10).unwrap().len(), 1);
671
+ }
672
+ }