@lix-js/sdk 0.6.0-preview.4 → 0.6.0-preview.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/README.md +1 -1
  2. package/SKILL.md +65 -64
  3. package/dist/engine-wasm/index.js +4 -4
  4. package/dist/engine-wasm/wasm/lix_engine.d.ts +5 -5
  5. package/dist/engine-wasm/wasm/lix_engine.js +130 -118
  6. package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
  7. package/dist/engine-wasm/wasm/lix_engine.wasm.d.ts +9 -8
  8. package/dist/generated/builtin-schemas.d.ts +69 -69
  9. package/dist/generated/builtin-schemas.js +94 -94
  10. package/dist/open-lix.d.ts +33 -26
  11. package/dist/open-lix.js +10 -10
  12. package/dist/sqlite/index.js +86 -30
  13. package/dist-engine-src/README.md +3 -3
  14. package/dist-engine-src/src/backend/capabilities.rs +67 -0
  15. package/dist-engine-src/src/backend/conformance/baseline.rs +1127 -0
  16. package/dist-engine-src/src/backend/conformance/factory.rs +93 -0
  17. package/dist-engine-src/src/backend/conformance/failure_tests.rs +608 -0
  18. package/dist-engine-src/src/backend/conformance/fixtures.rs +26 -0
  19. package/dist-engine-src/src/backend/conformance/mod.rs +75 -0
  20. package/dist-engine-src/src/backend/conformance/model.rs +28 -0
  21. package/dist-engine-src/src/backend/conformance/model_based.rs +257 -0
  22. package/dist-engine-src/src/backend/conformance/persistence.rs +204 -0
  23. package/dist-engine-src/src/backend/conformance/projection.rs +21 -0
  24. package/dist-engine-src/src/backend/conformance/pushdown.rs +24 -0
  25. package/dist-engine-src/src/backend/conformance/runner.rs +90 -0
  26. package/dist-engine-src/src/backend/conformance/scan.rs +24 -0
  27. package/dist-engine-src/src/backend/conformance/write.rs +16 -0
  28. package/dist-engine-src/src/backend/error.rs +94 -0
  29. package/dist-engine-src/src/backend/in_memory.rs +670 -0
  30. package/dist-engine-src/src/backend/mod.rs +36 -9
  31. package/dist-engine-src/src/backend/predicate.rs +80 -0
  32. package/dist-engine-src/src/backend/traits.rs +260 -0
  33. package/dist-engine-src/src/backend/types.rs +224 -81
  34. package/dist-engine-src/src/binary_cas/context.rs +8 -8
  35. package/dist-engine-src/src/binary_cas/kv.rs +234 -259
  36. package/dist-engine-src/src/{version → branch}/context.rs +12 -12
  37. package/dist-engine-src/src/branch/lifecycle.rs +221 -0
  38. package/dist-engine-src/src/branch/mod.rs +13 -0
  39. package/dist-engine-src/src/branch/refs.rs +321 -0
  40. package/dist-engine-src/src/branch/stage_rows.rs +67 -0
  41. package/dist-engine-src/src/branch/types.rs +21 -0
  42. package/dist-engine-src/src/catalog/context.rs +18 -18
  43. package/dist-engine-src/src/catalog/snapshot.rs +8 -8
  44. package/dist-engine-src/src/changelog/bench_support.rs +785 -0
  45. package/dist-engine-src/src/changelog/change.rs +1 -0
  46. package/dist-engine-src/src/changelog/codec.rs +497 -0
  47. package/dist-engine-src/src/changelog/commit.rs +1 -0
  48. package/dist-engine-src/src/changelog/context.rs +1614 -0
  49. package/dist-engine-src/src/changelog/mod.rs +29 -0
  50. package/dist-engine-src/src/changelog/store.rs +163 -0
  51. package/dist-engine-src/src/changelog/test_support.rs +54 -0
  52. package/dist-engine-src/src/changelog/types.rs +213 -0
  53. package/dist-engine-src/src/commit_graph/context.rs +317 -274
  54. package/dist-engine-src/src/commit_graph/mod.rs +2 -4
  55. package/dist-engine-src/src/commit_graph/types.rs +22 -42
  56. package/dist-engine-src/src/commit_graph/walker.rs +133 -103
  57. package/dist-engine-src/src/common/error.rs +52 -18
  58. package/dist-engine-src/src/common/identity.rs +2 -2
  59. package/dist-engine-src/src/common/mod.rs +1 -1
  60. package/dist-engine-src/src/domain.rs +42 -46
  61. package/dist-engine-src/src/engine.rs +74 -96
  62. package/dist-engine-src/src/{entity_identity.rs → entity_pk.rs} +89 -92
  63. package/dist-engine-src/src/functions/context.rs +56 -52
  64. package/dist-engine-src/src/functions/state.rs +51 -52
  65. package/dist-engine-src/src/init.rs +288 -154
  66. package/dist-engine-src/src/json_store/context.rs +15 -266
  67. package/dist-engine-src/src/json_store/mod.rs +26 -0
  68. package/dist-engine-src/src/json_store/store.rs +103 -718
  69. package/dist-engine-src/src/json_store/types.rs +4 -9
  70. package/dist-engine-src/src/lib.rs +49 -19
  71. package/dist-engine-src/src/live_state/context.rs +654 -790
  72. package/dist-engine-src/src/live_state/mod.rs +9 -3
  73. package/dist-engine-src/src/live_state/overlay.rs +4 -4
  74. package/dist-engine-src/src/live_state/types.rs +30 -21
  75. package/dist-engine-src/src/live_state/visibility.rs +514 -71
  76. package/dist-engine-src/src/plugin/install.rs +48 -48
  77. package/dist-engine-src/src/plugin/manifest.rs +7 -7
  78. package/dist-engine-src/src/plugin/materializer.rs +0 -275
  79. package/dist-engine-src/src/plugin/plugin_manifest.json +4 -3
  80. package/dist-engine-src/src/schema/builtin/lix_binary_blob_ref.json +2 -2
  81. package/dist-engine-src/src/schema/builtin/lix_branch_descriptor.json +34 -0
  82. package/dist-engine-src/src/schema/builtin/lix_branch_ref.json +48 -0
  83. package/dist-engine-src/src/schema/builtin/lix_change.json +3 -3
  84. package/dist-engine-src/src/schema/builtin/lix_commit.json +1 -1
  85. package/dist-engine-src/src/schema/builtin/lix_label_assignment.json +6 -6
  86. package/dist-engine-src/src/schema/builtin/mod.rs +18 -20
  87. package/dist-engine-src/src/schema/compatibility.rs +11 -11
  88. package/dist-engine-src/src/schema/definition.json +2 -2
  89. package/dist-engine-src/src/schema/definition.rs +5 -5
  90. package/dist-engine-src/src/schema/key.rs +3 -3
  91. package/dist-engine-src/src/schema/mod.rs +1 -1
  92. package/dist-engine-src/src/schema/tests.rs +18 -18
  93. package/dist-engine-src/src/session/context.rs +803 -148
  94. package/dist-engine-src/src/session/create_branch.rs +94 -0
  95. package/dist-engine-src/src/session/execute.rs +223 -83
  96. package/dist-engine-src/src/session/merge/analysis.rs +9 -3
  97. package/dist-engine-src/src/session/merge/{version.rs → branch.rs} +119 -129
  98. package/dist-engine-src/src/session/merge/conflicts.rs +2 -2
  99. package/dist-engine-src/src/session/merge/mod.rs +5 -6
  100. package/dist-engine-src/src/session/merge/stats.rs +7 -11
  101. package/dist-engine-src/src/session/mod.rs +15 -12
  102. package/dist-engine-src/src/session/switch_branch.rs +113 -0
  103. package/dist-engine-src/src/session/transaction.rs +495 -14
  104. package/dist-engine-src/src/sql2/{classify.rs → bind/classify.rs} +3 -75
  105. package/dist-engine-src/src/sql2/bind/error.rs +5 -0
  106. package/dist-engine-src/src/sql2/bind/expr.rs +29 -0
  107. package/dist-engine-src/src/sql2/bind/mod.rs +12 -0
  108. package/dist-engine-src/src/sql2/{udfs/public_call.rs → bind/public_udf.rs} +71 -3
  109. package/dist-engine-src/src/sql2/bind/read.rs +65 -0
  110. package/dist-engine-src/src/sql2/bind/statement.rs +2236 -0
  111. package/dist-engine-src/src/sql2/bind/table.rs +273 -0
  112. package/dist-engine-src/src/sql2/bind/write.rs +86 -0
  113. package/dist-engine-src/src/sql2/branch_scope.rs +436 -0
  114. package/dist-engine-src/src/sql2/catalog/capability.rs +20 -0
  115. package/dist-engine-src/src/sql2/catalog/entity_surface.rs +296 -0
  116. package/dist-engine-src/src/sql2/catalog/mod.rs +15 -0
  117. package/dist-engine-src/src/sql2/catalog/registry.rs +556 -0
  118. package/dist-engine-src/src/sql2/catalog/schema.rs +88 -0
  119. package/dist-engine-src/src/sql2/catalog/surface.rs +41 -0
  120. package/dist-engine-src/src/sql2/change_materialization.rs +122 -0
  121. package/dist-engine-src/src/sql2/context.rs +36 -30
  122. package/dist-engine-src/src/sql2/error.rs +1 -1
  123. package/dist-engine-src/src/sql2/exec/bound_public_write.rs +1593 -0
  124. package/dist-engine-src/src/sql2/exec/datafusion.rs +5266 -0
  125. package/dist-engine-src/src/sql2/exec/fast_write.rs +82 -0
  126. package/dist-engine-src/src/sql2/exec/mod.rs +24 -0
  127. package/dist-engine-src/src/sql2/exec/write.rs +661 -0
  128. package/dist-engine-src/src/sql2/filesystem_planner.rs +72 -77
  129. package/dist-engine-src/src/sql2/filesystem_visibility.rs +21 -21
  130. package/dist-engine-src/src/sql2/history_projection.rs +8 -8
  131. package/dist-engine-src/src/sql2/history_route.rs +35 -31
  132. package/dist-engine-src/src/sql2/mod.rs +28 -23
  133. package/dist-engine-src/src/sql2/optimize/datafusion.rs +1 -0
  134. package/dist-engine-src/src/sql2/optimize/mod.rs +2 -0
  135. package/dist-engine-src/src/sql2/optimize/simple_write.rs +116 -0
  136. package/dist-engine-src/src/sql2/parse/mod.rs +69 -0
  137. package/dist-engine-src/src/sql2/parse/normalize.rs +1 -0
  138. package/dist-engine-src/src/sql2/plan/branch_scope.rs +24 -0
  139. package/dist-engine-src/src/sql2/plan/mod.rs +5 -0
  140. package/dist-engine-src/src/sql2/plan/predicate.rs +22 -0
  141. package/dist-engine-src/src/sql2/plan/write.rs +147 -0
  142. package/dist-engine-src/src/sql2/predicate_typecheck.rs +258 -0
  143. package/dist-engine-src/src/sql2/{version_provider.rs → providers/branch.rs} +218 -214
  144. package/dist-engine-src/src/sql2/{change_provider.rs → providers/change.rs} +156 -42
  145. package/dist-engine-src/src/sql2/{directory_provider.rs → providers/directory.rs} +291 -322
  146. package/dist-engine-src/src/sql2/{directory_history_provider.rs → providers/directory_history.rs} +56 -42
  147. package/dist-engine-src/src/sql2/providers/entity.rs +1484 -0
  148. package/dist-engine-src/src/sql2/{entity_history_provider.rs → providers/entity_history.rs} +43 -31
  149. package/dist-engine-src/src/sql2/{file_provider.rs → providers/file.rs} +323 -316
  150. package/dist-engine-src/src/sql2/{file_history_provider.rs → providers/file_history.rs} +60 -46
  151. package/dist-engine-src/src/sql2/{history_provider.rs → providers/history.rs} +46 -32
  152. package/dist-engine-src/src/sql2/{lix_state_provider.rs → providers/lix_state.rs} +359 -329
  153. package/dist-engine-src/src/sql2/providers/mod.rs +508 -0
  154. package/dist-engine-src/src/sql2/read_only.rs +2 -2
  155. package/dist-engine-src/src/sql2/session.rs +47 -96
  156. package/dist-engine-src/src/sql2/storage/constraints.rs +1 -0
  157. package/dist-engine-src/src/sql2/storage/mod.rs +1 -0
  158. package/dist-engine-src/src/sql2/test_support/differential.rs +712 -0
  159. package/dist-engine-src/src/sql2/test_support/generators.rs +354 -0
  160. package/dist-engine-src/src/sql2/test_support/mod.rs +2 -0
  161. package/dist-engine-src/src/sql2/udfs/{lix_active_version_commit_id.rs → lix_active_branch_commit_id.rs} +7 -7
  162. package/dist-engine-src/src/sql2/udfs/mod.rs +3 -6
  163. package/dist-engine-src/src/sql2/write_normalization.rs +45 -22
  164. package/dist-engine-src/src/storage/conformance.rs +399 -0
  165. package/dist-engine-src/src/storage/context.rs +552 -288
  166. package/dist-engine-src/src/storage/mod.rs +48 -10
  167. package/dist-engine-src/src/storage/point.rs +440 -0
  168. package/dist-engine-src/src/storage/read_scope.rs +43 -64
  169. package/dist-engine-src/src/storage/reader.rs +867 -0
  170. package/dist-engine-src/src/storage/scan.rs +784 -0
  171. package/dist-engine-src/src/storage/spaces.rs +236 -0
  172. package/dist-engine-src/src/storage/stats.rs +80 -0
  173. package/dist-engine-src/src/storage/write_set.rs +962 -0
  174. package/dist-engine-src/src/storage_bench.rs +136 -4828
  175. package/dist-engine-src/src/test_support.rs +360 -138
  176. package/dist-engine-src/src/tracked_state/bench_support.rs +394 -0
  177. package/dist-engine-src/src/tracked_state/codec.rs +155 -1057
  178. package/dist-engine-src/src/tracked_state/commit_root_rebuild.rs +358 -0
  179. package/dist-engine-src/src/tracked_state/context.rs +1927 -993
  180. package/dist-engine-src/src/tracked_state/diff.rs +1715 -261
  181. package/dist-engine-src/src/tracked_state/merge.rs +74 -88
  182. package/dist-engine-src/src/tracked_state/mod.rs +19 -16
  183. package/dist-engine-src/src/tracked_state/{materialization.rs → row_materialization.rs} +50 -178
  184. package/dist-engine-src/src/tracked_state/storage.rs +243 -191
  185. package/dist-engine-src/src/tracked_state/tree.rs +247 -371
  186. package/dist-engine-src/src/tracked_state/types.rs +49 -42
  187. package/dist-engine-src/src/transaction/bench_support.rs +407 -0
  188. package/dist-engine-src/src/transaction/commit.rs +821 -713
  189. package/dist-engine-src/src/transaction/context.rs +705 -600
  190. package/dist-engine-src/src/transaction/mod.rs +13 -2
  191. package/dist-engine-src/src/transaction/normalization.rs +63 -76
  192. package/dist-engine-src/src/transaction/prep.rs +13 -13
  193. package/dist-engine-src/src/transaction/schema_resolver.rs +19 -5
  194. package/dist-engine-src/src/transaction/staging.rs +228 -434
  195. package/dist-engine-src/src/transaction/types.rs +41 -98
  196. package/dist-engine-src/src/transaction/validation.rs +382 -446
  197. package/dist-engine-src/src/untracked_state/codec.rs +337 -29
  198. package/dist-engine-src/src/untracked_state/context.rs +7 -7
  199. package/dist-engine-src/src/untracked_state/materialization.rs +2 -2
  200. package/dist-engine-src/src/untracked_state/mod.rs +1 -1
  201. package/dist-engine-src/src/untracked_state/storage.rs +659 -157
  202. package/dist-engine-src/src/untracked_state/types.rs +21 -21
  203. package/package.json +71 -68
  204. package/dist-engine-src/src/backend/kv.rs +0 -358
  205. package/dist-engine-src/src/backend/testing.rs +0 -658
  206. package/dist-engine-src/src/commit_store/codec.rs +0 -887
  207. package/dist-engine-src/src/commit_store/context.rs +0 -944
  208. package/dist-engine-src/src/commit_store/materialization.rs +0 -84
  209. package/dist-engine-src/src/commit_store/mod.rs +0 -16
  210. package/dist-engine-src/src/commit_store/storage.rs +0 -600
  211. package/dist-engine-src/src/commit_store/types.rs +0 -215
  212. package/dist-engine-src/src/schema/builtin/lix_version_descriptor.json +0 -34
  213. package/dist-engine-src/src/schema/builtin/lix_version_ref.json +0 -48
  214. package/dist-engine-src/src/session/create_version.rs +0 -88
  215. package/dist-engine-src/src/session/merge/apply.rs +0 -23
  216. package/dist-engine-src/src/session/optimization9_sql2_bench.rs +0 -100
  217. package/dist-engine-src/src/session/switch_version.rs +0 -110
  218. package/dist-engine-src/src/sql2/entity_provider.rs +0 -3211
  219. package/dist-engine-src/src/sql2/execute.rs +0 -3533
  220. package/dist-engine-src/src/sql2/public_bind/assignment.rs +0 -46
  221. package/dist-engine-src/src/sql2/public_bind/capability.rs +0 -41
  222. package/dist-engine-src/src/sql2/public_bind/dml.rs +0 -172
  223. package/dist-engine-src/src/sql2/public_bind/mod.rs +0 -26
  224. package/dist-engine-src/src/sql2/public_bind/table.rs +0 -168
  225. package/dist-engine-src/src/sql2/version_scope.rs +0 -394
  226. package/dist-engine-src/src/storage/types.rs +0 -501
  227. package/dist-engine-src/src/tracked_state/by_file_index.rs +0 -98
  228. package/dist-engine-src/src/tracked_state/materializer.rs +0 -488
  229. package/dist-engine-src/src/transaction/live_state_overlay.rs +0 -35
  230. package/dist-engine-src/src/version/lifecycle.rs +0 -221
  231. package/dist-engine-src/src/version/mod.rs +0 -13
  232. package/dist-engine-src/src/version/refs.rs +0 -330
  233. package/dist-engine-src/src/version/stage_rows.rs +0 -67
  234. package/dist-engine-src/src/version/types.rs +0 -21
@@ -1,5 +1,11 @@
1
- use crate::storage::KvScanRange;
2
- use crate::storage::{KvGetGroup, KvGetRequest, KvScanRequest, StorageReader, StorageWriteSet};
1
+ use bytes::Bytes;
2
+
3
+ use crate::entity_pk::EntityPk;
4
+ use crate::storage::{
5
+ PointReadPlan, ScanPlan, StorageCoreProjection, StorageGetOptions, StorageKey, StoragePrefix,
6
+ StorageProjectedValue, StorageRead, StorageScanOptions, StorageSpace, StorageSpaceId,
7
+ StorageValue, StorageWriteSet,
8
+ };
3
9
  use crate::untracked_state::{
4
10
  MaterializedUntrackedStateRow, UntrackedMaterializationProjection, UntrackedStateIdentity,
5
11
  UntrackedStateIdentityRef, UntrackedStateRow, UntrackedStateRowRef, UntrackedStateRowRequest,
@@ -7,69 +13,84 @@ use crate::untracked_state::{
7
13
  };
8
14
  use crate::{LixError, NullableKeyFilter};
9
15
 
10
- pub(super) const UNTRACKED_STATE_ROW_NAMESPACE: &str = "untracked_state.row";
16
+ pub(super) const UNTRACKED_STATE_ROW_NAMESPACE: &str = "untracked_state.row.v1";
17
+ pub(crate) const UNTRACKED_STATE_ROW_SPACE: StorageSpace =
18
+ StorageSpace::new(StorageSpaceId(0x0001_0002), UNTRACKED_STATE_ROW_NAMESPACE);
19
+ // Durable key bytes:
20
+ // b"LXUK" | branch:u8 |
21
+ // branch_id_len:u32be | branch_id:utf8 |
22
+ // schema_key_len:u32be | schema_key:utf8 |
23
+ // entity_part_count:u32be | {entity_part_len:u32be | entity_part:utf8}* |
24
+ // file_id_tag:u8 | [file_id_len:u32be | file_id:utf8]
25
+ const UNTRACKED_STATE_ROW_KEY_IDENTIFIER: &[u8; 4] = b"LXUK";
26
+ const UNTRACKED_STATE_ROW_KEY_BRANCH_V1: u8 = 1;
11
27
 
12
28
  pub(crate) async fn scan_rows(
13
- store: &mut impl StorageReader,
29
+ store: &impl StorageRead,
14
30
  request: &UntrackedStateScanRequest,
15
31
  ) -> Result<Vec<MaterializedUntrackedStateRow>, LixError> {
16
- let mut rows = scan_all_canonical_rows(store).await?;
17
- rows.retain(|row| row_matches_scan(row, request));
18
- if let Some(limit) = request.limit {
19
- rows.truncate(limit);
20
- }
21
32
  let projection = UntrackedMaterializationProjection::from_columns(&request.projection.columns);
22
- let mut materialized = Vec::with_capacity(rows.len());
23
- for row in rows {
24
- materialized.push(crate::untracked_state::materialize_row(row, &projection)?);
33
+ let plans = scan_plans_for_request(request)?;
34
+ let mut materialized = Vec::new();
35
+
36
+ for plan in plans {
37
+ scan_matching_rows(store, request, &projection, &plan, &mut materialized)?;
38
+ if request
39
+ .limit
40
+ .is_some_and(|limit| materialized.len() >= limit)
41
+ {
42
+ break;
43
+ }
25
44
  }
45
+
26
46
  Ok(materialized)
27
47
  }
28
48
 
29
49
  pub(crate) async fn load_row(
30
- store: &mut impl StorageReader,
50
+ store: &impl StorageRead,
31
51
  request: &UntrackedStateRowRequest,
32
52
  ) -> Result<Option<MaterializedUntrackedStateRow>, LixError> {
33
53
  let Some(identity) = identity_from_request(request) else {
34
54
  return Ok(None);
35
55
  };
36
- let bytes = store
37
- .get_values(KvGetRequest {
38
- groups: vec![KvGetGroup {
39
- namespace: UNTRACKED_STATE_ROW_NAMESPACE.to_string(),
40
- keys: vec![encode_untracked_state_row_key(&identity)],
41
- }],
42
- })
43
- .await?
44
- .groups
56
+ let result = PointReadPlan::new(
57
+ UNTRACKED_STATE_ROW_SPACE,
58
+ &[StorageKey(Bytes::from(encode_untracked_state_row_key(
59
+ &identity,
60
+ )?))],
61
+ )
62
+ .materialize(store, StorageGetOptions::default())?;
63
+ let bytes = result
64
+ .value
45
65
  .into_iter()
46
66
  .next()
47
- .and_then(|group| group.single_value_owned());
67
+ .flatten()
68
+ .and_then(full_value);
48
69
  let Some(bytes) = bytes else {
49
70
  return Ok(None);
50
71
  };
51
- let row = crate::untracked_state::codec::decode_row(&bytes)?;
72
+ let row = crate::untracked_state::codec::decode_payload_with_identity(identity, &bytes)?;
52
73
  crate::untracked_state::materialize_row(row, &UntrackedMaterializationProjection::full())
53
74
  .map(Some)
54
75
  }
55
76
 
56
77
  pub(super) async fn existing_identities<'a>(
57
- store: &mut (impl StorageReader + ?Sized),
78
+ store: &(impl StorageRead + ?Sized),
58
79
  identities: impl IntoIterator<Item = UntrackedStateIdentityRef<'a>>,
59
80
  ) -> Result<Vec<UntrackedStateIdentity>, LixError> {
60
81
  let mut candidates = identities
61
82
  .into_iter()
62
83
  .map(|identity| {
63
84
  let owned = UntrackedStateIdentity {
64
- version_id: identity.version_id.to_string(),
85
+ branch_id: identity.branch_id.to_string(),
65
86
  schema_key: identity.schema_key.to_string(),
66
- entity_id: identity.entity_id.clone(),
87
+ entity_pk: identity.entity_pk.clone(),
67
88
  file_id: identity.file_id.map(str::to_string),
68
89
  };
69
- let key = encode_untracked_state_row_key_ref(owned.as_ref());
70
- (key, owned)
90
+ let key = encode_untracked_state_row_key_ref(owned.as_ref())?;
91
+ Ok((key, owned))
71
92
  })
72
- .collect::<Vec<_>>();
93
+ .collect::<Result<Vec<_>, LixError>>()?;
73
94
  candidates.sort_by(|(left, _), (right, _)| left.cmp(right));
74
95
  candidates.dedup_by(|(left, _), (right, _)| left == right);
75
96
  if candidates.is_empty() {
@@ -77,29 +98,27 @@ pub(super) async fn existing_identities<'a>(
77
98
  }
78
99
  let keys = candidates
79
100
  .iter()
80
- .map(|(key, _)| key.clone())
101
+ .map(|(key, _)| StorageKey(Bytes::from(key.clone())))
81
102
  .collect::<Vec<_>>();
82
103
 
83
- let result = store
84
- .exists_many(KvGetRequest {
85
- groups: vec![KvGetGroup {
86
- namespace: UNTRACKED_STATE_ROW_NAMESPACE.to_string(),
87
- keys,
88
- }],
89
- })
90
- .await?;
91
- let group = result.groups.into_iter().next().ok_or_else(|| {
92
- LixError::new(
93
- LixError::CODE_INTERNAL_ERROR,
94
- "untracked identity existence probe returned no result group",
95
- )
96
- })?;
97
- if group.exists.len() != candidates.len() {
104
+ let result = PointReadPlan::from_unique_keys(UNTRACKED_STATE_ROW_SPACE, keys).materialize(
105
+ store,
106
+ StorageGetOptions {
107
+ projection: StorageCoreProjection::KeyOnly,
108
+ ..StorageGetOptions::default()
109
+ },
110
+ )?;
111
+ let exists = result
112
+ .value
113
+ .into_iter()
114
+ .map(|value| value.is_some())
115
+ .collect::<Vec<_>>();
116
+ if exists.len() != candidates.len() {
98
117
  return Err(LixError::new(
99
118
  LixError::CODE_INTERNAL_ERROR,
100
119
  format!(
101
120
  "untracked identity existence probe returned {} results for {} requested keys",
102
- group.exists.len(),
121
+ exists.len(),
103
122
  candidates.len()
104
123
  ),
105
124
  ));
@@ -107,7 +126,7 @@ pub(super) async fn existing_identities<'a>(
107
126
 
108
127
  Ok(candidates
109
128
  .into_iter()
110
- .zip(group.exists)
129
+ .zip(exists)
111
130
  .filter_map(|((_, identity), exists)| exists.then_some(identity))
112
131
  .collect())
113
132
  }
@@ -119,55 +138,189 @@ where
119
138
  for row in rows {
120
139
  if row.snapshot_content.is_none() {
121
140
  writes.delete(
122
- UNTRACKED_STATE_ROW_NAMESPACE,
123
- encode_untracked_state_row_key_ref(row.into()),
141
+ UNTRACKED_STATE_ROW_SPACE,
142
+ StorageKey(Bytes::from(encode_untracked_state_row_key_ref(row.into())?)),
124
143
  );
125
144
  } else {
126
145
  writes.put(
127
- UNTRACKED_STATE_ROW_NAMESPACE,
128
- encode_untracked_state_row_key_ref(row.into()),
129
- crate::untracked_state::codec::encode_row_ref(row)?,
146
+ UNTRACKED_STATE_ROW_SPACE,
147
+ StorageKey(Bytes::from(encode_untracked_state_row_key_ref(row.into())?)),
148
+ StorageValue {
149
+ bytes: Bytes::from(crate::untracked_state::codec::encode_payload_ref(row)?),
150
+ },
130
151
  );
131
152
  }
132
153
  }
133
154
  Ok(())
134
155
  }
135
156
 
136
- pub(crate) fn stage_delete_rows<'a, I>(writes: &mut StorageWriteSet, identities: I)
157
+ pub(crate) fn stage_delete_rows<'a, I>(
158
+ writes: &mut StorageWriteSet,
159
+ identities: I,
160
+ ) -> Result<(), LixError>
137
161
  where
138
162
  I: IntoIterator<Item = UntrackedStateIdentityRef<'a>>,
139
163
  {
140
164
  for identity in identities {
141
165
  writes.delete(
142
- UNTRACKED_STATE_ROW_NAMESPACE,
143
- encode_untracked_state_row_key_ref(identity),
166
+ UNTRACKED_STATE_ROW_SPACE,
167
+ StorageKey(Bytes::from(encode_untracked_state_row_key_ref(identity)?)),
144
168
  );
145
169
  }
170
+ Ok(())
171
+ }
172
+
173
+ fn scan_matching_rows(
174
+ store: &impl StorageRead,
175
+ request: &UntrackedStateScanRequest,
176
+ projection: &UntrackedMaterializationProjection,
177
+ plan: &ScanPlan,
178
+ materialized: &mut Vec<MaterializedUntrackedStateRow>,
179
+ ) -> Result<(), LixError> {
180
+ let mut resume_after = None;
181
+ loop {
182
+ let remaining_limit = request
183
+ .limit
184
+ .map(|limit| limit.saturating_sub(materialized.len()));
185
+ if matches!(remaining_limit, Some(0)) {
186
+ break;
187
+ }
188
+ let page = plan.collect(
189
+ store,
190
+ StorageScanOptions {
191
+ resume_after: resume_after.as_ref(),
192
+ limit_rows: remaining_limit
193
+ .unwrap_or_else(|| StorageScanOptions::default().limit_rows),
194
+ ..StorageScanOptions::default()
195
+ },
196
+ )?;
197
+ resume_after = page.value.entries.last().map(|entry| entry.key.clone());
198
+
199
+ for entry in page.value.entries {
200
+ let Some(bytes) = full_value(entry.value) else {
201
+ continue;
202
+ };
203
+ let identity = decode_untracked_state_row_key_ref(entry.key.0.as_ref())?;
204
+ let row = crate::untracked_state::codec::decode_payload_with_identity(
205
+ identity,
206
+ bytes.as_ref(),
207
+ )?;
208
+ if !row_matches_scan(&row, request) {
209
+ continue;
210
+ }
211
+ materialized.push(crate::untracked_state::materialize_row(row, projection)?);
212
+ if request
213
+ .limit
214
+ .is_some_and(|limit| materialized.len() >= limit)
215
+ {
216
+ break;
217
+ }
218
+ }
219
+
220
+ if !page.value.has_more || resume_after.is_none() {
221
+ break;
222
+ }
223
+ }
224
+ Ok(())
146
225
  }
147
226
 
148
- async fn scan_all_canonical_rows(
149
- store: &mut impl StorageReader,
150
- ) -> Result<Vec<UntrackedStateRow>, LixError> {
151
- let page = store
152
- .scan_values(KvScanRequest {
153
- namespace: UNTRACKED_STATE_ROW_NAMESPACE.to_string(),
154
- range: KvScanRange::prefix(Vec::new()),
155
- after: None,
156
- limit: usize::MAX,
227
+ fn scan_plans_for_request(request: &UntrackedStateScanRequest) -> Result<Vec<ScanPlan>, LixError> {
228
+ let mut prefixes = scan_prefixes_for_filter(&request.filter)?;
229
+ prefixes.sort();
230
+ prefixes.dedup();
231
+ Ok(prefixes
232
+ .into_iter()
233
+ .map(|prefix| {
234
+ ScanPlan::prefix(
235
+ UNTRACKED_STATE_ROW_SPACE,
236
+ StoragePrefix {
237
+ bytes: Bytes::from(prefix),
238
+ },
239
+ )
157
240
  })
158
- .await?;
159
- page.values
160
- .iter()
161
- .map(crate::untracked_state::codec::decode_row)
162
- .collect()
241
+ .collect())
242
+ }
243
+
244
+ fn scan_prefixes_for_filter(
245
+ filter: &crate::untracked_state::UntrackedStateFilter,
246
+ ) -> Result<Vec<Vec<u8>>, LixError> {
247
+ if filter.branch_ids.is_empty() {
248
+ return Ok(vec![Vec::new()]);
249
+ }
250
+
251
+ let mut prefixes = Vec::new();
252
+ for branch_id in &filter.branch_ids {
253
+ let mut branch_prefix = key_header();
254
+ push_component(&mut branch_prefix, branch_id)?;
255
+ if filter.schema_keys.is_empty() {
256
+ prefixes.push(branch_prefix);
257
+ continue;
258
+ }
259
+
260
+ for schema_key in &filter.schema_keys {
261
+ let mut schema_prefix = branch_prefix.clone();
262
+ push_component(&mut schema_prefix, schema_key)?;
263
+ if filter.entity_pks.is_empty() {
264
+ prefixes.push(schema_prefix);
265
+ continue;
266
+ }
267
+
268
+ for entity_pk in &filter.entity_pks {
269
+ let mut entity_prefix = schema_prefix.clone();
270
+ push_entity_component(&mut entity_prefix, entity_pk)?;
271
+ append_file_prefixes(&mut prefixes, entity_prefix, &filter.file_ids)?;
272
+ }
273
+ }
274
+ }
275
+ Ok(prefixes)
276
+ }
277
+
278
+ fn push_entity_component(out: &mut Vec<u8>, entity_pk: &EntityPk) -> Result<(), LixError> {
279
+ push_entity_tuple(out, entity_pk)
280
+ }
281
+
282
+ fn append_file_prefixes(
283
+ prefixes: &mut Vec<Vec<u8>>,
284
+ entity_prefix: Vec<u8>,
285
+ file_filters: &[NullableKeyFilter<String>],
286
+ ) -> Result<(), LixError> {
287
+ if file_filters.is_empty()
288
+ || file_filters
289
+ .iter()
290
+ .any(|filter| matches!(filter, NullableKeyFilter::Any))
291
+ {
292
+ prefixes.push(entity_prefix);
293
+ return Ok(());
294
+ }
295
+
296
+ for filter in file_filters {
297
+ let mut prefix = entity_prefix.clone();
298
+ match filter {
299
+ NullableKeyFilter::Null => prefix.push(0),
300
+ NullableKeyFilter::Value(file_id) => {
301
+ prefix.push(1);
302
+ push_component(&mut prefix, file_id)?;
303
+ }
304
+ NullableKeyFilter::Any => unreachable!("Any handled before exact file prefixes"),
305
+ }
306
+ prefixes.push(prefix);
307
+ }
308
+ Ok(())
309
+ }
310
+
311
+ fn full_value(value: StorageProjectedValue) -> Option<Bytes> {
312
+ match value {
313
+ StorageProjectedValue::FullValue(bytes) => Some(bytes),
314
+ StorageProjectedValue::KeyOnly => None,
315
+ }
163
316
  }
164
317
 
165
318
  fn row_matches_scan(row: &UntrackedStateRow, request: &UntrackedStateScanRequest) -> bool {
166
319
  (request.filter.schema_keys.is_empty() || request.filter.schema_keys.contains(&row.schema_key))
167
- && (request.filter.entity_ids.is_empty()
168
- || request.filter.entity_ids.contains(&row.entity_id))
169
- && (request.filter.version_ids.is_empty()
170
- || request.filter.version_ids.contains(&row.version_id))
320
+ && (request.filter.entity_pks.is_empty()
321
+ || request.filter.entity_pks.contains(&row.entity_pk))
322
+ && (request.filter.branch_ids.is_empty()
323
+ || request.filter.branch_ids.contains(&row.branch_id))
171
324
  && nullable_matches_filters(&row.file_id, &request.filter.file_ids)
172
325
  }
173
326
 
@@ -187,59 +340,370 @@ fn identity_from_request(request: &UntrackedStateRowRequest) -> Option<Untracked
187
340
  NullableKeyFilter::Any => return None,
188
341
  };
189
342
  Some(UntrackedStateIdentity {
190
- version_id: request.version_id.clone(),
343
+ branch_id: request.branch_id.clone(),
191
344
  schema_key: request.schema_key.clone(),
192
- entity_id: request.entity_id.clone(),
345
+ entity_pk: request.entity_pk.clone(),
193
346
  file_id,
194
347
  })
195
348
  }
196
349
 
197
- fn encode_untracked_state_row_key(identity: &UntrackedStateIdentity) -> Vec<u8> {
350
+ fn encode_untracked_state_row_key(identity: &UntrackedStateIdentity) -> Result<Vec<u8>, LixError> {
198
351
  encode_untracked_state_row_key_ref(identity.as_ref())
199
352
  }
200
353
 
201
- pub(super) fn encode_untracked_state_row_key_ref(
354
+ pub(crate) fn encode_untracked_state_row_key_ref(
202
355
  identity: UntrackedStateIdentityRef<'_>,
203
- ) -> Vec<u8> {
204
- let mut out = Vec::new();
205
- push_component(&mut out, identity.version_id);
206
- push_component(&mut out, identity.schema_key);
207
- let entity_id = identity
208
- .entity_id
209
- .as_json_array_text()
210
- .expect("untracked-state identity should project");
211
- push_component(&mut out, &entity_id);
356
+ ) -> Result<Vec<u8>, LixError> {
357
+ let mut out = key_header();
358
+ push_component(&mut out, identity.branch_id)?;
359
+ push_component(&mut out, identity.schema_key)?;
360
+ push_entity_tuple(&mut out, identity.entity_pk)?;
212
361
  match identity.file_id {
213
362
  Some(file_id) => {
214
363
  out.push(1);
215
- push_component(&mut out, file_id);
364
+ push_component(&mut out, file_id)?;
216
365
  }
217
366
  None => out.push(0),
218
367
  }
368
+ Ok(out)
369
+ }
370
+
371
+ fn decode_untracked_state_row_key_ref(bytes: &[u8]) -> Result<UntrackedStateIdentity, LixError> {
372
+ if !bytes.starts_with(UNTRACKED_STATE_ROW_KEY_IDENTIFIER) {
373
+ return Err(LixError::new(
374
+ LixError::CODE_INTERNAL_ERROR,
375
+ "failed to decode untracked-state key: invalid key identifier",
376
+ ));
377
+ }
378
+ let mut cursor = UNTRACKED_STATE_ROW_KEY_IDENTIFIER.len();
379
+ let branch = bytes.get(cursor).copied().ok_or_else(|| {
380
+ LixError::new(
381
+ LixError::CODE_INTERNAL_ERROR,
382
+ "failed to decode untracked-state key: missing branch",
383
+ )
384
+ })?;
385
+ cursor += 1;
386
+ if branch != UNTRACKED_STATE_ROW_KEY_BRANCH_V1 {
387
+ return Err(LixError::new(
388
+ LixError::CODE_INTERNAL_ERROR,
389
+ format!("failed to decode untracked-state key: unsupported branch {branch}"),
390
+ ));
391
+ }
392
+ let branch_id = read_key_component(bytes, &mut cursor, "branch_id")?;
393
+ let schema_key = read_key_component(bytes, &mut cursor, "schema_key")?;
394
+ let entity_pk = read_entity_tuple(bytes, &mut cursor)?;
395
+ let file_tag = bytes.get(cursor).copied().ok_or_else(|| {
396
+ LixError::new(
397
+ LixError::CODE_INTERNAL_ERROR,
398
+ "failed to decode untracked-state key: missing file_id tag",
399
+ )
400
+ })?;
401
+ cursor += 1;
402
+ let file_id = match file_tag {
403
+ 0 => None,
404
+ 1 => Some(read_key_component(bytes, &mut cursor, "file_id")?),
405
+ _ => {
406
+ return Err(LixError::new(
407
+ LixError::CODE_INTERNAL_ERROR,
408
+ "failed to decode untracked-state key: invalid file_id tag",
409
+ ));
410
+ }
411
+ };
412
+ if cursor != bytes.len() {
413
+ return Err(LixError::new(
414
+ LixError::CODE_INTERNAL_ERROR,
415
+ "failed to decode untracked-state key: trailing bytes",
416
+ ));
417
+ }
418
+ Ok(UntrackedStateIdentity {
419
+ branch_id,
420
+ schema_key,
421
+ entity_pk,
422
+ file_id,
423
+ })
424
+ }
425
+
426
+ fn read_key_component(bytes: &[u8], cursor: &mut usize, field: &str) -> Result<String, LixError> {
427
+ let len_end = cursor.checked_add(4).ok_or_else(|| {
428
+ LixError::new(
429
+ LixError::CODE_INTERNAL_ERROR,
430
+ format!("failed to decode untracked-state key: `{field}` cursor overflow"),
431
+ )
432
+ })?;
433
+ let len_bytes = bytes.get(*cursor..len_end).ok_or_else(|| {
434
+ LixError::new(
435
+ LixError::CODE_INTERNAL_ERROR,
436
+ format!("failed to decode untracked-state key: truncated `{field}` length"),
437
+ )
438
+ })?;
439
+ *cursor = len_end;
440
+ let len = u32::from_be_bytes(len_bytes.try_into().expect("slice length checked")) as usize;
441
+ let value_end = cursor.checked_add(len).ok_or_else(|| {
442
+ LixError::new(
443
+ LixError::CODE_INTERNAL_ERROR,
444
+ format!("failed to decode untracked-state key: `{field}` length overflow"),
445
+ )
446
+ })?;
447
+ let value = bytes.get(*cursor..value_end).ok_or_else(|| {
448
+ LixError::new(
449
+ LixError::CODE_INTERNAL_ERROR,
450
+ format!("failed to decode untracked-state key: truncated `{field}`"),
451
+ )
452
+ })?;
453
+ *cursor = value_end;
454
+ std::str::from_utf8(value)
455
+ .map(str::to_string)
456
+ .map_err(|error| {
457
+ LixError::new(
458
+ LixError::CODE_INTERNAL_ERROR,
459
+ format!(
460
+ "failed to decode untracked-state key: invalid utf-8 for `{field}`: {error}"
461
+ ),
462
+ )
463
+ })
464
+ }
465
+
466
+ fn read_entity_tuple(bytes: &[u8], cursor: &mut usize) -> Result<EntityPk, LixError> {
467
+ let part_count = read_key_u32(bytes, cursor, "entity_part_count")? as usize;
468
+ if part_count == 0 {
469
+ return Err(LixError::new(
470
+ LixError::CODE_INTERNAL_ERROR,
471
+ "failed to decode untracked-state key: entity primary key has no parts",
472
+ ));
473
+ }
474
+
475
+ let mut parts = Vec::with_capacity(part_count);
476
+ for _ in 0..part_count {
477
+ let part = read_key_component(bytes, cursor, "entity_part")?;
478
+ parts.push(part);
479
+ }
480
+ Ok(EntityPk { parts })
481
+ }
482
+
483
+ fn read_key_u32(bytes: &[u8], cursor: &mut usize, field: &str) -> Result<u32, LixError> {
484
+ let len_end = cursor.checked_add(4).ok_or_else(|| {
485
+ LixError::new(
486
+ LixError::CODE_INTERNAL_ERROR,
487
+ format!("failed to decode untracked-state key: `{field}` cursor overflow"),
488
+ )
489
+ })?;
490
+ let len_bytes = bytes.get(*cursor..len_end).ok_or_else(|| {
491
+ LixError::new(
492
+ LixError::CODE_INTERNAL_ERROR,
493
+ format!("failed to decode untracked-state key: truncated `{field}`"),
494
+ )
495
+ })?;
496
+ *cursor = len_end;
497
+ Ok(u32::from_be_bytes(
498
+ len_bytes.try_into().expect("slice length checked"),
499
+ ))
500
+ }
501
+
502
+ fn key_header() -> Vec<u8> {
503
+ let mut out = Vec::with_capacity(UNTRACKED_STATE_ROW_KEY_IDENTIFIER.len() + 1);
504
+ out.extend_from_slice(UNTRACKED_STATE_ROW_KEY_IDENTIFIER);
505
+ out.push(UNTRACKED_STATE_ROW_KEY_BRANCH_V1);
219
506
  out
220
507
  }
221
508
 
222
- fn push_component(out: &mut Vec<u8>, value: &str) {
509
+ fn push_component(out: &mut Vec<u8>, value: &str) -> Result<(), LixError> {
223
510
  let bytes = value.as_bytes();
224
- out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
511
+ push_bytes_component(out, bytes)
512
+ }
513
+
514
+ fn push_entity_tuple(out: &mut Vec<u8>, entity_pk: &EntityPk) -> Result<(), LixError> {
515
+ let part_count = u32::try_from(entity_pk.parts.len()).map_err(|_| {
516
+ LixError::new(
517
+ LixError::CODE_INTERNAL_ERROR,
518
+ "failed to encode untracked-state key: entity primary key part count exceeds u32",
519
+ )
520
+ })?;
521
+ if part_count == 0 {
522
+ return Err(LixError::new(
523
+ LixError::CODE_INTERNAL_ERROR,
524
+ "failed to encode untracked-state key: entity primary key has no parts",
525
+ ));
526
+ }
527
+ out.extend_from_slice(&part_count.to_be_bytes());
528
+ for part in &entity_pk.parts {
529
+ push_bytes_component(out, part.as_bytes())?;
530
+ }
531
+ Ok(())
532
+ }
533
+
534
+ fn push_bytes_component(out: &mut Vec<u8>, bytes: &[u8]) -> Result<(), LixError> {
535
+ let len = u32::try_from(bytes.len()).map_err(|_| {
536
+ LixError::new(
537
+ LixError::CODE_INTERNAL_ERROR,
538
+ "failed to encode untracked-state key: component length exceeds u32",
539
+ )
540
+ })?;
541
+ out.extend_from_slice(&len.to_be_bytes());
225
542
  out.extend_from_slice(bytes);
543
+ Ok(())
226
544
  }
227
545
 
228
546
  #[cfg(test)]
229
547
  mod tests {
230
- use std::sync::Arc;
231
-
232
548
  use super::*;
233
- use crate::backend::testing::UnitTestBackend;
234
- use crate::storage::{StorageContext, StorageWriteTransaction};
549
+ use crate::storage::StorageContext;
550
+ use crate::storage::{InMemoryStorageBackend, StorageReadOptions, StorageWriteOptions};
235
551
  use crate::untracked_state::UntrackedStateContext;
236
552
 
553
+ #[test]
554
+ fn key_v1_roundtrips_null_file_id() {
555
+ let identity = UntrackedStateIdentity {
556
+ branch_id: "branch-1".to_string(),
557
+ schema_key: "schema-1".to_string(),
558
+ entity_pk: crate::entity_pk::EntityPk::single("entity-1"),
559
+ file_id: None,
560
+ };
561
+ let key = encode_untracked_state_row_key_ref(identity.as_ref()).expect("key should encode");
562
+
563
+ assert_eq!(&key[..4], b"LXUK");
564
+ assert_eq!(key[4], 1);
565
+ assert_eq!(
566
+ decode_untracked_state_row_key_ref(&key).expect("key should decode"),
567
+ identity
568
+ );
569
+ }
570
+
571
+ #[test]
572
+ fn key_v1_roundtrips_empty_file_id() {
573
+ let identity = UntrackedStateIdentity {
574
+ branch_id: "branch-1".to_string(),
575
+ schema_key: "schema-1".to_string(),
576
+ entity_pk: crate::entity_pk::EntityPk::single("entity-1"),
577
+ file_id: Some(String::new()),
578
+ };
579
+ let key = encode_untracked_state_row_key_ref(identity.as_ref()).expect("key should encode");
580
+
581
+ assert_eq!(
582
+ decode_untracked_state_row_key_ref(&key).expect("key should decode"),
583
+ identity
584
+ );
585
+ }
586
+
587
+ #[test]
588
+ fn key_v1_roundtrips_empty_entity_pk_part() {
589
+ let identity = UntrackedStateIdentity {
590
+ branch_id: "branch-1".to_string(),
591
+ schema_key: "json_pointer".to_string(),
592
+ entity_pk: crate::entity_pk::EntityPk::single(""),
593
+ file_id: Some("file-1".to_string()),
594
+ };
595
+ let key = encode_untracked_state_row_key_ref(identity.as_ref()).expect("key should encode");
596
+
597
+ assert_eq!(
598
+ decode_untracked_state_row_key_ref(&key).expect("key should decode"),
599
+ identity
600
+ );
601
+ }
602
+
603
+ #[test]
604
+ fn key_v1_roundtrips_tuple_and_unicode_identity() {
605
+ let identity = UntrackedStateIdentity {
606
+ branch_id: "branch-東京".to_string(),
607
+ schema_key: "schema-1".to_string(),
608
+ entity_pk: crate::entity_pk::EntityPk::tuple(vec![
609
+ "entity-1".to_string(),
610
+ "ключ".to_string(),
611
+ ])
612
+ .expect("entity primary key should build"),
613
+ file_id: Some("file-δ".to_string()),
614
+ };
615
+ let key = encode_untracked_state_row_key_ref(identity.as_ref()).expect("key should encode");
616
+
617
+ assert_eq!(
618
+ decode_untracked_state_row_key_ref(&key).expect("key should decode"),
619
+ identity
620
+ );
621
+ }
622
+
623
+ #[test]
624
+ fn key_v1_encodes_entity_as_binary_tuple_not_json_text() {
625
+ let identity = UntrackedStateIdentity {
626
+ branch_id: "branch-1".to_string(),
627
+ schema_key: "schema-1".to_string(),
628
+ entity_pk: crate::entity_pk::EntityPk::tuple(vec![
629
+ "entity/1".to_string(),
630
+ "quote\"part".to_string(),
631
+ ])
632
+ .expect("entity primary key should build"),
633
+ file_id: None,
634
+ };
635
+ let key = encode_untracked_state_row_key_ref(identity.as_ref()).expect("key should encode");
636
+
637
+ assert!(!key
638
+ .windows(2)
639
+ .any(|window| window == br#"[""# || window == br#""]"#));
640
+ assert_eq!(
641
+ decode_untracked_state_row_key_ref(&key).expect("key should decode"),
642
+ identity
643
+ );
644
+ }
645
+
646
+ #[test]
647
+ fn key_decode_rejects_invalid_identifier() {
648
+ let error = decode_untracked_state_row_key_ref(b"LXUQ\x01")
649
+ .expect_err("invalid key identifier should fail");
650
+ assert!(error.to_string().contains("invalid key identifier"));
651
+ }
652
+
653
+ #[test]
654
+ fn key_decode_rejects_unknown_branch() {
655
+ let identity = UntrackedStateIdentity {
656
+ branch_id: "branch-1".to_string(),
657
+ schema_key: "schema-1".to_string(),
658
+ entity_pk: crate::entity_pk::EntityPk::single("entity-1"),
659
+ file_id: None,
660
+ };
661
+ let mut key =
662
+ encode_untracked_state_row_key_ref(identity.as_ref()).expect("key should encode");
663
+ key[4] = 2;
664
+ let error =
665
+ decode_untracked_state_row_key_ref(&key).expect_err("unknown key branch should fail");
666
+ assert!(error.to_string().contains("unsupported branch 2"));
667
+ }
668
+
669
+ #[test]
670
+ fn key_decode_rejects_trailing_bytes() {
671
+ let identity = UntrackedStateIdentity {
672
+ branch_id: "branch-1".to_string(),
673
+ schema_key: "schema-1".to_string(),
674
+ entity_pk: crate::entity_pk::EntityPk::single("entity-1"),
675
+ file_id: None,
676
+ };
677
+ let mut key =
678
+ encode_untracked_state_row_key_ref(identity.as_ref()).expect("key should encode");
679
+ key.push(0);
680
+ let error =
681
+ decode_untracked_state_row_key_ref(&key).expect_err("trailing key bytes should fail");
682
+ assert!(error.to_string().contains("trailing bytes"));
683
+ }
684
+
685
+ #[test]
686
+ fn key_decode_rejects_truncated_component() {
687
+ let identity = UntrackedStateIdentity {
688
+ branch_id: "branch-1".to_string(),
689
+ schema_key: "schema-1".to_string(),
690
+ entity_pk: crate::entity_pk::EntityPk::single("entity-1"),
691
+ file_id: None,
692
+ };
693
+ let mut key =
694
+ encode_untracked_state_row_key_ref(identity.as_ref()).expect("key should encode");
695
+ key.truncate(key.len() - 2);
696
+ let error =
697
+ decode_untracked_state_row_key_ref(&key).expect_err("truncated key should fail");
698
+ assert!(error.to_string().contains("truncated"));
699
+ }
700
+
237
701
  async fn write_materialized_rows_to_store(
238
702
  context: &UntrackedStateContext,
239
- store: &mut (impl StorageWriteTransaction + ?Sized),
703
+ storage: &StorageContext,
240
704
  rows: &[MaterializedUntrackedStateRow],
241
705
  ) {
242
- let mut writes = StorageWriteSet::new();
706
+ let mut writes = storage.new_write_set();
243
707
  let canonical_rows = rows
244
708
  .iter()
245
709
  .map(|row| crate::test_support::untracked_state_row_from_materialized(&mut writes, row))
@@ -249,35 +713,29 @@ mod tests {
249
713
  .writer(&mut writes)
250
714
  .stage_rows(canonical_rows.iter().map(|row| row.as_ref()))
251
715
  .expect("rows should write");
252
- writes.apply(store).await.expect("rows should apply");
716
+ storage
717
+ .commit_write_set(writes, StorageWriteOptions::default())
718
+ .expect("rows should commit");
253
719
  }
254
720
 
255
721
  #[tokio::test]
256
722
  async fn write_and_load_roundtrips() {
257
- let backend = Arc::new(UnitTestBackend::new());
258
- let storage = StorageContext::new(backend.clone());
723
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
259
724
  let context = UntrackedStateContext::new();
260
725
  let row = untracked_row("global", "lix_key_value", "ui-tab");
261
726
 
262
- let mut transaction = storage
263
- .begin_write_transaction()
264
- .await
265
- .expect("transaction should open");
266
- write_materialized_rows_to_store(
267
- &context,
268
- transaction.as_mut(),
269
- std::slice::from_ref(&row),
270
- )
271
- .await;
272
- transaction.commit().await.expect("commit should succeed");
727
+ write_materialized_rows_to_store(&context, &storage, std::slice::from_ref(&row)).await;
273
728
 
274
729
  let loaded = {
275
- let mut reader = context.reader(storage.clone());
730
+ let read = storage
731
+ .begin_read(StorageReadOptions::default())
732
+ .expect("read should open");
733
+ let mut reader = context.reader(read);
276
734
  reader
277
735
  .load_row(&UntrackedStateRowRequest {
278
736
  schema_key: "lix_key_value".to_string(),
279
- version_id: "global".to_string(),
280
- entity_id: crate::entity_identity::EntityIdentity::single("ui-tab"),
737
+ branch_id: "global".to_string(),
738
+ entity_pk: crate::entity_pk::EntityPk::single("ui-tab"),
281
739
  file_id: NullableKeyFilter::Null,
282
740
  })
283
741
  .await
@@ -287,33 +745,30 @@ mod tests {
287
745
  }
288
746
 
289
747
  #[tokio::test]
290
- async fn scan_filters_by_schema_and_version() {
291
- let backend = Arc::new(UnitTestBackend::new());
292
- let storage = StorageContext::new(backend.clone());
748
+ async fn scan_filters_by_schema_and_branch() {
749
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
293
750
  let context = UntrackedStateContext::new();
294
- let mut transaction = storage
295
- .begin_write_transaction()
296
- .await
297
- .expect("transaction should open");
298
751
  write_materialized_rows_to_store(
299
752
  &context,
300
- transaction.as_mut(),
753
+ &storage,
301
754
  &[
302
755
  untracked_row("global", "lix_key_value", "global-ui"),
303
- untracked_row("version-a", "lix_key_value", "version-ui"),
304
- untracked_row("version-a", "other_schema", "other"),
756
+ untracked_row("branch-a", "lix_key_value", "branch-ui"),
757
+ untracked_row("branch-a", "other_schema", "other"),
305
758
  ],
306
759
  )
307
760
  .await;
308
- transaction.commit().await.expect("commit should succeed");
309
761
 
310
762
  let rows = {
311
- let mut reader = context.reader(storage.clone());
763
+ let read = storage
764
+ .begin_read(StorageReadOptions::default())
765
+ .expect("read should open");
766
+ let mut reader = context.reader(read);
312
767
  reader
313
768
  .scan_rows(&UntrackedStateScanRequest {
314
769
  filter: crate::untracked_state::UntrackedStateFilter {
315
770
  schema_keys: vec!["lix_key_value".to_string()],
316
- version_ids: vec!["version-a".to_string()],
771
+ branch_ids: vec!["branch-a".to_string()],
317
772
  ..Default::default()
318
773
  },
319
774
  ..Default::default()
@@ -324,49 +779,43 @@ mod tests {
324
779
 
325
780
  assert_eq!(rows.len(), 1);
326
781
  assert_eq!(
327
- rows[0].entity_id,
328
- crate::entity_identity::EntityIdentity::single("version-ui")
782
+ rows[0].entity_pk,
783
+ crate::entity_pk::EntityPk::single("branch-ui")
329
784
  );
330
785
  }
331
786
 
332
787
  #[tokio::test]
333
788
  async fn delete_removes_row() {
334
- let backend = Arc::new(UnitTestBackend::new());
335
- let storage = StorageContext::new(backend.clone());
789
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
336
790
  let context = UntrackedStateContext::new();
337
791
  let row = untracked_row("global", "lix_key_value", "ui-tab");
338
792
  let identity = UntrackedStateIdentity {
339
- version_id: row.version_id.clone(),
793
+ branch_id: row.branch_id.clone(),
340
794
  schema_key: row.schema_key.clone(),
341
- entity_id: row.entity_id.clone(),
795
+ entity_pk: row.entity_pk.clone(),
342
796
  file_id: row.file_id.clone(),
343
797
  };
344
- let mut transaction = storage
345
- .begin_write_transaction()
346
- .await
347
- .expect("transaction should open");
348
- let mut writes = StorageWriteSet::new();
349
- let canonical_row =
350
- crate::test_support::untracked_state_row_from_materialized(&mut writes, &row)
351
- .expect("row should canonicalize");
798
+ write_materialized_rows_to_store(&context, &storage, std::slice::from_ref(&row)).await;
799
+
800
+ let mut writes = storage.new_write_set();
352
801
  let mut writer = context.writer(&mut writes);
353
802
  writer
354
- .stage_rows(std::iter::once(canonical_row.as_ref()))
355
- .expect("write should succeed");
356
- writer.stage_delete_rows(std::iter::once(identity.as_ref()));
357
- writes
358
- .apply(&mut transaction.as_mut())
359
- .await
360
- .expect("writes should apply");
361
- transaction.commit().await.expect("commit should succeed");
803
+ .stage_delete_rows(std::iter::once(identity.as_ref()))
804
+ .expect("delete should stage");
805
+ storage
806
+ .commit_write_set(writes, StorageWriteOptions::default())
807
+ .expect("writes should commit");
362
808
 
363
809
  let loaded = {
364
- let mut reader = context.reader(storage.clone());
810
+ let read = storage
811
+ .begin_read(StorageReadOptions::default())
812
+ .expect("read should open");
813
+ let mut reader = context.reader(read);
365
814
  reader
366
815
  .load_row(&UntrackedStateRowRequest {
367
816
  schema_key: "lix_key_value".to_string(),
368
- version_id: "global".to_string(),
369
- entity_id: crate::entity_identity::EntityIdentity::single("ui-tab"),
817
+ branch_id: "global".to_string(),
818
+ entity_pk: crate::entity_pk::EntityPk::single("ui-tab"),
370
819
  file_id: NullableKeyFilter::Null,
371
820
  })
372
821
  .await
@@ -375,22 +824,75 @@ mod tests {
375
824
  assert_eq!(loaded, None);
376
825
  }
377
826
 
827
+ #[tokio::test]
828
+ async fn v1_layout_ignores_previous_untracked_row_space() {
829
+ let storage = StorageContext::new(InMemoryStorageBackend::new());
830
+ let context = UntrackedStateContext::new();
831
+ let legacy_space = StorageSpace::new(StorageSpaceId(0x0001_0001), "untracked_state.row");
832
+ let mut writes = storage.new_write_set();
833
+ writes.put(
834
+ legacy_space,
835
+ StorageKey(Bytes::from_static(b"legacy-row-key")),
836
+ StorageValue {
837
+ bytes: Bytes::from_static(b"legacy-row-value"),
838
+ },
839
+ );
840
+ storage
841
+ .commit_write_set(writes, StorageWriteOptions::default())
842
+ .expect("legacy row should commit");
843
+
844
+ let loaded = {
845
+ let read = storage
846
+ .begin_read(StorageReadOptions::default())
847
+ .expect("read should open");
848
+ let mut reader = context.reader(read);
849
+ reader
850
+ .load_row(&UntrackedStateRowRequest {
851
+ schema_key: "lix_key_value".to_string(),
852
+ branch_id: "global".to_string(),
853
+ entity_pk: crate::entity_pk::EntityPk::single("legacy-row-key"),
854
+ file_id: NullableKeyFilter::Null,
855
+ })
856
+ .await
857
+ }
858
+ .expect("load should succeed");
859
+ assert_eq!(loaded, None);
860
+
861
+ let rows = {
862
+ let read = storage
863
+ .begin_read(StorageReadOptions::default())
864
+ .expect("read should open");
865
+ let mut reader = context.reader(read);
866
+ reader
867
+ .scan_rows(&UntrackedStateScanRequest {
868
+ filter: crate::untracked_state::UntrackedStateFilter {
869
+ branch_ids: vec!["global".to_string()],
870
+ ..Default::default()
871
+ },
872
+ ..Default::default()
873
+ })
874
+ .await
875
+ }
876
+ .expect("scan should succeed");
877
+ assert!(rows.is_empty());
878
+ }
879
+
378
880
  fn untracked_row(
379
- version_id: &str,
881
+ branch_id: &str,
380
882
  schema_key: &str,
381
- entity_id: &str,
883
+ entity_pk: &str,
382
884
  ) -> MaterializedUntrackedStateRow {
383
885
  MaterializedUntrackedStateRow {
384
- entity_id: crate::entity_identity::EntityIdentity::single(entity_id),
886
+ entity_pk: crate::entity_pk::EntityPk::single(entity_pk),
385
887
  schema_key: schema_key.to_string(),
386
888
  file_id: None,
387
- snapshot_content: Some(format!("{{\"key\":\"{}\",\"value\":\"value\"}}", entity_id)),
889
+ snapshot_content: Some(format!("{{\"key\":\"{}\",\"value\":\"value\"}}", entity_pk)),
388
890
  metadata: None,
389
891
  deleted: false,
390
892
  created_at: "2026-01-01T00:00:00Z".to_string(),
391
893
  updated_at: "2026-01-01T00:00:00Z".to_string(),
392
- global: version_id == "global",
393
- version_id: version_id.to_string(),
894
+ global: branch_id == "global",
895
+ branch_id: branch_id.to_string(),
394
896
  }
395
897
  }
396
898
  }