@lix-js/sdk 0.6.0-preview.0 → 0.6.0-preview.2

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 (196) hide show
  1. package/README.md +9 -0
  2. package/SKILL.md +468 -0
  3. package/dist/engine-wasm/index.d.ts +15 -11
  4. package/dist/engine-wasm/index.js +105 -38
  5. package/dist/engine-wasm/wasm/lix_engine.d.ts +14 -2
  6. package/dist/engine-wasm/wasm/lix_engine.js +18 -17
  7. package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
  8. package/dist/engine-wasm/wasm/lix_engine.wasm.d.ts +2 -1
  9. package/dist/generated/builtin-schemas.d.ts +31 -41
  10. package/dist/generated/builtin-schemas.js +52 -56
  11. package/dist/open-lix.d.ts +141 -24
  12. package/dist/open-lix.js +199 -35
  13. package/dist/sqlite/index.js +99 -22
  14. package/dist-engine-src/README.md +18 -0
  15. package/dist-engine-src/src/backend/kv.rs +358 -0
  16. package/dist-engine-src/src/backend/mod.rs +12 -0
  17. package/dist-engine-src/src/backend/testing.rs +658 -0
  18. package/dist-engine-src/src/backend/types.rs +96 -0
  19. package/dist-engine-src/src/binary_cas/chunking.rs +31 -0
  20. package/dist-engine-src/src/binary_cas/codec.rs +346 -0
  21. package/dist-engine-src/src/binary_cas/context.rs +139 -0
  22. package/dist-engine-src/src/binary_cas/kv.rs +1063 -0
  23. package/dist-engine-src/src/binary_cas/mod.rs +11 -0
  24. package/dist-engine-src/src/binary_cas/types.rs +127 -0
  25. package/dist-engine-src/src/cel/context.rs +86 -0
  26. package/dist-engine-src/src/cel/error.rs +19 -0
  27. package/dist-engine-src/src/cel/mod.rs +8 -0
  28. package/dist-engine-src/src/cel/provider.rs +9 -0
  29. package/dist-engine-src/src/cel/runtime.rs +167 -0
  30. package/dist-engine-src/src/cel/value.rs +50 -0
  31. package/dist-engine-src/src/changelog/codec.rs +321 -0
  32. package/dist-engine-src/src/changelog/context.rs +92 -0
  33. package/dist-engine-src/src/changelog/materialization.rs +121 -0
  34. package/dist-engine-src/src/changelog/mod.rs +13 -0
  35. package/dist-engine-src/src/changelog/reader.rs +20 -0
  36. package/dist-engine-src/src/changelog/storage.rs +220 -0
  37. package/dist-engine-src/src/changelog/types.rs +38 -0
  38. package/dist-engine-src/src/commit_graph/context.rs +1588 -0
  39. package/dist-engine-src/src/commit_graph/mod.rs +12 -0
  40. package/dist-engine-src/src/commit_graph/types.rs +145 -0
  41. package/dist-engine-src/src/commit_graph/walker.rs +780 -0
  42. package/dist-engine-src/src/common/error.rs +313 -0
  43. package/dist-engine-src/src/common/fingerprint.rs +3 -0
  44. package/dist-engine-src/src/common/fs_path.rs +1336 -0
  45. package/dist-engine-src/src/common/identity.rs +135 -0
  46. package/dist-engine-src/src/common/metadata.rs +35 -0
  47. package/dist-engine-src/src/common/mod.rs +23 -0
  48. package/dist-engine-src/src/common/types.rs +105 -0
  49. package/dist-engine-src/src/common/wire.rs +222 -0
  50. package/dist-engine-src/src/engine.rs +239 -0
  51. package/dist-engine-src/src/entity_identity.rs +285 -0
  52. package/dist-engine-src/src/functions/context.rs +327 -0
  53. package/dist-engine-src/src/functions/deterministic.rs +113 -0
  54. package/dist-engine-src/src/functions/mod.rs +18 -0
  55. package/dist-engine-src/src/functions/provider.rs +130 -0
  56. package/dist-engine-src/src/functions/state.rs +363 -0
  57. package/dist-engine-src/src/functions/types.rs +37 -0
  58. package/dist-engine-src/src/init.rs +505 -0
  59. package/dist-engine-src/src/json_store/compression.rs +77 -0
  60. package/dist-engine-src/src/json_store/context.rs +129 -0
  61. package/dist-engine-src/src/json_store/encoded.rs +15 -0
  62. package/dist-engine-src/src/json_store/mod.rs +9 -0
  63. package/dist-engine-src/src/json_store/store.rs +236 -0
  64. package/dist-engine-src/src/json_store/types.rs +52 -0
  65. package/dist-engine-src/src/lib.rs +61 -0
  66. package/dist-engine-src/src/live_state/context.rs +2241 -0
  67. package/dist-engine-src/src/live_state/mod.rs +15 -0
  68. package/dist-engine-src/src/live_state/overlay.rs +75 -0
  69. package/dist-engine-src/src/live_state/reader.rs +23 -0
  70. package/dist-engine-src/src/live_state/types.rs +239 -0
  71. package/dist-engine-src/src/live_state/visibility.rs +218 -0
  72. package/dist-engine-src/src/plugin/archive.rs +441 -0
  73. package/dist-engine-src/src/plugin/component.rs +183 -0
  74. package/dist-engine-src/src/plugin/install.rs +637 -0
  75. package/dist-engine-src/src/plugin/manifest.rs +516 -0
  76. package/dist-engine-src/src/plugin/materializer.rs +477 -0
  77. package/dist-engine-src/src/plugin/mod.rs +33 -0
  78. package/dist-engine-src/src/plugin/plugin_manifest.json +119 -0
  79. package/dist-engine-src/src/plugin/storage.rs +74 -0
  80. package/dist-engine-src/src/schema/annotations/defaults.rs +280 -0
  81. package/dist-engine-src/src/schema/annotations/mod.rs +1 -0
  82. package/dist-engine-src/src/schema/builtin/lix_account.json +22 -0
  83. package/dist-engine-src/src/schema/builtin/lix_active_account.json +30 -0
  84. package/dist-engine-src/src/schema/builtin/lix_binary_blob_ref.json +30 -0
  85. package/dist-engine-src/src/schema/builtin/lix_change.json +62 -0
  86. package/dist-engine-src/src/schema/builtin/lix_change_author.json +46 -0
  87. package/dist-engine-src/src/schema/builtin/lix_change_set.json +18 -0
  88. package/dist-engine-src/src/schema/builtin/lix_change_set_element.json +75 -0
  89. package/dist-engine-src/src/schema/builtin/lix_commit.json +62 -0
  90. package/dist-engine-src/src/schema/builtin/lix_commit_edge.json +46 -0
  91. package/dist-engine-src/src/schema/builtin/lix_directory_descriptor.json +53 -0
  92. package/dist-engine-src/src/schema/builtin/lix_entity_label.json +63 -0
  93. package/dist-engine-src/src/schema/builtin/lix_file_descriptor.json +53 -0
  94. package/dist-engine-src/src/schema/builtin/lix_key_value.json +41 -0
  95. package/dist-engine-src/src/schema/builtin/lix_label.json +22 -0
  96. package/dist-engine-src/src/schema/builtin/lix_registered_schema.json +31 -0
  97. package/dist-engine-src/src/schema/builtin/lix_version_descriptor.json +35 -0
  98. package/dist-engine-src/src/schema/builtin/lix_version_ref.json +49 -0
  99. package/dist-engine-src/src/schema/builtin/mod.rs +271 -0
  100. package/dist-engine-src/src/schema/definition.json +157 -0
  101. package/dist-engine-src/src/schema/definition.rs +636 -0
  102. package/dist-engine-src/src/schema/key.rs +206 -0
  103. package/dist-engine-src/src/schema/mod.rs +20 -0
  104. package/dist-engine-src/src/schema/seed.rs +14 -0
  105. package/dist-engine-src/src/schema/tests.rs +739 -0
  106. package/dist-engine-src/src/schema_registry.rs +294 -0
  107. package/dist-engine-src/src/session/context.rs +366 -0
  108. package/dist-engine-src/src/session/create_version.rs +80 -0
  109. package/dist-engine-src/src/session/execute.rs +447 -0
  110. package/dist-engine-src/src/session/merge/analysis.rs +102 -0
  111. package/dist-engine-src/src/session/merge/apply.rs +23 -0
  112. package/dist-engine-src/src/session/merge/conflicts.rs +62 -0
  113. package/dist-engine-src/src/session/merge/mod.rs +11 -0
  114. package/dist-engine-src/src/session/merge/stats.rs +65 -0
  115. package/dist-engine-src/src/session/merge/version.rs +437 -0
  116. package/dist-engine-src/src/session/mod.rs +25 -0
  117. package/dist-engine-src/src/session/switch_version.rs +121 -0
  118. package/dist-engine-src/src/sql2/change_provider.rs +337 -0
  119. package/dist-engine-src/src/sql2/classify.rs +147 -0
  120. package/dist-engine-src/src/sql2/commit_derived_provider.rs +591 -0
  121. package/dist-engine-src/src/sql2/context.rs +307 -0
  122. package/dist-engine-src/src/sql2/directory_history_provider.rs +623 -0
  123. package/dist-engine-src/src/sql2/directory_provider.rs +2405 -0
  124. package/dist-engine-src/src/sql2/dml.rs +148 -0
  125. package/dist-engine-src/src/sql2/entity_history_provider.rs +444 -0
  126. package/dist-engine-src/src/sql2/entity_provider.rs +2700 -0
  127. package/dist-engine-src/src/sql2/error.rs +196 -0
  128. package/dist-engine-src/src/sql2/execute.rs +3379 -0
  129. package/dist-engine-src/src/sql2/file_history_provider.rs +902 -0
  130. package/dist-engine-src/src/sql2/file_provider.rs +3254 -0
  131. package/dist-engine-src/src/sql2/filesystem_planner.rs +1526 -0
  132. package/dist-engine-src/src/sql2/filesystem_predicates.rs +159 -0
  133. package/dist-engine-src/src/sql2/filesystem_visibility.rs +369 -0
  134. package/dist-engine-src/src/sql2/history_projection.rs +80 -0
  135. package/dist-engine-src/src/sql2/history_provider.rs +418 -0
  136. package/dist-engine-src/src/sql2/history_route.rs +643 -0
  137. package/dist-engine-src/src/sql2/lix_state_provider.rs +2430 -0
  138. package/dist-engine-src/src/sql2/mod.rs +43 -0
  139. package/dist-engine-src/src/sql2/read_only.rs +65 -0
  140. package/dist-engine-src/src/sql2/record_batch.rs +17 -0
  141. package/dist-engine-src/src/sql2/result_metadata.rs +29 -0
  142. package/dist-engine-src/src/sql2/runtime.rs +60 -0
  143. package/dist-engine-src/src/sql2/session.rs +135 -0
  144. package/dist-engine-src/src/sql2/udfs/common.rs +295 -0
  145. package/dist-engine-src/src/sql2/udfs/lix_active_version_commit_id.rs +53 -0
  146. package/dist-engine-src/src/sql2/udfs/lix_empty_blob.rs +47 -0
  147. package/dist-engine-src/src/sql2/udfs/lix_json.rs +100 -0
  148. package/dist-engine-src/src/sql2/udfs/lix_json_get.rs +99 -0
  149. package/dist-engine-src/src/sql2/udfs/lix_json_get_text.rs +99 -0
  150. package/dist-engine-src/src/sql2/udfs/lix_text_decode.rs +82 -0
  151. package/dist-engine-src/src/sql2/udfs/lix_text_encode.rs +85 -0
  152. package/dist-engine-src/src/sql2/udfs/lix_uuid_v7.rs +76 -0
  153. package/dist-engine-src/src/sql2/udfs/mod.rs +82 -0
  154. package/dist-engine-src/src/sql2/version_provider.rs +1187 -0
  155. package/dist-engine-src/src/sql2/version_scope.rs +394 -0
  156. package/dist-engine-src/src/sql2/write_normalization.rs +345 -0
  157. package/dist-engine-src/src/storage/context.rs +356 -0
  158. package/dist-engine-src/src/storage/mod.rs +14 -0
  159. package/dist-engine-src/src/storage/read_scope.rs +88 -0
  160. package/dist-engine-src/src/storage/types.rs +501 -0
  161. package/dist-engine-src/src/storage_bench.rs +3406 -0
  162. package/dist-engine-src/src/test_support.rs +81 -0
  163. package/dist-engine-src/src/tracked_state/by_file_index.rs +102 -0
  164. package/dist-engine-src/src/tracked_state/codec.rs +747 -0
  165. package/dist-engine-src/src/tracked_state/context.rs +983 -0
  166. package/dist-engine-src/src/tracked_state/diff.rs +494 -0
  167. package/dist-engine-src/src/tracked_state/materialization.rs +141 -0
  168. package/dist-engine-src/src/tracked_state/merge.rs +474 -0
  169. package/dist-engine-src/src/tracked_state/mod.rs +31 -0
  170. package/dist-engine-src/src/tracked_state/rebuild.rs +771 -0
  171. package/dist-engine-src/src/tracked_state/storage.rs +243 -0
  172. package/dist-engine-src/src/tracked_state/tree.rs +2744 -0
  173. package/dist-engine-src/src/tracked_state/tree_types.rs +176 -0
  174. package/dist-engine-src/src/tracked_state/types.rs +61 -0
  175. package/dist-engine-src/src/transaction/commit.rs +1224 -0
  176. package/dist-engine-src/src/transaction/context.rs +1307 -0
  177. package/dist-engine-src/src/transaction/live_state_overlay.rs +34 -0
  178. package/dist-engine-src/src/transaction/mod.rs +11 -0
  179. package/dist-engine-src/src/transaction/normalization.rs +1026 -0
  180. package/dist-engine-src/src/transaction/schema_resolver.rs +127 -0
  181. package/dist-engine-src/src/transaction/staging.rs +1436 -0
  182. package/dist-engine-src/src/transaction/types.rs +351 -0
  183. package/dist-engine-src/src/transaction/validation.rs +4811 -0
  184. package/dist-engine-src/src/untracked_state/codec.rs +363 -0
  185. package/dist-engine-src/src/untracked_state/context.rs +82 -0
  186. package/dist-engine-src/src/untracked_state/materialization.rs +157 -0
  187. package/dist-engine-src/src/untracked_state/mod.rs +17 -0
  188. package/dist-engine-src/src/untracked_state/storage.rs +348 -0
  189. package/dist-engine-src/src/untracked_state/types.rs +96 -0
  190. package/dist-engine-src/src/version/context.rs +52 -0
  191. package/dist-engine-src/src/version/mod.rs +12 -0
  192. package/dist-engine-src/src/version/refs.rs +421 -0
  193. package/dist-engine-src/src/version/stage_rows.rs +71 -0
  194. package/dist-engine-src/src/version/types.rs +21 -0
  195. package/dist-engine-src/src/wasm/mod.rs +60 -0
  196. package/package.json +68 -63
@@ -0,0 +1,1436 @@
1
+ use std::collections::{BTreeMap, BTreeSet};
2
+ use std::sync::Mutex;
3
+
4
+ use crate::functions::{FunctionProvider, FunctionProviderHandle};
5
+ #[cfg(test)]
6
+ use crate::live_state::LiveStateRowRequest;
7
+ use crate::live_state::{LiveStateRow, LiveStateRowIdentity, LiveStateScanRequest};
8
+ use crate::transaction::types::{
9
+ LogicalPrimaryKey, StageAdoptedChange, StageFileData, StageRow, StageRowOrigin, StageWrite,
10
+ StageWriteMode, StageWriteOperation, StageWriteOutcome,
11
+ };
12
+ use crate::transaction::types::{StagedAdoptedStateRow, StagedCommitMembers, StagedStateRow};
13
+ use crate::GLOBAL_VERSION_ID;
14
+ use crate::{LixError, NullableKeyFilter};
15
+
16
+ /// Transaction-local writes decoded by DataFusion provider hooks.
17
+ ///
18
+ /// This is the engine2 seam between SQL execution and transaction ownership:
19
+ /// write frontends stage decoded writes here, the transaction normalizes them into
20
+ /// stable `StagedStateRow`s, reads build a `StagedStateRowOverlay` from those rows,
21
+ /// and commit later drains the same rows.
22
+ pub(crate) struct TransactionStagedWrites {
23
+ functions: FunctionProviderHandle,
24
+ rows: Mutex<BTreeMap<StagedStateRowIdentity, StagedStateRow>>,
25
+ adopted_rows: Mutex<BTreeMap<StagedStateRowIdentity, StagedAdoptedStateRow>>,
26
+ insert_identities: Mutex<BTreeMap<LiveStateRowIdentity, Option<StageRowOrigin>>>,
27
+ commit_members_by_version: Mutex<BTreeMap<String, StagedCommitMembers>>,
28
+ extra_commit_parents_by_version: Mutex<BTreeMap<String, Vec<String>>>,
29
+ file_data_writes: Mutex<Vec<StageFileData>>,
30
+ }
31
+
32
+ /// Drained transaction-local writes ready for commit.
33
+ pub(crate) struct StagedWriteSet {
34
+ pub(crate) state_rows: Vec<StagedStateRow>,
35
+ pub(crate) adopted_rows: Vec<StagedAdoptedStateRow>,
36
+ pub(crate) insert_identities: BTreeMap<LiveStateRowIdentity, Option<StageRowOrigin>>,
37
+ pub(crate) commit_members_by_version: BTreeMap<String, StagedCommitMembers>,
38
+ pub(crate) extra_commit_parents_by_version: BTreeMap<String, Vec<String>>,
39
+ pub(crate) file_data_writes: Vec<StageFileData>,
40
+ }
41
+
42
+ impl StagedWriteSet {
43
+ pub(crate) fn state_rows_for_validation(&self) -> Vec<StagedStateRow> {
44
+ self.state_rows
45
+ .iter()
46
+ .cloned()
47
+ .chain(self.adopted_rows.iter().map(StagedStateRow::from))
48
+ .collect()
49
+ }
50
+ }
51
+
52
+ impl TransactionStagedWrites {
53
+ pub(crate) fn new(functions: FunctionProviderHandle) -> Self {
54
+ Self {
55
+ functions,
56
+ rows: Mutex::new(BTreeMap::new()),
57
+ adopted_rows: Mutex::new(BTreeMap::new()),
58
+ insert_identities: Mutex::new(BTreeMap::new()),
59
+ commit_members_by_version: Mutex::new(BTreeMap::new()),
60
+ extra_commit_parents_by_version: Mutex::new(BTreeMap::new()),
61
+ file_data_writes: Mutex::new(Vec::new()),
62
+ }
63
+ }
64
+
65
+ /// Drains staged writes for commit.
66
+ pub(crate) fn drain(&self) -> Result<StagedWriteSet, LixError> {
67
+ let mut rows_guard = self.rows.lock().map_err(|_| {
68
+ LixError::new(
69
+ "LIX_ERROR_UNKNOWN",
70
+ "failed to acquire transaction staged writes lock",
71
+ )
72
+ })?;
73
+ let mut adopted_rows_guard = self.adopted_rows.lock().map_err(|_| {
74
+ LixError::new(
75
+ "LIX_ERROR_UNKNOWN",
76
+ "failed to acquire transaction staged adopted writes lock",
77
+ )
78
+ })?;
79
+ let mut file_data_guard = self.file_data_writes.lock().map_err(|_| {
80
+ LixError::new(
81
+ "LIX_ERROR_UNKNOWN",
82
+ "failed to acquire transaction staged file data lock",
83
+ )
84
+ })?;
85
+ let mut insert_identities_guard = self.insert_identities.lock().map_err(|_| {
86
+ LixError::new(
87
+ "LIX_ERROR_UNKNOWN",
88
+ "failed to acquire transaction staged insert identity lock",
89
+ )
90
+ })?;
91
+ let mut commit_members_guard = self.commit_members_by_version.lock().map_err(|_| {
92
+ LixError::new(
93
+ "LIX_ERROR_UNKNOWN",
94
+ "failed to acquire transaction staged commit membership lock",
95
+ )
96
+ })?;
97
+ let mut extra_parents_guard =
98
+ self.extra_commit_parents_by_version.lock().map_err(|_| {
99
+ LixError::new(
100
+ "LIX_ERROR_UNKNOWN",
101
+ "failed to acquire transaction staged extra commit parents lock",
102
+ )
103
+ })?;
104
+ Ok(StagedWriteSet {
105
+ state_rows: std::mem::take(&mut *rows_guard).into_values().collect(),
106
+ adopted_rows: std::mem::take(&mut *adopted_rows_guard)
107
+ .into_values()
108
+ .collect(),
109
+ insert_identities: std::mem::take(&mut *insert_identities_guard),
110
+ commit_members_by_version: std::mem::take(&mut *commit_members_guard),
111
+ extra_commit_parents_by_version: std::mem::take(&mut *extra_parents_guard),
112
+ file_data_writes: std::mem::take(&mut *file_data_guard),
113
+ })
114
+ }
115
+
116
+ /// Records an additional parent for the commit generated for `version_id`.
117
+ ///
118
+ /// Normal writes parent the new commit to the version's previous head.
119
+ /// Merges add the source version head as an extra parent so the commit graph
120
+ /// preserves branch ancestry while tracked-state roots still apply source
121
+ /// rows onto the target root.
122
+ pub(crate) fn add_commit_parent(
123
+ &self,
124
+ version_id: String,
125
+ parent_commit_id: String,
126
+ ) -> Result<(), LixError> {
127
+ let mut guard = self.extra_commit_parents_by_version.lock().map_err(|_| {
128
+ LixError::new(
129
+ "LIX_ERROR_UNKNOWN",
130
+ "failed to acquire transaction staged extra commit parents lock",
131
+ )
132
+ })?;
133
+ let parents = guard.entry(version_id).or_default();
134
+ if !parents.contains(&parent_commit_id) {
135
+ parents.push(parent_commit_id);
136
+ }
137
+ Ok(())
138
+ }
139
+
140
+ pub(crate) fn staged_commit_id(&self, version_id: &str) -> Result<Option<String>, LixError> {
141
+ let guard = self.commit_members_by_version.lock().map_err(|_| {
142
+ LixError::new(
143
+ "LIX_ERROR_UNKNOWN",
144
+ "failed to acquire transaction staged commit membership lock",
145
+ )
146
+ })?;
147
+ Ok(guard
148
+ .get(version_id)
149
+ .map(|members| members.commit_id.clone()))
150
+ }
151
+
152
+ /// Stages a commit for `version_id` even if no tracked state rows changed.
153
+ ///
154
+ /// Merge uses this to record graph ancestry for convergent merges where the
155
+ /// target already has the same final state as the source, but the source
156
+ /// head is not reachable from the target head.
157
+ pub(crate) fn stage_empty_commit(&self, version_id: String) -> Result<String, LixError> {
158
+ let mut functions = self.functions.clone();
159
+ let mut guard = self.commit_members_by_version.lock().map_err(|_| {
160
+ LixError::new(
161
+ "LIX_ERROR_UNKNOWN",
162
+ "failed to acquire transaction staged commit membership lock",
163
+ )
164
+ })?;
165
+ let members = guard.entry(version_id).or_insert_with(|| {
166
+ StagedCommitMembers::new(
167
+ functions.uuid_v7(),
168
+ functions.uuid_v7(),
169
+ functions.uuid_v7(),
170
+ functions.timestamp(),
171
+ )
172
+ });
173
+ members.allow_empty();
174
+ Ok(members.commit_id.clone())
175
+ }
176
+
177
+ /// Builds the transaction-local read overlay from currently staged writes.
178
+ pub(crate) fn staging_overlay(&self) -> Result<StagedStateRowOverlay, LixError> {
179
+ let guard = self.rows.lock().map_err(|_| {
180
+ LixError::new(
181
+ "LIX_ERROR_UNKNOWN",
182
+ "failed to acquire transaction staged writes lock",
183
+ )
184
+ })?;
185
+ let adopted_guard = self.adopted_rows.lock().map_err(|_| {
186
+ LixError::new(
187
+ "LIX_ERROR_UNKNOWN",
188
+ "failed to acquire transaction staged adopted writes lock",
189
+ )
190
+ })?;
191
+ Ok(StagedStateRowOverlay::new(
192
+ guard.clone(),
193
+ adopted_guard.clone(),
194
+ ))
195
+ }
196
+
197
+ /// Stages one decoded write batch into this transaction.
198
+ ///
199
+ /// This is the single hydration boundary for engine2 writes:
200
+ /// frontends hand us `StageRow`s, and this method assigns timestamps,
201
+ /// change ids, commit ids, and commit membership before commit routing ever
202
+ /// sees the rows.
203
+ pub(crate) fn stage_write(&self, write: StageWrite) -> Result<StageWriteOutcome, LixError> {
204
+ let (mode, count) = match &write {
205
+ StageWrite::Rows { mode, rows } => (Some(*mode), rows.len() as u64),
206
+ StageWrite::RowsWithFileData { mode, count, .. } => (Some(*mode), *count),
207
+ StageWrite::AdoptedChanges { changes } => (None, changes.len() as u64),
208
+ };
209
+ let mut functions = self.functions.clone();
210
+ let (rows, adopted_rows, file_data_writes) =
211
+ self.state_rows_from_stage_write(write, &mut functions)?;
212
+ for row in &rows {
213
+ validate_commit_membership_support(row)?;
214
+ }
215
+ for row in &adopted_rows {
216
+ validate_adopted_commit_membership_support(row)?;
217
+ }
218
+ reject_duplicate_present_rows_in_batch(&rows)?;
219
+ let mut guard = self.rows.lock().map_err(|_| {
220
+ LixError::new(
221
+ "LIX_ERROR_UNKNOWN",
222
+ "failed to acquire transaction staged writes lock",
223
+ )
224
+ })?;
225
+ let mut adopted_guard = self.adopted_rows.lock().map_err(|_| {
226
+ LixError::new(
227
+ "LIX_ERROR_UNKNOWN",
228
+ "failed to acquire transaction staged adopted writes lock",
229
+ )
230
+ })?;
231
+ let mut commit_members_guard = self.commit_members_by_version.lock().map_err(|_| {
232
+ LixError::new(
233
+ "LIX_ERROR_UNKNOWN",
234
+ "failed to acquire transaction staged commit membership lock",
235
+ )
236
+ })?;
237
+ let mut insert_identities_guard = self.insert_identities.lock().map_err(|_| {
238
+ LixError::new(
239
+ "LIX_ERROR_UNKNOWN",
240
+ "failed to acquire transaction staged insert identity lock",
241
+ )
242
+ })?;
243
+ for mut row in rows {
244
+ let identity = StagedStateRowIdentity::from(&row);
245
+ if mode == Some(StageWriteMode::Insert)
246
+ && (guard.contains_key(&identity)
247
+ || guard.contains_key(&identity.opposite_untracked())
248
+ || adopted_guard.contains_key(&identity))
249
+ {
250
+ return Err(duplicate_insert_identity_error(&row));
251
+ }
252
+ if adopted_guard.contains_key(&identity) {
253
+ return Err(conflicting_adopted_identity_error(&row));
254
+ }
255
+ if let Some(previous) = guard.remove(&identity.opposite_untracked()) {
256
+ remove_row_from_commit_members(&mut commit_members_guard, &previous);
257
+ }
258
+ if let Some(previous) = guard.remove(&identity) {
259
+ remove_row_from_commit_members(&mut commit_members_guard, &previous);
260
+ }
261
+ add_row_to_commit_members(&mut commit_members_guard, &mut row, &mut functions);
262
+ let identity = StagedStateRowIdentity::from(&row);
263
+ if mode == Some(StageWriteMode::Insert) {
264
+ insert_identities_guard.insert(
265
+ live_state_identity_from_staged_row(&row),
266
+ row.origin.clone(),
267
+ );
268
+ }
269
+ guard.insert(identity, row);
270
+ }
271
+ for mut row in adopted_rows {
272
+ let identity = StagedStateRowIdentity::from(&row);
273
+ if guard.contains_key(&identity) || adopted_guard.contains_key(&identity) {
274
+ return Err(conflicting_adopted_projection_error(&row));
275
+ }
276
+ add_adopted_row_to_commit_members(&mut commit_members_guard, &mut row, &mut functions);
277
+ let identity = StagedStateRowIdentity::from(&row);
278
+ adopted_guard.insert(identity, row);
279
+ }
280
+ if !file_data_writes.is_empty() {
281
+ self.file_data_writes
282
+ .lock()
283
+ .map_err(|_| {
284
+ LixError::new(
285
+ "LIX_ERROR_UNKNOWN",
286
+ "failed to acquire transaction staged file data lock",
287
+ )
288
+ })?
289
+ .extend(file_data_writes);
290
+ }
291
+ Ok(StageWriteOutcome { count })
292
+ }
293
+
294
+ fn state_rows_from_stage_write(
295
+ &self,
296
+ write: StageWrite,
297
+ functions: &mut dyn FunctionProvider,
298
+ ) -> Result<
299
+ (
300
+ Vec<StagedStateRow>,
301
+ Vec<StagedAdoptedStateRow>,
302
+ Vec<StageFileData>,
303
+ ),
304
+ LixError,
305
+ > {
306
+ let mut state_rows = Vec::new();
307
+ let mut adopted_rows = Vec::new();
308
+ let mut file_data_writes = Vec::new();
309
+ match write {
310
+ StageWrite::Rows { rows, .. } => {
311
+ self.push_state_rows(&mut state_rows, rows, functions)?;
312
+ }
313
+ StageWrite::RowsWithFileData {
314
+ rows, file_data, ..
315
+ } => {
316
+ self.push_state_rows(&mut state_rows, rows, functions)?;
317
+ file_data_writes.extend(file_data);
318
+ }
319
+ StageWrite::AdoptedChanges { changes } => {
320
+ self.push_adopted_rows(&mut adopted_rows, changes)?;
321
+ }
322
+ }
323
+ Ok((state_rows, adopted_rows, file_data_writes))
324
+ }
325
+
326
+ fn push_state_rows(
327
+ &self,
328
+ state_rows: &mut Vec<StagedStateRow>,
329
+ rows: Vec<StageRow>,
330
+ functions: &mut dyn FunctionProvider,
331
+ ) -> Result<(), LixError> {
332
+ state_rows.reserve(rows.len());
333
+ for row in rows {
334
+ state_rows.push(hydrate_state_write_row(row, functions)?);
335
+ }
336
+ Ok(())
337
+ }
338
+
339
+ fn push_adopted_rows(
340
+ &self,
341
+ adopted_rows: &mut Vec<StagedAdoptedStateRow>,
342
+ changes: Vec<StageAdoptedChange>,
343
+ ) -> Result<(), LixError> {
344
+ adopted_rows.reserve(changes.len());
345
+ for change in changes {
346
+ adopted_rows.push(hydrate_adopted_state_row(change)?);
347
+ }
348
+ Ok(())
349
+ }
350
+ }
351
+
352
+ /// Read overlay derived from staged transaction writes.
353
+ pub(crate) struct StagedStateRowOverlay {
354
+ rows: BTreeMap<StagedStateRowIdentity, StagedStateRow>,
355
+ adopted_rows: BTreeMap<StagedStateRowIdentity, StagedAdoptedStateRow>,
356
+ }
357
+
358
+ impl StagedStateRowOverlay {
359
+ fn new(
360
+ rows: BTreeMap<StagedStateRowIdentity, StagedStateRow>,
361
+ adopted_rows: BTreeMap<StagedStateRowIdentity, StagedAdoptedStateRow>,
362
+ ) -> Self {
363
+ Self { rows, adopted_rows }
364
+ }
365
+
366
+ /// Returns staged rows visible for a scan request.
367
+ pub(crate) fn scan(&self, request: &LiveStateScanRequest) -> Vec<LiveStateRow> {
368
+ self.rows
369
+ .values()
370
+ .filter(|row| staged_row_matches_scan(row, request))
371
+ .map(LiveStateRow::from)
372
+ .chain(
373
+ self.adopted_rows
374
+ .values()
375
+ .filter(|row| adopted_row_matches_scan(row, request))
376
+ .map(LiveStateRow::from),
377
+ )
378
+ .collect()
379
+ }
380
+
381
+ /// Returns staged identities that should suppress base live-state rows.
382
+ ///
383
+ /// Tombstones also suppress base live-state rows, even when the caller is not
384
+ /// asking to see tombstone rows.
385
+ pub(crate) fn identities_matching_scan(
386
+ &self,
387
+ request: &LiveStateScanRequest,
388
+ ) -> BTreeSet<StagedStateRowIdentity> {
389
+ self.rows
390
+ .values()
391
+ .filter(|row| staged_row_identity_matches_scan(row, request))
392
+ .map(StagedStateRowIdentity::from)
393
+ .chain(
394
+ self.adopted_rows
395
+ .values()
396
+ .filter(|row| adopted_row_identity_matches_scan(row, request))
397
+ .map(StagedStateRowIdentity::from),
398
+ )
399
+ .collect()
400
+ }
401
+
402
+ /// Returns a staged exact-row answer, if this transaction has one.
403
+ #[cfg(test)]
404
+ pub(crate) fn load_exact(&self, request: &LiveStateRowRequest) -> Option<StagedExactRow> {
405
+ let untracked_identity = StagedStateRowIdentity::from_exact_request(request, true)?;
406
+ if let Some(row) = self.rows.get(&untracked_identity) {
407
+ return Some(if row.snapshot_content.is_none() {
408
+ StagedExactRow::Tombstone
409
+ } else {
410
+ StagedExactRow::Row(LiveStateRow::from(row))
411
+ });
412
+ }
413
+
414
+ let identity = StagedStateRowIdentity::from_exact_request(request, false)?;
415
+ if let Some(row) = self.rows.get(&identity) {
416
+ return Some(if row.snapshot_content.is_none() {
417
+ StagedExactRow::Tombstone
418
+ } else {
419
+ StagedExactRow::Row(LiveStateRow::from(row))
420
+ });
421
+ }
422
+ self.adopted_rows.get(&identity).map(|row| {
423
+ if row.snapshot_content.is_none() {
424
+ StagedExactRow::Tombstone
425
+ } else {
426
+ StagedExactRow::Row(LiveStateRow::from(row))
427
+ }
428
+ })
429
+ }
430
+ }
431
+
432
+ #[cfg(test)]
433
+ pub(crate) enum StagedExactRow {
434
+ Row(LiveStateRow),
435
+ Tombstone,
436
+ }
437
+
438
+ #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
439
+ pub(crate) struct StagedStateRowIdentity {
440
+ untracked: bool,
441
+ schema_key: String,
442
+ entity_id: crate::entity_identity::EntityIdentity,
443
+ file_id: Option<String>,
444
+ version_id: String,
445
+ }
446
+
447
+ impl StagedStateRowIdentity {
448
+ fn from_staged_row(row: &StagedStateRow) -> Self {
449
+ Self {
450
+ untracked: row.untracked,
451
+ schema_key: row.schema_key.clone(),
452
+ entity_id: row.entity_id.clone(),
453
+ file_id: row.file_id.clone(),
454
+ version_id: row.version_id.clone(),
455
+ }
456
+ }
457
+
458
+ #[cfg(test)]
459
+ fn from_exact_request(request: &LiveStateRowRequest, untracked: bool) -> Option<Self> {
460
+ let file_id = match &request.file_id {
461
+ NullableKeyFilter::Null => None,
462
+ NullableKeyFilter::Value(value) => Some(value.clone()),
463
+ // Exact overlay lookup requires a concrete row identity.
464
+ NullableKeyFilter::Any => return None,
465
+ };
466
+ Some(Self {
467
+ untracked,
468
+ schema_key: request.schema_key.clone(),
469
+ entity_id: request.entity_id.clone(),
470
+ file_id,
471
+ version_id: request.version_id.clone(),
472
+ })
473
+ }
474
+
475
+ fn opposite_untracked(&self) -> Self {
476
+ Self {
477
+ untracked: !self.untracked,
478
+ schema_key: self.schema_key.clone(),
479
+ entity_id: self.entity_id.clone(),
480
+ file_id: self.file_id.clone(),
481
+ version_id: self.version_id.clone(),
482
+ }
483
+ }
484
+ }
485
+
486
+ impl From<&StagedStateRow> for StagedStateRowIdentity {
487
+ fn from(row: &StagedStateRow) -> Self {
488
+ Self::from_staged_row(row)
489
+ }
490
+ }
491
+
492
+ impl From<&StagedAdoptedStateRow> for StagedStateRowIdentity {
493
+ fn from(row: &StagedAdoptedStateRow) -> Self {
494
+ Self {
495
+ untracked: false,
496
+ schema_key: row.schema_key.clone(),
497
+ entity_id: row.entity_id.clone(),
498
+ file_id: row.file_id.clone(),
499
+ version_id: row.version_id.clone(),
500
+ }
501
+ }
502
+ }
503
+
504
+ impl From<&LiveStateRow> for StagedStateRowIdentity {
505
+ fn from(row: &LiveStateRow) -> Self {
506
+ Self {
507
+ untracked: row.untracked,
508
+ schema_key: row.schema_key.clone(),
509
+ entity_id: row.entity_id.clone(),
510
+ file_id: row.file_id.clone(),
511
+ version_id: row.version_id.clone(),
512
+ }
513
+ }
514
+ }
515
+
516
+ fn hydrate_state_write_row(
517
+ row: StageRow,
518
+ functions: &mut dyn FunctionProvider,
519
+ ) -> Result<StagedStateRow, LixError> {
520
+ let updated_at = row.updated_at.unwrap_or_else(|| functions.timestamp());
521
+ Ok(StagedStateRow {
522
+ entity_id: row.entity_id.ok_or_else(|| {
523
+ LixError::new(
524
+ "LIX_ERROR_UNKNOWN",
525
+ "normalized staged row is missing entity_id",
526
+ )
527
+ })?,
528
+ schema_key: row.schema_key,
529
+ file_id: row.file_id,
530
+ snapshot_content: row.snapshot_content,
531
+ metadata: row.metadata,
532
+ origin: row.origin,
533
+ schema_version: row.schema_version,
534
+ created_at: row.created_at.unwrap_or_else(|| updated_at.clone()),
535
+ updated_at,
536
+ global: row.global,
537
+ change_id: if row.untracked {
538
+ row.change_id
539
+ } else {
540
+ Some(row.change_id.unwrap_or_else(|| functions.uuid_v7()))
541
+ },
542
+ commit_id: row.commit_id,
543
+ untracked: row.untracked,
544
+ version_id: row.version_id,
545
+ })
546
+ }
547
+
548
+ fn hydrate_adopted_state_row(
549
+ change: StageAdoptedChange,
550
+ ) -> Result<StagedAdoptedStateRow, LixError> {
551
+ if change.change_id != change.projected_row.change_id {
552
+ return Err(LixError::new(
553
+ "LIX_ERROR_UNKNOWN",
554
+ format!(
555
+ "adopted change '{}' does not match projected row change_id '{}'",
556
+ change.change_id, change.projected_row.change_id
557
+ ),
558
+ ));
559
+ }
560
+ let row = change.projected_row;
561
+ Ok(StagedAdoptedStateRow {
562
+ entity_id: row.entity_id,
563
+ schema_key: row.schema_key,
564
+ file_id: row.file_id,
565
+ snapshot_content: row.snapshot_content,
566
+ metadata: row.metadata,
567
+ schema_version: row.schema_version,
568
+ created_at: row.created_at,
569
+ updated_at: row.updated_at,
570
+ global: change.version_id == GLOBAL_VERSION_ID,
571
+ change_id: change.change_id,
572
+ commit_id: String::new(),
573
+ version_id: change.version_id,
574
+ })
575
+ }
576
+
577
+ fn validate_commit_membership_support(row: &StagedStateRow) -> Result<(), LixError> {
578
+ if row.global && row.version_id != GLOBAL_VERSION_ID {
579
+ return Err(LixError::new(
580
+ "LIX_ERROR_UNKNOWN",
581
+ "engine2 global staged rows must use the global version id",
582
+ ));
583
+ }
584
+ Ok(())
585
+ }
586
+
587
+ fn validate_adopted_commit_membership_support(row: &StagedAdoptedStateRow) -> Result<(), LixError> {
588
+ if row.global && row.version_id != GLOBAL_VERSION_ID {
589
+ return Err(LixError::new(
590
+ "LIX_ERROR_UNKNOWN",
591
+ "engine2 global adopted rows must use the global version id",
592
+ ));
593
+ }
594
+ Ok(())
595
+ }
596
+
597
+ fn reject_duplicate_present_rows_in_batch(rows: &[StagedStateRow]) -> Result<(), LixError> {
598
+ let mut pending_present_rows = BTreeMap::<StagedStateRowIdentity, &StagedStateRow>::new();
599
+ for row in rows {
600
+ let identity = StagedStateRowIdentity::from(row);
601
+ if row.snapshot_content.is_none() {
602
+ pending_present_rows.remove(&identity);
603
+ continue;
604
+ }
605
+ if let Some(previous) = pending_present_rows.insert(identity, row) {
606
+ return Err(duplicate_staged_present_row_error(row, previous));
607
+ }
608
+ }
609
+ Ok(())
610
+ }
611
+
612
+ fn duplicate_staged_present_row_error(row: &StagedStateRow, previous: &StagedStateRow) -> LixError {
613
+ let message = logical_primary_key_violation_message(row.origin.as_ref())
614
+ .unwrap_or_else(|| {
615
+ format!(
616
+ "primary-key constraint violation on schema '{}' version '{}': duplicate staged rows for entity_id '{}' in version '{}'",
617
+ row.schema_key,
618
+ row.schema_version,
619
+ previous
620
+ .entity_id
621
+ .as_string()
622
+ .unwrap_or_else(|_| "<invalid entity_id>".to_string()),
623
+ row.version_id
624
+ )
625
+ });
626
+ LixError::new(LixError::CODE_UNIQUE, message)
627
+ }
628
+
629
+ pub(crate) fn duplicate_insert_identity_message(
630
+ schema_key: &str,
631
+ schema_version: &str,
632
+ entity_id: &crate::entity_identity::EntityIdentity,
633
+ version_id: Option<&str>,
634
+ origin: Option<&StageRowOrigin>,
635
+ ) -> String {
636
+ if let Some(message) = logical_primary_key_violation_message(origin) {
637
+ return message;
638
+ }
639
+ let entity_id = entity_id
640
+ .as_string()
641
+ .unwrap_or_else(|_| "<invalid entity_id>".to_string());
642
+ match version_id {
643
+ Some(version_id) => format!(
644
+ "primary-key constraint violation on schema '{schema_key}' version '{schema_version}': INSERT would duplicate entity_id '{entity_id}' in version '{version_id}'"
645
+ ),
646
+ None => format!(
647
+ "primary-key constraint violation on schema '{schema_key}' version '{schema_version}': INSERT would duplicate entity_id '{entity_id}'"
648
+ ),
649
+ }
650
+ }
651
+
652
+ fn duplicate_insert_identity_error(row: &StagedStateRow) -> LixError {
653
+ let message = duplicate_insert_identity_message(
654
+ &row.schema_key,
655
+ &row.schema_version,
656
+ &row.entity_id,
657
+ Some(&row.version_id),
658
+ row.origin.as_ref(),
659
+ );
660
+ LixError::new(LixError::CODE_UNIQUE, message)
661
+ }
662
+
663
+ fn logical_primary_key_violation_message(origin: Option<&StageRowOrigin>) -> Option<String> {
664
+ let origin = origin?;
665
+ if origin.operation != StageWriteOperation::Insert {
666
+ return None;
667
+ }
668
+ let primary_key = origin.primary_key.as_ref()?;
669
+ Some(format!(
670
+ "primary-key constraint violation on table '{}': INSERT would duplicate {}",
671
+ origin.surface,
672
+ format_logical_primary_key(primary_key)
673
+ ))
674
+ }
675
+
676
+ fn format_logical_primary_key(primary_key: &LogicalPrimaryKey) -> String {
677
+ primary_key
678
+ .columns
679
+ .iter()
680
+ .enumerate()
681
+ .map(|(index, column)| {
682
+ let value = primary_key
683
+ .values
684
+ .get(index)
685
+ .map(String::as_str)
686
+ .unwrap_or("<missing>");
687
+ format!("{column} '{value}'")
688
+ })
689
+ .collect::<Vec<_>>()
690
+ .join(", ")
691
+ }
692
+
693
+ fn conflicting_adopted_identity_error(row: &StagedStateRow) -> LixError {
694
+ LixError::new(
695
+ LixError::CODE_UNIQUE,
696
+ format!(
697
+ "transaction cannot stage a new row and an adopted projection for schema '{}' entity_id '{}' in version '{}'",
698
+ row.schema_key,
699
+ row.entity_id
700
+ .as_string()
701
+ .unwrap_or_else(|_| "<invalid entity_id>".to_string()),
702
+ row.version_id
703
+ ),
704
+ )
705
+ }
706
+
707
+ fn conflicting_adopted_projection_error(row: &StagedAdoptedStateRow) -> LixError {
708
+ LixError::new(
709
+ LixError::CODE_UNIQUE,
710
+ format!(
711
+ "transaction cannot stage duplicate adopted projections for schema '{}' entity_id '{}' in version '{}'",
712
+ row.schema_key,
713
+ row.entity_id
714
+ .as_string()
715
+ .unwrap_or_else(|_| "<invalid entity_id>".to_string()),
716
+ row.version_id
717
+ ),
718
+ )
719
+ }
720
+
721
+ fn live_state_identity_from_staged_row(row: &StagedStateRow) -> LiveStateRowIdentity {
722
+ LiveStateRowIdentity {
723
+ version_id: row.version_id.clone(),
724
+ schema_key: row.schema_key.clone(),
725
+ entity_id: row.entity_id.clone(),
726
+ file_id: row.file_id.clone(),
727
+ }
728
+ }
729
+
730
+ fn add_row_to_commit_members(
731
+ members_by_version: &mut BTreeMap<String, StagedCommitMembers>,
732
+ row: &mut StagedStateRow,
733
+ functions: &mut dyn FunctionProvider,
734
+ ) {
735
+ if row.untracked {
736
+ return;
737
+ }
738
+ let change_id = row
739
+ .change_id
740
+ .clone()
741
+ .expect("tracked staged rows must carry change_id for commit membership");
742
+ let members = members_by_version
743
+ .entry(row.version_id.clone())
744
+ .or_insert_with(|| {
745
+ StagedCommitMembers::new(
746
+ functions.uuid_v7(),
747
+ functions.uuid_v7(),
748
+ functions.uuid_v7(),
749
+ functions.timestamp(),
750
+ )
751
+ });
752
+ row.commit_id = Some(members.commit_id.clone());
753
+ members.add_change_id(change_id);
754
+ }
755
+
756
+ fn add_adopted_row_to_commit_members(
757
+ members_by_version: &mut BTreeMap<String, StagedCommitMembers>,
758
+ row: &mut StagedAdoptedStateRow,
759
+ functions: &mut dyn FunctionProvider,
760
+ ) {
761
+ let members = members_by_version
762
+ .entry(row.version_id.clone())
763
+ .or_insert_with(|| {
764
+ StagedCommitMembers::new(
765
+ functions.uuid_v7(),
766
+ functions.uuid_v7(),
767
+ functions.uuid_v7(),
768
+ functions.timestamp(),
769
+ )
770
+ });
771
+ row.commit_id = members.commit_id.clone();
772
+ members.add_change_id(row.change_id.clone());
773
+ }
774
+
775
+ fn remove_row_from_commit_members(
776
+ members_by_version: &mut BTreeMap<String, StagedCommitMembers>,
777
+ row: &StagedStateRow,
778
+ ) {
779
+ if row.untracked {
780
+ return;
781
+ }
782
+ let Some(members) = members_by_version.get_mut(&row.version_id) else {
783
+ return;
784
+ };
785
+ let Some(change_id) = row.change_id.as_deref() else {
786
+ return;
787
+ };
788
+ members.remove_change_id(change_id);
789
+ if members.is_empty() {
790
+ members_by_version.remove(&row.version_id);
791
+ }
792
+ }
793
+
794
+ fn staged_row_matches_scan(row: &StagedStateRow, request: &LiveStateScanRequest) -> bool {
795
+ staged_row_identity_matches_scan(row, request)
796
+ && (row.snapshot_content.is_some() || request.filter.include_tombstones)
797
+ }
798
+
799
+ fn adopted_row_matches_scan(row: &StagedAdoptedStateRow, request: &LiveStateScanRequest) -> bool {
800
+ adopted_row_identity_matches_scan(row, request)
801
+ && (row.snapshot_content.is_some() || request.filter.include_tombstones)
802
+ }
803
+
804
+ fn adopted_row_identity_matches_scan(
805
+ row: &StagedAdoptedStateRow,
806
+ request: &LiveStateScanRequest,
807
+ ) -> bool {
808
+ if !request.filter.schema_keys.is_empty()
809
+ && !request.filter.schema_keys.contains(&row.schema_key)
810
+ {
811
+ return false;
812
+ }
813
+ if !request.filter.entity_ids.is_empty() && !request.filter.entity_ids.contains(&row.entity_id)
814
+ {
815
+ return false;
816
+ }
817
+ if !request.filter.version_ids.is_empty()
818
+ && !request.filter.version_ids.contains(&row.version_id)
819
+ {
820
+ return false;
821
+ }
822
+ nullable_key_matches_filters(&row.file_id, &request.filter.file_ids)
823
+ }
824
+
825
+ fn staged_row_identity_matches_scan(row: &StagedStateRow, request: &LiveStateScanRequest) -> bool {
826
+ if !request.filter.schema_keys.is_empty()
827
+ && !request.filter.schema_keys.contains(&row.schema_key)
828
+ {
829
+ return false;
830
+ }
831
+ if !request.filter.entity_ids.is_empty() && !request.filter.entity_ids.contains(&row.entity_id)
832
+ {
833
+ return false;
834
+ }
835
+ if !request.filter.version_ids.is_empty()
836
+ && !request.filter.version_ids.contains(&row.version_id)
837
+ {
838
+ return false;
839
+ }
840
+ nullable_key_matches_filters(&row.file_id, &request.filter.file_ids)
841
+ }
842
+
843
+ fn nullable_key_matches_filters(
844
+ value: &Option<String>,
845
+ filters: &[NullableKeyFilter<String>],
846
+ ) -> bool {
847
+ filters.is_empty()
848
+ || filters
849
+ .iter()
850
+ .any(|filter| nullable_key_matches_filter(value, filter))
851
+ }
852
+
853
+ fn nullable_key_matches_filter(value: &Option<String>, filter: &NullableKeyFilter<String>) -> bool {
854
+ match filter {
855
+ NullableKeyFilter::Any => true,
856
+ NullableKeyFilter::Null => value.is_none(),
857
+ NullableKeyFilter::Value(expected) => value.as_ref() == Some(expected),
858
+ }
859
+ }
860
+
861
+ #[cfg(test)]
862
+ mod tests {
863
+ use super::*;
864
+ use crate::functions::SharedFunctionProvider;
865
+ use crate::live_state::{LiveStateFilter, LiveStateRowRequest};
866
+
867
+ #[tokio::test]
868
+ async fn staging_overlay_uses_last_staged_row_for_exact_load() {
869
+ let staged_writes = test_staged_writes();
870
+
871
+ staged_writes
872
+ .stage_write(StageWrite::Rows {
873
+ mode: StageWriteMode::Replace,
874
+ rows: vec![state_row("sql2-duplicate-key", "first")],
875
+ })
876
+ .expect("initial row should stage");
877
+ staged_writes
878
+ .stage_write(StageWrite::Rows {
879
+ mode: StageWriteMode::Replace,
880
+ rows: vec![state_row("sql2-duplicate-key", "second")],
881
+ })
882
+ .expect("staging rows should succeed");
883
+
884
+ let overlay = staged_writes
885
+ .staging_overlay()
886
+ .expect("overlay should build from staged rows");
887
+ let row = overlay
888
+ .load_exact(&LiveStateRowRequest {
889
+ schema_key: "lix_key_value".to_string(),
890
+ version_id: "global".to_string(),
891
+ entity_id: crate::entity_identity::EntityIdentity::single("sql2-duplicate-key"),
892
+ file_id: NullableKeyFilter::Null,
893
+ })
894
+ .expect("staged row should be visible");
895
+
896
+ let StagedExactRow::Row(row) = row else {
897
+ panic!("latest staged row should not be a tombstone");
898
+ };
899
+ assert_eq!(
900
+ row.snapshot_content.as_deref(),
901
+ Some("{\"key\":\"sql2-duplicate-key\",\"value\":\"second\"}")
902
+ );
903
+ }
904
+
905
+ #[tokio::test]
906
+ async fn staging_overlay_scan_returns_only_latest_row_per_identity() {
907
+ let staged_writes = test_staged_writes();
908
+
909
+ staged_writes
910
+ .stage_write(StageWrite::Rows {
911
+ mode: StageWriteMode::Replace,
912
+ rows: vec![state_row("sql2-duplicate-key", "first")],
913
+ })
914
+ .expect("initial row should stage");
915
+ staged_writes
916
+ .stage_write(StageWrite::Rows {
917
+ mode: StageWriteMode::Replace,
918
+ rows: vec![state_row("sql2-duplicate-key", "second")],
919
+ })
920
+ .expect("staging rows should succeed");
921
+
922
+ let overlay = staged_writes
923
+ .staging_overlay()
924
+ .expect("overlay should build from staged rows");
925
+ let rows = overlay.scan(&scan_request_for_key("sql2-duplicate-key", false));
926
+
927
+ assert_eq!(rows.len(), 1);
928
+ assert_eq!(
929
+ rows[0].snapshot_content.as_deref(),
930
+ Some("{\"key\":\"sql2-duplicate-key\",\"value\":\"second\"}")
931
+ );
932
+ }
933
+
934
+ #[tokio::test]
935
+ async fn staging_overlay_delete_hides_prior_staged_insert() {
936
+ let staged_writes = test_staged_writes();
937
+
938
+ staged_writes
939
+ .stage_write(StageWrite::Rows {
940
+ mode: StageWriteMode::Replace,
941
+ rows: vec![
942
+ state_row("sql2-delete-key", "visible"),
943
+ tombstone_row("sql2-delete-key"),
944
+ ],
945
+ })
946
+ .expect("staging rows should succeed");
947
+
948
+ let overlay = staged_writes
949
+ .staging_overlay()
950
+ .expect("overlay should build from staged rows");
951
+ let exact = overlay
952
+ .load_exact(&exact_request_for_key("sql2-delete-key"))
953
+ .expect("staged tombstone should answer exact load");
954
+ assert!(matches!(exact, StagedExactRow::Tombstone));
955
+ assert!(overlay
956
+ .scan(&scan_request_for_key("sql2-delete-key", false))
957
+ .is_empty());
958
+
959
+ let tombstones = overlay.scan(&scan_request_for_key("sql2-delete-key", true));
960
+ assert_eq!(tombstones.len(), 1);
961
+ assert_eq!(tombstones[0].snapshot_content, None);
962
+ }
963
+
964
+ #[tokio::test]
965
+ async fn staging_overlay_insert_after_delete_resurrects_row() {
966
+ let staged_writes = test_staged_writes();
967
+
968
+ staged_writes
969
+ .stage_write(StageWrite::Rows {
970
+ mode: StageWriteMode::Replace,
971
+ rows: vec![
972
+ tombstone_row("sql2-resurrect-key"),
973
+ state_row("sql2-resurrect-key", "visible-again"),
974
+ ],
975
+ })
976
+ .expect("staging rows should succeed");
977
+
978
+ let overlay = staged_writes
979
+ .staging_overlay()
980
+ .expect("overlay should build from staged rows");
981
+ let exact = overlay
982
+ .load_exact(&exact_request_for_key("sql2-resurrect-key"))
983
+ .expect("staged row should answer exact load");
984
+
985
+ let StagedExactRow::Row(row) = exact else {
986
+ panic!("latest staged row should be visible");
987
+ };
988
+ assert_eq!(
989
+ row.snapshot_content.as_deref(),
990
+ Some("{\"key\":\"sql2-resurrect-key\",\"value\":\"visible-again\"}")
991
+ );
992
+ assert_eq!(
993
+ overlay
994
+ .scan(&scan_request_for_key("sql2-resurrect-key", false))
995
+ .len(),
996
+ 1
997
+ );
998
+ }
999
+
1000
+ #[tokio::test]
1001
+ async fn staged_writes_drain_returns_coalesced_latest_rows() {
1002
+ let staged_writes = test_staged_writes();
1003
+
1004
+ staged_writes
1005
+ .stage_write(StageWrite::Rows {
1006
+ mode: StageWriteMode::Replace,
1007
+ rows: vec![
1008
+ state_row("sql2-key-a", "first"),
1009
+ state_row("sql2-key-b", "only"),
1010
+ ],
1011
+ })
1012
+ .expect("initial rows should stage");
1013
+ staged_writes
1014
+ .stage_write(StageWrite::Rows {
1015
+ mode: StageWriteMode::Replace,
1016
+ rows: vec![state_row("sql2-key-a", "second")],
1017
+ })
1018
+ .expect("staging rows should succeed");
1019
+
1020
+ let drained = staged_writes.drain().expect("drain should succeed");
1021
+
1022
+ assert_eq!(drained.state_rows.len(), 2);
1023
+ assert!(drained.state_rows.iter().any(|row| {
1024
+ row.entity_id == crate::entity_identity::EntityIdentity::single("sql2-key-a")
1025
+ && row.snapshot_content.as_deref()
1026
+ == Some("{\"key\":\"sql2-key-a\",\"value\":\"second\"}")
1027
+ }));
1028
+ assert!(drained.state_rows.iter().any(|row| {
1029
+ row.entity_id == crate::entity_identity::EntityIdentity::single("sql2-key-b")
1030
+ && row.snapshot_content.as_deref()
1031
+ == Some("{\"key\":\"sql2-key-b\",\"value\":\"only\"}")
1032
+ }));
1033
+ }
1034
+
1035
+ #[tokio::test]
1036
+ async fn staged_writes_drain_preserves_file_data_payloads() {
1037
+ let staged_writes = test_staged_writes();
1038
+
1039
+ staged_writes
1040
+ .stage_write(StageWrite::RowsWithFileData {
1041
+ mode: StageWriteMode::Replace,
1042
+ rows: vec![state_row("file-readme", "descriptor")],
1043
+ file_data: vec![StageFileData {
1044
+ file_id: "file-readme".to_string(),
1045
+ version_id: "global".to_string(),
1046
+ untracked: true,
1047
+ data: b"hello".to_vec(),
1048
+ }],
1049
+ count: 1,
1050
+ })
1051
+ .expect("staging rows with file data should succeed");
1052
+
1053
+ let drained = staged_writes.drain().expect("drain should succeed");
1054
+
1055
+ assert_eq!(drained.state_rows.len(), 1);
1056
+ assert_eq!(drained.file_data_writes.len(), 1);
1057
+ assert_eq!(drained.file_data_writes[0].file_id, "file-readme");
1058
+ assert_eq!(drained.file_data_writes[0].data, b"hello");
1059
+ }
1060
+
1061
+ #[tokio::test]
1062
+ async fn staged_writes_track_commit_members_for_tracked_global_rows() {
1063
+ let staged_writes = test_staged_writes();
1064
+
1065
+ staged_writes
1066
+ .stage_write(StageWrite::Rows {
1067
+ mode: StageWriteMode::Replace,
1068
+ rows: vec![state_row("tracked-key", "value").with_tracked()],
1069
+ })
1070
+ .expect("tracked global row should stage");
1071
+
1072
+ let drained = staged_writes.drain().expect("drain should succeed");
1073
+ let members = drained
1074
+ .commit_members_by_version
1075
+ .get("global")
1076
+ .expect("global commit members should exist");
1077
+ assert_eq!(
1078
+ members.change_ids.iter().cloned().collect::<Vec<_>>(),
1079
+ vec!["test-uuid-1".to_string()]
1080
+ );
1081
+ }
1082
+
1083
+ #[tokio::test]
1084
+ async fn staged_writes_do_not_track_untracked_rows_as_commit_members() {
1085
+ let staged_writes = test_staged_writes();
1086
+
1087
+ staged_writes
1088
+ .stage_write(StageWrite::Rows {
1089
+ mode: StageWriteMode::Replace,
1090
+ rows: vec![state_row("untracked-key", "value")],
1091
+ })
1092
+ .expect("untracked row should stage");
1093
+
1094
+ let drained = staged_writes.drain().expect("drain should succeed");
1095
+ assert!(drained.commit_members_by_version.is_empty());
1096
+ }
1097
+
1098
+ #[tokio::test]
1099
+ async fn staged_writes_replace_commit_member_on_tracked_overwrite() {
1100
+ let staged_writes = test_staged_writes();
1101
+
1102
+ staged_writes
1103
+ .stage_write(StageWrite::Rows {
1104
+ mode: StageWriteMode::Replace,
1105
+ rows: vec![state_row("overwrite-key", "first")
1106
+ .with_tracked()
1107
+ .with_change_id("change-first")],
1108
+ })
1109
+ .expect("initial tracked row should stage");
1110
+ staged_writes
1111
+ .stage_write(StageWrite::Rows {
1112
+ mode: StageWriteMode::Replace,
1113
+ rows: vec![state_row("overwrite-key", "second")
1114
+ .with_tracked()
1115
+ .with_change_id("change-second")],
1116
+ })
1117
+ .expect("tracked overwrite should stage");
1118
+
1119
+ let drained = staged_writes.drain().expect("drain should succeed");
1120
+ let members = drained
1121
+ .commit_members_by_version
1122
+ .get("global")
1123
+ .expect("global commit members should exist");
1124
+ assert_eq!(
1125
+ members.change_ids.iter().cloned().collect::<Vec<_>>(),
1126
+ vec!["change-second".to_string()]
1127
+ );
1128
+ }
1129
+
1130
+ #[tokio::test]
1131
+ async fn staged_writes_untracked_overwrite_removes_tracked_commit_member() {
1132
+ let staged_writes = test_staged_writes();
1133
+
1134
+ staged_writes
1135
+ .stage_write(StageWrite::Rows {
1136
+ mode: StageWriteMode::Replace,
1137
+ rows: vec![
1138
+ state_row("tracked-to-untracked-key", "tracked")
1139
+ .with_tracked()
1140
+ .with_change_id("change-tracked"),
1141
+ state_row("tracked-to-untracked-key", "untracked")
1142
+ .with_change_id("change-untracked"),
1143
+ ],
1144
+ })
1145
+ .expect("untracked overwrite should stage");
1146
+
1147
+ let drained = staged_writes.drain().expect("drain should succeed");
1148
+ assert_eq!(drained.state_rows.len(), 1);
1149
+ assert_eq!(
1150
+ drained.state_rows[0].change_id.as_deref(),
1151
+ Some("change-untracked")
1152
+ );
1153
+ assert!(drained.commit_members_by_version.is_empty());
1154
+ }
1155
+
1156
+ #[tokio::test]
1157
+ async fn staged_writes_reject_duplicate_present_rows_in_one_batch() {
1158
+ let staged_writes = test_staged_writes();
1159
+
1160
+ let error = staged_writes
1161
+ .stage_write(StageWrite::Rows {
1162
+ mode: StageWriteMode::Replace,
1163
+ rows: vec![
1164
+ state_row("duplicate-present-key", "first"),
1165
+ state_row("duplicate-present-key", "second"),
1166
+ ],
1167
+ })
1168
+ .expect_err("same-batch duplicate present rows should fail");
1169
+
1170
+ assert_eq!(error.code, LixError::CODE_UNIQUE);
1171
+ assert!(
1172
+ error.message.contains("primary-key constraint violation"),
1173
+ "error should explain the duplicate primary key: {error:?}"
1174
+ );
1175
+ }
1176
+
1177
+ #[tokio::test]
1178
+ async fn staged_writes_track_active_version_members_separately() {
1179
+ let staged_writes = test_staged_writes();
1180
+
1181
+ staged_writes
1182
+ .stage_write(StageWrite::Rows {
1183
+ mode: StageWriteMode::Replace,
1184
+ rows: vec![state_row("active-version-key", "value")
1185
+ .with_tracked()
1186
+ .with_version("version-a")],
1187
+ })
1188
+ .expect("active-version tracked staging should accumulate members");
1189
+
1190
+ let drained = staged_writes.drain().expect("drain should succeed");
1191
+ let members = drained
1192
+ .commit_members_by_version
1193
+ .get("version-a")
1194
+ .expect("active-version commit members should exist");
1195
+ assert_eq!(
1196
+ members.change_ids.iter().cloned().collect::<Vec<_>>(),
1197
+ vec!["test-uuid-1".to_string()]
1198
+ );
1199
+ }
1200
+
1201
+ #[tokio::test]
1202
+ async fn staged_writes_reject_global_rows_with_non_global_version_id() {
1203
+ let staged_writes = test_staged_writes();
1204
+
1205
+ let error = staged_writes
1206
+ .stage_write(StageWrite::Rows {
1207
+ mode: StageWriteMode::Replace,
1208
+ rows: vec![{
1209
+ let mut row = state_row("invalid-global-key", "value");
1210
+ row.version_id = "version-a".to_string();
1211
+ row
1212
+ }],
1213
+ })
1214
+ .expect_err("global row with non-global version should fail");
1215
+
1216
+ assert!(error
1217
+ .message
1218
+ .contains("global staged rows must use the global version id"));
1219
+ }
1220
+
1221
+ #[tokio::test]
1222
+ async fn staging_overlay_identity_matches_live_state_conflict_key() {
1223
+ let staged_writes = test_staged_writes();
1224
+
1225
+ staged_writes
1226
+ .stage_write(StageWrite::Rows {
1227
+ mode: StageWriteMode::Replace,
1228
+ rows: vec![
1229
+ state_row("shared-entity", "other-schema-version").with_schema_version("2")
1230
+ ],
1231
+ })
1232
+ .expect("initial same-identity row should stage");
1233
+ staged_writes
1234
+ .stage_write(StageWrite::Rows {
1235
+ mode: StageWriteMode::Replace,
1236
+ rows: vec![
1237
+ state_row("shared-entity", "base"),
1238
+ state_row("shared-entity", "other-version").with_version("version-b"),
1239
+ state_row("shared-entity", "other-schema").with_schema("other_schema"),
1240
+ state_row("shared-entity", "other-file").with_file_id("file-a"),
1241
+ state_row("shared-entity", "tracked").with_tracked(),
1242
+ ],
1243
+ })
1244
+ .expect("staging rows should succeed");
1245
+
1246
+ let overlay = staged_writes
1247
+ .staging_overlay()
1248
+ .expect("overlay should build from staged rows");
1249
+ let rows = overlay.scan(&LiveStateScanRequest {
1250
+ filter: LiveStateFilter {
1251
+ entity_ids: vec![crate::entity_identity::EntityIdentity::single(
1252
+ "shared-entity",
1253
+ )],
1254
+ include_tombstones: true,
1255
+ ..LiveStateFilter::default()
1256
+ },
1257
+ ..LiveStateScanRequest::default()
1258
+ });
1259
+
1260
+ assert_eq!(rows.len(), 4);
1261
+ assert!(rows.iter().any(|row| {
1262
+ row.snapshot_content.as_deref()
1263
+ == Some("{\"key\":\"shared-entity\",\"value\":\"tracked\"}")
1264
+ }));
1265
+ }
1266
+
1267
+ #[tokio::test]
1268
+ async fn staged_writes_use_injected_function_provider_for_row_metadata() {
1269
+ let staged_writes = test_staged_writes();
1270
+
1271
+ staged_writes
1272
+ .stage_write(StageWrite::Rows {
1273
+ mode: StageWriteMode::Replace,
1274
+ rows: vec![state_row("sql2-functions-key", "value").with_tracked()],
1275
+ })
1276
+ .expect("staging rows should succeed");
1277
+
1278
+ let drained = staged_writes.drain().expect("drain should succeed");
1279
+ assert_eq!(drained.state_rows.len(), 1);
1280
+ assert_eq!(
1281
+ drained.state_rows[0].change_id.as_deref(),
1282
+ Some("test-uuid-1")
1283
+ );
1284
+ assert_eq!(
1285
+ drained.state_rows[0].created_at.as_str(),
1286
+ "test-timestamp-1"
1287
+ );
1288
+ assert_eq!(
1289
+ drained.state_rows[0].updated_at.as_str(),
1290
+ "test-timestamp-1"
1291
+ );
1292
+ }
1293
+
1294
+ #[tokio::test]
1295
+ async fn staged_writes_stamp_tracked_rows_with_commit_id_during_staging() {
1296
+ let staged_writes = test_staged_writes();
1297
+
1298
+ staged_writes
1299
+ .stage_write(StageWrite::Rows {
1300
+ mode: StageWriteMode::Replace,
1301
+ rows: vec![state_row("tracked-commit-key", "value").with_tracked()],
1302
+ })
1303
+ .expect("tracked row should stage");
1304
+
1305
+ let drained = staged_writes.drain().expect("drain should succeed");
1306
+ assert_eq!(drained.state_rows.len(), 1);
1307
+ assert_eq!(
1308
+ drained.state_rows[0].commit_id.as_deref(),
1309
+ Some("test-uuid-2")
1310
+ );
1311
+ assert_eq!(
1312
+ drained
1313
+ .commit_members_by_version
1314
+ .get("global")
1315
+ .expect("global commit members should exist")
1316
+ .commit_id,
1317
+ "test-uuid-2"
1318
+ );
1319
+ }
1320
+
1321
+ fn test_staged_writes() -> TransactionStagedWrites {
1322
+ TransactionStagedWrites::new(SharedFunctionProvider::new(Box::new(
1323
+ TestFunctionProvider::default(),
1324
+ )
1325
+ as Box<dyn FunctionProvider + Send>))
1326
+ }
1327
+
1328
+ #[derive(Default)]
1329
+ struct TestFunctionProvider {
1330
+ uuid_count: usize,
1331
+ timestamp_count: usize,
1332
+ }
1333
+
1334
+ impl FunctionProvider for TestFunctionProvider {
1335
+ fn uuid_v7(&mut self) -> String {
1336
+ self.uuid_count += 1;
1337
+ format!("test-uuid-{}", self.uuid_count)
1338
+ }
1339
+
1340
+ fn timestamp(&mut self) -> String {
1341
+ self.timestamp_count += 1;
1342
+ format!("test-timestamp-{}", self.timestamp_count)
1343
+ }
1344
+ }
1345
+
1346
+ fn state_row(key: &str, value: &str) -> StageRow {
1347
+ StageRow {
1348
+ entity_id: Some(crate::entity_identity::EntityIdentity::single(key)),
1349
+ schema_key: "lix_key_value".to_string(),
1350
+ file_id: None,
1351
+ snapshot_content: Some(format!("{{\"key\":\"{key}\",\"value\":\"{value}\"}}")),
1352
+ metadata: None,
1353
+ origin: None,
1354
+ schema_version: "1".to_string(),
1355
+ created_at: None,
1356
+ updated_at: None,
1357
+ global: true,
1358
+ change_id: None,
1359
+ commit_id: None,
1360
+ untracked: true,
1361
+ version_id: "global".to_string(),
1362
+ }
1363
+ }
1364
+
1365
+ fn tombstone_row(key: &str) -> StageRow {
1366
+ StageRow {
1367
+ snapshot_content: None,
1368
+ ..state_row(key, "deleted")
1369
+ }
1370
+ }
1371
+
1372
+ fn exact_request_for_key(key: &str) -> LiveStateRowRequest {
1373
+ LiveStateRowRequest {
1374
+ schema_key: "lix_key_value".to_string(),
1375
+ version_id: "global".to_string(),
1376
+ entity_id: crate::entity_identity::EntityIdentity::single(key),
1377
+ file_id: NullableKeyFilter::Null,
1378
+ }
1379
+ }
1380
+
1381
+ fn scan_request_for_key(key: &str, include_tombstones: bool) -> LiveStateScanRequest {
1382
+ LiveStateScanRequest {
1383
+ filter: LiveStateFilter {
1384
+ schema_keys: vec!["lix_key_value".to_string()],
1385
+ entity_ids: vec![crate::entity_identity::EntityIdentity::single(key)],
1386
+ version_ids: vec!["global".to_string()],
1387
+ file_ids: vec![NullableKeyFilter::Null],
1388
+ include_tombstones,
1389
+ ..LiveStateFilter::default()
1390
+ },
1391
+ ..LiveStateScanRequest::default()
1392
+ }
1393
+ }
1394
+
1395
+ trait StateRowTestExt {
1396
+ fn with_schema(self, schema_key: &str) -> Self;
1397
+ fn with_schema_version(self, schema_version: &str) -> Self;
1398
+ fn with_file_id(self, file_id: &str) -> Self;
1399
+ fn with_tracked(self) -> Self;
1400
+ fn with_version(self, version_id: &str) -> Self;
1401
+ fn with_change_id(self, change_id: &str) -> Self;
1402
+ }
1403
+
1404
+ impl StateRowTestExt for StageRow {
1405
+ fn with_schema(mut self, schema_key: &str) -> Self {
1406
+ self.schema_key = schema_key.to_string();
1407
+ self
1408
+ }
1409
+
1410
+ fn with_schema_version(mut self, schema_version: &str) -> Self {
1411
+ self.schema_version = schema_version.to_string();
1412
+ self
1413
+ }
1414
+
1415
+ fn with_file_id(mut self, file_id: &str) -> Self {
1416
+ self.file_id = Some(file_id.to_string());
1417
+ self
1418
+ }
1419
+
1420
+ fn with_tracked(mut self) -> Self {
1421
+ self.untracked = false;
1422
+ self
1423
+ }
1424
+
1425
+ fn with_version(mut self, version_id: &str) -> Self {
1426
+ self.version_id = version_id.to_string();
1427
+ self.global = version_id == GLOBAL_VERSION_ID;
1428
+ self
1429
+ }
1430
+
1431
+ fn with_change_id(mut self, change_id: &str) -> Self {
1432
+ self.change_id = Some(change_id.to_string());
1433
+ self
1434
+ }
1435
+ }
1436
+ }