@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,284 +1,603 @@
1
- use std::collections::BTreeMap;
2
-
3
1
  use crate::binary_cas::BinaryCasContext;
4
- use crate::changelog::{CanonicalChange, ChangelogContext};
2
+ use crate::commit_store::{ChangeRef, CommitDraftRef, CommitStoreContext, StagedCommitStoreCommit};
5
3
  use crate::functions::FunctionContext;
6
- use crate::json_store::{JsonRef, JsonStoreContext, JsonStoreWriter};
7
- use crate::live_state::{LiveStateContext, LiveStateRow};
4
+ use crate::json_store::{JsonStoreContext, JsonWritePlacementRef, NormalizedJsonRef};
8
5
  use crate::storage::{StorageReader, StorageWriteSet, StorageWriteTransaction};
9
- use crate::transaction::staging::StagedWriteSet;
10
- use crate::transaction::types::{StagedAdoptedStateRow, StagedCommitMembers, StagedStateRow};
6
+ use crate::tracked_state::{TrackedStateContext, TrackedStateDeltaRef};
7
+ use crate::transaction::prepare_version_ref_row;
8
+ use crate::transaction::staging::PreparedWriteSet;
9
+ use crate::transaction::types::{PreparedAdoptedStateRow, PreparedStateRow, StagedCommitMembers};
10
+ use crate::untracked_state::{
11
+ UntrackedStateContext, UntrackedStateIdentity, UntrackedStateIdentityRef, UntrackedStateRowRef,
12
+ };
11
13
  use crate::version::{VersionContext, VersionRefReader};
12
- use crate::GLOBAL_VERSION_ID;
13
- use crate::{serialize_row_metadata, LixError, RowMetadata};
14
+ use crate::LixError;
15
+ use std::collections::BTreeMap;
16
+
17
+ type RowIndex = usize;
18
+ type AdoptedRowIndex = usize;
14
19
 
15
- /// Commits transaction-staged rows into durable tracked and untracked stores.
20
+ /// Commits prepared transaction rows into durable tracked and untracked stores.
16
21
  ///
17
- /// Providers decode DataFusion DML into hydrated `StagedStateRow`s. Untracked
18
- /// rows are durable local overlay state and bypass changelog/commit rows.
19
- /// Tracked rows receive normal `lix_commit` rows, append canonical changelog
20
- /// facts, then update the live-state serving projection. The tracked side of
21
- /// that projection is a prolly root keyed by the new commit id.
22
- pub(crate) async fn commit_staged_writes(
22
+ /// Providers decode DataFusion DML into hydrated `PreparedStateRow`s. Untracked
23
+ /// rows are durable local overlay state and bypass commit-store rows. Tracked
24
+ /// rows stage canonical commit-store facts, then update the live-state serving
25
+ /// projection. The tracked side of that projection is a prolly root keyed by
26
+ /// the new commit id.
27
+ pub(crate) async fn commit_prepared_writes(
23
28
  binary_cas: &BinaryCasContext,
24
- changelog: &ChangelogContext,
25
- live_state: &LiveStateContext,
29
+ commit_store: &CommitStoreContext,
26
30
  version_ctx: &VersionContext,
27
31
  runtime_functions: Option<&FunctionContext>,
28
32
  transaction: &mut (impl StorageWriteTransaction + ?Sized),
29
- staged_writes: StagedWriteSet,
33
+ prepared_writes: PreparedWriteSet,
30
34
  ) -> Result<(), LixError> {
31
35
  let mut writes = StorageWriteSet::new();
32
36
  let mut json_writer = JsonStoreContext::new().writer();
33
37
 
34
- if !staged_writes.file_data_writes.is_empty() {
38
+ if !prepared_writes.file_data_writes.is_empty() {
35
39
  let mut blob_writer = binary_cas.writer(&mut writes);
36
- for write in &staged_writes.file_data_writes {
40
+ for write in &prepared_writes.file_data_writes {
37
41
  blob_writer.stage_bytes(&write.data)?;
38
42
  }
39
43
  }
40
44
 
41
- let (mut changelog_rows, untracked_rows): (Vec<_>, Vec<_>) = staged_writes
42
- .state_rows
43
- .into_iter()
44
- .partition(|row| !row.untracked);
45
- let adopted_rows = staged_writes.adopted_rows;
45
+ let state_rows = prepared_writes.state_rows;
46
+ let adopted_rows = prepared_writes.adopted_rows;
46
47
  let finalized = finalize_commit_rows(
47
- staged_writes.commit_members_by_version,
48
- staged_writes.extra_commit_parents_by_version,
48
+ prepared_writes.commit_members_by_version,
49
+ prepared_writes.extra_commit_parents_by_version,
49
50
  version_ctx,
50
51
  transaction,
51
52
  )
52
53
  .await?;
53
- changelog_rows.extend(finalized.commit_rows);
54
+ let commit_rows = finalized.commit_rows;
54
55
  let version_heads = finalized.version_heads;
56
+ let tracked_roots = finalized.tracked_roots;
57
+ let row_index = index_prepared_rows(&state_rows)?;
58
+ let adopted_index = index_adopted_rows(&adopted_rows);
55
59
 
56
60
  if let Some(runtime_functions) = runtime_functions {
57
- let mut writer = live_state.writer(&mut *transaction);
58
61
  runtime_functions
59
- .stage_persist_if_needed(&mut writer, &mut writes, &mut json_writer)
62
+ .stage_persist_if_needed(&mut writes)
60
63
  .await?;
61
64
  }
62
65
 
63
- if changelog_rows.is_empty()
66
+ if state_rows.is_empty()
64
67
  && adopted_rows.is_empty()
65
- && untracked_rows.is_empty()
68
+ && commit_rows.is_empty()
66
69
  && version_heads.is_empty()
67
70
  && writes.is_empty()
68
71
  {
69
72
  return Ok(());
70
73
  }
71
74
 
72
- if !changelog_rows.is_empty() {
73
- let canonical_changes = new_canonical_changes(
74
- changelog,
75
- transaction,
76
- &mut writes,
77
- &mut json_writer,
78
- &changelog_rows,
79
- )
80
- .await?;
81
- {
82
- let mut writer = changelog.writer(&mut writes);
83
- writer.stage_changes(&canonical_changes)?;
84
- }
85
- }
86
- if !adopted_rows.is_empty() {
87
- validate_adopted_canonical_changes(changelog, transaction, &adopted_rows).await?;
88
- }
75
+ let staged_commits = stage_commit_store_commits(
76
+ commit_store,
77
+ transaction,
78
+ &mut writes,
79
+ &state_rows,
80
+ &row_index.tracked_row_indices_by_commit,
81
+ &adopted_rows,
82
+ &adopted_index.tracked_row_indices_by_commit,
83
+ &commit_rows,
84
+ )
85
+ .await?;
86
+
87
+ let json_pack_indexes_by_commit = stage_prepared_json_payloads(
88
+ &mut json_writer,
89
+ &mut writes,
90
+ &state_rows,
91
+ &row_index.tracked_row_indices_by_commit,
92
+ &staged_commits,
93
+ &row_index.untracked_row_indices,
94
+ )?;
89
95
 
90
96
  // The serving projection is updated in the same backend transaction as the
91
- // changelog append. Tracked rows become prolly mutations under their owning
97
+ // commit-store append. Tracked rows become prolly mutations under their owning
92
98
  // commit root; untracked rows remain in the separate local overlay store.
93
- let live_state_rows = changelog_rows
94
- .into_iter()
95
- .map(LiveStateRow::from)
96
- .chain(adopted_rows.into_iter().map(LiveStateRow::from))
97
- .chain(untracked_rows.into_iter().map(LiveStateRow::from))
98
- .collect::<Vec<_>>();
99
-
100
99
  {
101
- let mut writer = live_state.writer(&mut *transaction);
102
- writer
103
- .stage_rows(&mut writes, &mut json_writer, &live_state_rows)
104
- .await?;
100
+ let untracked_overlay_delete_identities = existing_untracked_overlay_delete_identities(
101
+ transaction,
102
+ row_index
103
+ .canonical_row_indices
104
+ .iter()
105
+ .map(|&row_index| untracked_identity_ref_from_state_row(&state_rows[row_index]))
106
+ .chain(
107
+ adopted_rows
108
+ .iter()
109
+ .map(untracked_identity_ref_from_adopted_row),
110
+ ),
111
+ )
112
+ .await?;
113
+ UntrackedStateContext::new()
114
+ .writer(&mut writes)
115
+ .stage_rows(
116
+ row_index
117
+ .untracked_row_indices
118
+ .iter()
119
+ .map(|&row_index| untracked_row_ref_from_state_row(&state_rows[row_index])),
120
+ )?;
121
+ UntrackedStateContext::new()
122
+ .writer(&mut writes)
123
+ .stage_delete_rows(
124
+ untracked_overlay_delete_identities
125
+ .iter()
126
+ .map(UntrackedStateIdentity::as_ref),
127
+ );
128
+ stage_tracked_roots(
129
+ transaction,
130
+ &mut writes,
131
+ &state_rows,
132
+ row_index.tracked_row_indices_by_commit,
133
+ &adopted_rows,
134
+ adopted_index.tracked_row_indices_by_commit,
135
+ tracked_roots,
136
+ staged_commits,
137
+ json_pack_indexes_by_commit,
138
+ )
139
+ .await?;
105
140
  }
106
141
 
107
142
  for version_head in version_heads {
108
- let canonical_row = version_ctx.canonical_ref_row(
109
- &mut writes,
110
- &mut json_writer,
143
+ let canonical_row = prepare_version_ref_row(
111
144
  &version_head.version_id,
112
145
  &version_head.commit_id,
113
146
  &version_head.timestamp,
114
147
  )?;
115
- version_ctx.stage_canonical_ref_rows(&mut writes, &[canonical_row])?;
148
+ version_ctx.stage_canonical_ref_rows(&mut writes, &[canonical_row.row])?;
116
149
  }
117
150
 
118
151
  writes.apply(transaction).await?;
119
152
  Ok(())
120
153
  }
121
154
 
122
- async fn new_canonical_changes(
123
- changelog: &ChangelogContext,
124
- transaction: &mut (impl StorageReader + ?Sized),
155
+ fn stage_prepared_json_payloads(
156
+ json_writer: &mut crate::json_store::JsonStoreWriter,
125
157
  writes: &mut StorageWriteSet,
126
- json_writer: &mut JsonStoreWriter,
127
- rows: &[StagedStateRow],
128
- ) -> Result<Vec<CanonicalChange>, LixError> {
129
- let reader = changelog.reader(&mut *transaction);
130
- let mut changes = Vec::new();
131
- for row in rows {
132
- let change = canonical_change_from_staged_row(writes, json_writer, row)?;
133
- match reader.load_change(&change.id).await? {
134
- Some(existing) => {
135
- let entity_id = existing
136
- .entity_id
137
- .as_string()
138
- .unwrap_or_else(|_| "<invalid entity_id>".to_string());
139
- return Err(LixError::new(
140
- LixError::CODE_UNIQUE,
141
- format!(
142
- "canonical change id '{}' already exists with different content for schema '{}' entity '{}'",
143
- change.id,
144
- existing.schema_key,
145
- entity_id
146
- ),
147
- ));
148
- }
149
- None => changes.push(change),
158
+ state_rows: &[PreparedStateRow],
159
+ tracked_row_indices_by_commit: &BTreeMap<String, Vec<RowIndex>>,
160
+ staged_commits: &BTreeMap<String, StagedCommitStoreCommit>,
161
+ untracked_row_indices: &[RowIndex],
162
+ ) -> Result<BTreeMap<String, BTreeMap<u32, std::collections::HashMap<[u8; 32], usize>>>, LixError> {
163
+ let mut pack_indexes_by_commit = BTreeMap::new();
164
+ for (commit_id, row_indices) in tracked_row_indices_by_commit {
165
+ let staged_commit = staged_commits.get(commit_id).ok_or_else(|| {
166
+ LixError::new(
167
+ LixError::CODE_INTERNAL_ERROR,
168
+ format!("commit '{commit_id}' has tracked JSON rows but no staged commit-store locators"),
169
+ )
170
+ })?;
171
+ if row_indices.len() != staged_commit.authored_locators.len() {
172
+ return Err(LixError::new(
173
+ LixError::CODE_INTERNAL_ERROR,
174
+ format!(
175
+ "commit '{commit_id}' has {} tracked JSON rows but {} authored locators",
176
+ row_indices.len(),
177
+ staged_commit.authored_locators.len()
178
+ ),
179
+ ));
180
+ }
181
+ let mut row_indices_by_pack = BTreeMap::<u32, Vec<RowIndex>>::new();
182
+ for (&row_index, locator) in row_indices.iter().zip(&staged_commit.authored_locators) {
183
+ row_indices_by_pack
184
+ .entry(locator.source_pack_id)
185
+ .or_default()
186
+ .push(row_index);
187
+ }
188
+ for (pack_id, pack_row_indices) in row_indices_by_pack {
189
+ let report = json_writer.stage_batch_report(
190
+ writes,
191
+ JsonWritePlacementRef::CommitPack { commit_id, pack_id },
192
+ pack_row_indices
193
+ .iter()
194
+ .flat_map(|&row_index| json_payloads_from_state_row(&state_rows[row_index])),
195
+ )?;
196
+ pack_indexes_by_commit
197
+ .entry(commit_id.clone())
198
+ .or_insert_with(BTreeMap::new)
199
+ .insert(pack_id, report.pack_indexes);
150
200
  }
151
201
  }
152
- Ok(changes)
202
+ json_writer.stage_batch(
203
+ writes,
204
+ JsonWritePlacementRef::OutOfBand,
205
+ untracked_row_indices
206
+ .iter()
207
+ .flat_map(|&row_index| json_payloads_from_state_row(&state_rows[row_index])),
208
+ )?;
209
+ Ok(pack_indexes_by_commit)
210
+ }
211
+
212
+ fn json_payloads_from_state_row(
213
+ row: &PreparedStateRow,
214
+ ) -> impl Iterator<Item = NormalizedJsonRef<'_>> {
215
+ row.snapshot
216
+ .iter()
217
+ .chain(row.metadata.iter())
218
+ .map(|json| NormalizedJsonRef::trusted_prehashed(json.normalized.as_ref(), json.json_ref))
153
219
  }
154
220
 
155
- async fn validate_adopted_canonical_changes(
156
- changelog: &ChangelogContext,
221
+ async fn existing_untracked_overlay_delete_identities<'a>(
157
222
  transaction: &mut (impl StorageReader + ?Sized),
158
- rows: &[StagedAdoptedStateRow],
159
- ) -> Result<(), LixError> {
160
- let mut writes = StorageWriteSet::new();
161
- let mut json_writer = JsonStoreContext::new().writer();
162
- let reader = changelog.reader(&mut *transaction);
163
- for row in rows {
164
- let expected = canonical_change_from_adopted_row(&mut writes, &mut json_writer, row)?;
165
- match reader.load_change(&expected.id).await? {
166
- Some(existing) if existing == expected => {}
167
- Some(existing) => {
168
- let entity_id = existing
169
- .entity_id
170
- .as_string()
171
- .unwrap_or_else(|_| "<invalid entity_id>".to_string());
172
- return Err(LixError::new(
173
- LixError::CODE_UNIQUE,
174
- format!(
175
- "adopted canonical change id '{}' exists with different content for schema '{}' entity '{}'",
176
- expected.id, existing.schema_key, entity_id
177
- ),
178
- ));
179
- }
180
- None => {
181
- return Err(LixError::new(
182
- LixError::CODE_INTERNAL_ERROR,
183
- format!(
184
- "adopted canonical change id '{}' does not exist in the changelog",
185
- expected.id
186
- ),
187
- ));
188
- }
223
+ identities: impl IntoIterator<Item = UntrackedStateIdentityRef<'a>>,
224
+ ) -> Result<Vec<UntrackedStateIdentity>, LixError> {
225
+ UntrackedStateContext::new()
226
+ .reader(transaction)
227
+ .existing_identities(identities)
228
+ .await
229
+ }
230
+
231
+ struct PreparedRowIndex {
232
+ canonical_row_indices: Vec<RowIndex>,
233
+ untracked_row_indices: Vec<RowIndex>,
234
+ tracked_row_indices_by_commit: BTreeMap<String, Vec<RowIndex>>,
235
+ }
236
+
237
+ struct PreparedAdoptedRowIndex {
238
+ tracked_row_indices_by_commit: BTreeMap<String, Vec<AdoptedRowIndex>>,
239
+ }
240
+
241
+ fn index_prepared_rows(rows: &[PreparedStateRow]) -> Result<PreparedRowIndex, LixError> {
242
+ let mut canonical_row_indices = Vec::new();
243
+ let mut untracked_row_indices = Vec::new();
244
+ let mut tracked_row_indices_by_commit = BTreeMap::<String, Vec<RowIndex>>::new();
245
+
246
+ for (row_index, row) in rows.iter().enumerate() {
247
+ if row.untracked {
248
+ untracked_row_indices.push(row_index);
249
+ continue;
189
250
  }
251
+ let Some(commit_id) = row.commit_id.as_ref() else {
252
+ return Err(LixError::new(
253
+ LixError::CODE_INTERNAL_ERROR,
254
+ "tracked prepared row is missing commit_id before commit indexing",
255
+ ));
256
+ };
257
+ canonical_row_indices.push(row_index);
258
+ tracked_row_indices_by_commit
259
+ .entry(commit_id.clone())
260
+ .or_default()
261
+ .push(row_index);
262
+ }
263
+
264
+ Ok(PreparedRowIndex {
265
+ canonical_row_indices,
266
+ untracked_row_indices,
267
+ tracked_row_indices_by_commit,
268
+ })
269
+ }
270
+
271
+ fn index_adopted_rows(rows: &[PreparedAdoptedStateRow]) -> PreparedAdoptedRowIndex {
272
+ let mut tracked_row_indices_by_commit = BTreeMap::<String, Vec<AdoptedRowIndex>>::new();
273
+ for (row_index, row) in rows.iter().enumerate() {
274
+ tracked_row_indices_by_commit
275
+ .entry(row.commit_id.clone())
276
+ .or_default()
277
+ .push(row_index);
278
+ }
279
+ PreparedAdoptedRowIndex {
280
+ tracked_row_indices_by_commit,
190
281
  }
191
- Ok(())
192
282
  }
193
283
 
194
- fn canonical_change_from_staged_row(
284
+ async fn stage_commit_store_commits(
285
+ commit_store: &CommitStoreContext,
286
+ transaction: &mut (impl StorageReader + ?Sized),
195
287
  writes: &mut StorageWriteSet,
196
- json_writer: &mut JsonStoreWriter,
197
- row: &StagedStateRow,
198
- ) -> Result<CanonicalChange, LixError> {
199
- let Some(change_id) = row.change_id.as_ref() else {
288
+ state_rows: &[PreparedStateRow],
289
+ tracked_row_indices_by_commit: &BTreeMap<String, Vec<RowIndex>>,
290
+ adopted_rows: &[PreparedAdoptedStateRow],
291
+ adopted_row_indices_by_commit: &BTreeMap<String, Vec<AdoptedRowIndex>>,
292
+ commit_rows: &[FinalizedCommitRow],
293
+ ) -> Result<BTreeMap<String, StagedCommitStoreCommit>, LixError> {
294
+ let mut commits = Vec::with_capacity(commit_rows.len());
295
+ let mut commit_ids = Vec::with_capacity(commit_rows.len());
296
+ for commit_row in commit_rows {
297
+ let state_row_indices = tracked_row_indices_by_commit
298
+ .get(&commit_row.commit_id)
299
+ .map(Vec::as_slice)
300
+ .unwrap_or_default();
301
+ let adopted_row_indices = adopted_row_indices_by_commit
302
+ .get(&commit_row.commit_id)
303
+ .map(Vec::as_slice)
304
+ .unwrap_or_default();
305
+ let mut authored_changes = Vec::with_capacity(state_row_indices.len());
306
+ for &row_index in state_row_indices {
307
+ authored_changes.push(change_ref_from_state_row(&state_rows[row_index])?);
308
+ }
309
+ let mut adopted_changes = Vec::with_capacity(adopted_row_indices.len());
310
+ for &row_index in adopted_row_indices {
311
+ adopted_changes.push(change_ref_from_adopted_row(&adopted_rows[row_index]));
312
+ }
313
+
314
+ let commit = CommitDraftRef {
315
+ id: &commit_row.commit_id,
316
+ change_id: &commit_row.change_id,
317
+ parent_ids: &commit_row.parent_commit_ids,
318
+ author_account_ids: &[],
319
+ created_at: &commit_row.created_at,
320
+ };
321
+ commit_ids.push(commit_row.commit_id.clone());
322
+ commits.push((commit, authored_changes, adopted_changes));
323
+ }
324
+ let staged = commit_store
325
+ .writer(transaction, writes)
326
+ .stage_tracked_commit_drafts(commits)
327
+ .await?;
328
+ if staged.len() != commit_ids.len() {
329
+ return Err(LixError::new(
330
+ LixError::CODE_INTERNAL_ERROR,
331
+ format!(
332
+ "commit-store staged {} commits for {} finalized commit rows",
333
+ staged.len(),
334
+ commit_ids.len()
335
+ ),
336
+ ));
337
+ }
338
+ Ok(commit_ids.into_iter().zip(staged).collect())
339
+ }
340
+
341
+ fn change_ref_from_state_row(row: &PreparedStateRow) -> Result<ChangeRef<'_>, LixError> {
342
+ let Some(change_id) = row.change_id.as_deref() else {
200
343
  return Err(LixError::new(
201
344
  "LIX_ERROR_UNKNOWN",
202
- "tracked staged row is missing change_id before changelog append",
345
+ "tracked staged row is missing change_id before commit-store append",
203
346
  ));
204
347
  };
205
348
 
206
- Ok(CanonicalChange {
207
- id: change_id.clone(),
208
- entity_id: row.entity_id.clone(),
209
- schema_key: row.schema_key.clone(),
210
- schema_version: row.schema_version.clone(),
211
- file_id: row.file_id.clone(),
212
- snapshot_ref: stage_optional_json(writes, json_writer, row.snapshot_content.as_deref())?,
213
- metadata_ref: stage_optional_metadata(writes, json_writer, row.metadata.as_ref())?,
214
- created_at: row.created_at.clone(),
349
+ Ok(ChangeRef {
350
+ id: change_id,
351
+ entity_id: &row.entity_id,
352
+ schema_key: &row.schema_key,
353
+ file_id: row.file_id.as_deref(),
354
+ snapshot_ref: row.snapshot.as_ref().map(|snapshot| &snapshot.json_ref),
355
+ metadata_ref: row.metadata.as_ref().map(|metadata| &metadata.json_ref),
356
+ created_at: &row.updated_at,
215
357
  })
216
358
  }
217
359
 
218
- fn stage_optional_json(
219
- writes: &mut StorageWriteSet,
220
- json_writer: &mut JsonStoreWriter,
221
- value: Option<&str>,
222
- ) -> Result<Option<JsonRef>, LixError> {
223
- let Some(value) = value else {
224
- return Ok(None);
225
- };
226
- json_writer.stage_bytes(writes, value.as_bytes()).map(Some)
360
+ fn change_ref_from_adopted_row(row: &PreparedAdoptedStateRow) -> ChangeRef<'_> {
361
+ ChangeRef {
362
+ id: &row.change_id,
363
+ entity_id: &row.entity_id,
364
+ schema_key: &row.schema_key,
365
+ file_id: row.file_id.as_deref(),
366
+ snapshot_ref: row.snapshot.as_ref().map(|snapshot| &snapshot.json_ref),
367
+ metadata_ref: row.metadata.as_ref().map(|metadata| &metadata.json_ref),
368
+ created_at: &row.updated_at,
369
+ }
227
370
  }
228
371
 
229
- fn stage_optional_metadata(
372
+ async fn stage_tracked_roots(
373
+ transaction: &mut (impl StorageReader + ?Sized),
230
374
  writes: &mut StorageWriteSet,
231
- json_writer: &mut JsonStoreWriter,
232
- value: Option<&RowMetadata>,
233
- ) -> Result<Option<JsonRef>, LixError> {
234
- let Some(value) = value else {
235
- return Ok(None);
236
- };
237
- let serialized = serialize_row_metadata(value);
238
- json_writer
239
- .stage_bytes(writes, serialized.as_bytes())
240
- .map(Some)
375
+ state_rows: &[PreparedStateRow],
376
+ mut tracked_row_indices_by_commit: BTreeMap<String, Vec<RowIndex>>,
377
+ adopted_rows: &[PreparedAdoptedStateRow],
378
+ mut adopted_row_indices_by_commit: BTreeMap<String, Vec<AdoptedRowIndex>>,
379
+ tracked_roots: Vec<PendingTrackedRoot>,
380
+ mut staged_commits: BTreeMap<String, StagedCommitStoreCommit>,
381
+ json_pack_indexes_by_commit: BTreeMap<
382
+ String,
383
+ BTreeMap<u32, std::collections::HashMap<[u8; 32], usize>>,
384
+ >,
385
+ ) -> Result<(), LixError> {
386
+ let tracked_state = TrackedStateContext::new();
387
+ let mut writer = tracked_state.writer(transaction, writes);
388
+ for root in tracked_roots {
389
+ let staged = staged_commits.remove(&root.commit_id).ok_or_else(|| {
390
+ LixError::new(
391
+ LixError::CODE_INTERNAL_ERROR,
392
+ format!(
393
+ "tracked-state root for commit '{}' has no staged commit-store locators",
394
+ root.commit_id
395
+ ),
396
+ )
397
+ })?;
398
+ let state_row_indices = tracked_row_indices_by_commit
399
+ .remove(&root.commit_id)
400
+ .unwrap_or_default();
401
+ let adopted_row_indices = adopted_row_indices_by_commit
402
+ .remove(&root.commit_id)
403
+ .unwrap_or_default();
404
+ if state_row_indices.len() != staged.authored_locators.len() {
405
+ return Err(LixError::new(
406
+ LixError::CODE_INTERNAL_ERROR,
407
+ format!(
408
+ "commit '{}' has {} tracked authored rows but {} commit-store authored locators",
409
+ root.commit_id,
410
+ state_row_indices.len(),
411
+ staged.authored_locators.len()
412
+ ),
413
+ ));
414
+ }
415
+ if adopted_row_indices.len() != staged.adopted_locators.len() {
416
+ return Err(LixError::new(
417
+ LixError::CODE_INTERNAL_ERROR,
418
+ format!(
419
+ "commit '{}' has {} tracked adopted rows but {} commit-store adopted locators",
420
+ root.commit_id,
421
+ adopted_row_indices.len(),
422
+ staged.adopted_locators.len()
423
+ ),
424
+ ));
425
+ }
426
+ let authored_changes = state_row_indices
427
+ .iter()
428
+ .map(|&row_index| change_ref_from_state_row(&state_rows[row_index]))
429
+ .collect::<Result<Vec<_>, _>>()?;
430
+ let adopted_changes = adopted_row_indices
431
+ .iter()
432
+ .map(|&row_index| change_ref_from_adopted_row(&adopted_rows[row_index]))
433
+ .collect::<Vec<_>>();
434
+ let authored_updated_at = state_row_indices
435
+ .iter()
436
+ .map(|&row_index| state_rows[row_index].updated_at.as_str())
437
+ .collect::<Vec<_>>();
438
+ let authored_created_at = state_row_indices
439
+ .iter()
440
+ .map(|&row_index| state_rows[row_index].created_at.as_str())
441
+ .collect::<Vec<_>>();
442
+ let adopted_updated_at = adopted_row_indices
443
+ .iter()
444
+ .map(|&row_index| adopted_rows[row_index].updated_at.as_str())
445
+ .collect::<Vec<_>>();
446
+ let adopted_created_at = adopted_row_indices
447
+ .iter()
448
+ .map(|&row_index| adopted_rows[row_index].created_at.as_str())
449
+ .collect::<Vec<_>>();
450
+ let mut deltas = Vec::with_capacity(authored_changes.len() + adopted_changes.len());
451
+ deltas.extend(
452
+ authored_changes
453
+ .iter()
454
+ .zip(&staged.authored_locators)
455
+ .zip(authored_created_at)
456
+ .zip(authored_updated_at)
457
+ .map(
458
+ |(((change, locator), created_at), updated_at)| TrackedStateDeltaRef {
459
+ change: *change,
460
+ locator: locator.as_ref(),
461
+ created_at,
462
+ updated_at,
463
+ },
464
+ ),
465
+ );
466
+ deltas.extend(
467
+ adopted_changes
468
+ .iter()
469
+ .zip(&staged.adopted_locators)
470
+ .zip(adopted_created_at)
471
+ .zip(adopted_updated_at)
472
+ .map(
473
+ |(((change, locator), created_at), updated_at)| TrackedStateDeltaRef {
474
+ change: *change,
475
+ locator: locator.as_ref(),
476
+ created_at,
477
+ updated_at,
478
+ },
479
+ ),
480
+ );
481
+ if let Some(indexes) = json_pack_indexes_by_commit
482
+ .get(&root.commit_id)
483
+ .and_then(|packs| packs.get(&0))
484
+ {
485
+ writer
486
+ .stage_delta_with_json_pack_indexes(
487
+ &root.commit_id,
488
+ root.parent_commit_id.as_deref(),
489
+ &deltas,
490
+ crate::tracked_state::DeltaJsonPackIndexesRef {
491
+ commit_id: &root.commit_id,
492
+ pack_id: 0,
493
+ indexes,
494
+ },
495
+ )
496
+ .await?;
497
+ } else {
498
+ writer
499
+ .stage_delta(&root.commit_id, root.parent_commit_id.as_deref(), &deltas)
500
+ .await?;
501
+ }
502
+ }
503
+ if !tracked_row_indices_by_commit.is_empty() || !adopted_row_indices_by_commit.is_empty() {
504
+ let mut commit_ids = tracked_row_indices_by_commit
505
+ .keys()
506
+ .chain(adopted_row_indices_by_commit.keys())
507
+ .cloned()
508
+ .collect::<Vec<_>>();
509
+ commit_ids.sort();
510
+ commit_ids.dedup();
511
+ return Err(LixError::new(
512
+ LixError::CODE_INTERNAL_ERROR,
513
+ format!(
514
+ "tracked live_state rows have no finalized root metadata for commit ids: {}",
515
+ commit_ids.join(", ")
516
+ ),
517
+ ));
518
+ }
519
+ if !staged_commits.is_empty() {
520
+ let commit_ids = staged_commits.keys().cloned().collect::<Vec<_>>();
521
+ return Err(LixError::new(
522
+ LixError::CODE_INTERNAL_ERROR,
523
+ format!(
524
+ "commit-store staged commits without tracked root metadata: {}",
525
+ commit_ids.join(", ")
526
+ ),
527
+ ));
528
+ }
529
+ Ok(())
241
530
  }
242
531
 
243
- fn canonical_change_from_adopted_row(
244
- writes: &mut StorageWriteSet,
245
- json_writer: &mut JsonStoreWriter,
246
- row: &StagedAdoptedStateRow,
247
- ) -> Result<CanonicalChange, LixError> {
248
- Ok(CanonicalChange {
249
- id: row.change_id.clone(),
250
- entity_id: row.entity_id.clone(),
251
- schema_key: row.schema_key.clone(),
252
- schema_version: row.schema_version.clone(),
253
- file_id: row.file_id.clone(),
254
- snapshot_ref: stage_optional_json(writes, json_writer, row.snapshot_content.as_deref())?,
255
- metadata_ref: stage_optional_metadata(writes, json_writer, row.metadata.as_ref())?,
256
- created_at: row.created_at.clone(),
257
- })
532
+ fn untracked_row_ref_from_state_row(row: &PreparedStateRow) -> UntrackedStateRowRef<'_> {
533
+ UntrackedStateRowRef {
534
+ entity_id: &row.entity_id,
535
+ schema_key: &row.schema_key,
536
+ file_id: row.file_id.as_deref(),
537
+ snapshot_content: row
538
+ .snapshot
539
+ .as_ref()
540
+ .map(|snapshot| snapshot.normalized.as_ref()),
541
+ metadata: row
542
+ .metadata
543
+ .as_ref()
544
+ .map(|metadata| metadata.normalized.as_ref()),
545
+ created_at: &row.created_at,
546
+ updated_at: &row.updated_at,
547
+ global: row.global,
548
+ version_id: &row.version_id,
549
+ }
550
+ }
551
+
552
+ fn untracked_identity_ref_from_state_row(row: &PreparedStateRow) -> UntrackedStateIdentityRef<'_> {
553
+ UntrackedStateIdentityRef {
554
+ version_id: &row.version_id,
555
+ schema_key: &row.schema_key,
556
+ entity_id: &row.entity_id,
557
+ file_id: row.file_id.as_deref(),
558
+ }
559
+ }
560
+
561
+ fn untracked_identity_ref_from_adopted_row(
562
+ row: &PreparedAdoptedStateRow,
563
+ ) -> UntrackedStateIdentityRef<'_> {
564
+ UntrackedStateIdentityRef {
565
+ version_id: &row.version_id,
566
+ schema_key: &row.schema_key,
567
+ entity_id: &row.entity_id,
568
+ file_id: row.file_id.as_deref(),
569
+ }
258
570
  }
259
571
 
260
- /// Materializes tracked staged membership into `lix_commit` rows.
572
+ /// Materializes tracked staged membership into commit-store commits.
261
573
  ///
262
574
  /// Staging only accumulates `version_id -> change_ids` because commit ids,
263
575
  /// parent heads, and commit-row timestamps belong to transaction finalization.
264
576
  /// The `change_ids` list is the ordered set of canonical changes whose effects
265
577
  /// the commit introduces relative to its first parent; merge commits may later
266
578
  /// populate this list with existing source-parent changes instead of copied
267
- /// changelog facts.
268
- /// This function turns those membership sets into normal `StagedStateRow`s with
269
- /// `schema_key = "lix_commit"`, so the changelog/live_state flush can treat
270
- /// commit rows exactly like any other staged state row.
579
+ /// change payloads.
580
+ /// This function turns those membership sets into finalized commit facts.
271
581
  ///
272
582
  /// Commit finalization output split by durability target.
273
583
  ///
274
- /// `commit_rows` are ordinary changelog facts. live_state later projects them
275
- /// from commit_graph; tracked_state roots do not store commit graph facts.
584
+ /// `commit_rows` are canonical commit-store facts. live_state later projects
585
+ /// commit SQL surfaces from commit_store; tracked_state roots do not store
586
+ /// commit graph facts.
276
587
  ///
277
- /// `version_heads` are moving refs. They are written through `VersionContext`
278
- /// and must never be appended to changelog.
588
+ /// `version_heads` are moving refs. They are written through `VersionContext`,
589
+ /// not the canonical commit store.
279
590
  struct FinalizedCommitRows {
280
- commit_rows: Vec<StagedStateRow>,
591
+ commit_rows: Vec<FinalizedCommitRow>,
281
592
  version_heads: Vec<PendingVersionHead>,
593
+ tracked_roots: Vec<PendingTrackedRoot>,
594
+ }
595
+
596
+ struct FinalizedCommitRow {
597
+ commit_id: String,
598
+ parent_commit_ids: Vec<String>,
599
+ created_at: String,
600
+ change_id: String,
282
601
  }
283
602
 
284
603
  struct PendingVersionHead {
@@ -287,6 +606,11 @@ struct PendingVersionHead {
287
606
  timestamp: String,
288
607
  }
289
608
 
609
+ struct PendingTrackedRoot {
610
+ commit_id: String,
611
+ parent_commit_id: Option<String>,
612
+ }
613
+
290
614
  async fn finalize_commit_rows(
291
615
  commit_members_by_version: BTreeMap<String, StagedCommitMembers>,
292
616
  extra_commit_parents_by_version: BTreeMap<String, Vec<String>>,
@@ -295,6 +619,7 @@ async fn finalize_commit_rows(
295
619
  ) -> Result<FinalizedCommitRows, LixError> {
296
620
  let mut commit_rows = Vec::new();
297
621
  let mut version_heads = Vec::new();
622
+ let mut tracked_roots = Vec::new();
298
623
 
299
624
  for (version_id, members) in commit_members_by_version {
300
625
  if members.is_empty() && !members.allow_empty {
@@ -303,9 +628,8 @@ async fn finalize_commit_rows(
303
628
 
304
629
  let commit_id = members.commit_id;
305
630
  let commit_change_id = members.commit_change_id;
306
- let change_set_id = members.change_set_id;
307
631
  let timestamp = members.created_at;
308
- let change_ids = members.change_ids.into_iter().collect::<Vec<_>>();
632
+ let _change_ids = members.change_ids;
309
633
  let parent_commit_ids = version_ctx
310
634
  .ref_reader(&mut *transaction)
311
635
  .load_head_commit_id(&version_id)
@@ -319,46 +643,29 @@ async fn finalize_commit_rows(
319
643
  .cloned()
320
644
  .unwrap_or_default(),
321
645
  );
322
- let snapshot_content = serde_json::to_string(&serde_json::json!({
323
- "id": commit_id,
324
- "change_set_id": change_set_id,
325
- "change_ids": change_ids,
326
- "author_account_ids": [],
327
- "parent_commit_ids": parent_commit_ids,
328
- }))
329
- .map_err(|error| {
330
- LixError::new(
331
- "LIX_ERROR_UNKNOWN",
332
- format!("engine2 commit row snapshot serialization failed: {error}"),
333
- )
334
- })?;
646
+ let parent_commit_id = parent_commit_ids.first().cloned();
335
647
 
336
- commit_rows.push(StagedStateRow {
337
- entity_id: crate::entity_identity::EntityIdentity::single(&commit_id),
338
- schema_key: "lix_commit".to_string(),
339
- file_id: None,
340
- snapshot_content: Some(snapshot_content),
341
- metadata: None,
342
- origin: None,
343
- schema_version: "1".to_string(),
648
+ commit_rows.push(FinalizedCommitRow {
649
+ commit_id: commit_id.clone(),
650
+ parent_commit_ids: parent_commit_ids.clone(),
344
651
  created_at: timestamp.clone(),
345
- updated_at: timestamp.clone(),
346
- global: true,
347
- change_id: Some(commit_change_id),
348
- commit_id: Some(commit_id.clone()),
349
- untracked: false,
350
- version_id: GLOBAL_VERSION_ID.to_string(),
652
+ change_id: commit_change_id,
351
653
  });
352
654
  version_heads.push(PendingVersionHead {
353
- version_id,
354
- commit_id,
655
+ version_id: version_id.clone(),
656
+ commit_id: commit_id.clone(),
355
657
  timestamp,
356
658
  });
659
+ tracked_roots.push(PendingTrackedRoot {
660
+ commit_id,
661
+ parent_commit_id,
662
+ });
357
663
  }
358
664
 
359
665
  Ok(FinalizedCommitRows {
360
666
  commit_rows,
361
667
  version_heads,
668
+ tracked_roots,
362
669
  })
363
670
  }
364
671
 
@@ -379,9 +686,6 @@ mod tests {
379
686
  Arc,
380
687
  };
381
688
 
382
- use async_trait::async_trait;
383
- use serde_json::Value as JsonValue;
384
-
385
689
  use super::*;
386
690
  use crate::backend::{
387
691
  testing::UnitTestBackend, Backend, BackendKvEntryPage, BackendKvExistsBatch,
@@ -389,15 +693,18 @@ mod tests {
389
693
  BackendKvValuePage, BackendKvWriteBatch, BackendKvWriteStats, BackendReadTransaction,
390
694
  BackendWriteTransaction,
391
695
  };
392
- use crate::changelog::ChangelogContext;
696
+ use crate::catalog::SchemaPlanId;
697
+ use crate::commit_store::{ChangeIndexEntry, ChangeLocator};
393
698
  use crate::live_state::{LiveStateContext, LiveStateRowRequest};
394
699
  use crate::storage::StorageContext;
700
+ use crate::transaction::types::PreparedRowFacts;
395
701
  use crate::untracked_state::{
396
- canonicalize_materialized_row, MaterializedUntrackedStateRow, UntrackedStateContext,
397
- UntrackedStateRowRequest,
702
+ MaterializedUntrackedStateRow, UntrackedStateContext, UntrackedStateRowRequest,
398
703
  };
399
704
  use crate::version::VersionContext;
400
705
  use crate::NullableKeyFilter;
706
+ use crate::GLOBAL_VERSION_ID;
707
+ use async_trait::async_trait;
401
708
 
402
709
  const DETERMINISTIC_MODE_KEY: &str = "lix_deterministic_mode";
403
710
  const DETERMINISTIC_SEQUENCE_KEY: &str = "lix_deterministic_sequence_number";
@@ -406,33 +713,31 @@ mod tests {
406
713
  LiveStateContext::new(
407
714
  crate::tracked_state::TrackedStateContext::new(),
408
715
  crate::untracked_state::UntrackedStateContext::new(),
409
- crate::commit_graph::CommitGraphContext::new(crate::changelog::ChangelogContext::new()),
716
+ crate::commit_graph::CommitGraphContext::new(),
410
717
  )
411
718
  }
412
719
 
413
720
  #[tokio::test]
414
- async fn commit_staged_writes_appends_changelog_and_updates_serving_projection() {
721
+ async fn commit_staged_writes_appends_commit_store_and_updates_serving_projection() {
415
722
  let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
416
723
  let storage = StorageContext::new(Arc::clone(&backend));
417
724
  let binary_cas = BinaryCasContext::new();
418
- let changelog = ChangelogContext::new();
419
- let live_state = Arc::new(live_state_context());
420
725
  let version_ctx = VersionContext::new(Arc::new(UntrackedStateContext::new()));
421
726
  let mut transaction = storage
422
727
  .begin_write_transaction()
423
728
  .await
424
729
  .expect("transaction should open");
425
730
 
426
- commit_staged_writes(
731
+ let state_rows = vec![tracked_global_row("change-1")];
732
+ commit_prepared_writes(
427
733
  &binary_cas,
428
- &changelog,
429
- live_state.as_ref(),
734
+ &crate::commit_store::CommitStoreContext::new(),
430
735
  &version_ctx,
431
736
  None,
432
737
  transaction.as_mut(),
433
- StagedWriteSet {
738
+ PreparedWriteSet {
434
739
  insert_identities: BTreeMap::new(),
435
- state_rows: vec![tracked_global_row("change-1")],
740
+ state_rows,
436
741
  adopted_rows: Vec::new(),
437
742
  commit_members_by_version: BTreeMap::from([(
438
743
  GLOBAL_VERSION_ID.to_string(),
@@ -449,24 +754,44 @@ mod tests {
449
754
  .await
450
755
  .expect("commit should persist kv");
451
756
 
452
- let changes = {
453
- let reader = changelog.reader(storage.clone());
454
- reader
455
- .scan_changes(&crate::changelog::ChangelogScanRequest::default())
456
- .await
457
- }
458
- .expect("changelog scan should succeed");
459
- let change_ids = changes
460
- .iter()
461
- .map(|change| change.id.as_str())
462
- .collect::<Vec<_>>();
463
- assert_eq!(change_ids, vec!["change-1", "test-uuid-2"]);
464
- assert!(changes
465
- .iter()
466
- .any(|change| change.schema_key == "lix_commit"));
467
- assert!(!changes
468
- .iter()
469
- .any(|change| change.schema_key == "lix_version_ref"));
757
+ let commit_reader = crate::commit_store::CommitStoreContext::new().reader(storage.clone());
758
+ let commit = commit_reader
759
+ .load_commit("test-uuid-1")
760
+ .await
761
+ .expect("commit-store commit should load")
762
+ .expect("commit-store commit should exist");
763
+ assert_eq!(commit.change_id, "test-uuid-2");
764
+ assert_eq!(commit.change_pack_count, 1);
765
+ assert_eq!(commit.membership_pack_count, 0);
766
+ let index_entries = commit_reader
767
+ .load_change_index_entries(&["change-1".to_string(), "test-uuid-2".to_string()])
768
+ .await
769
+ .expect("commit-store change index should load");
770
+ assert_eq!(
771
+ index_entries,
772
+ vec![
773
+ Some(ChangeIndexEntry::PackedChange {
774
+ locator: ChangeLocator {
775
+ source_commit_id: "test-uuid-1".to_string(),
776
+ source_pack_id: 0,
777
+ source_ordinal: 0,
778
+ change_id: "change-1".to_string(),
779
+ },
780
+ }),
781
+ Some(ChangeIndexEntry::CommitHeader {
782
+ commit_id: "test-uuid-1".to_string(),
783
+ change_id: "test-uuid-2".to_string(),
784
+ }),
785
+ ]
786
+ );
787
+ let change_pack = commit_reader
788
+ .load_change_pack("test-uuid-1", 0)
789
+ .await
790
+ .expect("commit-store change pack should load")
791
+ .expect("commit-store change pack should exist");
792
+ assert_eq!(change_pack.len(), 1);
793
+ assert_eq!(change_pack[0].id, "change-1");
794
+ assert_eq!(change_pack[0].schema_key, "test_schema");
470
795
 
471
796
  let loaded_head = version_ctx
472
797
  .ref_reader(storage.clone())
@@ -481,8 +806,6 @@ mod tests {
481
806
  let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
482
807
  let storage = StorageContext::new(Arc::clone(&backend));
483
808
  let binary_cas = BinaryCasContext::new();
484
- let changelog = ChangelogContext::new();
485
- let live_state = Arc::new(live_state_context());
486
809
  let version_ctx = VersionContext::new(Arc::new(UntrackedStateContext::new()));
487
810
  let untracked_state = UntrackedStateContext::new();
488
811
  let mut transaction = storage
@@ -490,16 +813,16 @@ mod tests {
490
813
  .await
491
814
  .expect("transaction should open");
492
815
 
493
- commit_staged_writes(
816
+ let state_rows = vec![untracked_global_row("change-untracked")];
817
+ commit_prepared_writes(
494
818
  &binary_cas,
495
- &changelog,
496
- live_state.as_ref(),
819
+ &crate::commit_store::CommitStoreContext::new(),
497
820
  &version_ctx,
498
821
  None,
499
822
  transaction.as_mut(),
500
- StagedWriteSet {
823
+ PreparedWriteSet {
501
824
  insert_identities: BTreeMap::new(),
502
- state_rows: vec![untracked_global_row("change-untracked")],
825
+ state_rows,
503
826
  adopted_rows: Vec::new(),
504
827
  commit_members_by_version: BTreeMap::new(),
505
828
  extra_commit_parents_by_version: BTreeMap::new(),
@@ -513,14 +836,12 @@ mod tests {
513
836
  .await
514
837
  .expect("commit should persist kv");
515
838
 
516
- let changes = {
517
- let reader = changelog.reader(storage.clone());
518
- reader
519
- .scan_changes(&crate::changelog::ChangelogScanRequest::default())
520
- .await
521
- }
522
- .expect("changelog scan should succeed");
523
- assert!(changes.is_empty());
839
+ let commit_reader = crate::commit_store::CommitStoreContext::new().reader(storage.clone());
840
+ let index_entries = commit_reader
841
+ .load_change_index_entries(&["change-untracked".to_string()])
842
+ .await
843
+ .expect("commit-store change index should load");
844
+ assert_eq!(index_entries, vec![None]);
524
845
 
525
846
  let loaded = {
526
847
  let mut untracked_reader = untracked_state.reader(storage.clone());
@@ -546,7 +867,6 @@ mod tests {
546
867
  let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
547
868
  let storage = StorageContext::new(Arc::clone(&backend));
548
869
  let binary_cas = BinaryCasContext::new();
549
- let changelog = ChangelogContext::new();
550
870
  let untracked_state = UntrackedStateContext::new();
551
871
  let live_state = Arc::new(live_state_context());
552
872
  let version_ctx = VersionContext::new(Arc::new(UntrackedStateContext::new()));
@@ -556,18 +876,15 @@ mod tests {
556
876
  .await
557
877
  .expect("seed transaction should open");
558
878
  let mut writes = StorageWriteSet::new();
559
- let canonical_row = {
560
- let mut json_writer = JsonStoreContext::new().writer();
561
- canonicalize_materialized_row(
562
- &mut writes,
563
- &mut json_writer,
564
- &MaterializedUntrackedStateRow::from(untracked_global_row("change-untracked")),
565
- )
566
- .expect("untracked seed should canonicalize")
567
- };
879
+ let staged_row = untracked_global_row("change-untracked");
880
+ let canonical_row = crate::test_support::untracked_state_row_from_materialized(
881
+ &mut writes,
882
+ &MaterializedUntrackedStateRow::from(staged_row),
883
+ )
884
+ .expect("untracked seed should canonicalize");
568
885
  untracked_state
569
886
  .writer(&mut writes)
570
- .stage_rows(&[canonical_row])
887
+ .stage_rows(std::iter::once(canonical_row.as_ref()))
571
888
  .expect("untracked seed should write");
572
889
  writes
573
890
  .apply(&mut seed_transaction.as_mut())
@@ -582,16 +899,16 @@ mod tests {
582
899
  .begin_write_transaction()
583
900
  .await
584
901
  .expect("transaction should open");
585
- commit_staged_writes(
902
+ let state_rows = vec![tracked_global_row("change-tracked")];
903
+ commit_prepared_writes(
586
904
  &binary_cas,
587
- &changelog,
588
- live_state.as_ref(),
905
+ &crate::commit_store::CommitStoreContext::new(),
589
906
  &version_ctx,
590
907
  None,
591
908
  transaction.as_mut(),
592
- StagedWriteSet {
909
+ PreparedWriteSet {
593
910
  insert_identities: BTreeMap::new(),
594
- state_rows: vec![tracked_global_row("change-tracked")],
911
+ state_rows,
595
912
  adopted_rows: Vec::new(),
596
913
  commit_members_by_version: BTreeMap::from([(
597
914
  GLOBAL_VERSION_ID.to_string(),
@@ -633,7 +950,6 @@ mod tests {
633
950
  let backend: Arc<dyn Backend + Send + Sync> = counting_backend;
634
951
  let storage = StorageContext::new(backend);
635
952
  let binary_cas = BinaryCasContext::new();
636
- let changelog = ChangelogContext::new();
637
953
  let live_state = Arc::new(live_state_context());
638
954
  let untracked_state = UntrackedStateContext::new();
639
955
  let version_ctx = VersionContext::new(Arc::new(UntrackedStateContext::new()));
@@ -644,39 +960,34 @@ mod tests {
644
960
  .await
645
961
  .expect("seed transaction should open");
646
962
  let mut writes = StorageWriteSet::new();
647
- let mut json_writer = JsonStoreContext::new().writer();
648
963
  let mode_snapshot = serde_json::to_string(&serde_json::json!({
649
964
  "key": DETERMINISTIC_MODE_KEY,
650
965
  "value": { "enabled": true },
651
966
  }))
652
967
  .expect("mode snapshot should serialize");
653
- {
654
- let mut writer = live_state.writer(seed_transaction.as_mut());
655
- writer
656
- .stage_rows(
657
- &mut writes,
658
- &mut json_writer,
659
- &[LiveStateRow {
660
- entity_id: crate::entity_identity::EntityIdentity::single(
661
- DETERMINISTIC_MODE_KEY,
662
- ),
663
- schema_key: "lix_key_value".to_string(),
664
- file_id: None,
665
- snapshot_content: Some(mode_snapshot),
666
- metadata: None,
667
- schema_version: "1".to_string(),
668
- created_at: "2026-01-01T00:00:00Z".to_string(),
669
- updated_at: "2026-01-01T00:00:00Z".to_string(),
670
- global: true,
671
- change_id: None,
672
- commit_id: None,
673
- untracked: true,
674
- version_id: GLOBAL_VERSION_ID.to_string(),
675
- }],
676
- )
677
- .await
678
- .expect("deterministic mode should stage");
679
- }
968
+ JsonStoreContext::new()
969
+ .writer()
970
+ .stage_batch(
971
+ &mut writes,
972
+ JsonWritePlacementRef::OutOfBand,
973
+ [NormalizedJsonRef::new(mode_snapshot.as_str())],
974
+ )
975
+ .expect("deterministic mode snapshot should stage");
976
+ let row = crate::untracked_state::UntrackedStateRow {
977
+ entity_id: crate::entity_identity::EntityIdentity::single(DETERMINISTIC_MODE_KEY),
978
+ schema_key: "lix_key_value".to_string(),
979
+ file_id: None,
980
+ snapshot_content: Some(mode_snapshot.to_string()),
981
+ metadata: None,
982
+ created_at: "2026-01-01T00:00:00Z".to_string(),
983
+ updated_at: "2026-01-01T00:00:00Z".to_string(),
984
+ global: true,
985
+ version_id: GLOBAL_VERSION_ID.to_string(),
986
+ };
987
+ UntrackedStateContext::new()
988
+ .writer(&mut writes)
989
+ .stage_rows(std::iter::once(row.as_ref()))
990
+ .expect("deterministic mode should stage");
680
991
  writes
681
992
  .apply(&mut seed_transaction.as_mut())
682
993
  .await
@@ -699,19 +1010,19 @@ mod tests {
699
1010
  .await
700
1011
  .expect("transaction should open");
701
1012
 
1013
+ let tracked_row = tracked_global_row("change-tracked");
702
1014
  let mut untracked_row = untracked_global_row("change-untracked");
703
1015
  untracked_row.entity_id = crate::entity_identity::EntityIdentity::single("entity-2");
704
1016
 
705
- commit_staged_writes(
1017
+ commit_prepared_writes(
706
1018
  &binary_cas,
707
- &changelog,
708
- live_state.as_ref(),
1019
+ &crate::commit_store::CommitStoreContext::new(),
709
1020
  &version_ctx,
710
1021
  Some(&runtime_functions),
711
1022
  transaction.as_mut(),
712
- StagedWriteSet {
1023
+ PreparedWriteSet {
713
1024
  insert_identities: BTreeMap::new(),
714
- state_rows: vec![tracked_global_row("change-tracked"), untracked_row],
1025
+ state_rows: vec![tracked_row, untracked_row],
715
1026
  adopted_rows: Vec::new(),
716
1027
  commit_members_by_version: BTreeMap::from([(
717
1028
  GLOBAL_VERSION_ID.to_string(),
@@ -727,7 +1038,7 @@ mod tests {
727
1038
  assert_eq!(
728
1039
  write_batches.load(Ordering::SeqCst),
729
1040
  1,
730
- "tracked, json, untracked, changelog, and version refs must apply as one backend write batch"
1041
+ "tracked, json, untracked, commit-store, and version refs must apply as one backend write batch"
731
1042
  );
732
1043
 
733
1044
  transaction
@@ -736,15 +1047,21 @@ mod tests {
736
1047
  .expect("commit should persist kv");
737
1048
  assert_eq!(write_batches.load(Ordering::SeqCst), 1);
738
1049
 
739
- let changes = changelog
740
- .reader(storage.clone())
741
- .scan_changes(&crate::changelog::ChangelogScanRequest::default())
1050
+ let commit_reader = crate::commit_store::CommitStoreContext::new().reader(storage.clone());
1051
+ let commit = commit_reader
1052
+ .load_commit("test-uuid-1")
742
1053
  .await
743
- .expect("changelog scan should succeed");
744
- assert!(changes.iter().any(|change| change.id == "change-tracked"));
745
- assert!(changes
746
- .iter()
747
- .any(|change| change.schema_key == "lix_commit"));
1054
+ .expect("commit-store commit should load")
1055
+ .expect("commit-store commit should exist");
1056
+ assert_eq!(commit.change_id, "test-uuid-2");
1057
+ let index_entries = commit_reader
1058
+ .load_change_index_entries(&["change-tracked".to_string()])
1059
+ .await
1060
+ .expect("commit-store change index should load");
1061
+ assert!(matches!(
1062
+ index_entries.as_slice(),
1063
+ [Some(ChangeIndexEntry::PackedChange { .. })]
1064
+ ));
748
1065
 
749
1066
  let loaded_head = version_ctx
750
1067
  .ref_reader(storage.clone())
@@ -795,8 +1112,6 @@ mod tests {
795
1112
  let backend: Arc<dyn Backend + Send + Sync> = Arc::new(UnitTestBackend::new());
796
1113
  let storage = StorageContext::new(Arc::clone(&backend));
797
1114
  let binary_cas = BinaryCasContext::new();
798
- let changelog = ChangelogContext::new();
799
- let live_state = Arc::new(live_state_context());
800
1115
  let version_ctx = VersionContext::new(Arc::new(UntrackedStateContext::new()));
801
1116
  crate::test_support::seed_version_head(storage.clone(), GLOBAL_VERSION_ID, "global-before")
802
1117
  .await;
@@ -807,16 +1122,16 @@ mod tests {
807
1122
  .begin_write_transaction()
808
1123
  .await
809
1124
  .expect("transaction should open");
810
- commit_staged_writes(
1125
+ let state_rows = vec![tracked_version_row("version-a", "change-version-a")];
1126
+ commit_prepared_writes(
811
1127
  &binary_cas,
812
- &changelog,
813
- live_state.as_ref(),
1128
+ &crate::commit_store::CommitStoreContext::new(),
814
1129
  &version_ctx,
815
1130
  None,
816
1131
  transaction.as_mut(),
817
- StagedWriteSet {
1132
+ PreparedWriteSet {
818
1133
  insert_identities: BTreeMap::new(),
819
- state_rows: vec![tracked_version_row("version-a", "change-version-a")],
1134
+ state_rows,
820
1135
  adopted_rows: Vec::new(),
821
1136
  commit_members_by_version: BTreeMap::from([(
822
1137
  "version-a".to_string(),
@@ -833,31 +1148,22 @@ mod tests {
833
1148
  .await
834
1149
  .expect("commit should persist kv");
835
1150
 
836
- let changes = changelog
837
- .reader(storage.clone())
838
- .scan_changes(&crate::changelog::ChangelogScanRequest::default())
1151
+ let commit_reader = crate::commit_store::CommitStoreContext::new().reader(storage.clone());
1152
+ let commit = commit_reader
1153
+ .load_commit("test-uuid-1")
839
1154
  .await
840
- .expect("changelog scan should succeed");
841
- let commit_changes = changes
842
- .iter()
843
- .filter(|change| change.schema_key == "lix_commit")
844
- .collect::<Vec<_>>();
845
- assert_eq!(
846
- commit_changes.len(),
847
- 1,
848
- "a write to one non-global version must create exactly one commit"
849
- );
850
- assert_eq!(
851
- commit_changes[0]
852
- .entity_id
853
- .as_string()
854
- .expect("commit entity id should project"),
855
- "test-uuid-1"
856
- );
857
- assert!(changes.iter().any(|change| change.id == "change-version-a"));
858
- assert!(!changes
859
- .iter()
860
- .any(|change| change.schema_key == "lix_version_ref"));
1155
+ .expect("commit-store commit should load")
1156
+ .expect("commit-store commit should exist");
1157
+ assert_eq!(commit.change_id, "test-uuid-2");
1158
+ assert_eq!(commit.parent_ids, vec!["version-a-before"]);
1159
+ let index_entries = commit_reader
1160
+ .load_change_index_entries(&["change-version-a".to_string()])
1161
+ .await
1162
+ .expect("commit-store change index should load");
1163
+ assert!(matches!(
1164
+ index_entries.as_slice(),
1165
+ [Some(ChangeIndexEntry::PackedChange { .. })]
1166
+ ));
861
1167
 
862
1168
  let global_head = version_ctx
863
1169
  .ref_reader(storage.clone())
@@ -904,47 +1210,10 @@ mod tests {
904
1210
  assert_eq!(rows.commit_rows.len(), 1);
905
1211
  assert_eq!(rows.version_heads.len(), 1);
906
1212
  let row = &rows.commit_rows[0];
907
- assert_eq!(row.entity_id.as_string().as_deref(), Ok("test-uuid-1"));
908
- assert_eq!(row.schema_key, "lix_commit");
909
- assert_eq!(row.schema_version, "1");
910
- assert_eq!(row.change_id.as_deref(), Some("test-uuid-2"));
911
- assert_eq!(row.commit_id.as_deref(), Some("test-uuid-1"));
912
- assert!(row.global);
913
- assert!(!row.untracked);
914
- assert_eq!(row.version_id, GLOBAL_VERSION_ID);
1213
+ assert_eq!(row.commit_id, "test-uuid-1");
1214
+ assert_eq!(row.change_id, "test-uuid-2");
915
1215
  assert_eq!(row.created_at, "test-timestamp-1");
916
- assert_eq!(row.updated_at, "test-timestamp-1");
917
-
918
- let snapshot = serde_json::from_str::<JsonValue>(
919
- row.snapshot_content
920
- .as_deref()
921
- .expect("commit row should have snapshot"),
922
- )
923
- .expect("commit snapshot should be JSON");
924
- assert_eq!(
925
- snapshot.get("id").and_then(JsonValue::as_str),
926
- Some("test-uuid-1")
927
- );
928
- assert_eq!(
929
- snapshot
930
- .get("change_ids")
931
- .and_then(JsonValue::as_array)
932
- .expect("change_ids should be array")
933
- .iter()
934
- .map(|value| value.as_str().expect("change id should be string"))
935
- .collect::<Vec<_>>(),
936
- vec!["change-a", "change-b"]
937
- );
938
- assert_eq!(
939
- snapshot
940
- .get("parent_commit_ids")
941
- .and_then(JsonValue::as_array)
942
- .expect("parent_commit_ids should be array")
943
- .iter()
944
- .map(|value| value.as_str().expect("parent id should be string"))
945
- .collect::<Vec<_>>(),
946
- vec!["initial-commit"]
947
- );
1216
+ assert_eq!(row.parent_commit_ids, vec!["initial-commit"]);
948
1217
 
949
1218
  let version_head = &rows.version_heads[0];
950
1219
  assert_eq!(version_head.version_id, GLOBAL_VERSION_ID);
@@ -999,21 +1268,8 @@ mod tests {
999
1268
  .await
1000
1269
  .expect("active-version commit finalization should resolve parent");
1001
1270
 
1002
- let snapshot = serde_json::from_str::<JsonValue>(
1003
- rows.commit_rows[0]
1004
- .snapshot_content
1005
- .as_deref()
1006
- .expect("commit row should have snapshot"),
1007
- )
1008
- .expect("commit snapshot should be JSON");
1009
1271
  assert_eq!(
1010
- snapshot
1011
- .get("parent_commit_ids")
1012
- .and_then(JsonValue::as_array)
1013
- .expect("parent_commit_ids should be array")
1014
- .iter()
1015
- .map(|value| value.as_str().expect("parent id should be text"))
1016
- .collect::<Vec<_>>(),
1272
+ rows.commit_rows[0].parent_commit_ids,
1017
1273
  vec!["previous-commit"]
1018
1274
  );
1019
1275
  assert_eq!(rows.version_heads[0].version_id, "version-a");
@@ -1039,21 +1295,8 @@ mod tests {
1039
1295
  .await
1040
1296
  .expect("merge commit finalization should resolve parents");
1041
1297
 
1042
- let snapshot = serde_json::from_str::<JsonValue>(
1043
- rows.commit_rows[0]
1044
- .snapshot_content
1045
- .as_deref()
1046
- .expect("commit row should have snapshot"),
1047
- )
1048
- .expect("commit snapshot should be JSON");
1049
1298
  assert_eq!(
1050
- snapshot
1051
- .get("parent_commit_ids")
1052
- .and_then(JsonValue::as_array)
1053
- .expect("parent_commit_ids should be array")
1054
- .iter()
1055
- .map(|value| value.as_str().expect("parent id should be text"))
1056
- .collect::<Vec<_>>(),
1299
+ rows.commit_rows[0].parent_commit_ids,
1057
1300
  vec!["target-head", "source-head"]
1058
1301
  );
1059
1302
  }
@@ -1062,7 +1305,6 @@ mod tests {
1062
1305
  let mut members = StagedCommitMembers::new(
1063
1306
  "test-uuid-1".to_string(),
1064
1307
  "test-uuid-2".to_string(),
1065
- "test-uuid-3".to_string(),
1066
1308
  "test-timestamp-1".to_string(),
1067
1309
  );
1068
1310
  for change_id in change_ids {
@@ -1071,19 +1313,28 @@ mod tests {
1071
1313
  members
1072
1314
  }
1073
1315
 
1074
- fn tracked_global_row(change_id: &str) -> StagedStateRow {
1316
+ fn tracked_global_row(change_id: &str) -> PreparedStateRow {
1075
1317
  tracked_version_row(GLOBAL_VERSION_ID, change_id)
1076
1318
  }
1077
1319
 
1078
- fn tracked_version_row(version_id: &str, change_id: &str) -> StagedStateRow {
1079
- StagedStateRow {
1320
+ fn tracked_version_row(version_id: &str, change_id: &str) -> PreparedStateRow {
1321
+ PreparedStateRow {
1322
+ schema_plan_id: SchemaPlanId::for_test(0),
1323
+ facts: PreparedRowFacts::default(),
1080
1324
  entity_id: crate::entity_identity::EntityIdentity::single("entity-1"),
1081
1325
  schema_key: "test_schema".to_string(),
1082
1326
  file_id: None,
1083
- snapshot_content: Some("{\"value\":1}".to_string()),
1327
+ snapshot: Some(
1328
+ crate::transaction::types::stage_json_from_value(
1329
+ crate::transaction::types::TransactionJson::from_value_for_test(
1330
+ serde_json::json!({ "value": 1 }),
1331
+ ),
1332
+ "test tracked row snapshot",
1333
+ )
1334
+ .expect("test snapshot should stage"),
1335
+ ),
1084
1336
  metadata: None,
1085
1337
  origin: None,
1086
- schema_version: "1".to_string(),
1087
1338
  created_at: "2026-01-01T00:00:00Z".to_string(),
1088
1339
  updated_at: "2026-01-01T00:00:00Z".to_string(),
1089
1340
  global: version_id == GLOBAL_VERSION_ID,
@@ -1094,13 +1345,22 @@ mod tests {
1094
1345
  }
1095
1346
  }
1096
1347
 
1097
- fn untracked_global_row(change_id: &str) -> StagedStateRow {
1098
- StagedStateRow {
1099
- snapshot_content: Some("{\"value\":\"untracked\"}".to_string()),
1348
+ fn untracked_global_row(change_id: &str) -> PreparedStateRow {
1349
+ let mut row = tracked_global_row(change_id);
1350
+ row.snapshot = Some(
1351
+ crate::transaction::types::stage_json_from_value(
1352
+ crate::transaction::types::TransactionJson::from_value_for_test(
1353
+ serde_json::json!({ "value": "untracked" }),
1354
+ ),
1355
+ "test untracked row snapshot",
1356
+ )
1357
+ .expect("test snapshot should stage"),
1358
+ );
1359
+ PreparedStateRow {
1100
1360
  change_id: None,
1101
1361
  commit_id: None,
1102
1362
  untracked: true,
1103
- ..tracked_global_row(change_id)
1363
+ ..row
1104
1364
  }
1105
1365
  }
1106
1366