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

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 (165) hide show
  1. package/SKILL.md +4 -5
  2. package/dist/engine-wasm/wasm/lix_engine.js +1 -1
  3. package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
  4. package/dist/generated/builtin-schemas.d.ts +87 -162
  5. package/dist/generated/builtin-schemas.js +139 -236
  6. package/dist/open-lix.d.ts +1 -1
  7. package/dist-engine-src/src/binary_cas/types.rs +0 -6
  8. package/dist-engine-src/src/catalog/context.rs +412 -0
  9. package/dist-engine-src/src/catalog/mod.rs +10 -0
  10. package/dist-engine-src/src/catalog/schema.rs +4 -0
  11. package/dist-engine-src/src/catalog/snapshot.rs +1114 -0
  12. package/dist-engine-src/src/cel/mod.rs +1 -1
  13. package/dist-engine-src/src/cel/provider.rs +1 -1
  14. package/dist-engine-src/src/commit_graph/context.rs +328 -1015
  15. package/dist-engine-src/src/commit_graph/mod.rs +2 -3
  16. package/dist-engine-src/src/commit_graph/types.rs +7 -43
  17. package/dist-engine-src/src/commit_graph/walker.rs +57 -81
  18. package/dist-engine-src/src/commit_store/codec.rs +887 -0
  19. package/dist-engine-src/src/commit_store/context.rs +944 -0
  20. package/dist-engine-src/src/commit_store/materialization.rs +84 -0
  21. package/dist-engine-src/src/commit_store/mod.rs +16 -0
  22. package/dist-engine-src/src/commit_store/storage.rs +600 -0
  23. package/dist-engine-src/src/commit_store/types.rs +215 -0
  24. package/dist-engine-src/src/common/identity.rs +15 -5
  25. package/dist-engine-src/src/common/json_pointer.rs +67 -0
  26. package/dist-engine-src/src/common/metadata.rs +17 -12
  27. package/dist-engine-src/src/common/mod.rs +5 -5
  28. package/dist-engine-src/src/domain.rs +324 -0
  29. package/dist-engine-src/src/engine.rs +29 -43
  30. package/dist-engine-src/src/entity_identity.rs +238 -118
  31. package/dist-engine-src/src/functions/context.rs +17 -52
  32. package/dist-engine-src/src/functions/deterministic.rs +1 -1
  33. package/dist-engine-src/src/functions/mod.rs +1 -1
  34. package/dist-engine-src/src/functions/provider.rs +4 -4
  35. package/dist-engine-src/src/functions/state.rs +39 -66
  36. package/dist-engine-src/src/functions/types.rs +1 -1
  37. package/dist-engine-src/src/init.rs +204 -151
  38. package/dist-engine-src/src/json_store/context.rs +354 -60
  39. package/dist-engine-src/src/json_store/encoded.rs +6 -6
  40. package/dist-engine-src/src/json_store/mod.rs +4 -1
  41. package/dist-engine-src/src/json_store/store.rs +884 -11
  42. package/dist-engine-src/src/json_store/types.rs +166 -1
  43. package/dist-engine-src/src/lib.rs +10 -9
  44. package/dist-engine-src/src/live_state/context.rs +608 -830
  45. package/dist-engine-src/src/live_state/mod.rs +3 -3
  46. package/dist-engine-src/src/live_state/overlay.rs +7 -7
  47. package/dist-engine-src/src/live_state/reader.rs +5 -5
  48. package/dist-engine-src/src/live_state/types.rs +19 -36
  49. package/dist-engine-src/src/live_state/visibility.rs +19 -14
  50. package/dist-engine-src/src/plugin/archive.rs +3 -6
  51. package/dist-engine-src/src/plugin/install.rs +0 -18
  52. package/dist-engine-src/src/plugin/plugin_manifest.json +0 -1
  53. package/dist-engine-src/src/schema/annotations/defaults.rs +2 -7
  54. package/dist-engine-src/src/schema/builtin/lix_account.json +0 -1
  55. package/dist-engine-src/src/schema/builtin/lix_active_account.json +0 -1
  56. package/dist-engine-src/src/schema/builtin/lix_binary_blob_ref.json +0 -1
  57. package/dist-engine-src/src/schema/builtin/lix_change.json +11 -10
  58. package/dist-engine-src/src/schema/builtin/lix_change_author.json +0 -1
  59. package/dist-engine-src/src/schema/builtin/lix_commit.json +8 -46
  60. package/dist-engine-src/src/schema/builtin/lix_commit_edge.json +29 -22
  61. package/dist-engine-src/src/schema/builtin/lix_directory_descriptor.json +0 -1
  62. package/dist-engine-src/src/schema/builtin/lix_file_descriptor.json +0 -1
  63. package/dist-engine-src/src/schema/builtin/lix_key_value.json +0 -1
  64. package/dist-engine-src/src/schema/builtin/lix_label.json +10 -3
  65. package/dist-engine-src/src/schema/builtin/lix_label_assignment.json +74 -0
  66. package/dist-engine-src/src/schema/builtin/lix_registered_schema.json +2 -8
  67. package/dist-engine-src/src/schema/builtin/lix_version_descriptor.json +0 -1
  68. package/dist-engine-src/src/schema/builtin/lix_version_ref.json +0 -1
  69. package/dist-engine-src/src/schema/builtin/mod.rs +10 -59
  70. package/dist-engine-src/src/schema/compatibility.rs +787 -0
  71. package/dist-engine-src/src/schema/definition.json +47 -17
  72. package/dist-engine-src/src/schema/definition.rs +202 -96
  73. package/dist-engine-src/src/schema/key.rs +9 -77
  74. package/dist-engine-src/src/schema/mod.rs +4 -4
  75. package/dist-engine-src/src/schema/tests.rs +133 -92
  76. package/dist-engine-src/src/session/context.rs +40 -42
  77. package/dist-engine-src/src/session/create_version.rs +22 -14
  78. package/dist-engine-src/src/session/execute.rs +45 -14
  79. package/dist-engine-src/src/session/merge/apply.rs +4 -4
  80. package/dist-engine-src/src/session/merge/conflicts.rs +3 -2
  81. package/dist-engine-src/src/session/merge/stats.rs +1 -1
  82. package/dist-engine-src/src/session/merge/version.rs +35 -45
  83. package/dist-engine-src/src/session/mod.rs +4 -2
  84. package/dist-engine-src/src/session/optimization9_sql2_bench.rs +100 -0
  85. package/dist-engine-src/src/session/switch_version.rs +16 -28
  86. package/dist-engine-src/src/sql2/change_provider.rs +14 -20
  87. package/dist-engine-src/src/sql2/classify.rs +61 -26
  88. package/dist-engine-src/src/sql2/context.rs +22 -18
  89. package/dist-engine-src/src/sql2/directory_history_provider.rs +28 -20
  90. package/dist-engine-src/src/sql2/directory_provider.rs +131 -83
  91. package/dist-engine-src/src/sql2/entity_history_provider.rs +10 -14
  92. package/dist-engine-src/src/sql2/entity_provider.rs +680 -169
  93. package/dist-engine-src/src/sql2/error.rs +21 -1
  94. package/dist-engine-src/src/sql2/execute.rs +325 -264
  95. package/dist-engine-src/src/sql2/file_history_provider.rs +29 -21
  96. package/dist-engine-src/src/sql2/file_provider.rs +533 -108
  97. package/dist-engine-src/src/sql2/filesystem_planner.rs +58 -94
  98. package/dist-engine-src/src/sql2/filesystem_visibility.rs +37 -23
  99. package/dist-engine-src/src/sql2/history_projection.rs +3 -27
  100. package/dist-engine-src/src/sql2/history_provider.rs +11 -17
  101. package/dist-engine-src/src/sql2/history_route.rs +22 -8
  102. package/dist-engine-src/src/sql2/lix_state_provider.rs +178 -96
  103. package/dist-engine-src/src/sql2/mod.rs +6 -3
  104. package/dist-engine-src/src/sql2/predicate_typecheck.rs +246 -0
  105. package/dist-engine-src/src/sql2/public_bind/assignment.rs +46 -0
  106. package/dist-engine-src/src/sql2/public_bind/capability.rs +41 -0
  107. package/dist-engine-src/src/sql2/public_bind/dml.rs +166 -0
  108. package/dist-engine-src/src/sql2/public_bind/mod.rs +25 -0
  109. package/dist-engine-src/src/sql2/public_bind/table.rs +168 -0
  110. package/dist-engine-src/src/sql2/read_only.rs +10 -12
  111. package/dist-engine-src/src/sql2/session.rs +7 -10
  112. package/dist-engine-src/src/sql2/udfs/lix_timestamp.rs +76 -0
  113. package/dist-engine-src/src/sql2/udfs/mod.rs +8 -1
  114. package/dist-engine-src/src/sql2/udfs/public_call.rs +211 -0
  115. package/dist-engine-src/src/sql2/version_provider.rs +46 -31
  116. package/dist-engine-src/src/sql2/version_scope.rs +4 -4
  117. package/dist-engine-src/src/storage_bench.rs +1782 -325
  118. package/dist-engine-src/src/test_support.rs +183 -36
  119. package/dist-engine-src/src/tracked_state/by_file_index.rs +20 -24
  120. package/dist-engine-src/src/tracked_state/codec.rs +1519 -181
  121. package/dist-engine-src/src/tracked_state/context.rs +1155 -271
  122. package/dist-engine-src/src/tracked_state/diff.rs +249 -57
  123. package/dist-engine-src/src/tracked_state/materialization.rs +365 -103
  124. package/dist-engine-src/src/tracked_state/materializer.rs +488 -0
  125. package/dist-engine-src/src/tracked_state/merge.rs +37 -19
  126. package/dist-engine-src/src/tracked_state/mod.rs +8 -7
  127. package/dist-engine-src/src/tracked_state/storage.rs +138 -6
  128. package/dist-engine-src/src/tracked_state/tree.rs +695 -252
  129. package/dist-engine-src/src/tracked_state/types.rs +176 -6
  130. package/dist-engine-src/src/transaction/commit.rs +695 -435
  131. package/dist-engine-src/src/transaction/context.rs +551 -310
  132. package/dist-engine-src/src/transaction/live_state_overlay.rs +9 -8
  133. package/dist-engine-src/src/transaction/mod.rs +2 -0
  134. package/dist-engine-src/src/transaction/normalization.rs +311 -447
  135. package/dist-engine-src/src/transaction/prep.rs +37 -0
  136. package/dist-engine-src/src/transaction/schema_resolver.rs +93 -71
  137. package/dist-engine-src/src/transaction/staging.rs +701 -406
  138. package/dist-engine-src/src/transaction/types.rs +231 -122
  139. package/dist-engine-src/src/transaction/validation.rs +2717 -1698
  140. package/dist-engine-src/src/untracked_state/codec.rs +40 -96
  141. package/dist-engine-src/src/untracked_state/context.rs +21 -5
  142. package/dist-engine-src/src/untracked_state/materialization.rs +10 -104
  143. package/dist-engine-src/src/untracked_state/mod.rs +3 -5
  144. package/dist-engine-src/src/untracked_state/storage.rs +105 -57
  145. package/dist-engine-src/src/untracked_state/types.rs +63 -13
  146. package/dist-engine-src/src/version/context.rs +1 -13
  147. package/dist-engine-src/src/version/lifecycle.rs +221 -0
  148. package/dist-engine-src/src/version/mod.rs +3 -2
  149. package/dist-engine-src/src/version/refs.rs +12 -103
  150. package/dist-engine-src/src/version/stage_rows.rs +15 -19
  151. package/package.json +1 -1
  152. package/dist-engine-src/src/changelog/codec.rs +0 -321
  153. package/dist-engine-src/src/changelog/context.rs +0 -92
  154. package/dist-engine-src/src/changelog/materialization.rs +0 -121
  155. package/dist-engine-src/src/changelog/mod.rs +0 -13
  156. package/dist-engine-src/src/changelog/reader.rs +0 -20
  157. package/dist-engine-src/src/changelog/storage.rs +0 -220
  158. package/dist-engine-src/src/changelog/types.rs +0 -38
  159. package/dist-engine-src/src/schema/builtin/lix_change_set.json +0 -18
  160. package/dist-engine-src/src/schema/builtin/lix_change_set_element.json +0 -75
  161. package/dist-engine-src/src/schema/builtin/lix_entity_label.json +0 -63
  162. package/dist-engine-src/src/schema_registry.rs +0 -294
  163. package/dist-engine-src/src/sql2/commit_derived_provider.rs +0 -591
  164. package/dist-engine-src/src/tracked_state/rebuild.rs +0 -771
  165. package/dist-engine-src/src/tracked_state/tree_types.rs +0 -176
@@ -1,60 +1,303 @@
1
- use std::collections::{BTreeMap, BTreeSet};
2
- use std::sync::Mutex;
1
+ use std::collections::{BTreeMap, BTreeSet, HashMap};
2
+ use std::sync::{Arc, Mutex};
3
3
 
4
+ use crate::catalog::SchemaPlanId;
5
+ use crate::domain::{Domain, DomainRowIdentity};
6
+ use crate::entity_identity::EntityIdentity;
4
7
  use crate::functions::{FunctionProvider, FunctionProviderHandle};
5
8
  #[cfg(test)]
6
9
  use crate::live_state::LiveStateRowRequest;
7
- use crate::live_state::{LiveStateRow, LiveStateRowIdentity, LiveStateScanRequest};
10
+ use crate::live_state::{LiveStateScanRequest, MaterializedLiveStateRow};
11
+ #[cfg(test)]
12
+ use crate::transaction::types::{stage_json_from_value, TransactionJson};
8
13
  use crate::transaction::types::{
9
- LogicalPrimaryKey, StageAdoptedChange, StageFileData, StageRow, StageRowOrigin, StageWrite,
10
- StageWriteMode, StageWriteOperation, StageWriteOutcome,
14
+ LogicalPrimaryKey, PreparedTransactionWrite, TransactionFileData, TransactionWriteMode,
15
+ TransactionWriteOperation, TransactionWriteOrigin, TransactionWriteOutcome,
11
16
  };
12
- use crate::transaction::types::{StagedAdoptedStateRow, StagedCommitMembers, StagedStateRow};
17
+ use crate::transaction::types::{PreparedAdoptedStateRow, PreparedStateRow, StagedCommitMembers};
13
18
  use crate::GLOBAL_VERSION_ID;
14
19
  use crate::{LixError, NullableKeyFilter};
15
20
 
16
- /// Transaction-local writes decoded by DataFusion provider hooks.
21
+ /// Transaction-local write buffer after transaction-boundary preparation.
17
22
  ///
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
+ /// This is the engine seam between SQL execution and transaction ownership:
24
+ /// write frontends pass decoded `TransactionWriteRow`s to `Transaction`, the
25
+ /// transaction prepares them into stable `PreparedStateRow`s, reads build a
26
+ /// `PreparedStateRowOverlay` from those rows, and commit drains the same rows.
27
+ pub(crate) struct TransactionWriteBuffer {
23
28
  functions: FunctionProviderHandle,
24
- rows: Mutex<BTreeMap<StagedStateRowIdentity, StagedStateRow>>,
25
- adopted_rows: Mutex<BTreeMap<StagedStateRowIdentity, StagedAdoptedStateRow>>,
26
- insert_identities: Mutex<BTreeMap<LiveStateRowIdentity, Option<StageRowOrigin>>>,
29
+ rows: Mutex<Vec<Option<PreparedStateRow>>>,
30
+ adopted_rows: Mutex<Vec<Option<PreparedAdoptedStateRow>>>,
31
+ by_identity: Mutex<HashMap<PreparedStateRowIdentity, RowSlot>>,
32
+ insert_identities: Mutex<BTreeMap<PreparedStateRowIdentity, Option<TransactionWriteOrigin>>>,
27
33
  commit_members_by_version: Mutex<BTreeMap<String, StagedCommitMembers>>,
28
34
  extra_commit_parents_by_version: Mutex<BTreeMap<String, Vec<String>>>,
29
- file_data_writes: Mutex<Vec<StageFileData>>,
35
+ file_data_writes: Mutex<Vec<TransactionFileData>>,
36
+ }
37
+
38
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
39
+ pub(crate) enum RowSlot {
40
+ State(usize),
41
+ Adopted(usize),
30
42
  }
31
43
 
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>>,
44
+ /// Drained prepared transaction writes ready for commit.
45
+ pub(crate) struct PreparedWriteSet {
46
+ pub(crate) state_rows: Vec<PreparedStateRow>,
47
+ pub(crate) adopted_rows: Vec<PreparedAdoptedStateRow>,
48
+ pub(crate) insert_identities:
49
+ BTreeMap<PreparedStateRowIdentity, Option<TransactionWriteOrigin>>,
37
50
  pub(crate) commit_members_by_version: BTreeMap<String, StagedCommitMembers>,
38
51
  pub(crate) extra_commit_parents_by_version: BTreeMap<String, Vec<String>>,
39
- pub(crate) file_data_writes: Vec<StageFileData>,
52
+ pub(crate) file_data_writes: Vec<TransactionFileData>,
53
+ }
54
+
55
+ pub(crate) struct PreparedWriteValidationSet<'a> {
56
+ rows: Vec<PreparedValidationRow<'a>>,
57
+ constraint_rows: Vec<PreparedValidationRow<'a>>,
58
+ insert_identities: Vec<(
59
+ &'a PreparedStateRowIdentity,
60
+ Option<&'a TransactionWriteOrigin>,
61
+ )>,
62
+ }
63
+
64
+ pub(crate) struct PreparedWriteValidationIndex<'a> {
65
+ rows_by_schema_scope: BTreeMap<Domain, Vec<PreparedValidationRow<'a>>>,
66
+ insert_identities_by_schema_scope: BTreeMap<
67
+ Domain,
68
+ Vec<(
69
+ &'a PreparedStateRowIdentity,
70
+ Option<&'a TransactionWriteOrigin>,
71
+ )>,
72
+ >,
73
+ }
74
+
75
+ #[derive(Clone, Copy)]
76
+ pub(crate) enum PreparedValidationRow<'a> {
77
+ State(&'a PreparedStateRow),
78
+ Adopted(&'a PreparedAdoptedStateRow),
79
+ }
80
+
81
+ impl<'a> PreparedValidationRow<'a> {
82
+ pub(crate) fn entity_id(&self) -> &EntityIdentity {
83
+ match self {
84
+ Self::State(row) => &row.entity_id,
85
+ Self::Adopted(row) => &row.entity_id,
86
+ }
87
+ }
88
+
89
+ pub(crate) fn schema_plan_id(&self) -> SchemaPlanId {
90
+ match self {
91
+ Self::State(row) => row.schema_plan_id,
92
+ Self::Adopted(row) => row.schema_plan_id,
93
+ }
94
+ }
95
+
96
+ pub(crate) fn schema_key(&self) -> &str {
97
+ match self {
98
+ Self::State(row) => &row.schema_key,
99
+ Self::Adopted(row) => &row.schema_key,
100
+ }
101
+ }
102
+
103
+ pub(crate) fn file_id(&self) -> &Option<String> {
104
+ match self {
105
+ Self::State(row) => &row.file_id,
106
+ Self::Adopted(row) => &row.file_id,
107
+ }
108
+ }
109
+
110
+ #[cfg(test)]
111
+ pub(crate) fn snapshot_content(&self) -> Option<&str> {
112
+ match self {
113
+ Self::State(row) => row
114
+ .snapshot
115
+ .as_ref()
116
+ .map(|snapshot| snapshot.normalized.as_ref()),
117
+ Self::Adopted(row) => row
118
+ .snapshot
119
+ .as_ref()
120
+ .map(|snapshot| snapshot.normalized.as_ref()),
121
+ }
122
+ }
123
+
124
+ pub(crate) fn snapshot_json(self) -> Option<&'a serde_json::Value> {
125
+ match self {
126
+ Self::State(row) => row
127
+ .snapshot
128
+ .as_ref()
129
+ .map(|snapshot| snapshot.value.as_ref()),
130
+ Self::Adopted(row) => row
131
+ .snapshot
132
+ .as_ref()
133
+ .map(|snapshot| snapshot.value.as_ref()),
134
+ }
135
+ }
136
+
137
+ pub(crate) fn metadata_json(self) -> Option<&'a serde_json::Value> {
138
+ match self {
139
+ Self::State(row) => row
140
+ .metadata
141
+ .as_ref()
142
+ .map(|metadata| metadata.value.as_ref()),
143
+ Self::Adopted(row) => row
144
+ .metadata
145
+ .as_ref()
146
+ .map(|metadata| metadata.value.as_ref()),
147
+ }
148
+ }
149
+
150
+ pub(crate) fn untracked(&self) -> bool {
151
+ match self {
152
+ Self::State(row) => row.untracked,
153
+ Self::Adopted(_) => false,
154
+ }
155
+ }
156
+
157
+ pub(crate) fn version_id(&self) -> &str {
158
+ match self {
159
+ Self::State(row) => &row.version_id,
160
+ Self::Adopted(row) => &row.version_id,
161
+ }
162
+ }
163
+
164
+ pub(crate) fn domain(&self) -> Domain {
165
+ Domain::exact_file(
166
+ self.version_id().to_string(),
167
+ self.untracked(),
168
+ self.file_id().clone(),
169
+ )
170
+ }
171
+
172
+ pub(crate) fn domain_row_identity(&self) -> DomainRowIdentity {
173
+ DomainRowIdentity::in_domain(
174
+ self.domain(),
175
+ self.schema_key().to_string(),
176
+ self.entity_id().clone(),
177
+ )
178
+ }
179
+ }
180
+
181
+ impl<'a> PreparedWriteValidationIndex<'a> {
182
+ pub(crate) fn schema_scopes(&self) -> impl Iterator<Item = &Domain> {
183
+ self.rows_by_schema_scope.keys()
184
+ }
185
+
186
+ pub(crate) fn validation_set_for_schema_scope(
187
+ &self,
188
+ schema_scope: &Domain,
189
+ ) -> PreparedWriteValidationSet<'a> {
190
+ let constraint_rows = self
191
+ .rows_by_schema_scope
192
+ .iter()
193
+ .flat_map(|(target_scope, rows)| {
194
+ rows.iter().copied().filter(move |row| {
195
+ schema_scope.validation_scope_contains_constraint_domain(target_scope)
196
+ || (row.snapshot_json().is_none()
197
+ && target_scope.tombstone_domain_affects_validation_scope(schema_scope))
198
+ })
199
+ })
200
+ .collect();
201
+ PreparedWriteValidationSet {
202
+ rows: self
203
+ .rows_by_schema_scope
204
+ .get(schema_scope)
205
+ .cloned()
206
+ .unwrap_or_default(),
207
+ constraint_rows,
208
+ insert_identities: self
209
+ .insert_identities_by_schema_scope
210
+ .get(schema_scope)
211
+ .cloned()
212
+ .unwrap_or_default(),
213
+ }
214
+ }
215
+ }
216
+
217
+ impl<'a> PreparedWriteValidationSet<'a> {
218
+ pub(crate) fn rows(&self) -> impl Iterator<Item = PreparedValidationRow<'a>> + '_ {
219
+ self.rows.iter().copied()
220
+ }
221
+
222
+ pub(crate) fn constraint_rows(&self) -> impl Iterator<Item = PreparedValidationRow<'a>> + '_ {
223
+ self.constraint_rows.iter().copied()
224
+ }
225
+
226
+ pub(crate) fn insert_identities(
227
+ &self,
228
+ ) -> impl Iterator<Item = (&PreparedStateRowIdentity, Option<&TransactionWriteOrigin>)> {
229
+ self.insert_identities
230
+ .iter()
231
+ .map(|(identity, origin)| (*identity, *origin))
232
+ }
40
233
  }
41
234
 
42
- impl StagedWriteSet {
43
- pub(crate) fn state_rows_for_validation(&self) -> Vec<StagedStateRow> {
235
+ impl PreparedWriteSet {
236
+ #[cfg(test)]
237
+ pub(crate) fn validation_rows(&self) -> impl Iterator<Item = PreparedValidationRow<'_>> + '_ {
44
238
  self.state_rows
45
239
  .iter()
46
- .cloned()
47
- .chain(self.adopted_rows.iter().map(StagedStateRow::from))
48
- .collect()
240
+ .map(PreparedValidationRow::State)
241
+ .chain(self.adopted_rows.iter().map(PreparedValidationRow::Adopted))
242
+ }
243
+
244
+ pub(crate) fn validation_index(&self) -> PreparedWriteValidationIndex<'_> {
245
+ let mut rows_by_schema_scope = BTreeMap::<Domain, Vec<PreparedValidationRow<'_>>>::new();
246
+ for row in &self.state_rows {
247
+ let row = PreparedValidationRow::State(row);
248
+ rows_by_schema_scope
249
+ .entry(row.domain().schema_catalog_domain())
250
+ .or_default()
251
+ .push(row);
252
+ }
253
+ for row in &self.adopted_rows {
254
+ let row = PreparedValidationRow::Adopted(row);
255
+ rows_by_schema_scope
256
+ .entry(row.domain().schema_catalog_domain())
257
+ .or_default()
258
+ .push(row);
259
+ }
260
+
261
+ let mut insert_identities_by_schema_scope = BTreeMap::<
262
+ Domain,
263
+ Vec<(&PreparedStateRowIdentity, Option<&TransactionWriteOrigin>)>,
264
+ >::new();
265
+ for (identity, origin) in &self.insert_identities {
266
+ insert_identities_by_schema_scope
267
+ .entry(identity.domain().schema_catalog_domain())
268
+ .or_default()
269
+ .push((identity, origin.as_ref()));
270
+ }
271
+
272
+ PreparedWriteValidationIndex {
273
+ rows_by_schema_scope,
274
+ insert_identities_by_schema_scope,
275
+ }
276
+ }
277
+
278
+ #[cfg(test)]
279
+ pub(crate) fn validation_set_for_tests(&self) -> PreparedWriteValidationSet<'_> {
280
+ let rows: Vec<_> = self.validation_rows().collect();
281
+ let insert_identities = self
282
+ .insert_identities
283
+ .iter()
284
+ .map(|(identity, origin)| (identity, origin.as_ref()))
285
+ .collect();
286
+ PreparedWriteValidationSet {
287
+ constraint_rows: rows.clone(),
288
+ rows,
289
+ insert_identities,
290
+ }
49
291
  }
50
292
  }
51
293
 
52
- impl TransactionStagedWrites {
294
+ impl TransactionWriteBuffer {
53
295
  pub(crate) fn new(functions: FunctionProviderHandle) -> Self {
54
296
  Self {
55
297
  functions,
56
- rows: Mutex::new(BTreeMap::new()),
57
- adopted_rows: Mutex::new(BTreeMap::new()),
298
+ rows: Mutex::new(Vec::new()),
299
+ adopted_rows: Mutex::new(Vec::new()),
300
+ by_identity: Mutex::new(HashMap::new()),
58
301
  insert_identities: Mutex::new(BTreeMap::new()),
59
302
  commit_members_by_version: Mutex::new(BTreeMap::new()),
60
303
  extra_commit_parents_by_version: Mutex::new(BTreeMap::new()),
@@ -63,7 +306,7 @@ impl TransactionStagedWrites {
63
306
  }
64
307
 
65
308
  /// Drains staged writes for commit.
66
- pub(crate) fn drain(&self) -> Result<StagedWriteSet, LixError> {
309
+ pub(crate) fn drain(&self) -> Result<PreparedWriteSet, LixError> {
67
310
  let mut rows_guard = self.rows.lock().map_err(|_| {
68
311
  LixError::new(
69
312
  "LIX_ERROR_UNKNOWN",
@@ -76,6 +319,12 @@ impl TransactionStagedWrites {
76
319
  "failed to acquire transaction staged adopted writes lock",
77
320
  )
78
321
  })?;
322
+ let mut by_identity_guard = self.by_identity.lock().map_err(|_| {
323
+ LixError::new(
324
+ "LIX_ERROR_UNKNOWN",
325
+ "failed to acquire transaction staged identity index lock",
326
+ )
327
+ })?;
79
328
  let mut file_data_guard = self.file_data_writes.lock().map_err(|_| {
80
329
  LixError::new(
81
330
  "LIX_ERROR_UNKNOWN",
@@ -101,16 +350,22 @@ impl TransactionStagedWrites {
101
350
  "failed to acquire transaction staged extra commit parents lock",
102
351
  )
103
352
  })?;
104
- Ok(StagedWriteSet {
105
- state_rows: std::mem::take(&mut *rows_guard).into_values().collect(),
353
+ let result = Ok(PreparedWriteSet {
354
+ state_rows: std::mem::take(&mut *rows_guard)
355
+ .into_iter()
356
+ .flatten()
357
+ .collect(),
106
358
  adopted_rows: std::mem::take(&mut *adopted_rows_guard)
107
- .into_values()
359
+ .into_iter()
360
+ .flatten()
108
361
  .collect(),
109
362
  insert_identities: std::mem::take(&mut *insert_identities_guard),
110
363
  commit_members_by_version: std::mem::take(&mut *commit_members_guard),
111
364
  extra_commit_parents_by_version: std::mem::take(&mut *extra_parents_guard),
112
365
  file_data_writes: std::mem::take(&mut *file_data_guard),
113
- })
366
+ });
367
+ by_identity_guard.clear();
368
+ result
114
369
  }
115
370
 
116
371
  /// Records an additional parent for the commit generated for `version_id`.
@@ -164,7 +419,6 @@ impl TransactionStagedWrites {
164
419
  })?;
165
420
  let members = guard.entry(version_id).or_insert_with(|| {
166
421
  StagedCommitMembers::new(
167
- functions.uuid_v7(),
168
422
  functions.uuid_v7(),
169
423
  functions.uuid_v7(),
170
424
  functions.timestamp(),
@@ -175,40 +429,39 @@ impl TransactionStagedWrites {
175
429
  }
176
430
 
177
431
  /// 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(|_| {
432
+ pub(crate) fn staging_overlay(self: &Arc<Self>) -> Result<PreparedStateRowOverlay, LixError> {
433
+ let by_identity_guard = self.by_identity.lock().map_err(|_| {
186
434
  LixError::new(
187
435
  "LIX_ERROR_UNKNOWN",
188
- "failed to acquire transaction staged adopted writes lock",
436
+ "failed to acquire transaction staged identity index lock",
189
437
  )
190
438
  })?;
191
- Ok(StagedStateRowOverlay::new(
192
- guard.clone(),
193
- adopted_guard.clone(),
194
- ))
439
+ let slots = by_identity_guard
440
+ .iter()
441
+ .map(|(identity, slot)| (identity.clone(), *slot))
442
+ .collect();
443
+ Ok(PreparedStateRowOverlay {
444
+ staged_writes: Arc::clone(self),
445
+ slots,
446
+ })
195
447
  }
196
448
 
197
- /// Stages one decoded write batch into this transaction.
449
+ /// Stages one prepared write batch into this transaction.
198
450
  ///
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> {
451
+ /// Frontends hand raw `TransactionWriteRow`s to `Transaction`; normalization prepares
452
+ /// stable `PreparedStateRow`s before this method indexes them for transaction-
453
+ /// local reads and commit routing.
454
+ pub(crate) fn stage_write(
455
+ &self,
456
+ write: PreparedTransactionWrite,
457
+ ) -> Result<TransactionWriteOutcome, LixError> {
204
458
  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),
459
+ PreparedTransactionWrite::Rows { mode, rows } => (Some(*mode), rows.len() as u64),
460
+ PreparedTransactionWrite::RowsWithFileData { mode, count, .. } => (Some(*mode), *count),
461
+ PreparedTransactionWrite::AdoptedChanges { rows } => (None, rows.len() as u64),
208
462
  };
209
463
  let mut functions = self.functions.clone();
210
- let (rows, adopted_rows, file_data_writes) =
211
- self.state_rows_from_stage_write(write, &mut functions)?;
464
+ let (rows, adopted_rows, file_data_writes) = self.state_rows_from_stage_write(write)?;
212
465
  for row in &rows {
213
466
  validate_commit_membership_support(row)?;
214
467
  }
@@ -228,6 +481,12 @@ impl TransactionStagedWrites {
228
481
  "failed to acquire transaction staged adopted writes lock",
229
482
  )
230
483
  })?;
484
+ let mut by_identity_guard = self.by_identity.lock().map_err(|_| {
485
+ LixError::new(
486
+ "LIX_ERROR_UNKNOWN",
487
+ "failed to acquire transaction staged identity index lock",
488
+ )
489
+ })?;
231
490
  let mut commit_members_guard = self.commit_members_by_version.lock().map_err(|_| {
232
491
  LixError::new(
233
492
  "LIX_ERROR_UNKNOWN",
@@ -241,41 +500,49 @@ impl TransactionStagedWrites {
241
500
  )
242
501
  })?;
243
502
  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))
503
+ let identity = PreparedStateRowIdentity::from(&row);
504
+ if mode == Some(TransactionWriteMode::Insert)
505
+ && by_identity_guard.contains_key(&identity)
249
506
  {
250
507
  return Err(duplicate_insert_identity_error(&row));
251
508
  }
252
- if adopted_guard.contains_key(&identity) {
509
+ if matches!(by_identity_guard.get(&identity), Some(RowSlot::Adopted(_))) {
253
510
  return Err(conflicting_adopted_identity_error(&row));
254
511
  }
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);
512
+ let existing_slot = by_identity_guard.remove(&identity);
513
+ if let Some(RowSlot::State(index)) = existing_slot {
514
+ if let Some(previous) = guard.get_mut(index).and_then(Option::take) {
515
+ remove_row_from_commit_members(&mut commit_members_guard, &previous);
516
+ }
260
517
  }
261
518
  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
- );
519
+ let identity = PreparedStateRowIdentity::from(&row);
520
+ if mode == Some(TransactionWriteMode::Insert) {
521
+ insert_identities_guard.insert(identity.clone(), row.origin.clone());
268
522
  }
269
- guard.insert(identity, row);
523
+ let slot = match existing_slot {
524
+ Some(RowSlot::State(index)) => {
525
+ guard[index] = Some(row);
526
+ RowSlot::State(index)
527
+ }
528
+ _ => {
529
+ let index = guard.len();
530
+ guard.push(Some(row));
531
+ RowSlot::State(index)
532
+ }
533
+ };
534
+ by_identity_guard.insert(identity, slot);
270
535
  }
271
536
  for mut row in adopted_rows {
272
- let identity = StagedStateRowIdentity::from(&row);
273
- if guard.contains_key(&identity) || adopted_guard.contains_key(&identity) {
537
+ let identity = PreparedStateRowIdentity::from(&row);
538
+ if by_identity_guard.contains_key(&identity) {
274
539
  return Err(conflicting_adopted_projection_error(&row));
275
540
  }
276
541
  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);
542
+ let identity = PreparedStateRowIdentity::from(&row);
543
+ let index = adopted_guard.len();
544
+ adopted_guard.push(Some(row));
545
+ by_identity_guard.insert(identity, RowSlot::Adopted(index));
279
546
  }
280
547
  if !file_data_writes.is_empty() {
281
548
  self.file_data_writes
@@ -288,18 +555,17 @@ impl TransactionStagedWrites {
288
555
  })?
289
556
  .extend(file_data_writes);
290
557
  }
291
- Ok(StageWriteOutcome { count })
558
+ Ok(TransactionWriteOutcome { count })
292
559
  }
293
560
 
294
561
  fn state_rows_from_stage_write(
295
562
  &self,
296
- write: StageWrite,
297
- functions: &mut dyn FunctionProvider,
563
+ write: PreparedTransactionWrite,
298
564
  ) -> Result<
299
565
  (
300
- Vec<StagedStateRow>,
301
- Vec<StagedAdoptedStateRow>,
302
- Vec<StageFileData>,
566
+ Vec<PreparedStateRow>,
567
+ Vec<PreparedAdoptedStateRow>,
568
+ Vec<TransactionFileData>,
303
569
  ),
304
570
  LixError,
305
571
  > {
@@ -307,136 +573,170 @@ impl TransactionStagedWrites {
307
573
  let mut adopted_rows = Vec::new();
308
574
  let mut file_data_writes = Vec::new();
309
575
  match write {
310
- StageWrite::Rows { rows, .. } => {
311
- self.push_state_rows(&mut state_rows, rows, functions)?;
576
+ PreparedTransactionWrite::Rows { rows, .. } => {
577
+ state_rows.extend(rows);
312
578
  }
313
- StageWrite::RowsWithFileData {
579
+ PreparedTransactionWrite::RowsWithFileData {
314
580
  rows, file_data, ..
315
581
  } => {
316
- self.push_state_rows(&mut state_rows, rows, functions)?;
582
+ state_rows.extend(rows);
317
583
  file_data_writes.extend(file_data);
318
584
  }
319
- StageWrite::AdoptedChanges { changes } => {
320
- self.push_adopted_rows(&mut adopted_rows, changes)?;
585
+ PreparedTransactionWrite::AdoptedChanges { rows } => {
586
+ adopted_rows.extend(rows);
321
587
  }
322
588
  }
323
589
  Ok((state_rows, adopted_rows, file_data_writes))
324
590
  }
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
591
  }
351
592
 
352
593
  /// Read overlay derived from staged transaction writes.
353
- pub(crate) struct StagedStateRowOverlay {
354
- rows: BTreeMap<StagedStateRowIdentity, StagedStateRow>,
355
- adopted_rows: BTreeMap<StagedStateRowIdentity, StagedAdoptedStateRow>,
594
+ pub(crate) struct PreparedStateRowOverlay {
595
+ staged_writes: Arc<TransactionWriteBuffer>,
596
+ slots: BTreeMap<PreparedStateRowIdentity, RowSlot>,
356
597
  }
357
598
 
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
- }
599
+ pub(crate) struct StagedScanParts {
600
+ pub(crate) rows: Vec<MaterializedLiveStateRow>,
601
+ pub(crate) hidden_identities: BTreeSet<PreparedStateRowIdentity>,
602
+ }
365
603
 
604
+ impl PreparedStateRowOverlay {
366
605
  /// 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()
606
+ #[cfg(test)]
607
+ pub(crate) fn scan(
608
+ &self,
609
+ request: &LiveStateScanRequest,
610
+ ) -> Result<Vec<MaterializedLiveStateRow>, LixError> {
611
+ Ok(self.scan_parts(request)?.rows)
379
612
  }
380
613
 
381
- /// Returns staged identities that should suppress base live-state rows.
614
+ /// Returns staged rows and base-row identities hidden by staged rows in one pass.
382
615
  ///
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(
616
+ /// Tombstones hide base rows even when the request does not include
617
+ /// tombstone rows in the visible result set.
618
+ pub(crate) fn scan_parts(
386
619
  &self,
387
620
  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),
621
+ ) -> Result<StagedScanParts, LixError> {
622
+ let rows_guard = self.staged_writes.rows.lock().map_err(|_| {
623
+ LixError::new(
624
+ "LIX_ERROR_UNKNOWN",
625
+ "failed to acquire transaction staged writes lock",
626
+ )
627
+ })?;
628
+ let adopted_guard = self.staged_writes.adopted_rows.lock().map_err(|_| {
629
+ LixError::new(
630
+ "LIX_ERROR_UNKNOWN",
631
+ "failed to acquire transaction staged adopted writes lock",
398
632
  )
399
- .collect()
633
+ })?;
634
+
635
+ let mut rows = Vec::new();
636
+ let mut hidden_identities = BTreeSet::new();
637
+ for (identity, slot) in &self.slots {
638
+ match *slot {
639
+ RowSlot::State(index) => {
640
+ let Some(row) = rows_guard.get(index).and_then(Option::as_ref) else {
641
+ continue;
642
+ };
643
+ if !staged_row_identity_matches_scan(row, request) {
644
+ continue;
645
+ }
646
+ hidden_identities.insert(identity.clone());
647
+ if row.snapshot.is_some() || request.filter.include_tombstones {
648
+ rows.push(MaterializedLiveStateRow::from(row));
649
+ }
650
+ }
651
+ RowSlot::Adopted(index) => {
652
+ let Some(row) = adopted_guard.get(index).and_then(Option::as_ref) else {
653
+ continue;
654
+ };
655
+ if !adopted_row_identity_matches_scan(row, request) {
656
+ continue;
657
+ }
658
+ hidden_identities.insert(identity.clone());
659
+ if row.snapshot.is_some() || request.filter.include_tombstones {
660
+ rows.push(MaterializedLiveStateRow::from(row));
661
+ }
662
+ }
663
+ }
664
+ }
665
+ Ok(StagedScanParts {
666
+ rows,
667
+ hidden_identities,
668
+ })
400
669
  }
401
670
 
402
671
  /// Returns a staged exact-row answer, if this transaction has one.
403
672
  #[cfg(test)]
404
673
  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() {
674
+ let untracked_identity = PreparedStateRowIdentity::from_exact_request(request, true)?;
675
+ if let Some(row) = self.load_state_slot(&untracked_identity) {
676
+ return Some(if row.snapshot.is_none() {
408
677
  StagedExactRow::Tombstone
409
678
  } else {
410
- StagedExactRow::Row(LiveStateRow::from(row))
679
+ StagedExactRow::Row(MaterializedLiveStateRow::from(&row))
411
680
  });
412
681
  }
413
682
 
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() {
683
+ let identity = PreparedStateRowIdentity::from_exact_request(request, false)?;
684
+ if let Some(row) = self.load_state_slot(&identity) {
685
+ return Some(if row.snapshot.is_none() {
417
686
  StagedExactRow::Tombstone
418
687
  } else {
419
- StagedExactRow::Row(LiveStateRow::from(row))
688
+ StagedExactRow::Row(MaterializedLiveStateRow::from(&row))
420
689
  });
421
690
  }
422
- self.adopted_rows.get(&identity).map(|row| {
423
- if row.snapshot_content.is_none() {
691
+ self.load_adopted_slot(&identity).map(|row| {
692
+ if row.snapshot.is_none() {
424
693
  StagedExactRow::Tombstone
425
694
  } else {
426
- StagedExactRow::Row(LiveStateRow::from(row))
695
+ StagedExactRow::Row(MaterializedLiveStateRow::from(&row))
427
696
  }
428
697
  })
429
698
  }
699
+
700
+ #[cfg(test)]
701
+ fn load_state_slot(&self, identity: &PreparedStateRowIdentity) -> Option<PreparedStateRow> {
702
+ let Some(RowSlot::State(index)) = self.slots.get(identity).copied() else {
703
+ return None;
704
+ };
705
+ self.staged_writes
706
+ .rows
707
+ .lock()
708
+ .ok()?
709
+ .get(index)?
710
+ .as_ref()
711
+ .cloned()
712
+ }
713
+
714
+ #[cfg(test)]
715
+ fn load_adopted_slot(
716
+ &self,
717
+ identity: &PreparedStateRowIdentity,
718
+ ) -> Option<PreparedAdoptedStateRow> {
719
+ let Some(RowSlot::Adopted(index)) = self.slots.get(identity).copied() else {
720
+ return None;
721
+ };
722
+ self.staged_writes
723
+ .adopted_rows
724
+ .lock()
725
+ .ok()?
726
+ .get(index)?
727
+ .as_ref()
728
+ .cloned()
729
+ }
430
730
  }
431
731
 
432
732
  #[cfg(test)]
433
733
  pub(crate) enum StagedExactRow {
434
- Row(LiveStateRow),
734
+ Row(MaterializedLiveStateRow),
435
735
  Tombstone,
436
736
  }
437
737
 
438
- #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
439
- pub(crate) struct StagedStateRowIdentity {
738
+ #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
739
+ pub(crate) struct PreparedStateRowIdentity {
440
740
  untracked: bool,
441
741
  schema_key: String,
442
742
  entity_id: crate::entity_identity::EntityIdentity,
@@ -444,8 +744,8 @@ pub(crate) struct StagedStateRowIdentity {
444
744
  version_id: String,
445
745
  }
446
746
 
447
- impl StagedStateRowIdentity {
448
- fn from_staged_row(row: &StagedStateRow) -> Self {
747
+ impl PreparedStateRowIdentity {
748
+ fn from_staged_row(row: &PreparedStateRow) -> Self {
449
749
  Self {
450
750
  untracked: row.untracked,
451
751
  schema_key: row.schema_key.clone(),
@@ -472,25 +772,31 @@ impl StagedStateRowIdentity {
472
772
  })
473
773
  }
474
774
 
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
- }
775
+ pub(crate) fn schema_key(&self) -> &str {
776
+ &self.schema_key
777
+ }
778
+
779
+ pub(crate) fn entity_id(&self) -> &crate::entity_identity::EntityIdentity {
780
+ &self.entity_id
781
+ }
782
+
783
+ pub(crate) fn domain(&self) -> Domain {
784
+ Domain::exact_file(
785
+ self.version_id.clone(),
786
+ self.untracked,
787
+ self.file_id.clone(),
788
+ )
483
789
  }
484
790
  }
485
791
 
486
- impl From<&StagedStateRow> for StagedStateRowIdentity {
487
- fn from(row: &StagedStateRow) -> Self {
792
+ impl From<&PreparedStateRow> for PreparedStateRowIdentity {
793
+ fn from(row: &PreparedStateRow) -> Self {
488
794
  Self::from_staged_row(row)
489
795
  }
490
796
  }
491
797
 
492
- impl From<&StagedAdoptedStateRow> for StagedStateRowIdentity {
493
- fn from(row: &StagedAdoptedStateRow) -> Self {
798
+ impl From<&PreparedAdoptedStateRow> for PreparedStateRowIdentity {
799
+ fn from(row: &PreparedAdoptedStateRow) -> Self {
494
800
  Self {
495
801
  untracked: false,
496
802
  schema_key: row.schema_key.clone(),
@@ -501,8 +807,8 @@ impl From<&StagedAdoptedStateRow> for StagedStateRowIdentity {
501
807
  }
502
808
  }
503
809
 
504
- impl From<&LiveStateRow> for StagedStateRowIdentity {
505
- fn from(row: &LiveStateRow) -> Self {
810
+ impl From<&MaterializedLiveStateRow> for PreparedStateRowIdentity {
811
+ fn from(row: &MaterializedLiveStateRow) -> Self {
506
812
  Self {
507
813
  untracked: row.untracked,
508
814
  schema_key: row.schema_key.clone(),
@@ -513,92 +819,33 @@ impl From<&LiveStateRow> for StagedStateRowIdentity {
513
819
  }
514
820
  }
515
821
 
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> {
822
+ fn validate_commit_membership_support(row: &PreparedStateRow) -> Result<(), LixError> {
578
823
  if row.global && row.version_id != GLOBAL_VERSION_ID {
579
824
  return Err(LixError::new(
580
825
  "LIX_ERROR_UNKNOWN",
581
- "engine2 global staged rows must use the global version id",
826
+ "engine global staged rows must use the global version id",
582
827
  ));
583
828
  }
584
829
  Ok(())
585
830
  }
586
831
 
587
- fn validate_adopted_commit_membership_support(row: &StagedAdoptedStateRow) -> Result<(), LixError> {
832
+ fn validate_adopted_commit_membership_support(
833
+ row: &PreparedAdoptedStateRow,
834
+ ) -> Result<(), LixError> {
588
835
  if row.global && row.version_id != GLOBAL_VERSION_ID {
589
836
  return Err(LixError::new(
590
837
  "LIX_ERROR_UNKNOWN",
591
- "engine2 global adopted rows must use the global version id",
838
+ "engine global adopted rows must use the global version id",
592
839
  ));
593
840
  }
594
841
  Ok(())
595
842
  }
596
843
 
597
- fn reject_duplicate_present_rows_in_batch(rows: &[StagedStateRow]) -> Result<(), LixError> {
598
- let mut pending_present_rows = BTreeMap::<StagedStateRowIdentity, &StagedStateRow>::new();
844
+ fn reject_duplicate_present_rows_in_batch(rows: &[PreparedStateRow]) -> Result<(), LixError> {
845
+ let mut pending_present_rows = BTreeMap::<PreparedStateRowIdentity, &PreparedStateRow>::new();
599
846
  for row in rows {
600
- let identity = StagedStateRowIdentity::from(row);
601
- if row.snapshot_content.is_none() {
847
+ let identity = PreparedStateRowIdentity::from(row);
848
+ if row.snapshot.is_none() {
602
849
  pending_present_rows.remove(&identity);
603
850
  continue;
604
851
  }
@@ -609,16 +856,18 @@ fn reject_duplicate_present_rows_in_batch(rows: &[StagedStateRow]) -> Result<(),
609
856
  Ok(())
610
857
  }
611
858
 
612
- fn duplicate_staged_present_row_error(row: &StagedStateRow, previous: &StagedStateRow) -> LixError {
859
+ fn duplicate_staged_present_row_error(
860
+ row: &PreparedStateRow,
861
+ previous: &PreparedStateRow,
862
+ ) -> LixError {
613
863
  let message = logical_primary_key_violation_message(row.origin.as_ref())
614
864
  .unwrap_or_else(|| {
615
865
  format!(
616
- "primary-key constraint violation on schema '{}' version '{}': duplicate staged rows for entity_id '{}' in version '{}'",
866
+ "primary-key constraint violation on schema '{}': duplicate staged rows for entity_id '{}' in version '{}'",
617
867
  row.schema_key,
618
- row.schema_version,
619
868
  previous
620
869
  .entity_id
621
- .as_string()
870
+ .as_json_array_text()
622
871
  .unwrap_or_else(|_| "<invalid entity_id>".to_string()),
623
872
  row.version_id
624
873
  )
@@ -628,31 +877,29 @@ fn duplicate_staged_present_row_error(row: &StagedStateRow, previous: &StagedSta
628
877
 
629
878
  pub(crate) fn duplicate_insert_identity_message(
630
879
  schema_key: &str,
631
- schema_version: &str,
632
880
  entity_id: &crate::entity_identity::EntityIdentity,
633
881
  version_id: Option<&str>,
634
- origin: Option<&StageRowOrigin>,
882
+ origin: Option<&TransactionWriteOrigin>,
635
883
  ) -> String {
636
884
  if let Some(message) = logical_primary_key_violation_message(origin) {
637
885
  return message;
638
886
  }
639
887
  let entity_id = entity_id
640
- .as_string()
888
+ .as_json_array_text()
641
889
  .unwrap_or_else(|_| "<invalid entity_id>".to_string());
642
890
  match version_id {
643
891
  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}'"
892
+ "primary-key constraint violation on schema '{schema_key}': INSERT would duplicate entity_id '{entity_id}' in version '{version_id}'"
645
893
  ),
646
894
  None => format!(
647
- "primary-key constraint violation on schema '{schema_key}' version '{schema_version}': INSERT would duplicate entity_id '{entity_id}'"
895
+ "primary-key constraint violation on schema '{schema_key}': INSERT would duplicate entity_id '{entity_id}'"
648
896
  ),
649
897
  }
650
898
  }
651
899
 
652
- fn duplicate_insert_identity_error(row: &StagedStateRow) -> LixError {
900
+ fn duplicate_insert_identity_error(row: &PreparedStateRow) -> LixError {
653
901
  let message = duplicate_insert_identity_message(
654
902
  &row.schema_key,
655
- &row.schema_version,
656
903
  &row.entity_id,
657
904
  Some(&row.version_id),
658
905
  row.origin.as_ref(),
@@ -660,9 +907,11 @@ fn duplicate_insert_identity_error(row: &StagedStateRow) -> LixError {
660
907
  LixError::new(LixError::CODE_UNIQUE, message)
661
908
  }
662
909
 
663
- fn logical_primary_key_violation_message(origin: Option<&StageRowOrigin>) -> Option<String> {
910
+ fn logical_primary_key_violation_message(
911
+ origin: Option<&TransactionWriteOrigin>,
912
+ ) -> Option<String> {
664
913
  let origin = origin?;
665
- if origin.operation != StageWriteOperation::Insert {
914
+ if origin.operation != TransactionWriteOperation::Insert {
666
915
  return None;
667
916
  }
668
917
  let primary_key = origin.primary_key.as_ref()?;
@@ -690,46 +939,37 @@ fn format_logical_primary_key(primary_key: &LogicalPrimaryKey) -> String {
690
939
  .join(", ")
691
940
  }
692
941
 
693
- fn conflicting_adopted_identity_error(row: &StagedStateRow) -> LixError {
942
+ fn conflicting_adopted_identity_error(row: &PreparedStateRow) -> LixError {
694
943
  LixError::new(
695
944
  LixError::CODE_UNIQUE,
696
945
  format!(
697
946
  "transaction cannot stage a new row and an adopted projection for schema '{}' entity_id '{}' in version '{}'",
698
947
  row.schema_key,
699
948
  row.entity_id
700
- .as_string()
949
+ .as_json_array_text()
701
950
  .unwrap_or_else(|_| "<invalid entity_id>".to_string()),
702
951
  row.version_id
703
952
  ),
704
953
  )
705
954
  }
706
955
 
707
- fn conflicting_adopted_projection_error(row: &StagedAdoptedStateRow) -> LixError {
956
+ fn conflicting_adopted_projection_error(row: &PreparedAdoptedStateRow) -> LixError {
708
957
  LixError::new(
709
958
  LixError::CODE_UNIQUE,
710
959
  format!(
711
960
  "transaction cannot stage duplicate adopted projections for schema '{}' entity_id '{}' in version '{}'",
712
961
  row.schema_key,
713
962
  row.entity_id
714
- .as_string()
963
+ .as_json_array_text()
715
964
  .unwrap_or_else(|_| "<invalid entity_id>".to_string()),
716
965
  row.version_id
717
966
  ),
718
967
  )
719
968
  }
720
969
 
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
970
  fn add_row_to_commit_members(
731
971
  members_by_version: &mut BTreeMap<String, StagedCommitMembers>,
732
- row: &mut StagedStateRow,
972
+ row: &mut PreparedStateRow,
733
973
  functions: &mut dyn FunctionProvider,
734
974
  ) {
735
975
  if row.untracked {
@@ -743,7 +983,6 @@ fn add_row_to_commit_members(
743
983
  .entry(row.version_id.clone())
744
984
  .or_insert_with(|| {
745
985
  StagedCommitMembers::new(
746
- functions.uuid_v7(),
747
986
  functions.uuid_v7(),
748
987
  functions.uuid_v7(),
749
988
  functions.timestamp(),
@@ -755,14 +994,13 @@ fn add_row_to_commit_members(
755
994
 
756
995
  fn add_adopted_row_to_commit_members(
757
996
  members_by_version: &mut BTreeMap<String, StagedCommitMembers>,
758
- row: &mut StagedAdoptedStateRow,
997
+ row: &mut PreparedAdoptedStateRow,
759
998
  functions: &mut dyn FunctionProvider,
760
999
  ) {
761
1000
  let members = members_by_version
762
1001
  .entry(row.version_id.clone())
763
1002
  .or_insert_with(|| {
764
1003
  StagedCommitMembers::new(
765
- functions.uuid_v7(),
766
1004
  functions.uuid_v7(),
767
1005
  functions.uuid_v7(),
768
1006
  functions.timestamp(),
@@ -774,7 +1012,7 @@ fn add_adopted_row_to_commit_members(
774
1012
 
775
1013
  fn remove_row_from_commit_members(
776
1014
  members_by_version: &mut BTreeMap<String, StagedCommitMembers>,
777
- row: &StagedStateRow,
1015
+ row: &PreparedStateRow,
778
1016
  ) {
779
1017
  if row.untracked {
780
1018
  return;
@@ -791,18 +1029,8 @@ fn remove_row_from_commit_members(
791
1029
  }
792
1030
  }
793
1031
 
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
1032
  fn adopted_row_identity_matches_scan(
805
- row: &StagedAdoptedStateRow,
1033
+ row: &PreparedAdoptedStateRow,
806
1034
  request: &LiveStateScanRequest,
807
1035
  ) -> bool {
808
1036
  if !request.filter.schema_keys.is_empty()
@@ -819,10 +1047,16 @@ fn adopted_row_identity_matches_scan(
819
1047
  {
820
1048
  return false;
821
1049
  }
1050
+ if request.filter.untracked == Some(true) {
1051
+ return false;
1052
+ }
822
1053
  nullable_key_matches_filters(&row.file_id, &request.filter.file_ids)
823
1054
  }
824
1055
 
825
- fn staged_row_identity_matches_scan(row: &StagedStateRow, request: &LiveStateScanRequest) -> bool {
1056
+ fn staged_row_identity_matches_scan(
1057
+ row: &PreparedStateRow,
1058
+ request: &LiveStateScanRequest,
1059
+ ) -> bool {
826
1060
  if !request.filter.schema_keys.is_empty()
827
1061
  && !request.filter.schema_keys.contains(&row.schema_key)
828
1062
  {
@@ -837,6 +1071,13 @@ fn staged_row_identity_matches_scan(row: &StagedStateRow, request: &LiveStateSca
837
1071
  {
838
1072
  return false;
839
1073
  }
1074
+ if request
1075
+ .filter
1076
+ .untracked
1077
+ .is_some_and(|untracked| row.untracked != untracked)
1078
+ {
1079
+ return false;
1080
+ }
840
1081
  nullable_key_matches_filters(&row.file_id, &request.filter.file_ids)
841
1082
  }
842
1083
 
@@ -869,14 +1110,14 @@ mod tests {
869
1110
  let staged_writes = test_staged_writes();
870
1111
 
871
1112
  staged_writes
872
- .stage_write(StageWrite::Rows {
873
- mode: StageWriteMode::Replace,
1113
+ .stage_write(PreparedTransactionWrite::Rows {
1114
+ mode: TransactionWriteMode::Replace,
874
1115
  rows: vec![state_row("sql2-duplicate-key", "first")],
875
1116
  })
876
1117
  .expect("initial row should stage");
877
1118
  staged_writes
878
- .stage_write(StageWrite::Rows {
879
- mode: StageWriteMode::Replace,
1119
+ .stage_write(PreparedTransactionWrite::Rows {
1120
+ mode: TransactionWriteMode::Replace,
880
1121
  rows: vec![state_row("sql2-duplicate-key", "second")],
881
1122
  })
882
1123
  .expect("staging rows should succeed");
@@ -907,14 +1148,14 @@ mod tests {
907
1148
  let staged_writes = test_staged_writes();
908
1149
 
909
1150
  staged_writes
910
- .stage_write(StageWrite::Rows {
911
- mode: StageWriteMode::Replace,
1151
+ .stage_write(PreparedTransactionWrite::Rows {
1152
+ mode: TransactionWriteMode::Replace,
912
1153
  rows: vec![state_row("sql2-duplicate-key", "first")],
913
1154
  })
914
1155
  .expect("initial row should stage");
915
1156
  staged_writes
916
- .stage_write(StageWrite::Rows {
917
- mode: StageWriteMode::Replace,
1157
+ .stage_write(PreparedTransactionWrite::Rows {
1158
+ mode: TransactionWriteMode::Replace,
918
1159
  rows: vec![state_row("sql2-duplicate-key", "second")],
919
1160
  })
920
1161
  .expect("staging rows should succeed");
@@ -922,7 +1163,9 @@ mod tests {
922
1163
  let overlay = staged_writes
923
1164
  .staging_overlay()
924
1165
  .expect("overlay should build from staged rows");
925
- let rows = overlay.scan(&scan_request_for_key("sql2-duplicate-key", false));
1166
+ let rows = overlay
1167
+ .scan(&scan_request_for_key("sql2-duplicate-key", false))
1168
+ .expect("overlay scan should succeed");
926
1169
 
927
1170
  assert_eq!(rows.len(), 1);
928
1171
  assert_eq!(
@@ -936,8 +1179,8 @@ mod tests {
936
1179
  let staged_writes = test_staged_writes();
937
1180
 
938
1181
  staged_writes
939
- .stage_write(StageWrite::Rows {
940
- mode: StageWriteMode::Replace,
1182
+ .stage_write(PreparedTransactionWrite::Rows {
1183
+ mode: TransactionWriteMode::Replace,
941
1184
  rows: vec![
942
1185
  state_row("sql2-delete-key", "visible"),
943
1186
  tombstone_row("sql2-delete-key"),
@@ -954,9 +1197,12 @@ mod tests {
954
1197
  assert!(matches!(exact, StagedExactRow::Tombstone));
955
1198
  assert!(overlay
956
1199
  .scan(&scan_request_for_key("sql2-delete-key", false))
1200
+ .expect("overlay scan should succeed")
957
1201
  .is_empty());
958
1202
 
959
- let tombstones = overlay.scan(&scan_request_for_key("sql2-delete-key", true));
1203
+ let tombstones = overlay
1204
+ .scan(&scan_request_for_key("sql2-delete-key", true))
1205
+ .expect("overlay scan should succeed");
960
1206
  assert_eq!(tombstones.len(), 1);
961
1207
  assert_eq!(tombstones[0].snapshot_content, None);
962
1208
  }
@@ -966,8 +1212,8 @@ mod tests {
966
1212
  let staged_writes = test_staged_writes();
967
1213
 
968
1214
  staged_writes
969
- .stage_write(StageWrite::Rows {
970
- mode: StageWriteMode::Replace,
1215
+ .stage_write(PreparedTransactionWrite::Rows {
1216
+ mode: TransactionWriteMode::Replace,
971
1217
  rows: vec![
972
1218
  tombstone_row("sql2-resurrect-key"),
973
1219
  state_row("sql2-resurrect-key", "visible-again"),
@@ -992,6 +1238,7 @@ mod tests {
992
1238
  assert_eq!(
993
1239
  overlay
994
1240
  .scan(&scan_request_for_key("sql2-resurrect-key", false))
1241
+ .expect("overlay scan should succeed")
995
1242
  .len(),
996
1243
  1
997
1244
  );
@@ -1002,8 +1249,8 @@ mod tests {
1002
1249
  let staged_writes = test_staged_writes();
1003
1250
 
1004
1251
  staged_writes
1005
- .stage_write(StageWrite::Rows {
1006
- mode: StageWriteMode::Replace,
1252
+ .stage_write(PreparedTransactionWrite::Rows {
1253
+ mode: TransactionWriteMode::Replace,
1007
1254
  rows: vec![
1008
1255
  state_row("sql2-key-a", "first"),
1009
1256
  state_row("sql2-key-b", "only"),
@@ -1011,8 +1258,8 @@ mod tests {
1011
1258
  })
1012
1259
  .expect("initial rows should stage");
1013
1260
  staged_writes
1014
- .stage_write(StageWrite::Rows {
1015
- mode: StageWriteMode::Replace,
1261
+ .stage_write(PreparedTransactionWrite::Rows {
1262
+ mode: TransactionWriteMode::Replace,
1016
1263
  rows: vec![state_row("sql2-key-a", "second")],
1017
1264
  })
1018
1265
  .expect("staging rows should succeed");
@@ -1022,12 +1269,18 @@ mod tests {
1022
1269
  assert_eq!(drained.state_rows.len(), 2);
1023
1270
  assert!(drained.state_rows.iter().any(|row| {
1024
1271
  row.entity_id == crate::entity_identity::EntityIdentity::single("sql2-key-a")
1025
- && row.snapshot_content.as_deref()
1272
+ && row
1273
+ .snapshot
1274
+ .as_ref()
1275
+ .map(|snapshot| snapshot.normalized.as_ref())
1026
1276
  == Some("{\"key\":\"sql2-key-a\",\"value\":\"second\"}")
1027
1277
  }));
1028
1278
  assert!(drained.state_rows.iter().any(|row| {
1029
1279
  row.entity_id == crate::entity_identity::EntityIdentity::single("sql2-key-b")
1030
- && row.snapshot_content.as_deref()
1280
+ && row
1281
+ .snapshot
1282
+ .as_ref()
1283
+ .map(|snapshot| snapshot.normalized.as_ref())
1031
1284
  == Some("{\"key\":\"sql2-key-b\",\"value\":\"only\"}")
1032
1285
  }));
1033
1286
  }
@@ -1037,10 +1290,10 @@ mod tests {
1037
1290
  let staged_writes = test_staged_writes();
1038
1291
 
1039
1292
  staged_writes
1040
- .stage_write(StageWrite::RowsWithFileData {
1041
- mode: StageWriteMode::Replace,
1293
+ .stage_write(PreparedTransactionWrite::RowsWithFileData {
1294
+ mode: TransactionWriteMode::Replace,
1042
1295
  rows: vec![state_row("file-readme", "descriptor")],
1043
- file_data: vec![StageFileData {
1296
+ file_data: vec![TransactionFileData {
1044
1297
  file_id: "file-readme".to_string(),
1045
1298
  version_id: "global".to_string(),
1046
1299
  untracked: true,
@@ -1063,8 +1316,8 @@ mod tests {
1063
1316
  let staged_writes = test_staged_writes();
1064
1317
 
1065
1318
  staged_writes
1066
- .stage_write(StageWrite::Rows {
1067
- mode: StageWriteMode::Replace,
1319
+ .stage_write(PreparedTransactionWrite::Rows {
1320
+ mode: TransactionWriteMode::Replace,
1068
1321
  rows: vec![state_row("tracked-key", "value").with_tracked()],
1069
1322
  })
1070
1323
  .expect("tracked global row should stage");
@@ -1076,7 +1329,7 @@ mod tests {
1076
1329
  .expect("global commit members should exist");
1077
1330
  assert_eq!(
1078
1331
  members.change_ids.iter().cloned().collect::<Vec<_>>(),
1079
- vec!["test-uuid-1".to_string()]
1332
+ vec!["test-change-id".to_string()]
1080
1333
  );
1081
1334
  }
1082
1335
 
@@ -1085,8 +1338,8 @@ mod tests {
1085
1338
  let staged_writes = test_staged_writes();
1086
1339
 
1087
1340
  staged_writes
1088
- .stage_write(StageWrite::Rows {
1089
- mode: StageWriteMode::Replace,
1341
+ .stage_write(PreparedTransactionWrite::Rows {
1342
+ mode: TransactionWriteMode::Replace,
1090
1343
  rows: vec![state_row("untracked-key", "value")],
1091
1344
  })
1092
1345
  .expect("untracked row should stage");
@@ -1100,16 +1353,16 @@ mod tests {
1100
1353
  let staged_writes = test_staged_writes();
1101
1354
 
1102
1355
  staged_writes
1103
- .stage_write(StageWrite::Rows {
1104
- mode: StageWriteMode::Replace,
1356
+ .stage_write(PreparedTransactionWrite::Rows {
1357
+ mode: TransactionWriteMode::Replace,
1105
1358
  rows: vec![state_row("overwrite-key", "first")
1106
1359
  .with_tracked()
1107
1360
  .with_change_id("change-first")],
1108
1361
  })
1109
1362
  .expect("initial tracked row should stage");
1110
1363
  staged_writes
1111
- .stage_write(StageWrite::Rows {
1112
- mode: StageWriteMode::Replace,
1364
+ .stage_write(PreparedTransactionWrite::Rows {
1365
+ mode: TransactionWriteMode::Replace,
1113
1366
  rows: vec![state_row("overwrite-key", "second")
1114
1367
  .with_tracked()
1115
1368
  .with_change_id("change-second")],
@@ -1128,12 +1381,12 @@ mod tests {
1128
1381
  }
1129
1382
 
1130
1383
  #[tokio::test]
1131
- async fn staged_writes_untracked_overwrite_removes_tracked_commit_member() {
1384
+ async fn staged_writes_keep_tracked_and_untracked_domains_separate() {
1132
1385
  let staged_writes = test_staged_writes();
1133
1386
 
1134
1387
  staged_writes
1135
- .stage_write(StageWrite::Rows {
1136
- mode: StageWriteMode::Replace,
1388
+ .stage_write(PreparedTransactionWrite::Rows {
1389
+ mode: TransactionWriteMode::Replace,
1137
1390
  rows: vec![
1138
1391
  state_row("tracked-to-untracked-key", "tracked")
1139
1392
  .with_tracked()
@@ -1145,12 +1398,23 @@ mod tests {
1145
1398
  .expect("untracked overwrite should stage");
1146
1399
 
1147
1400
  let drained = staged_writes.drain().expect("drain should succeed");
1148
- assert_eq!(drained.state_rows.len(), 1);
1401
+ assert_eq!(drained.state_rows.len(), 2);
1402
+ assert!(drained
1403
+ .state_rows
1404
+ .iter()
1405
+ .any(|row| { row.change_id.as_deref() == Some("change-tracked") && !row.untracked }));
1406
+ assert!(drained
1407
+ .state_rows
1408
+ .iter()
1409
+ .any(|row| { row.change_id.as_deref() == Some("change-untracked") && row.untracked }));
1410
+ let members = drained
1411
+ .commit_members_by_version
1412
+ .get("global")
1413
+ .expect("tracked commit member should remain in tracked domain");
1149
1414
  assert_eq!(
1150
- drained.state_rows[0].change_id.as_deref(),
1151
- Some("change-untracked")
1415
+ members.change_ids.iter().cloned().collect::<Vec<_>>(),
1416
+ vec!["change-tracked".to_string()]
1152
1417
  );
1153
- assert!(drained.commit_members_by_version.is_empty());
1154
1418
  }
1155
1419
 
1156
1420
  #[tokio::test]
@@ -1158,8 +1422,8 @@ mod tests {
1158
1422
  let staged_writes = test_staged_writes();
1159
1423
 
1160
1424
  let error = staged_writes
1161
- .stage_write(StageWrite::Rows {
1162
- mode: StageWriteMode::Replace,
1425
+ .stage_write(PreparedTransactionWrite::Rows {
1426
+ mode: TransactionWriteMode::Replace,
1163
1427
  rows: vec![
1164
1428
  state_row("duplicate-present-key", "first"),
1165
1429
  state_row("duplicate-present-key", "second"),
@@ -1174,13 +1438,39 @@ mod tests {
1174
1438
  );
1175
1439
  }
1176
1440
 
1441
+ #[tokio::test]
1442
+ async fn staged_writes_insert_keeps_tracked_and_untracked_rows_as_distinct_identities() {
1443
+ let staged_writes = test_staged_writes();
1444
+
1445
+ staged_writes
1446
+ .stage_write(PreparedTransactionWrite::Rows {
1447
+ mode: TransactionWriteMode::Insert,
1448
+ rows: vec![
1449
+ state_row("shared-domain-key", "tracked").with_tracked(),
1450
+ state_row("shared-domain-key", "untracked"),
1451
+ ],
1452
+ })
1453
+ .expect("tracked and untracked rows are distinct domain identities");
1454
+
1455
+ let drained = staged_writes.drain().expect("drain should succeed");
1456
+ assert_eq!(drained.state_rows.len(), 2);
1457
+ assert!(drained.state_rows.iter().any(|row| {
1458
+ row.entity_id == crate::entity_identity::EntityIdentity::single("shared-domain-key")
1459
+ && !row.untracked
1460
+ }));
1461
+ assert!(drained.state_rows.iter().any(|row| {
1462
+ row.entity_id == crate::entity_identity::EntityIdentity::single("shared-domain-key")
1463
+ && row.untracked
1464
+ }));
1465
+ }
1466
+
1177
1467
  #[tokio::test]
1178
1468
  async fn staged_writes_track_active_version_members_separately() {
1179
1469
  let staged_writes = test_staged_writes();
1180
1470
 
1181
1471
  staged_writes
1182
- .stage_write(StageWrite::Rows {
1183
- mode: StageWriteMode::Replace,
1472
+ .stage_write(PreparedTransactionWrite::Rows {
1473
+ mode: TransactionWriteMode::Replace,
1184
1474
  rows: vec![state_row("active-version-key", "value")
1185
1475
  .with_tracked()
1186
1476
  .with_version("version-a")],
@@ -1194,7 +1484,7 @@ mod tests {
1194
1484
  .expect("active-version commit members should exist");
1195
1485
  assert_eq!(
1196
1486
  members.change_ids.iter().cloned().collect::<Vec<_>>(),
1197
- vec!["test-uuid-1".to_string()]
1487
+ vec!["test-change-id".to_string()]
1198
1488
  );
1199
1489
  }
1200
1490
 
@@ -1203,8 +1493,8 @@ mod tests {
1203
1493
  let staged_writes = test_staged_writes();
1204
1494
 
1205
1495
  let error = staged_writes
1206
- .stage_write(StageWrite::Rows {
1207
- mode: StageWriteMode::Replace,
1496
+ .stage_write(PreparedTransactionWrite::Rows {
1497
+ mode: TransactionWriteMode::Replace,
1208
1498
  rows: vec![{
1209
1499
  let mut row = state_row("invalid-global-key", "value");
1210
1500
  row.version_id = "version-a".to_string();
@@ -1223,16 +1513,14 @@ mod tests {
1223
1513
  let staged_writes = test_staged_writes();
1224
1514
 
1225
1515
  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
- ],
1516
+ .stage_write(PreparedTransactionWrite::Rows {
1517
+ mode: TransactionWriteMode::Replace,
1518
+ rows: vec![state_row("shared-entity", "base")],
1231
1519
  })
1232
1520
  .expect("initial same-identity row should stage");
1233
1521
  staged_writes
1234
- .stage_write(StageWrite::Rows {
1235
- mode: StageWriteMode::Replace,
1522
+ .stage_write(PreparedTransactionWrite::Rows {
1523
+ mode: TransactionWriteMode::Replace,
1236
1524
  rows: vec![
1237
1525
  state_row("shared-entity", "base"),
1238
1526
  state_row("shared-entity", "other-version").with_version("version-b"),
@@ -1246,18 +1534,30 @@ mod tests {
1246
1534
  let overlay = staged_writes
1247
1535
  .staging_overlay()
1248
1536
  .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
- });
1537
+ let rows = overlay
1538
+ .scan(&LiveStateScanRequest {
1539
+ filter: LiveStateFilter {
1540
+ entity_ids: vec![crate::entity_identity::EntityIdentity::single(
1541
+ "shared-entity",
1542
+ )],
1543
+ include_tombstones: true,
1544
+ ..LiveStateFilter::default()
1545
+ },
1546
+ ..LiveStateScanRequest::default()
1547
+ })
1548
+ .expect("overlay scan should succeed");
1259
1549
 
1260
- assert_eq!(rows.len(), 4);
1550
+ assert_eq!(rows.len(), 5);
1551
+ assert_eq!(
1552
+ rows.iter()
1553
+ .filter(|row| row.entity_id
1554
+ == crate::entity_identity::EntityIdentity::single("shared-entity")
1555
+ && row.version_id == "global"
1556
+ && row.schema_key == "lix_key_value"
1557
+ && row.file_id.is_none())
1558
+ .count(),
1559
+ 2
1560
+ );
1261
1561
  assert!(rows.iter().any(|row| {
1262
1562
  row.snapshot_content.as_deref()
1263
1563
  == Some("{\"key\":\"shared-entity\",\"value\":\"tracked\"}")
@@ -1265,30 +1565,24 @@ mod tests {
1265
1565
  }
1266
1566
 
1267
1567
  #[tokio::test]
1268
- async fn staged_writes_use_injected_function_provider_for_row_metadata() {
1568
+ async fn staged_writes_use_injected_function_provider_for_commit_metadata() {
1269
1569
  let staged_writes = test_staged_writes();
1270
1570
 
1271
1571
  staged_writes
1272
- .stage_write(StageWrite::Rows {
1273
- mode: StageWriteMode::Replace,
1572
+ .stage_write(PreparedTransactionWrite::Rows {
1573
+ mode: TransactionWriteMode::Replace,
1274
1574
  rows: vec![state_row("sql2-functions-key", "value").with_tracked()],
1275
1575
  })
1276
1576
  .expect("staging rows should succeed");
1277
1577
 
1278
1578
  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
- );
1579
+ let members = drained
1580
+ .commit_members_by_version
1581
+ .get("global")
1582
+ .expect("global commit members should exist");
1583
+ assert_eq!(members.commit_id, "test-uuid-1");
1584
+ assert_eq!(members.commit_change_id, "test-uuid-2");
1585
+ assert_eq!(members.created_at, "test-timestamp-1");
1292
1586
  }
1293
1587
 
1294
1588
  #[tokio::test]
@@ -1296,8 +1590,8 @@ mod tests {
1296
1590
  let staged_writes = test_staged_writes();
1297
1591
 
1298
1592
  staged_writes
1299
- .stage_write(StageWrite::Rows {
1300
- mode: StageWriteMode::Replace,
1593
+ .stage_write(PreparedTransactionWrite::Rows {
1594
+ mode: TransactionWriteMode::Replace,
1301
1595
  rows: vec![state_row("tracked-commit-key", "value").with_tracked()],
1302
1596
  })
1303
1597
  .expect("tracked row should stage");
@@ -1306,7 +1600,7 @@ mod tests {
1306
1600
  assert_eq!(drained.state_rows.len(), 1);
1307
1601
  assert_eq!(
1308
1602
  drained.state_rows[0].commit_id.as_deref(),
1309
- Some("test-uuid-2")
1603
+ Some("test-uuid-1")
1310
1604
  );
1311
1605
  assert_eq!(
1312
1606
  drained
@@ -1314,15 +1608,14 @@ mod tests {
1314
1608
  .get("global")
1315
1609
  .expect("global commit members should exist")
1316
1610
  .commit_id,
1317
- "test-uuid-2"
1611
+ "test-uuid-1"
1318
1612
  );
1319
1613
  }
1320
1614
 
1321
- fn test_staged_writes() -> TransactionStagedWrites {
1322
- TransactionStagedWrites::new(SharedFunctionProvider::new(Box::new(
1323
- TestFunctionProvider::default(),
1324
- )
1325
- as Box<dyn FunctionProvider + Send>))
1615
+ fn test_staged_writes() -> Arc<TransactionWriteBuffer> {
1616
+ Arc::new(TransactionWriteBuffer::new(SharedFunctionProvider::new(
1617
+ Box::new(TestFunctionProvider::default()) as Box<dyn FunctionProvider + Send>,
1618
+ )))
1326
1619
  }
1327
1620
 
1328
1621
  #[derive(Default)]
@@ -1343,17 +1636,23 @@ mod tests {
1343
1636
  }
1344
1637
  }
1345
1638
 
1346
- fn state_row(key: &str, value: &str) -> StageRow {
1347
- StageRow {
1348
- entity_id: Some(crate::entity_identity::EntityIdentity::single(key)),
1639
+ fn state_row(key: &str, value: &str) -> PreparedStateRow {
1640
+ let snapshot = stage_json_from_value(
1641
+ TransactionJson::from_value_for_test(serde_json::json!({ "key": key, "value": value })),
1642
+ "test staged row snapshot_content",
1643
+ )
1644
+ .expect("test snapshot should prepare");
1645
+ PreparedStateRow {
1646
+ schema_plan_id: SchemaPlanId::for_test(0),
1647
+ facts: crate::transaction::types::PreparedRowFacts::default(),
1648
+ entity_id: crate::entity_identity::EntityIdentity::single(key),
1349
1649
  schema_key: "lix_key_value".to_string(),
1350
1650
  file_id: None,
1351
- snapshot_content: Some(format!("{{\"key\":\"{key}\",\"value\":\"{value}\"}}")),
1651
+ snapshot: Some(snapshot),
1352
1652
  metadata: None,
1353
1653
  origin: None,
1354
- schema_version: "1".to_string(),
1355
- created_at: None,
1356
- updated_at: None,
1654
+ created_at: "test-created-at".to_string(),
1655
+ updated_at: "test-updated-at".to_string(),
1357
1656
  global: true,
1358
1657
  change_id: None,
1359
1658
  commit_id: None,
@@ -1362,11 +1661,10 @@ mod tests {
1362
1661
  }
1363
1662
  }
1364
1663
 
1365
- fn tombstone_row(key: &str) -> StageRow {
1366
- StageRow {
1367
- snapshot_content: None,
1368
- ..state_row(key, "deleted")
1369
- }
1664
+ fn tombstone_row(key: &str) -> PreparedStateRow {
1665
+ let mut row = state_row(key, "deleted");
1666
+ row.snapshot = None;
1667
+ row
1370
1668
  }
1371
1669
 
1372
1670
  fn exact_request_for_key(key: &str) -> LiveStateRowRequest {
@@ -1394,24 +1692,18 @@ mod tests {
1394
1692
 
1395
1693
  trait StateRowTestExt {
1396
1694
  fn with_schema(self, schema_key: &str) -> Self;
1397
- fn with_schema_version(self, schema_version: &str) -> Self;
1398
1695
  fn with_file_id(self, file_id: &str) -> Self;
1399
1696
  fn with_tracked(self) -> Self;
1400
1697
  fn with_version(self, version_id: &str) -> Self;
1401
1698
  fn with_change_id(self, change_id: &str) -> Self;
1402
1699
  }
1403
1700
 
1404
- impl StateRowTestExt for StageRow {
1701
+ impl StateRowTestExt for PreparedStateRow {
1405
1702
  fn with_schema(mut self, schema_key: &str) -> Self {
1406
1703
  self.schema_key = schema_key.to_string();
1407
1704
  self
1408
1705
  }
1409
1706
 
1410
- fn with_schema_version(mut self, schema_version: &str) -> Self {
1411
- self.schema_version = schema_version.to_string();
1412
- self
1413
- }
1414
-
1415
1707
  fn with_file_id(mut self, file_id: &str) -> Self {
1416
1708
  self.file_id = Some(file_id.to_string());
1417
1709
  self
@@ -1419,6 +1711,9 @@ mod tests {
1419
1711
 
1420
1712
  fn with_tracked(mut self) -> Self {
1421
1713
  self.untracked = false;
1714
+ if self.change_id.is_none() {
1715
+ self.change_id = Some("test-change-id".to_string());
1716
+ }
1422
1717
  self
1423
1718
  }
1424
1719