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

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 (274) hide show
  1. package/README.md +76 -4
  2. package/dist/errors.d.ts +7 -0
  3. package/dist/errors.js +19 -0
  4. package/dist/index.d.ts +4 -5
  5. package/dist/index.js +3 -3
  6. package/dist/native.d.ts +1 -0
  7. package/dist/native.js +47 -0
  8. package/dist/open-lix.d.ts +38 -207
  9. package/dist/open-lix.js +59 -284
  10. package/dist/result.d.ts +18 -0
  11. package/dist/result.js +48 -0
  12. package/dist/types.d.ts +114 -1
  13. package/dist/value.d.ts +28 -0
  14. package/dist/value.js +245 -0
  15. package/package.json +38 -71
  16. package/SKILL.md +0 -507
  17. package/dist/builtin-schemas.d.ts +0 -1
  18. package/dist/builtin-schemas.js +0 -1
  19. package/dist/engine-wasm/index.d.ts +0 -87
  20. package/dist/engine-wasm/index.js +0 -339
  21. package/dist/engine-wasm/wasm/lix_engine.d.ts +0 -79
  22. package/dist/engine-wasm/wasm/lix_engine.js +0 -833
  23. package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
  24. package/dist/engine-wasm/wasm/lix_engine.wasm.d.ts +0 -27
  25. package/dist/generated/builtin-schemas.d.ts +0 -427
  26. package/dist/generated/builtin-schemas.js +0 -643
  27. package/dist/sqlite/index.d.ts +0 -12
  28. package/dist/sqlite/index.js +0 -359
  29. package/dist-engine-src/README.md +0 -18
  30. package/dist-engine-src/src/backend/capabilities.rs +0 -67
  31. package/dist-engine-src/src/backend/conformance/baseline.rs +0 -1127
  32. package/dist-engine-src/src/backend/conformance/factory.rs +0 -93
  33. package/dist-engine-src/src/backend/conformance/failure_tests.rs +0 -608
  34. package/dist-engine-src/src/backend/conformance/fixtures.rs +0 -26
  35. package/dist-engine-src/src/backend/conformance/mod.rs +0 -75
  36. package/dist-engine-src/src/backend/conformance/model.rs +0 -28
  37. package/dist-engine-src/src/backend/conformance/model_based.rs +0 -257
  38. package/dist-engine-src/src/backend/conformance/persistence.rs +0 -204
  39. package/dist-engine-src/src/backend/conformance/projection.rs +0 -21
  40. package/dist-engine-src/src/backend/conformance/pushdown.rs +0 -24
  41. package/dist-engine-src/src/backend/conformance/runner.rs +0 -90
  42. package/dist-engine-src/src/backend/conformance/scan.rs +0 -24
  43. package/dist-engine-src/src/backend/conformance/write.rs +0 -16
  44. package/dist-engine-src/src/backend/error.rs +0 -94
  45. package/dist-engine-src/src/backend/in_memory.rs +0 -670
  46. package/dist-engine-src/src/backend/mod.rs +0 -39
  47. package/dist-engine-src/src/backend/predicate.rs +0 -80
  48. package/dist-engine-src/src/backend/traits.rs +0 -260
  49. package/dist-engine-src/src/backend/types.rs +0 -239
  50. package/dist-engine-src/src/binary_cas/chunking.rs +0 -31
  51. package/dist-engine-src/src/binary_cas/codec.rs +0 -346
  52. package/dist-engine-src/src/binary_cas/context.rs +0 -139
  53. package/dist-engine-src/src/binary_cas/kv.rs +0 -1038
  54. package/dist-engine-src/src/binary_cas/mod.rs +0 -11
  55. package/dist-engine-src/src/binary_cas/types.rs +0 -121
  56. package/dist-engine-src/src/branch/context.rs +0 -40
  57. package/dist-engine-src/src/branch/lifecycle.rs +0 -221
  58. package/dist-engine-src/src/branch/mod.rs +0 -13
  59. package/dist-engine-src/src/branch/refs.rs +0 -321
  60. package/dist-engine-src/src/branch/stage_rows.rs +0 -67
  61. package/dist-engine-src/src/branch/types.rs +0 -21
  62. package/dist-engine-src/src/catalog/context.rs +0 -412
  63. package/dist-engine-src/src/catalog/mod.rs +0 -10
  64. package/dist-engine-src/src/catalog/schema.rs +0 -4
  65. package/dist-engine-src/src/catalog/snapshot.rs +0 -1114
  66. package/dist-engine-src/src/cel/context.rs +0 -86
  67. package/dist-engine-src/src/cel/error.rs +0 -19
  68. package/dist-engine-src/src/cel/mod.rs +0 -8
  69. package/dist-engine-src/src/cel/provider.rs +0 -9
  70. package/dist-engine-src/src/cel/runtime.rs +0 -167
  71. package/dist-engine-src/src/cel/value.rs +0 -50
  72. package/dist-engine-src/src/changelog/bench_support.rs +0 -785
  73. package/dist-engine-src/src/changelog/change.rs +0 -1
  74. package/dist-engine-src/src/changelog/codec.rs +0 -497
  75. package/dist-engine-src/src/changelog/commit.rs +0 -1
  76. package/dist-engine-src/src/changelog/context.rs +0 -1614
  77. package/dist-engine-src/src/changelog/mod.rs +0 -29
  78. package/dist-engine-src/src/changelog/store.rs +0 -163
  79. package/dist-engine-src/src/changelog/test_support.rs +0 -54
  80. package/dist-engine-src/src/changelog/types.rs +0 -213
  81. package/dist-engine-src/src/commit_graph/context.rs +0 -944
  82. package/dist-engine-src/src/commit_graph/mod.rs +0 -9
  83. package/dist-engine-src/src/commit_graph/types.rs +0 -89
  84. package/dist-engine-src/src/commit_graph/walker.rs +0 -786
  85. package/dist-engine-src/src/common/error.rs +0 -347
  86. package/dist-engine-src/src/common/fingerprint.rs +0 -3
  87. package/dist-engine-src/src/common/fs_path.rs +0 -1336
  88. package/dist-engine-src/src/common/identity.rs +0 -145
  89. package/dist-engine-src/src/common/json_pointer.rs +0 -67
  90. package/dist-engine-src/src/common/metadata.rs +0 -40
  91. package/dist-engine-src/src/common/mod.rs +0 -23
  92. package/dist-engine-src/src/common/types.rs +0 -105
  93. package/dist-engine-src/src/common/wire.rs +0 -222
  94. package/dist-engine-src/src/domain.rs +0 -320
  95. package/dist-engine-src/src/engine.rs +0 -203
  96. package/dist-engine-src/src/entity_pk.rs +0 -402
  97. package/dist-engine-src/src/functions/context.rs +0 -296
  98. package/dist-engine-src/src/functions/deterministic.rs +0 -113
  99. package/dist-engine-src/src/functions/mod.rs +0 -18
  100. package/dist-engine-src/src/functions/provider.rs +0 -130
  101. package/dist-engine-src/src/functions/state.rs +0 -335
  102. package/dist-engine-src/src/functions/types.rs +0 -37
  103. package/dist-engine-src/src/init.rs +0 -692
  104. package/dist-engine-src/src/json_store/compression.rs +0 -77
  105. package/dist-engine-src/src/json_store/context.rs +0 -172
  106. package/dist-engine-src/src/json_store/encoded.rs +0 -15
  107. package/dist-engine-src/src/json_store/mod.rs +0 -38
  108. package/dist-engine-src/src/json_store/store.rs +0 -494
  109. package/dist-engine-src/src/json_store/types.rs +0 -212
  110. package/dist-engine-src/src/lib.rs +0 -92
  111. package/dist-engine-src/src/live_state/context.rs +0 -1883
  112. package/dist-engine-src/src/live_state/mod.rs +0 -21
  113. package/dist-engine-src/src/live_state/overlay.rs +0 -75
  114. package/dist-engine-src/src/live_state/reader.rs +0 -23
  115. package/dist-engine-src/src/live_state/types.rs +0 -231
  116. package/dist-engine-src/src/live_state/visibility.rs +0 -666
  117. package/dist-engine-src/src/plugin/archive.rs +0 -438
  118. package/dist-engine-src/src/plugin/component.rs +0 -183
  119. package/dist-engine-src/src/plugin/install.rs +0 -619
  120. package/dist-engine-src/src/plugin/manifest.rs +0 -516
  121. package/dist-engine-src/src/plugin/materializer.rs +0 -202
  122. package/dist-engine-src/src/plugin/mod.rs +0 -33
  123. package/dist-engine-src/src/plugin/plugin_manifest.json +0 -119
  124. package/dist-engine-src/src/plugin/storage.rs +0 -74
  125. package/dist-engine-src/src/schema/annotations/defaults.rs +0 -275
  126. package/dist-engine-src/src/schema/annotations/mod.rs +0 -1
  127. package/dist-engine-src/src/schema/builtin/lix_account.json +0 -21
  128. package/dist-engine-src/src/schema/builtin/lix_active_account.json +0 -29
  129. package/dist-engine-src/src/schema/builtin/lix_binary_blob_ref.json +0 -29
  130. package/dist-engine-src/src/schema/builtin/lix_branch_descriptor.json +0 -34
  131. package/dist-engine-src/src/schema/builtin/lix_branch_ref.json +0 -48
  132. package/dist-engine-src/src/schema/builtin/lix_change.json +0 -63
  133. package/dist-engine-src/src/schema/builtin/lix_change_author.json +0 -45
  134. package/dist-engine-src/src/schema/builtin/lix_commit.json +0 -24
  135. package/dist-engine-src/src/schema/builtin/lix_commit_edge.json +0 -53
  136. package/dist-engine-src/src/schema/builtin/lix_directory_descriptor.json +0 -52
  137. package/dist-engine-src/src/schema/builtin/lix_file_descriptor.json +0 -52
  138. package/dist-engine-src/src/schema/builtin/lix_key_value.json +0 -40
  139. package/dist-engine-src/src/schema/builtin/lix_label.json +0 -29
  140. package/dist-engine-src/src/schema/builtin/lix_label_assignment.json +0 -74
  141. package/dist-engine-src/src/schema/builtin/lix_registered_schema.json +0 -25
  142. package/dist-engine-src/src/schema/builtin/mod.rs +0 -220
  143. package/dist-engine-src/src/schema/compatibility.rs +0 -787
  144. package/dist-engine-src/src/schema/definition.json +0 -187
  145. package/dist-engine-src/src/schema/definition.rs +0 -742
  146. package/dist-engine-src/src/schema/key.rs +0 -138
  147. package/dist-engine-src/src/schema/mod.rs +0 -20
  148. package/dist-engine-src/src/schema/seed.rs +0 -14
  149. package/dist-engine-src/src/schema/tests.rs +0 -780
  150. package/dist-engine-src/src/session/context.rs +0 -1059
  151. package/dist-engine-src/src/session/create_branch.rs +0 -94
  152. package/dist-engine-src/src/session/execute.rs +0 -681
  153. package/dist-engine-src/src/session/merge/analysis.rs +0 -108
  154. package/dist-engine-src/src/session/merge/branch.rs +0 -417
  155. package/dist-engine-src/src/session/merge/conflicts.rs +0 -63
  156. package/dist-engine-src/src/session/merge/mod.rs +0 -10
  157. package/dist-engine-src/src/session/merge/stats.rs +0 -61
  158. package/dist-engine-src/src/session/mod.rs +0 -30
  159. package/dist-engine-src/src/session/switch_branch.rs +0 -113
  160. package/dist-engine-src/src/session/transaction.rs +0 -557
  161. package/dist-engine-src/src/sql2/bind/classify.rs +0 -102
  162. package/dist-engine-src/src/sql2/bind/error.rs +0 -5
  163. package/dist-engine-src/src/sql2/bind/expr.rs +0 -29
  164. package/dist-engine-src/src/sql2/bind/mod.rs +0 -12
  165. package/dist-engine-src/src/sql2/bind/public_udf.rs +0 -306
  166. package/dist-engine-src/src/sql2/bind/read.rs +0 -65
  167. package/dist-engine-src/src/sql2/bind/statement.rs +0 -2236
  168. package/dist-engine-src/src/sql2/bind/table.rs +0 -273
  169. package/dist-engine-src/src/sql2/bind/write.rs +0 -86
  170. package/dist-engine-src/src/sql2/branch_scope.rs +0 -436
  171. package/dist-engine-src/src/sql2/catalog/capability.rs +0 -20
  172. package/dist-engine-src/src/sql2/catalog/entity_surface.rs +0 -296
  173. package/dist-engine-src/src/sql2/catalog/mod.rs +0 -15
  174. package/dist-engine-src/src/sql2/catalog/registry.rs +0 -556
  175. package/dist-engine-src/src/sql2/catalog/schema.rs +0 -88
  176. package/dist-engine-src/src/sql2/catalog/surface.rs +0 -41
  177. package/dist-engine-src/src/sql2/change_materialization.rs +0 -122
  178. package/dist-engine-src/src/sql2/context.rs +0 -317
  179. package/dist-engine-src/src/sql2/dml.rs +0 -148
  180. package/dist-engine-src/src/sql2/error.rs +0 -215
  181. package/dist-engine-src/src/sql2/exec/bound_public_write.rs +0 -1593
  182. package/dist-engine-src/src/sql2/exec/datafusion.rs +0 -5266
  183. package/dist-engine-src/src/sql2/exec/fast_write.rs +0 -82
  184. package/dist-engine-src/src/sql2/exec/mod.rs +0 -24
  185. package/dist-engine-src/src/sql2/exec/write.rs +0 -661
  186. package/dist-engine-src/src/sql2/filesystem_planner.rs +0 -1485
  187. package/dist-engine-src/src/sql2/filesystem_predicates.rs +0 -159
  188. package/dist-engine-src/src/sql2/filesystem_visibility.rs +0 -383
  189. package/dist-engine-src/src/sql2/history_projection.rs +0 -56
  190. package/dist-engine-src/src/sql2/history_route.rs +0 -661
  191. package/dist-engine-src/src/sql2/mod.rs +0 -52
  192. package/dist-engine-src/src/sql2/optimize/datafusion.rs +0 -1
  193. package/dist-engine-src/src/sql2/optimize/mod.rs +0 -2
  194. package/dist-engine-src/src/sql2/optimize/simple_write.rs +0 -116
  195. package/dist-engine-src/src/sql2/parse/mod.rs +0 -69
  196. package/dist-engine-src/src/sql2/parse/normalize.rs +0 -1
  197. package/dist-engine-src/src/sql2/plan/branch_scope.rs +0 -24
  198. package/dist-engine-src/src/sql2/plan/mod.rs +0 -5
  199. package/dist-engine-src/src/sql2/plan/predicate.rs +0 -22
  200. package/dist-engine-src/src/sql2/plan/write.rs +0 -147
  201. package/dist-engine-src/src/sql2/predicate_typecheck.rs +0 -504
  202. package/dist-engine-src/src/sql2/providers/branch.rs +0 -1206
  203. package/dist-engine-src/src/sql2/providers/change.rs +0 -445
  204. package/dist-engine-src/src/sql2/providers/directory.rs +0 -2422
  205. package/dist-engine-src/src/sql2/providers/directory_history.rs +0 -645
  206. package/dist-engine-src/src/sql2/providers/entity.rs +0 -1484
  207. package/dist-engine-src/src/sql2/providers/entity_history.rs +0 -452
  208. package/dist-engine-src/src/sql2/providers/file.rs +0 -3686
  209. package/dist-engine-src/src/sql2/providers/file_history.rs +0 -924
  210. package/dist-engine-src/src/sql2/providers/history.rs +0 -426
  211. package/dist-engine-src/src/sql2/providers/lix_state.rs +0 -2542
  212. package/dist-engine-src/src/sql2/providers/mod.rs +0 -508
  213. package/dist-engine-src/src/sql2/read_only.rs +0 -63
  214. package/dist-engine-src/src/sql2/record_batch.rs +0 -17
  215. package/dist-engine-src/src/sql2/result_metadata.rs +0 -29
  216. package/dist-engine-src/src/sql2/runtime.rs +0 -60
  217. package/dist-engine-src/src/sql2/session.rs +0 -83
  218. package/dist-engine-src/src/sql2/storage/constraints.rs +0 -1
  219. package/dist-engine-src/src/sql2/storage/mod.rs +0 -1
  220. package/dist-engine-src/src/sql2/test_support/differential.rs +0 -712
  221. package/dist-engine-src/src/sql2/test_support/generators.rs +0 -354
  222. package/dist-engine-src/src/sql2/test_support/mod.rs +0 -2
  223. package/dist-engine-src/src/sql2/udfs/common.rs +0 -295
  224. package/dist-engine-src/src/sql2/udfs/lix_active_branch_commit_id.rs +0 -53
  225. package/dist-engine-src/src/sql2/udfs/lix_empty_blob.rs +0 -47
  226. package/dist-engine-src/src/sql2/udfs/lix_json.rs +0 -100
  227. package/dist-engine-src/src/sql2/udfs/lix_json_get.rs +0 -99
  228. package/dist-engine-src/src/sql2/udfs/lix_json_get_text.rs +0 -99
  229. package/dist-engine-src/src/sql2/udfs/lix_text_decode.rs +0 -82
  230. package/dist-engine-src/src/sql2/udfs/lix_text_encode.rs +0 -85
  231. package/dist-engine-src/src/sql2/udfs/lix_timestamp.rs +0 -76
  232. package/dist-engine-src/src/sql2/udfs/lix_uuid_v7.rs +0 -76
  233. package/dist-engine-src/src/sql2/udfs/mod.rs +0 -86
  234. package/dist-engine-src/src/sql2/write_normalization.rs +0 -368
  235. package/dist-engine-src/src/storage/conformance.rs +0 -399
  236. package/dist-engine-src/src/storage/context.rs +0 -620
  237. package/dist-engine-src/src/storage/mod.rs +0 -52
  238. package/dist-engine-src/src/storage/point.rs +0 -440
  239. package/dist-engine-src/src/storage/read_scope.rs +0 -67
  240. package/dist-engine-src/src/storage/reader.rs +0 -867
  241. package/dist-engine-src/src/storage/scan.rs +0 -784
  242. package/dist-engine-src/src/storage/spaces.rs +0 -236
  243. package/dist-engine-src/src/storage/stats.rs +0 -80
  244. package/dist-engine-src/src/storage/write_set.rs +0 -962
  245. package/dist-engine-src/src/storage_bench.rs +0 -171
  246. package/dist-engine-src/src/test_support.rs +0 -450
  247. package/dist-engine-src/src/tracked_state/bench_support.rs +0 -394
  248. package/dist-engine-src/src/tracked_state/codec.rs +0 -1183
  249. package/dist-engine-src/src/tracked_state/commit_root_rebuild.rs +0 -358
  250. package/dist-engine-src/src/tracked_state/context.rs +0 -2801
  251. package/dist-engine-src/src/tracked_state/diff.rs +0 -2140
  252. package/dist-engine-src/src/tracked_state/merge.rs +0 -478
  253. package/dist-engine-src/src/tracked_state/mod.rs +0 -35
  254. package/dist-engine-src/src/tracked_state/row_materialization.rs +0 -275
  255. package/dist-engine-src/src/tracked_state/storage.rs +0 -427
  256. package/dist-engine-src/src/tracked_state/tree.rs +0 -3063
  257. package/dist-engine-src/src/tracked_state/types.rs +0 -238
  258. package/dist-engine-src/src/transaction/bench_support.rs +0 -407
  259. package/dist-engine-src/src/transaction/commit.rs +0 -1592
  260. package/dist-engine-src/src/transaction/context.rs +0 -1653
  261. package/dist-engine-src/src/transaction/mod.rs +0 -24
  262. package/dist-engine-src/src/transaction/normalization.rs +0 -877
  263. package/dist-engine-src/src/transaction/prep.rs +0 -37
  264. package/dist-engine-src/src/transaction/schema_resolver.rs +0 -163
  265. package/dist-engine-src/src/transaction/staging.rs +0 -1525
  266. package/dist-engine-src/src/transaction/types.rs +0 -403
  267. package/dist-engine-src/src/transaction/validation.rs +0 -5766
  268. package/dist-engine-src/src/untracked_state/codec.rs +0 -615
  269. package/dist-engine-src/src/untracked_state/context.rs +0 -98
  270. package/dist-engine-src/src/untracked_state/materialization.rs +0 -63
  271. package/dist-engine-src/src/untracked_state/mod.rs +0 -15
  272. package/dist-engine-src/src/untracked_state/storage.rs +0 -898
  273. package/dist-engine-src/src/untracked_state/types.rs +0 -146
  274. package/dist-engine-src/src/wasm/mod.rs +0 -60
@@ -1,2140 +0,0 @@
1
- use crate::entity_pk::EntityPk;
2
- use crate::json_store::JsonRef;
3
- use crate::tracked_state::types::{
4
- TrackedStateIndexValue, TrackedStateKey, TrackedStateTreeScanRequest,
5
- };
6
- use crate::tracked_state::{TrackedStateFilter, TrackedStateStoreReader};
7
- use crate::LixError;
8
-
9
- /// Filter for comparing two tracked-state commit roots.
10
- #[derive(Debug, Clone, PartialEq, Eq, Default)]
11
- pub(crate) struct TrackedStateDiffRequest {
12
- pub(crate) filter: TrackedStateFilter,
13
- }
14
-
15
- /// Changed tracked-state rows between two commit roots.
16
- #[derive(Debug, Clone, PartialEq, Eq, Default)]
17
- pub(crate) struct TrackedStateDiff {
18
- pub(crate) entries: Vec<TrackedStateDiffEntry>,
19
- }
20
-
21
- /// One changed identity between two commit roots.
22
- #[derive(Debug, Clone, PartialEq, Eq)]
23
- pub(crate) struct TrackedStateDiffEntry {
24
- pub(crate) identity: TrackedStateDiffIdentity,
25
- pub(crate) kind: TrackedStateDiffKind,
26
- /// Raw row in the left root.
27
- ///
28
- /// This can be a tombstone. Callers that need user-visible semantics
29
- /// should use `visible_before()` instead of inspecting this directly.
30
- pub(crate) before: Option<TrackedStateDiffRow>,
31
- /// Raw row in the right root.
32
- ///
33
- /// This can be a tombstone. Keeping the raw tombstone is what lets merge
34
- /// apply deletes without reloading the source root.
35
- pub(crate) after: Option<TrackedStateDiffRow>,
36
- }
37
-
38
- /// Payload-light tracked-state row carried by diff and merge planning.
39
- ///
40
- /// This deliberately stores JSON refs, not JSON payload strings. Diff can
41
- /// compare and report rows from tracked-state tree values without hydrating
42
- /// snapshot or metadata bytes.
43
- #[derive(Debug, Clone, PartialEq, Eq)]
44
- pub(crate) struct TrackedStateDiffRow {
45
- pub(crate) entity_pk: EntityPk,
46
- pub(crate) schema_key: String,
47
- pub(crate) file_id: Option<String>,
48
- pub(crate) deleted: bool,
49
- pub(crate) snapshot_ref: Option<JsonRef>,
50
- pub(crate) metadata_ref: Option<JsonRef>,
51
- pub(crate) created_at: String,
52
- pub(crate) updated_at: String,
53
- pub(crate) change_id: String,
54
- pub(crate) commit_id: String,
55
- }
56
-
57
- /// Root-local tracked-state identity.
58
- ///
59
- /// Entity pk used by merge/diff logic.
60
- #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
61
- pub(crate) struct TrackedStateDiffIdentity {
62
- pub(crate) schema_key: String,
63
- pub(crate) entity_pk: EntityPk,
64
- pub(crate) file_id: Option<String>,
65
- }
66
-
67
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
68
- pub(crate) enum TrackedStateDiffKind {
69
- Added,
70
- Modified,
71
- Removed,
72
- }
73
-
74
- /// Diffs two tracked-state commit roots.
75
- ///
76
- pub(crate) async fn diff_commits<S>(
77
- reader: &mut TrackedStateStoreReader<S>,
78
- left_commit_id: &str,
79
- right_commit_id: &str,
80
- request: &TrackedStateDiffRequest,
81
- ) -> Result<TrackedStateDiff, LixError>
82
- where
83
- S: crate::storage::StorageRead + Send + Sync,
84
- {
85
- diff_commits_with_validation(reader, left_commit_id, right_commit_id, request, true, true).await
86
- }
87
-
88
- pub(crate) async fn diff_commits_with_validation<S>(
89
- reader: &mut TrackedStateStoreReader<S>,
90
- left_commit_id: &str,
91
- right_commit_id: &str,
92
- request: &TrackedStateDiffRequest,
93
- validate_left_root: bool,
94
- validate_right_root: bool,
95
- ) -> Result<TrackedStateDiff, LixError>
96
- where
97
- S: crate::storage::StorageRead + Send + Sync,
98
- {
99
- let scan_request = scan_request_for_diff(request);
100
- let tree_entries = reader
101
- .diff_tree_entries_at_commits(left_commit_id, right_commit_id, &scan_request)
102
- .await?;
103
- if validate_left_root {
104
- reader
105
- .validate_tree_rows_at_commit_against_changelog(left_commit_id, &scan_request)
106
- .await?;
107
- }
108
- if validate_right_root && left_commit_id != right_commit_id {
109
- reader
110
- .validate_tree_rows_at_commit_against_changelog(right_commit_id, &scan_request)
111
- .await?;
112
- }
113
- let mut raw_rows = Vec::with_capacity(tree_entries.len());
114
- for tree_entry in tree_entries.into_iter() {
115
- let before = tree_entry
116
- .before
117
- .map(|(key, value)| TrackedStateDiffRow::from_tree_entry(key, value));
118
- let after = tree_entry
119
- .after
120
- .map(|(key, value)| TrackedStateDiffRow::from_tree_entry(key, value));
121
- raw_rows.push((before, after));
122
- }
123
-
124
- let mut entries = Vec::with_capacity(raw_rows.len());
125
- for (before, after) in raw_rows {
126
- let identity = match before.as_ref().or(after.as_ref()) {
127
- Some(row) => TrackedStateDiffIdentity::from(row),
128
- None => continue,
129
- };
130
- if identity.schema_key == "lix_commit" {
131
- continue;
132
- }
133
- let Some(entry) = classify_diff(identity, before, after) else {
134
- continue;
135
- };
136
- entries.push(entry);
137
- }
138
-
139
- let diff = TrackedStateDiff { entries };
140
- Ok(diff)
141
- }
142
-
143
- fn scan_request_for_diff(request: &TrackedStateDiffRequest) -> TrackedStateTreeScanRequest {
144
- let mut filter = request.filter.clone();
145
- filter.include_tombstones = true;
146
- TrackedStateTreeScanRequest {
147
- schema_keys: filter.schema_keys,
148
- entity_pks: filter.entity_pks,
149
- file_ids: filter.file_ids,
150
- include_tombstones: true,
151
- limit: None,
152
- }
153
- }
154
-
155
- fn classify_diff(
156
- identity: TrackedStateDiffIdentity,
157
- before: Option<TrackedStateDiffRow>,
158
- after: Option<TrackedStateDiffRow>,
159
- ) -> Option<TrackedStateDiffEntry> {
160
- match (is_live_row(before.as_ref()), is_live_row(after.as_ref())) {
161
- (None, None) => None,
162
- (None, Some(_)) => Some(TrackedStateDiffEntry {
163
- identity,
164
- kind: TrackedStateDiffKind::Added,
165
- before,
166
- after,
167
- }),
168
- (Some(_), None) => Some(TrackedStateDiffEntry {
169
- identity,
170
- kind: TrackedStateDiffKind::Removed,
171
- before,
172
- after,
173
- }),
174
- (Some(before), Some(after)) if tracked_row_payload_eq(before, after) => None,
175
- (Some(_), Some(_)) => Some(TrackedStateDiffEntry {
176
- identity,
177
- kind: TrackedStateDiffKind::Modified,
178
- before,
179
- after,
180
- }),
181
- }
182
- }
183
-
184
- fn is_live_row(row: Option<&TrackedStateDiffRow>) -> Option<&TrackedStateDiffRow> {
185
- row.filter(|row| !row.deleted)
186
- }
187
-
188
- fn tracked_row_payload_eq(left: &TrackedStateDiffRow, right: &TrackedStateDiffRow) -> bool {
189
- left.snapshot_ref == right.snapshot_ref && left.metadata_ref == right.metadata_ref
190
- }
191
-
192
- impl TrackedStateDiffIdentity {
193
- fn from(row: &TrackedStateDiffRow) -> Self {
194
- Self {
195
- schema_key: row.schema_key.clone(),
196
- entity_pk: row.entity_pk.clone(),
197
- file_id: row.file_id.clone(),
198
- }
199
- }
200
- }
201
-
202
- impl TrackedStateDiffRow {
203
- pub(crate) fn from_tree_entry(key: TrackedStateKey, value: TrackedStateIndexValue) -> Self {
204
- Self {
205
- entity_pk: key.entity_pk,
206
- schema_key: key.schema_key,
207
- file_id: key.file_id,
208
- deleted: value.deleted,
209
- snapshot_ref: value.snapshot_ref,
210
- metadata_ref: value.metadata_ref,
211
- created_at: value.created_at,
212
- updated_at: value.updated_at,
213
- change_id: value.change_id,
214
- commit_id: value.commit_id,
215
- }
216
- }
217
-
218
- pub(crate) fn into_index_entry(self) -> (TrackedStateKey, TrackedStateIndexValue) {
219
- (
220
- TrackedStateKey {
221
- schema_key: self.schema_key,
222
- file_id: self.file_id,
223
- entity_pk: self.entity_pk,
224
- },
225
- TrackedStateIndexValue {
226
- change_id: self.change_id,
227
- commit_id: self.commit_id,
228
- deleted: self.deleted,
229
- snapshot_ref: self.snapshot_ref,
230
- metadata_ref: self.metadata_ref,
231
- created_at: self.created_at,
232
- updated_at: self.updated_at,
233
- },
234
- )
235
- }
236
- }
237
-
238
- impl TrackedStateDiffEntry {
239
- #[cfg(test)]
240
- pub(crate) fn before_is_live(&self) -> bool {
241
- self.visible_before().is_some()
242
- }
243
-
244
- #[cfg(test)]
245
- pub(crate) fn after_is_live(&self) -> bool {
246
- self.visible_after().is_some()
247
- }
248
-
249
- #[cfg(test)]
250
- pub(crate) fn visible_before(&self) -> Option<&TrackedStateDiffRow> {
251
- self.before.as_ref().filter(|row| !row.deleted)
252
- }
253
-
254
- #[cfg(test)]
255
- pub(crate) fn visible_after(&self) -> Option<&TrackedStateDiffRow> {
256
- self.after.as_ref().filter(|row| !row.deleted)
257
- }
258
- }
259
-
260
- #[cfg(test)]
261
- mod tests {
262
- use super::*;
263
- use crate::storage::StorageContext;
264
- use crate::storage::{InMemoryStorageBackend, StorageReadOptions, StorageWriteOptions};
265
- use crate::tracked_state::types::{
266
- TrackedStateCommitRoot, TrackedStateCommitRootParent, TrackedStateMutation,
267
- TrackedStateRootId,
268
- };
269
- use crate::tracked_state::{MaterializedTrackedStateRow, TrackedStateContext};
270
- use crate::NullableKeyFilter;
271
-
272
- #[tokio::test]
273
- async fn diff_commits_reports_added_rows() {
274
- let (storage, tracked_state) = seed_roots(&[], &[row("entity-a", None, "after")]).await;
275
-
276
- let diff = diff(&storage, &tracked_state).await;
277
-
278
- assert_eq!(
279
- kinds(&diff),
280
- vec![("entity-a".to_string(), TrackedStateDiffKind::Added)]
281
- );
282
- assert!(diff.entries[0].before.is_none());
283
- assert_eq!(
284
- diff.entries[0]
285
- .after
286
- .as_ref()
287
- .map(|row| row.change_id.as_str()),
288
- Some("after")
289
- );
290
- assert!(!diff.entries[0].before_is_live());
291
- assert!(diff.entries[0].after_is_live());
292
- }
293
-
294
- #[tokio::test]
295
- async fn diff_commits_reports_removed_rows_when_right_side_is_absent() {
296
- let (storage, tracked_state) = seed_roots(&[row("entity-a", None, "before")], &[]).await;
297
-
298
- let diff = diff(&storage, &tracked_state).await;
299
-
300
- assert_eq!(
301
- kinds(&diff),
302
- vec![("entity-a".to_string(), TrackedStateDiffKind::Removed)]
303
- );
304
- assert_eq!(
305
- diff.entries[0]
306
- .before
307
- .as_ref()
308
- .map(|row| row.change_id.as_str()),
309
- Some("before")
310
- );
311
- assert!(diff.entries[0].after.is_none());
312
- assert!(diff.entries[0].before_is_live());
313
- assert!(!diff.entries[0].after_is_live());
314
- }
315
-
316
- #[tokio::test]
317
- async fn diff_commits_reports_removed_rows_when_right_side_is_tombstone() {
318
- let (storage, tracked_state) = seed_roots(
319
- &[row("entity-a", None, "before")],
320
- &[tombstone("entity-a", None, "delete")],
321
- )
322
- .await;
323
-
324
- let diff = diff(&storage, &tracked_state).await;
325
-
326
- assert_eq!(
327
- kinds(&diff),
328
- vec![("entity-a".to_string(), TrackedStateDiffKind::Removed)]
329
- );
330
- let entry = &diff.entries[0];
331
- assert_eq!(
332
- entry.after.as_ref().map(|row| row.change_id.as_str()),
333
- Some("delete")
334
- );
335
- assert!(
336
- entry.after.as_ref().is_some_and(|row| row.deleted),
337
- "removed diff should preserve the right-side tombstone for merge"
338
- );
339
- assert!(entry.before_is_live());
340
- assert!(!entry.after_is_live());
341
- }
342
-
343
- #[tokio::test]
344
- async fn diff_commits_reports_added_rows_when_left_side_is_tombstone() {
345
- let (storage, tracked_state) = seed_roots(
346
- &[tombstone("entity-a", None, "delete")],
347
- &[row("entity-a", None, "after")],
348
- )
349
- .await;
350
-
351
- let diff = diff(&storage, &tracked_state).await;
352
-
353
- assert_eq!(
354
- kinds(&diff),
355
- vec![("entity-a".to_string(), TrackedStateDiffKind::Added)]
356
- );
357
- let entry = &diff.entries[0];
358
- assert_eq!(
359
- entry.before.as_ref().map(|row| row.change_id.as_str()),
360
- Some("delete")
361
- );
362
- assert!(
363
- entry.before.as_ref().is_some_and(|row| row.deleted),
364
- "added diff should preserve the left-side tombstone for merge"
365
- );
366
- assert!(!entry.before_is_live());
367
- assert!(entry.after_is_live());
368
- }
369
-
370
- #[tokio::test]
371
- async fn diff_commits_reports_modified_rows_for_changed_payload() {
372
- let (storage, tracked_state) = seed_roots(
373
- &[row_with_value("entity-a", None, "before", "one")],
374
- &[row_with_value("entity-a", None, "after", "two")],
375
- )
376
- .await;
377
-
378
- let diff = diff(&storage, &tracked_state).await;
379
-
380
- assert_eq!(
381
- kinds(&diff),
382
- vec![("entity-a".to_string(), TrackedStateDiffKind::Modified)]
383
- );
384
- assert!(diff.entries[0].before_is_live());
385
- assert!(diff.entries[0].after_is_live());
386
- }
387
-
388
- #[tokio::test]
389
- async fn diff_commits_omits_unchanged_rows_even_when_metadata_differs_only_by_commit() {
390
- let (storage, tracked_state) = seed_roots(
391
- &[row_with_value("entity-a", None, "before", "same")],
392
- &[row_with_value("entity-a", None, "after", "same")],
393
- )
394
- .await;
395
-
396
- let diff = diff(&storage, &tracked_state).await;
397
-
398
- assert!(diff.entries.is_empty());
399
- }
400
-
401
- #[tokio::test]
402
- async fn diff_commits_distinguishes_same_entity_with_different_file_id() {
403
- let (storage, tracked_state) = seed_parent_child_delta(
404
- &[row("entity-a", Some("file-a"), "before-a")],
405
- &[row("entity-a", Some("file-b"), "after-b")],
406
- )
407
- .await;
408
-
409
- let read = storage
410
- .begin_read(StorageReadOptions::default())
411
- .expect("read should open");
412
- let diff = tracked_state
413
- .reader(read)
414
- .diff_commits("parent", "child", &TrackedStateDiffRequest::default())
415
- .await
416
- .expect("diff should load");
417
-
418
- assert_eq!(diff.entries.len(), 1);
419
- assert_eq!(diff.entries[0].identity.file_id.as_deref(), Some("file-b"));
420
- assert_eq!(diff.entries[0].kind, TrackedStateDiffKind::Added);
421
- }
422
-
423
- #[tokio::test]
424
- async fn diff_commits_filters_by_schema_entity_and_file_id() {
425
- let (storage, tracked_state) = seed_roots(
426
- &[],
427
- &[
428
- row_with_schema("entity-a", Some("file-a"), "schema-a", "change-a"),
429
- row_with_schema("entity-b", Some("file-b"), "schema-b", "change-b"),
430
- ],
431
- )
432
- .await;
433
- let read = storage
434
- .begin_read(StorageReadOptions::default())
435
- .expect("read should open");
436
- let mut reader = tracked_state.reader(read);
437
- let diff = reader
438
- .diff_commits(
439
- "left",
440
- "right",
441
- &TrackedStateDiffRequest {
442
- filter: TrackedStateFilter {
443
- schema_keys: vec!["schema-b".to_string()],
444
- entity_pks: vec![crate::entity_pk::EntityPk::single("entity-b")],
445
- file_ids: vec![NullableKeyFilter::Value("file-b".to_string())],
446
- ..Default::default()
447
- },
448
- },
449
- )
450
- .await
451
- .expect("diff should load");
452
-
453
- assert_eq!(
454
- kinds(&diff),
455
- vec![("entity-b".to_string(), TrackedStateDiffKind::Added)]
456
- );
457
- }
458
-
459
- #[tokio::test]
460
- async fn diff_validation_rejects_row_identity_that_does_not_match_changelog_change() {
461
- let (storage, tracked_state) = seed_roots(&[], &[row("entity-a", None, "after")]).await;
462
- let mut diff = diff(&storage, &tracked_state).await;
463
- diff.entries[0].after.as_mut().expect("after row").entity_pk =
464
- EntityPk::single("entity-corrupt");
465
-
466
- let read = storage
467
- .begin_read(StorageReadOptions::default())
468
- .expect("read should open");
469
- let error = tracked_state
470
- .reader(read)
471
- .validate_diff_rows_for_commits_against_changelog(&[(
472
- diff.entries[0].after.as_ref().expect("after row"),
473
- "right",
474
- )])
475
- .await
476
- .expect_err("identity drift must be rejected");
477
-
478
- assert!(
479
- error
480
- .message
481
- .contains("does not match changelog change identity")
482
- || error.message.contains("changelog commit"),
483
- "unexpected error: {error}"
484
- );
485
- }
486
-
487
- #[tokio::test]
488
- async fn diff_validation_rejects_missing_changelog_change() {
489
- let (storage, tracked_state) = seed_roots(&[], &[row("entity-a", None, "after")]).await;
490
- let mut diff = diff(&storage, &tracked_state).await;
491
- diff.entries[0].after.as_mut().expect("after row").change_id = "missing-change".to_string();
492
-
493
- let read = storage
494
- .begin_read(StorageReadOptions::default())
495
- .expect("read should open");
496
- let error = tracked_state
497
- .reader(read)
498
- .validate_diff_rows_for_commits_against_changelog(&[(
499
- diff.entries[0].after.as_ref().expect("after row"),
500
- "right",
501
- )])
502
- .await
503
- .expect_err("missing change must be rejected");
504
-
505
- assert!(
506
- error.message.contains("missing changelog change"),
507
- "unexpected error: {error}"
508
- );
509
- }
510
-
511
- #[tokio::test]
512
- async fn diff_validation_rejects_forged_updated_at() {
513
- let (storage, tracked_state) = seed_roots(&[], &[row("entity-a", None, "after")]).await;
514
- let mut diff = diff(&storage, &tracked_state).await;
515
- diff.entries[0]
516
- .after
517
- .as_mut()
518
- .expect("after row")
519
- .updated_at = "2026-01-02T00:00:00Z".to_string();
520
-
521
- let read = storage
522
- .begin_read(StorageReadOptions::default())
523
- .expect("read should open");
524
- let error = tracked_state
525
- .reader(read)
526
- .validate_diff_rows_for_commits_against_changelog(&[(
527
- diff.entries[0].after.as_ref().expect("after row"),
528
- "right",
529
- )])
530
- .await
531
- .expect_err("forged updated_at must be rejected");
532
-
533
- assert!(
534
- error.message.contains("updated_at does not match"),
535
- "unexpected error: {error}"
536
- );
537
- }
538
-
539
- #[tokio::test]
540
- async fn diff_validation_rejects_forged_created_at() {
541
- let (storage, tracked_state) = seed_roots(&[], &[row("entity-a", None, "after")]).await;
542
- let mut diff = diff(&storage, &tracked_state).await;
543
- diff.entries[0]
544
- .after
545
- .as_mut()
546
- .expect("after row")
547
- .created_at = "2025-12-31T00:00:00Z".to_string();
548
-
549
- let read = storage
550
- .begin_read(StorageReadOptions::default())
551
- .expect("read should open");
552
- let error = tracked_state
553
- .reader(read)
554
- .validate_diff_rows_for_commits_against_changelog(&[(
555
- diff.entries[0].after.as_ref().expect("after row"),
556
- "right",
557
- )])
558
- .await
559
- .expect_err("forged created_at must be rejected");
560
-
561
- assert!(
562
- error.message.contains("created_at"),
563
- "unexpected error: {error}"
564
- );
565
- }
566
-
567
- #[tokio::test]
568
- async fn diff_commits_rejects_update_with_arbitrary_forged_created_at() {
569
- let storage = StorageContext::new(InMemoryStorageBackend::new());
570
- let tracked_state = TrackedStateContext::new();
571
- write_root_committed_for_test(&storage, &tracked_state, "left", None, &[])
572
- .await
573
- .expect("left root should write");
574
- write_root_committed_for_test(
575
- &storage,
576
- &tracked_state,
577
- "parent",
578
- None,
579
- &[row_with_times(
580
- "entity-a",
581
- None,
582
- "parent-change",
583
- "old",
584
- "2026-01-01T00:00:00Z",
585
- "2026-01-01T00:00:00Z",
586
- )],
587
- )
588
- .await
589
- .expect("parent root should write");
590
- write_root_committed_for_test(
591
- &storage,
592
- &tracked_state,
593
- "child",
594
- Some("parent"),
595
- &[row_with_times(
596
- "entity-a",
597
- None,
598
- "child-change",
599
- "new",
600
- "2026-01-02T00:00:00Z",
601
- "2026-01-02T00:00:00Z",
602
- )],
603
- )
604
- .await
605
- .expect("child root should write");
606
-
607
- let read = storage
608
- .begin_read(StorageReadOptions::default())
609
- .expect("read should open");
610
- let valid_diff = tracked_state
611
- .reader(read)
612
- .diff_commits("left", "child", &TrackedStateDiffRequest::default())
613
- .await
614
- .expect("valid update should load");
615
- let row = valid_diff
616
- .entries
617
- .iter()
618
- .find_map(|entry| entry.after.clone())
619
- .expect("child row should appear");
620
- let (key, mut value) = row.into_index_entry();
621
- value.created_at = "2026-01-03T00:00:00Z".to_string();
622
- let parent_commit_row =
623
- commit_root_row_entry("parent", "parent:commit", "2026-01-01T00:00:00Z");
624
- let commit_row = commit_root_row_entry("child", "child:commit", "2026-01-02T00:00:00Z");
625
- stage_corrupt_commit_root(
626
- &storage,
627
- "child",
628
- vec![(key, value), parent_commit_row, commit_row],
629
- vec![TrackedStateCommitRootParent {
630
- commit_id: "parent".to_string(),
631
- root_id: tracked_state_root_id(&storage, "parent").await,
632
- }],
633
- )
634
- .await;
635
-
636
- let read = storage
637
- .begin_read(StorageReadOptions::default())
638
- .expect("read should open");
639
- let error = tracked_state
640
- .reader(read)
641
- .diff_commits("left", "child", &TrackedStateDiffRequest::default())
642
- .await
643
- .expect_err("arbitrary forged created_at must be rejected");
644
-
645
- assert!(
646
- error.message.contains("created_at"),
647
- "unexpected error: {error}"
648
- );
649
- }
650
-
651
- #[tokio::test]
652
- async fn diff_commits_validates_same_payload_rows_before_classification_drops_them() {
653
- let storage = StorageContext::new(InMemoryStorageBackend::new());
654
- let tracked_state = TrackedStateContext::new();
655
- write_root_committed_for_test(
656
- &storage,
657
- &tracked_state,
658
- "left",
659
- None,
660
- &[row_with_value("entity-a", None, "left-a", "same")],
661
- )
662
- .await
663
- .expect("left root should write");
664
- write_root_committed_for_test(
665
- &storage,
666
- &tracked_state,
667
- "right-valid",
668
- None,
669
- &[row_with_value("entity-b", None, "right-b", "same")],
670
- )
671
- .await
672
- .expect("right changelog should write");
673
-
674
- let read = storage
675
- .begin_read(StorageReadOptions::default())
676
- .expect("read should open");
677
- let valid_diff = tracked_state
678
- .reader(read)
679
- .diff_commits("left", "right-valid", &TrackedStateDiffRequest::default())
680
- .await
681
- .expect("valid diff should load");
682
- let source_row = valid_diff
683
- .entries
684
- .iter()
685
- .find_map(|entry| entry.after.clone())
686
- .expect("right row should appear in valid diff");
687
- let (_source_key, source_value) = source_row.into_index_entry();
688
- let corrupt_key = TrackedStateKey {
689
- schema_key: "test_schema".to_string(),
690
- file_id: None,
691
- entity_pk: EntityPk::single("entity-a"),
692
- };
693
- let result = {
694
- let mut read = storage
695
- .begin_read(StorageReadOptions::default())
696
- .expect("read should open");
697
- let mut writes = storage.new_write_set();
698
- let result = crate::tracked_state::tree::TrackedStateTree::new()
699
- .apply_mutations(
700
- &mut read,
701
- &mut writes,
702
- None,
703
- vec![TrackedStateMutation::put_encoded(
704
- crate::tracked_state::codec::encode_key(&corrupt_key),
705
- crate::tracked_state::codec::encode_value(&source_value),
706
- )],
707
- Some("right-corrupt"),
708
- )
709
- .await
710
- .expect("corrupt root should write");
711
- crate::tracked_state::storage::stage_commit_root(
712
- &mut writes,
713
- &TrackedStateCommitRoot {
714
- commit_id: "right-corrupt".to_string(),
715
- root_id: result.root_id.clone(),
716
- parent_roots: Vec::new(),
717
- changed_key_count: 1,
718
- row_count_estimate: result.row_count as u64,
719
- tree_height: result.tree_height as u32,
720
- primary_chunk_count: result.chunk_count as u64,
721
- primary_chunk_bytes: result.chunk_bytes as u64,
722
- },
723
- )
724
- .expect("metadata should encode");
725
- storage
726
- .commit_write_set(writes, StorageWriteOptions::default())
727
- .expect("corrupt root should commit");
728
- result
729
- };
730
- assert_eq!(result.row_count, 1);
731
-
732
- let read = storage
733
- .begin_read(StorageReadOptions::default())
734
- .expect("read should open");
735
- let error = tracked_state
736
- .reader(read)
737
- .diff_commits("left", "right-corrupt", &TrackedStateDiffRequest::default())
738
- .await
739
- .expect_err("raw same-payload corruption must be rejected before classification");
740
-
741
- assert!(
742
- error
743
- .message
744
- .contains("does not match changelog change identity")
745
- || error.message.contains("changelog commit"),
746
- "unexpected error: {error}"
747
- );
748
- }
749
-
750
- #[tokio::test]
751
- async fn diff_commits_rejects_stale_ancestor_row_that_is_not_root_winner() {
752
- let storage = StorageContext::new(InMemoryStorageBackend::new());
753
- let tracked_state = TrackedStateContext::new();
754
- write_root_committed_for_test(&storage, &tracked_state, "left", None, &[])
755
- .await
756
- .expect("left root should write");
757
- write_root_committed_for_test(
758
- &storage,
759
- &tracked_state,
760
- "parent",
761
- None,
762
- &[row_with_value("entity-a", None, "parent-change", "old")],
763
- )
764
- .await
765
- .expect("parent root should write");
766
- write_root_committed_for_test(
767
- &storage,
768
- &tracked_state,
769
- "child",
770
- Some("parent"),
771
- &[row_with_value("entity-a", None, "child-change", "new")],
772
- )
773
- .await
774
- .expect("child root should write");
775
-
776
- let read = storage
777
- .begin_read(StorageReadOptions::default())
778
- .expect("read should open");
779
- let parent_diff = tracked_state
780
- .reader(read)
781
- .diff_commits("left", "parent", &TrackedStateDiffRequest::default())
782
- .await
783
- .expect("parent diff should load");
784
- let stale_row = parent_diff
785
- .entries
786
- .iter()
787
- .find_map(|entry| entry.after.clone())
788
- .expect("parent row should appear");
789
- let (stale_key, stale_value) = stale_row.into_index_entry();
790
- stage_corrupt_commit_root(
791
- &storage,
792
- "child",
793
- vec![(stale_key, stale_value)],
794
- vec![TrackedStateCommitRootParent {
795
- commit_id: "parent".to_string(),
796
- root_id: tracked_state_root_id(&storage, "parent").await,
797
- }],
798
- )
799
- .await;
800
-
801
- let read = storage
802
- .begin_read(StorageReadOptions::default())
803
- .expect("read should open");
804
- let error = tracked_state
805
- .reader(read)
806
- .diff_commits("left", "child", &TrackedStateDiffRequest::default())
807
- .await
808
- .expect_err("stale ancestor winner must be rejected");
809
-
810
- assert!(
811
- is_commit_root_validation_error(&error),
812
- "unexpected error: {error}"
813
- );
814
- }
815
-
816
- #[tokio::test]
817
- async fn diff_commits_rejects_valid_change_from_unreachable_commit_root() {
818
- let storage = StorageContext::new(InMemoryStorageBackend::new());
819
- let tracked_state = TrackedStateContext::new();
820
- write_root_committed_for_test(&storage, &tracked_state, "left", None, &[])
821
- .await
822
- .expect("left root should write");
823
- write_root_committed_for_test(
824
- &storage,
825
- &tracked_state,
826
- "unrelated",
827
- None,
828
- &[row_with_value(
829
- "entity-a",
830
- None,
831
- "unrelated-change",
832
- "value",
833
- )],
834
- )
835
- .await
836
- .expect("unrelated changelog should write");
837
-
838
- let read = storage
839
- .begin_read(StorageReadOptions::default())
840
- .expect("read should open");
841
- let unrelated_diff = tracked_state
842
- .reader(read)
843
- .diff_commits("left", "unrelated", &TrackedStateDiffRequest::default())
844
- .await
845
- .expect("valid unrelated diff should load");
846
- let source_row = unrelated_diff
847
- .entries
848
- .iter()
849
- .find_map(|entry| entry.after.clone())
850
- .expect("unrelated row should appear in valid diff");
851
- let (source_key, source_value) = source_row.into_index_entry();
852
-
853
- let result = {
854
- let mut read = storage
855
- .begin_read(StorageReadOptions::default())
856
- .expect("read should open");
857
- let mut writes = storage.new_write_set();
858
- crate::test_support::stage_empty_changelog_commit(
859
- &mut read,
860
- &mut writes,
861
- "right-corrupt",
862
- None,
863
- )
864
- .await
865
- .expect("empty right changelog should write");
866
- let result = crate::tracked_state::tree::TrackedStateTree::new()
867
- .apply_mutations(
868
- &mut read,
869
- &mut writes,
870
- None,
871
- vec![TrackedStateMutation::put_encoded(
872
- crate::tracked_state::codec::encode_key(&source_key),
873
- crate::tracked_state::codec::encode_value(&source_value),
874
- )],
875
- Some("right-corrupt"),
876
- )
877
- .await
878
- .expect("corrupt root should write");
879
- crate::tracked_state::storage::stage_commit_root(
880
- &mut writes,
881
- &TrackedStateCommitRoot {
882
- commit_id: "right-corrupt".to_string(),
883
- root_id: result.root_id.clone(),
884
- parent_roots: Vec::new(),
885
- changed_key_count: 1,
886
- row_count_estimate: result.row_count as u64,
887
- tree_height: result.tree_height as u32,
888
- primary_chunk_count: result.chunk_count as u64,
889
- primary_chunk_bytes: result.chunk_bytes as u64,
890
- },
891
- )
892
- .expect("metadata should encode");
893
- storage
894
- .commit_write_set(writes, StorageWriteOptions::default())
895
- .expect("corrupt root should commit");
896
- result
897
- };
898
- assert_eq!(result.row_count, 1);
899
-
900
- let read = storage
901
- .begin_read(StorageReadOptions::default())
902
- .expect("read should open");
903
- let error = tracked_state
904
- .reader(read)
905
- .diff_commits("left", "right-corrupt", &TrackedStateDiffRequest::default())
906
- .await
907
- .expect_err("unreachable valid change must be rejected");
908
-
909
- assert!(
910
- is_commit_root_validation_error(&error),
911
- "unexpected error: {error}"
912
- );
913
- }
914
-
915
- #[tokio::test]
916
- async fn diff_commits_rejects_second_parent_row_without_commit_root_proof() {
917
- let storage = StorageContext::new(InMemoryStorageBackend::new());
918
- let tracked_state = TrackedStateContext::new();
919
- write_root_committed_for_test(&storage, &tracked_state, "left", None, &[])
920
- .await
921
- .expect("left root should write");
922
- write_root_committed_for_test(&storage, &tracked_state, "target", None, &[])
923
- .await
924
- .expect("target root should write");
925
- write_root_committed_for_test(
926
- &storage,
927
- &tracked_state,
928
- "source",
929
- None,
930
- &[row_with_value("entity-a", None, "source-change", "value")],
931
- )
932
- .await
933
- .expect("source root should write");
934
-
935
- let read = storage
936
- .begin_read(StorageReadOptions::default())
937
- .expect("read should open");
938
- let source_diff = tracked_state
939
- .reader(read)
940
- .diff_commits("left", "source", &TrackedStateDiffRequest::default())
941
- .await
942
- .expect("source diff should load");
943
- let source_row = source_diff
944
- .entries
945
- .iter()
946
- .find_map(|entry| entry.after.clone())
947
- .expect("source row should appear");
948
- let (source_key, source_value) = source_row.into_index_entry();
949
-
950
- {
951
- let mut read = storage
952
- .begin_read(StorageReadOptions::default())
953
- .expect("read should open");
954
- let mut writes = storage.new_write_set();
955
- crate::test_support::stage_empty_changelog_commit_with_parents(
956
- &mut read,
957
- &mut writes,
958
- "merge",
959
- &["target".to_string(), "source".to_string()],
960
- )
961
- .await
962
- .expect("merge changelog should write");
963
- storage
964
- .commit_write_set(writes, StorageWriteOptions::default())
965
- .expect("merge changelog should commit");
966
- }
967
- stage_corrupt_commit_root(
968
- &storage,
969
- "merge",
970
- vec![(source_key, source_value)],
971
- vec![TrackedStateCommitRootParent {
972
- commit_id: "target".to_string(),
973
- root_id: tracked_state_root_id(&storage, "target").await,
974
- }],
975
- )
976
- .await;
977
-
978
- let read = storage
979
- .begin_read(StorageReadOptions::default())
980
- .expect("read should open");
981
- let error = tracked_state
982
- .reader(read)
983
- .diff_commits("left", "merge", &TrackedStateDiffRequest::default())
984
- .await
985
- .expect_err("second-parent row without commit-root proof must be rejected");
986
-
987
- assert!(
988
- is_commit_root_validation_error(&error),
989
- "unexpected error: {error}"
990
- );
991
- }
992
-
993
- #[tokio::test]
994
- async fn diff_commits_rejects_second_parent_row_with_forged_commit_root_parent() {
995
- let storage = StorageContext::new(InMemoryStorageBackend::new());
996
- let tracked_state = TrackedStateContext::new();
997
- write_root_committed_for_test(&storage, &tracked_state, "left", None, &[])
998
- .await
999
- .expect("left root should write");
1000
- write_root_committed_for_test(&storage, &tracked_state, "target", None, &[])
1001
- .await
1002
- .expect("target root should write");
1003
- write_root_committed_for_test(
1004
- &storage,
1005
- &tracked_state,
1006
- "source",
1007
- None,
1008
- &[row_with_value("entity-a", None, "source-change", "value")],
1009
- )
1010
- .await
1011
- .expect("source root should write");
1012
-
1013
- let read = storage
1014
- .begin_read(StorageReadOptions::default())
1015
- .expect("read should open");
1016
- let source_diff = tracked_state
1017
- .reader(read)
1018
- .diff_commits("left", "source", &TrackedStateDiffRequest::default())
1019
- .await
1020
- .expect("source diff should load");
1021
- let source_row = source_diff
1022
- .entries
1023
- .iter()
1024
- .find_map(|entry| entry.after.clone())
1025
- .expect("source row should appear");
1026
- let (source_key, source_value) = source_row.into_index_entry();
1027
-
1028
- {
1029
- let mut read = storage
1030
- .begin_read(StorageReadOptions::default())
1031
- .expect("read should open");
1032
- let mut writes = storage.new_write_set();
1033
- crate::test_support::stage_empty_changelog_commit_with_parents(
1034
- &mut read,
1035
- &mut writes,
1036
- "merge",
1037
- &["target".to_string(), "source".to_string()],
1038
- )
1039
- .await
1040
- .expect("merge changelog should write");
1041
- storage
1042
- .commit_write_set(writes, StorageWriteOptions::default())
1043
- .expect("merge changelog should commit");
1044
- }
1045
- stage_corrupt_commit_root(
1046
- &storage,
1047
- "merge",
1048
- vec![(source_key, source_value)],
1049
- vec![TrackedStateCommitRootParent {
1050
- commit_id: "source".to_string(),
1051
- root_id: tracked_state_root_id(&storage, "source").await,
1052
- }],
1053
- )
1054
- .await;
1055
-
1056
- let read = storage
1057
- .begin_read(StorageReadOptions::default())
1058
- .expect("read should open");
1059
- let error = tracked_state
1060
- .reader(read)
1061
- .diff_commits("left", "merge", &TrackedStateDiffRequest::default())
1062
- .await
1063
- .expect_err("forged source parent must be rejected");
1064
-
1065
- assert!(
1066
- is_commit_root_validation_error(&error),
1067
- "unexpected error: {error}"
1068
- );
1069
- }
1070
-
1071
- #[tokio::test]
1072
- async fn diff_commits_rejects_unrelated_row_with_forged_commit_root_parent() {
1073
- let storage = StorageContext::new(InMemoryStorageBackend::new());
1074
- let tracked_state = TrackedStateContext::new();
1075
- write_root_committed_for_test(&storage, &tracked_state, "left", None, &[])
1076
- .await
1077
- .expect("left root should write");
1078
- write_root_committed_for_test(
1079
- &storage,
1080
- &tracked_state,
1081
- "source",
1082
- None,
1083
- &[row_with_value("entity-a", None, "source-change", "value")],
1084
- )
1085
- .await
1086
- .expect("source root should write");
1087
-
1088
- let read = storage
1089
- .begin_read(StorageReadOptions::default())
1090
- .expect("read should open");
1091
- let source_diff = tracked_state
1092
- .reader(read)
1093
- .diff_commits("left", "source", &TrackedStateDiffRequest::default())
1094
- .await
1095
- .expect("source diff should load");
1096
- let source_row = source_diff
1097
- .entries
1098
- .iter()
1099
- .find_map(|entry| entry.after.clone())
1100
- .expect("source row should appear");
1101
- let (source_key, source_value) = source_row.into_index_entry();
1102
-
1103
- {
1104
- let mut read = storage
1105
- .begin_read(StorageReadOptions::default())
1106
- .expect("read should open");
1107
- let mut writes = storage.new_write_set();
1108
- crate::test_support::stage_empty_changelog_commit(
1109
- &mut read,
1110
- &mut writes,
1111
- "right-corrupt",
1112
- None,
1113
- )
1114
- .await
1115
- .expect("empty right changelog should write");
1116
- storage
1117
- .commit_write_set(writes, StorageWriteOptions::default())
1118
- .expect("right changelog should commit");
1119
- }
1120
- stage_corrupt_commit_root(
1121
- &storage,
1122
- "right-corrupt",
1123
- vec![(source_key, source_value)],
1124
- vec![TrackedStateCommitRootParent {
1125
- commit_id: "source".to_string(),
1126
- root_id: tracked_state_root_id(&storage, "source").await,
1127
- }],
1128
- )
1129
- .await;
1130
-
1131
- let read = storage
1132
- .begin_read(StorageReadOptions::default())
1133
- .expect("read should open");
1134
- let error = tracked_state
1135
- .reader(read)
1136
- .diff_commits("left", "right-corrupt", &TrackedStateDiffRequest::default())
1137
- .await
1138
- .expect_err("forged unrelated parent must be rejected");
1139
-
1140
- assert!(
1141
- is_commit_root_validation_error(&error),
1142
- "unexpected error: {error}"
1143
- );
1144
- }
1145
-
1146
- #[tokio::test]
1147
- async fn diff_commits_rejects_forged_parent_metadata_even_for_current_winner_rows() {
1148
- let storage = StorageContext::new(InMemoryStorageBackend::new());
1149
- let tracked_state = TrackedStateContext::new();
1150
- write_root_committed_for_test(&storage, &tracked_state, "left", None, &[])
1151
- .await
1152
- .expect("left root should write");
1153
- write_root_committed_for_test(&storage, &tracked_state, "target", None, &[])
1154
- .await
1155
- .expect("target root should write");
1156
- write_root_committed_for_test(
1157
- &storage,
1158
- &tracked_state,
1159
- "source",
1160
- None,
1161
- &[row_with_value("entity-b", None, "source-b", "source")],
1162
- )
1163
- .await
1164
- .expect("source root should write");
1165
- write_root_committed_for_test(
1166
- &storage,
1167
- &tracked_state,
1168
- "child",
1169
- Some("target"),
1170
- &[row_with_value("entity-a", None, "child-a", "current")],
1171
- )
1172
- .await
1173
- .expect("child root should write");
1174
-
1175
- let read = storage
1176
- .begin_read(StorageReadOptions::default())
1177
- .expect("read should open");
1178
- let child_diff = tracked_state
1179
- .reader(read)
1180
- .diff_commits("left", "child", &TrackedStateDiffRequest::default())
1181
- .await
1182
- .expect("child diff should load");
1183
- let child_row = child_diff
1184
- .entries
1185
- .iter()
1186
- .find_map(|entry| entry.after.clone())
1187
- .expect("child row should appear");
1188
- let (child_key, child_value) = child_row.into_index_entry();
1189
-
1190
- stage_corrupt_commit_root(
1191
- &storage,
1192
- "child",
1193
- vec![(child_key, child_value)],
1194
- vec![TrackedStateCommitRootParent {
1195
- commit_id: "source".to_string(),
1196
- root_id: tracked_state_root_id(&storage, "source").await,
1197
- }],
1198
- )
1199
- .await;
1200
-
1201
- let read = storage
1202
- .begin_read(StorageReadOptions::default())
1203
- .expect("read should open");
1204
- let error = tracked_state
1205
- .reader(read)
1206
- .diff_commits("left", "child", &TrackedStateDiffRequest::default())
1207
- .await
1208
- .expect_err("current winner root metadata must still be validated");
1209
-
1210
- assert!(
1211
- is_commit_root_validation_error(&error),
1212
- "unexpected error: {error}"
1213
- );
1214
- }
1215
-
1216
- #[tokio::test]
1217
- async fn diff_commits_rejects_stale_grandparent_row_with_forged_commit_root_parent() {
1218
- let storage = StorageContext::new(InMemoryStorageBackend::new());
1219
- let tracked_state = TrackedStateContext::new();
1220
- write_root_committed_for_test(&storage, &tracked_state, "left", None, &[])
1221
- .await
1222
- .expect("left root should write");
1223
- write_root_committed_for_test(
1224
- &storage,
1225
- &tracked_state,
1226
- "grandparent",
1227
- None,
1228
- &[row_with_value("entity-a", None, "grandparent-a", "old")],
1229
- )
1230
- .await
1231
- .expect("grandparent root should write");
1232
- write_root_committed_for_test(
1233
- &storage,
1234
- &tracked_state,
1235
- "parent",
1236
- Some("grandparent"),
1237
- &[row_with_value("entity-a", None, "parent-a", "new")],
1238
- )
1239
- .await
1240
- .expect("parent root should write");
1241
- write_root_committed_for_test(&storage, &tracked_state, "child", Some("parent"), &[])
1242
- .await
1243
- .expect("child root should write");
1244
-
1245
- let read = storage
1246
- .begin_read(StorageReadOptions::default())
1247
- .expect("read should open");
1248
- let stale_diff = tracked_state
1249
- .reader(read)
1250
- .diff_commits("left", "grandparent", &TrackedStateDiffRequest::default())
1251
- .await
1252
- .expect("grandparent diff should load");
1253
- let stale_row = stale_diff
1254
- .entries
1255
- .iter()
1256
- .find_map(|entry| entry.after.clone())
1257
- .expect("grandparent row should appear");
1258
- let (stale_key, stale_value) = stale_row.into_index_entry();
1259
-
1260
- stage_corrupt_commit_root(
1261
- &storage,
1262
- "child",
1263
- vec![(stale_key, stale_value)],
1264
- vec![TrackedStateCommitRootParent {
1265
- commit_id: "grandparent".to_string(),
1266
- root_id: tracked_state_root_id(&storage, "grandparent").await,
1267
- }],
1268
- )
1269
- .await;
1270
-
1271
- let read = storage
1272
- .begin_read(StorageReadOptions::default())
1273
- .expect("read should open");
1274
- let error = tracked_state
1275
- .reader(read)
1276
- .diff_commits("left", "child", &TrackedStateDiffRequest::default())
1277
- .await
1278
- .expect_err("forged grandparent parent must be rejected");
1279
-
1280
- assert!(
1281
- is_commit_root_validation_error(&error),
1282
- "unexpected error: {error}"
1283
- );
1284
- }
1285
-
1286
- #[tokio::test]
1287
- async fn diff_commits_allows_rows_reachable_through_parent_commit() {
1288
- let storage = StorageContext::new(InMemoryStorageBackend::new());
1289
- let tracked_state = TrackedStateContext::new();
1290
- write_root_committed_for_test(&storage, &tracked_state, "left", None, &[])
1291
- .await
1292
- .expect("left root should write");
1293
- write_root_committed_for_test(
1294
- &storage,
1295
- &tracked_state,
1296
- "parent",
1297
- None,
1298
- &[row_with_value("entity-a", None, "parent-change", "value")],
1299
- )
1300
- .await
1301
- .expect("parent root should write");
1302
- write_root_committed_for_test(&storage, &tracked_state, "child", Some("parent"), &[])
1303
- .await
1304
- .expect("child root should write");
1305
-
1306
- let read = storage
1307
- .begin_read(StorageReadOptions::default())
1308
- .expect("read should open");
1309
- let diff = tracked_state
1310
- .reader(read)
1311
- .diff_commits("left", "child", &TrackedStateDiffRequest::default())
1312
- .await
1313
- .expect("ancestor-reachable row should validate");
1314
-
1315
- assert_eq!(
1316
- kinds(&diff),
1317
- vec![("entity-a".to_string(), TrackedStateDiffKind::Added)]
1318
- );
1319
- }
1320
-
1321
- #[tokio::test]
1322
- async fn diff_commits_allows_source_update_with_source_created_at() {
1323
- let storage = StorageContext::new(InMemoryStorageBackend::new());
1324
- let tracked_state = TrackedStateContext::new();
1325
- write_root_committed_for_test(&storage, &tracked_state, "target", None, &[])
1326
- .await
1327
- .expect("target root should write");
1328
- write_root_committed_for_test(
1329
- &storage,
1330
- &tracked_state,
1331
- "source-add",
1332
- None,
1333
- &[row_with_times(
1334
- "entity-a",
1335
- None,
1336
- "source-add-a",
1337
- "old",
1338
- "2026-01-01T00:00:00Z",
1339
- "2026-01-01T00:00:00Z",
1340
- )],
1341
- )
1342
- .await
1343
- .expect("source add root should write");
1344
- let source_update = row_with_times(
1345
- "entity-a",
1346
- None,
1347
- "source-update-a",
1348
- "new",
1349
- "2026-01-01T00:00:00Z",
1350
- "2026-01-02T00:00:00Z",
1351
- );
1352
- write_root_committed_for_test(
1353
- &storage,
1354
- &tracked_state,
1355
- "source-update",
1356
- Some("source-add"),
1357
- std::slice::from_ref(&source_update),
1358
- )
1359
- .await
1360
- .expect("source update root should write");
1361
- {
1362
- let mut read = storage
1363
- .begin_read(StorageReadOptions::default())
1364
- .expect("read should open");
1365
- let mut writes = storage.new_write_set();
1366
- crate::test_support::stage_tracked_root_from_materialized_with_parents(
1367
- &mut read,
1368
- &mut writes,
1369
- &tracked_state,
1370
- "merge",
1371
- &["target".to_string(), "source-update".to_string()],
1372
- Some("target"),
1373
- std::slice::from_ref(&source_update),
1374
- )
1375
- .await
1376
- .expect("merge root should stage");
1377
- storage
1378
- .commit_write_set(writes, StorageWriteOptions::default())
1379
- .expect("merge root should commit");
1380
- }
1381
-
1382
- let read = storage
1383
- .begin_read(StorageReadOptions::default())
1384
- .expect("read should open");
1385
- let diff = tracked_state
1386
- .reader(read)
1387
- .diff_commits("target", "merge", &TrackedStateDiffRequest::default())
1388
- .await
1389
- .expect("source update should validate");
1390
-
1391
- assert_eq!(
1392
- kinds(&diff),
1393
- vec![("entity-a".to_string(), TrackedStateDiffKind::Added)]
1394
- );
1395
- let row = diff.entries[0].after.as_ref().expect("after row");
1396
- assert_eq!(row.created_at, "2026-01-01T00:00:00Z");
1397
- assert_eq!(row.updated_at, "2026-01-02T00:00:00Z");
1398
- assert_eq!(row.change_id, "source-update-a");
1399
- }
1400
-
1401
- #[tokio::test]
1402
- async fn diff_commits_rejects_omitted_inherited_row_even_when_diff_is_non_empty() {
1403
- let storage = StorageContext::new(InMemoryStorageBackend::new());
1404
- let tracked_state = TrackedStateContext::new();
1405
- write_root_committed_for_test(
1406
- &storage,
1407
- &tracked_state,
1408
- "parent",
1409
- None,
1410
- &[row_with_value("entity-a", None, "parent-a", "inherited")],
1411
- )
1412
- .await
1413
- .expect("parent root should write");
1414
- write_root_committed_for_test(
1415
- &storage,
1416
- &tracked_state,
1417
- "child",
1418
- Some("parent"),
1419
- &[row_with_value("entity-b", None, "child-b", "unrelated")],
1420
- )
1421
- .await
1422
- .expect("child root should write");
1423
-
1424
- let read = storage
1425
- .begin_read(StorageReadOptions::default())
1426
- .expect("read should open");
1427
- let valid_diff = tracked_state
1428
- .reader(read)
1429
- .diff_commits("parent", "child", &TrackedStateDiffRequest::default())
1430
- .await
1431
- .expect("valid child diff should load");
1432
- let unrelated_row = valid_diff
1433
- .entries
1434
- .iter()
1435
- .find_map(|entry| {
1436
- entry
1437
- .after
1438
- .as_ref()
1439
- .filter(|row| row.change_id == "child-b")
1440
- .cloned()
1441
- })
1442
- .expect("unrelated child row should appear");
1443
- let (unrelated_key, unrelated_value) = unrelated_row.into_index_entry();
1444
- stage_corrupt_commit_root(
1445
- &storage,
1446
- "child",
1447
- vec![(unrelated_key, unrelated_value)],
1448
- vec![TrackedStateCommitRootParent {
1449
- commit_id: "parent".to_string(),
1450
- root_id: tracked_state_root_id(&storage, "parent").await,
1451
- }],
1452
- )
1453
- .await;
1454
-
1455
- let read = storage
1456
- .begin_read(StorageReadOptions::default())
1457
- .expect("read should open");
1458
- let error = tracked_state
1459
- .reader(read)
1460
- .diff_commits("parent", "child", &TrackedStateDiffRequest::default())
1461
- .await
1462
- .expect_err("omitted inherited row must be rejected");
1463
-
1464
- assert!(
1465
- is_commit_root_validation_error(&error),
1466
- "unexpected error: {error}"
1467
- );
1468
- }
1469
-
1470
- #[tokio::test]
1471
- async fn diff_commits_rejects_omitted_updated_row_even_when_diff_is_non_empty() {
1472
- let storage = StorageContext::new(InMemoryStorageBackend::new());
1473
- let tracked_state = TrackedStateContext::new();
1474
- write_root_committed_for_test(
1475
- &storage,
1476
- &tracked_state,
1477
- "parent",
1478
- None,
1479
- &[row_with_value("entity-a", None, "parent-a", "old")],
1480
- )
1481
- .await
1482
- .expect("parent root should write");
1483
- write_root_committed_for_test(
1484
- &storage,
1485
- &tracked_state,
1486
- "child",
1487
- Some("parent"),
1488
- &[
1489
- row_with_value("entity-a", None, "child-a", "new"),
1490
- row_with_value("entity-b", None, "child-b", "unrelated"),
1491
- ],
1492
- )
1493
- .await
1494
- .expect("child root should write");
1495
-
1496
- let read = storage
1497
- .begin_read(StorageReadOptions::default())
1498
- .expect("read should open");
1499
- let valid_diff = tracked_state
1500
- .reader(read)
1501
- .diff_commits("parent", "child", &TrackedStateDiffRequest::default())
1502
- .await
1503
- .expect("valid child diff should load");
1504
- let unrelated_row = valid_diff
1505
- .entries
1506
- .iter()
1507
- .find_map(|entry| {
1508
- entry
1509
- .after
1510
- .as_ref()
1511
- .filter(|row| row.change_id == "child-b")
1512
- .cloned()
1513
- })
1514
- .expect("unrelated child row should appear");
1515
- let (unrelated_key, unrelated_value) = unrelated_row.into_index_entry();
1516
- stage_corrupt_commit_root(
1517
- &storage,
1518
- "child",
1519
- vec![(unrelated_key, unrelated_value)],
1520
- vec![TrackedStateCommitRootParent {
1521
- commit_id: "parent".to_string(),
1522
- root_id: tracked_state_root_id(&storage, "parent").await,
1523
- }],
1524
- )
1525
- .await;
1526
-
1527
- let read = storage
1528
- .begin_read(StorageReadOptions::default())
1529
- .expect("read should open");
1530
- let error = tracked_state
1531
- .reader(read)
1532
- .diff_commits("parent", "child", &TrackedStateDiffRequest::default())
1533
- .await
1534
- .expect_err("omitted updated row must be rejected");
1535
-
1536
- assert!(
1537
- is_commit_root_validation_error(&error),
1538
- "unexpected error: {error}"
1539
- );
1540
- }
1541
-
1542
- #[tokio::test]
1543
- async fn diff_commits_rejects_shared_omitted_row_even_when_diff_is_non_empty() {
1544
- let storage = StorageContext::new(InMemoryStorageBackend::new());
1545
- let tracked_state = TrackedStateContext::new();
1546
- write_root_committed_for_test(
1547
- &storage,
1548
- &tracked_state,
1549
- "parent",
1550
- None,
1551
- &[row_with_value("entity-a", None, "parent-a", "shared")],
1552
- )
1553
- .await
1554
- .expect("parent root should write");
1555
- write_root_committed_for_test(
1556
- &storage,
1557
- &tracked_state,
1558
- "left",
1559
- Some("parent"),
1560
- &[row_with_value("entity-b", None, "left-b", "left")],
1561
- )
1562
- .await
1563
- .expect("left root should write");
1564
- write_root_committed_for_test(
1565
- &storage,
1566
- &tracked_state,
1567
- "right",
1568
- Some("parent"),
1569
- &[row_with_value("entity-c", None, "right-c", "right")],
1570
- )
1571
- .await
1572
- .expect("right root should write");
1573
-
1574
- let read = storage
1575
- .begin_read(StorageReadOptions::default())
1576
- .expect("read should open");
1577
- let left_diff = tracked_state
1578
- .reader(read)
1579
- .diff_commits("parent", "left", &TrackedStateDiffRequest::default())
1580
- .await
1581
- .expect("left diff should load");
1582
- let left_row = left_diff
1583
- .entries
1584
- .iter()
1585
- .find_map(|entry| {
1586
- entry
1587
- .after
1588
- .as_ref()
1589
- .filter(|row| row.change_id == "left-b")
1590
- .cloned()
1591
- })
1592
- .expect("left row should appear");
1593
- let (left_key, left_value) = left_row.into_index_entry();
1594
- let read = storage
1595
- .begin_read(StorageReadOptions::default())
1596
- .expect("read should open");
1597
- let right_diff = tracked_state
1598
- .reader(read)
1599
- .diff_commits("parent", "right", &TrackedStateDiffRequest::default())
1600
- .await
1601
- .expect("right diff should load");
1602
- let right_row = right_diff
1603
- .entries
1604
- .iter()
1605
- .find_map(|entry| {
1606
- entry
1607
- .after
1608
- .as_ref()
1609
- .filter(|row| row.change_id == "right-c")
1610
- .cloned()
1611
- })
1612
- .expect("right row should appear");
1613
- let (right_key, right_value) = right_row.into_index_entry();
1614
- stage_corrupt_commit_root(
1615
- &storage,
1616
- "left",
1617
- vec![(left_key, left_value)],
1618
- vec![TrackedStateCommitRootParent {
1619
- commit_id: "parent".to_string(),
1620
- root_id: tracked_state_root_id(&storage, "parent").await,
1621
- }],
1622
- )
1623
- .await;
1624
- stage_corrupt_commit_root(
1625
- &storage,
1626
- "right",
1627
- vec![(right_key, right_value)],
1628
- vec![TrackedStateCommitRootParent {
1629
- commit_id: "parent".to_string(),
1630
- root_id: tracked_state_root_id(&storage, "parent").await,
1631
- }],
1632
- )
1633
- .await;
1634
-
1635
- let read = storage
1636
- .begin_read(StorageReadOptions::default())
1637
- .expect("read should open");
1638
- let error = tracked_state
1639
- .reader(read)
1640
- .diff_commits("left", "right", &TrackedStateDiffRequest::default())
1641
- .await
1642
- .expect_err("shared hidden omission must be rejected");
1643
-
1644
- assert!(
1645
- is_commit_root_validation_error(&error),
1646
- "unexpected error: {error}"
1647
- );
1648
- }
1649
-
1650
- #[tokio::test]
1651
- async fn diff_commits_validates_roots_even_when_tree_diff_is_empty() {
1652
- let storage = StorageContext::new(InMemoryStorageBackend::new());
1653
- let tracked_state = TrackedStateContext::new();
1654
- write_root_committed_for_test(
1655
- &storage,
1656
- &tracked_state,
1657
- "source",
1658
- None,
1659
- &[row_with_value("entity-a", None, "source-change", "value")],
1660
- )
1661
- .await
1662
- .expect("source root should write");
1663
- write_root_committed_for_test(&storage, &tracked_state, "left-corrupt", None, &[])
1664
- .await
1665
- .expect("left changelog should write");
1666
- write_root_committed_for_test(&storage, &tracked_state, "right-corrupt", None, &[])
1667
- .await
1668
- .expect("right changelog should write");
1669
-
1670
- let read = storage
1671
- .begin_read(StorageReadOptions::default())
1672
- .expect("read should open");
1673
- let source_diff = tracked_state
1674
- .reader(read)
1675
- .diff_commits(
1676
- "left-corrupt",
1677
- "source",
1678
- &TrackedStateDiffRequest::default(),
1679
- )
1680
- .await
1681
- .expect("source diff should load");
1682
- let source_row = source_diff
1683
- .entries
1684
- .iter()
1685
- .find_map(|entry| entry.after.clone())
1686
- .expect("source row should appear");
1687
- let (source_key, source_value) = source_row.into_index_entry();
1688
-
1689
- stage_corrupt_commit_root(
1690
- &storage,
1691
- "left-corrupt",
1692
- vec![(source_key.clone(), source_value.clone())],
1693
- Vec::new(),
1694
- )
1695
- .await;
1696
- stage_corrupt_commit_root(
1697
- &storage,
1698
- "right-corrupt",
1699
- vec![(source_key, source_value)],
1700
- Vec::new(),
1701
- )
1702
- .await;
1703
-
1704
- let read = storage
1705
- .begin_read(StorageReadOptions::default())
1706
- .expect("read should open");
1707
- let error = tracked_state
1708
- .reader(read)
1709
- .diff_commits(
1710
- "left-corrupt",
1711
- "right-corrupt",
1712
- &TrackedStateDiffRequest::default(),
1713
- )
1714
- .await
1715
- .expect_err("identical corrupt roots must still be validated");
1716
-
1717
- assert!(
1718
- is_commit_root_validation_error(&error),
1719
- "unexpected error: {error}"
1720
- );
1721
- }
1722
-
1723
- #[tokio::test]
1724
- async fn diff_commits_between_delta_parent_and_child_reports_suffix_rows() {
1725
- let storage = StorageContext::new(InMemoryStorageBackend::new());
1726
- let tracked_state = TrackedStateContext::new();
1727
- let mut read = storage
1728
- .begin_read(StorageReadOptions::default())
1729
- .expect("read should open");
1730
- let mut writes = storage.new_write_set();
1731
- write_root_for_test(
1732
- &mut read,
1733
- &mut writes,
1734
- &tracked_state,
1735
- "parent",
1736
- None,
1737
- &[
1738
- row_with_value("entity-a", None, "parent-a", "before"),
1739
- row_with_value("entity-b", None, "parent-b", "same"),
1740
- ],
1741
- )
1742
- .await
1743
- .expect("parent should write");
1744
- storage
1745
- .commit_write_set(writes, StorageWriteOptions::default())
1746
- .expect("parent writes should commit");
1747
- let mut read = storage
1748
- .begin_read(StorageReadOptions::default())
1749
- .expect("child read should open");
1750
- let mut writes = storage.new_write_set();
1751
- write_root_for_test(
1752
- &mut read,
1753
- &mut writes,
1754
- &tracked_state,
1755
- "child",
1756
- Some("parent"),
1757
- &[row_with_value("entity-a", None, "child-a", "after")],
1758
- )
1759
- .await
1760
- .expect("child should write");
1761
- storage
1762
- .commit_write_set(writes, StorageWriteOptions::default())
1763
- .expect("writes should commit");
1764
-
1765
- let read = storage
1766
- .begin_read(StorageReadOptions::default())
1767
- .expect("read should open");
1768
- let diff = tracked_state
1769
- .reader(read)
1770
- .diff_commits("parent", "child", &TrackedStateDiffRequest::default())
1771
- .await
1772
- .expect("diff should load");
1773
-
1774
- assert_eq!(
1775
- kinds(&diff),
1776
- vec![("entity-a".to_string(), TrackedStateDiffKind::Modified)]
1777
- );
1778
- assert_ne!(
1779
- diff.entries[0]
1780
- .before
1781
- .as_ref()
1782
- .and_then(|row| row.snapshot_ref.as_ref()),
1783
- diff.entries[0]
1784
- .after
1785
- .as_ref()
1786
- .and_then(|row| row.snapshot_ref.as_ref())
1787
- );
1788
- }
1789
-
1790
- #[tokio::test]
1791
- async fn diff_commits_between_delta_child_and_parent_reports_reverse_suffix_rows() {
1792
- let (storage, tracked_state) = seed_parent_child_delta(
1793
- &[
1794
- row_with_value("entity-a", None, "parent-a", "before"),
1795
- row_with_value("entity-b", None, "parent-b", "same"),
1796
- ],
1797
- &[row_with_value("entity-a", None, "child-a", "after")],
1798
- )
1799
- .await;
1800
-
1801
- let read = storage
1802
- .begin_read(StorageReadOptions::default())
1803
- .expect("read should open");
1804
- let diff = tracked_state
1805
- .reader(read)
1806
- .diff_commits("child", "parent", &TrackedStateDiffRequest::default())
1807
- .await
1808
- .expect("diff should load");
1809
-
1810
- assert_eq!(
1811
- kinds(&diff),
1812
- vec![("entity-a".to_string(), TrackedStateDiffKind::Modified)]
1813
- );
1814
- assert_ne!(
1815
- diff.entries[0]
1816
- .before
1817
- .as_ref()
1818
- .and_then(|row| row.snapshot_ref.as_ref()),
1819
- diff.entries[0]
1820
- .after
1821
- .as_ref()
1822
- .and_then(|row| row.snapshot_ref.as_ref())
1823
- );
1824
- }
1825
-
1826
- #[tokio::test]
1827
- async fn diff_commits_between_delta_parent_and_child_preserves_suffix_tombstones() {
1828
- let (storage, tracked_state) = seed_parent_child_delta(
1829
- &[
1830
- row_with_value("entity-a", None, "parent-a", "before"),
1831
- row_with_value("entity-b", None, "parent-b", "same"),
1832
- ],
1833
- &[tombstone("entity-a", None, "child-delete")],
1834
- )
1835
- .await;
1836
-
1837
- let read = storage
1838
- .begin_read(StorageReadOptions::default())
1839
- .expect("read should open");
1840
- let diff = tracked_state
1841
- .reader(read)
1842
- .diff_commits("parent", "child", &TrackedStateDiffRequest::default())
1843
- .await
1844
- .expect("diff should load");
1845
-
1846
- assert_eq!(
1847
- kinds(&diff),
1848
- vec![("entity-a".to_string(), TrackedStateDiffKind::Removed)]
1849
- );
1850
- assert!(diff.entries[0].before_is_live());
1851
- assert!(!diff.entries[0].after_is_live());
1852
- assert_eq!(
1853
- diff.entries[0]
1854
- .after
1855
- .as_ref()
1856
- .map(|row| row.change_id.as_str()),
1857
- Some("child-delete")
1858
- );
1859
- }
1860
-
1861
- async fn diff(
1862
- storage: &StorageContext,
1863
- tracked_state: &TrackedStateContext,
1864
- ) -> TrackedStateDiff {
1865
- let read = storage
1866
- .begin_read(StorageReadOptions::default())
1867
- .expect("read should open");
1868
- tracked_state
1869
- .reader(read)
1870
- .diff_commits("left", "right", &TrackedStateDiffRequest::default())
1871
- .await
1872
- .expect("diff should load")
1873
- }
1874
-
1875
- async fn seed_roots(
1876
- left_rows: &[MaterializedTrackedStateRow],
1877
- right_rows: &[MaterializedTrackedStateRow],
1878
- ) -> (StorageContext, TrackedStateContext) {
1879
- let storage = StorageContext::new(InMemoryStorageBackend::new());
1880
- let tracked_state = TrackedStateContext::new();
1881
- write_root_committed_for_test(&storage, &tracked_state, "left", None, left_rows)
1882
- .await
1883
- .expect("left root should write");
1884
- write_root_committed_for_test(&storage, &tracked_state, "right", None, right_rows)
1885
- .await
1886
- .expect("right root should write");
1887
- (storage, tracked_state)
1888
- }
1889
-
1890
- async fn seed_parent_child_delta(
1891
- parent_rows: &[MaterializedTrackedStateRow],
1892
- child_rows: &[MaterializedTrackedStateRow],
1893
- ) -> (StorageContext, TrackedStateContext) {
1894
- let storage = StorageContext::new(InMemoryStorageBackend::new());
1895
- let tracked_state = TrackedStateContext::new();
1896
- write_root_committed_for_test(&storage, &tracked_state, "parent", None, parent_rows)
1897
- .await
1898
- .expect("parent should write");
1899
- write_root_committed_for_test(
1900
- &storage,
1901
- &tracked_state,
1902
- "child",
1903
- Some("parent"),
1904
- child_rows,
1905
- )
1906
- .await
1907
- .expect("child should write");
1908
- (storage, tracked_state)
1909
- }
1910
-
1911
- async fn write_root_committed_for_test(
1912
- storage: &StorageContext,
1913
- tracked_state: &TrackedStateContext,
1914
- commit_id: &str,
1915
- parent_commit_id: Option<&str>,
1916
- rows: &[MaterializedTrackedStateRow],
1917
- ) -> Result<(), LixError> {
1918
- let mut read = storage
1919
- .begin_read(StorageReadOptions::default())
1920
- .expect("read should open");
1921
- let mut writes = storage.new_write_set();
1922
- write_root_for_test(
1923
- &mut read,
1924
- &mut writes,
1925
- tracked_state,
1926
- commit_id,
1927
- parent_commit_id,
1928
- rows,
1929
- )
1930
- .await?;
1931
- storage.commit_write_set(writes, StorageWriteOptions::default())?;
1932
- Ok(())
1933
- }
1934
-
1935
- async fn write_root_for_test(
1936
- read: &mut (impl crate::storage::StorageRead + Send + Sync + ?Sized),
1937
- writes: &mut crate::storage::StorageWriteSet,
1938
- tracked_state: &TrackedStateContext,
1939
- commit_id: &str,
1940
- parent_commit_id: Option<&str>,
1941
- rows: &[MaterializedTrackedStateRow],
1942
- ) -> Result<(), LixError> {
1943
- crate::test_support::stage_tracked_root_from_materialized(
1944
- read,
1945
- writes,
1946
- tracked_state,
1947
- commit_id,
1948
- parent_commit_id,
1949
- rows,
1950
- )
1951
- .await
1952
- }
1953
-
1954
- async fn tracked_state_root_id(
1955
- storage: &StorageContext,
1956
- commit_id: &str,
1957
- ) -> TrackedStateRootId {
1958
- let read = storage
1959
- .begin_read(StorageReadOptions::default())
1960
- .expect("read should open");
1961
- crate::tracked_state::storage::load_root(&read, commit_id)
1962
- .await
1963
- .expect("root should load")
1964
- .expect("root should exist")
1965
- }
1966
-
1967
- async fn stage_corrupt_commit_root(
1968
- storage: &StorageContext,
1969
- commit_id: &str,
1970
- entries: Vec<(TrackedStateKey, TrackedStateIndexValue)>,
1971
- parent_roots: Vec<TrackedStateCommitRootParent>,
1972
- ) {
1973
- let read = storage
1974
- .begin_read(StorageReadOptions::default())
1975
- .expect("read should open");
1976
- let mut writes = storage.new_write_set();
1977
- let mutations = entries
1978
- .into_iter()
1979
- .map(|(key, value)| {
1980
- TrackedStateMutation::put_encoded(
1981
- crate::tracked_state::codec::encode_key(&key),
1982
- crate::tracked_state::codec::encode_value(&value),
1983
- )
1984
- })
1985
- .collect::<Vec<_>>();
1986
- let changed_key_count = mutations.len() as u64;
1987
- let result = crate::tracked_state::tree::TrackedStateTree::new()
1988
- .apply_mutations(&read, &mut writes, None, mutations, Some(commit_id))
1989
- .await
1990
- .expect("corrupt root should write");
1991
- crate::tracked_state::storage::stage_commit_root(
1992
- &mut writes,
1993
- &TrackedStateCommitRoot {
1994
- commit_id: commit_id.to_string(),
1995
- root_id: result.root_id,
1996
- parent_roots,
1997
- changed_key_count,
1998
- row_count_estimate: result.row_count as u64,
1999
- tree_height: result.tree_height as u32,
2000
- primary_chunk_count: result.chunk_count as u64,
2001
- primary_chunk_bytes: result.chunk_bytes as u64,
2002
- },
2003
- )
2004
- .expect("metadata should encode");
2005
- storage
2006
- .commit_write_set(writes, StorageWriteOptions::default())
2007
- .expect("corrupt root should commit");
2008
- }
2009
-
2010
- fn kinds(diff: &TrackedStateDiff) -> Vec<(String, TrackedStateDiffKind)> {
2011
- diff.entries
2012
- .iter()
2013
- .map(|entry| {
2014
- (
2015
- entry
2016
- .identity
2017
- .entity_pk
2018
- .as_single_string_owned()
2019
- .expect("identity"),
2020
- entry.kind,
2021
- )
2022
- })
2023
- .collect()
2024
- }
2025
-
2026
- fn is_commit_root_validation_error(error: &LixError) -> bool {
2027
- error.message.contains("not the first-parent winner")
2028
- || error.message.contains("does not match parent root")
2029
- || error
2030
- .message
2031
- .contains("does not match changelog first-parent winners")
2032
- || error.message.contains("contains non-winner identity")
2033
- || error.message.contains("but changelog first parent is")
2034
- || error
2035
- .message
2036
- .contains("nearest available first-parent root")
2037
- || error.message.contains("references unexpected parent")
2038
- || error.message.contains("missing changelog winner")
2039
- || error.message.contains("has change")
2040
- || error.message.contains("omits current changelog change")
2041
- || error.message.contains("omits inherited identity")
2042
- || error
2043
- .message
2044
- .contains("does not preserve inherited identity")
2045
- || error.message.contains("but changelog winner is")
2046
- }
2047
-
2048
- fn commit_root_row_entry(
2049
- commit_id: &str,
2050
- change_id: &str,
2051
- created_at: &str,
2052
- ) -> (TrackedStateKey, TrackedStateIndexValue) {
2053
- (
2054
- TrackedStateKey {
2055
- schema_key: "lix_commit".to_string(),
2056
- file_id: None,
2057
- entity_pk: EntityPk::single(commit_id),
2058
- },
2059
- TrackedStateIndexValue {
2060
- change_id: change_id.to_string(),
2061
- commit_id: commit_id.to_string(),
2062
- deleted: false,
2063
- snapshot_ref: Some(JsonRef::for_content(
2064
- format!("{{\"id\":\"{commit_id}\"}}").as_bytes(),
2065
- )),
2066
- metadata_ref: None,
2067
- created_at: created_at.to_string(),
2068
- updated_at: created_at.to_string(),
2069
- },
2070
- )
2071
- }
2072
-
2073
- fn tombstone(
2074
- entity_pk: &str,
2075
- file_id: Option<&str>,
2076
- change_id: &str,
2077
- ) -> MaterializedTrackedStateRow {
2078
- let mut row = row(entity_pk, file_id, change_id);
2079
- row.snapshot_content = None;
2080
- row.deleted = true;
2081
- row
2082
- }
2083
-
2084
- fn row(entity_pk: &str, file_id: Option<&str>, change_id: &str) -> MaterializedTrackedStateRow {
2085
- row_with_schema(entity_pk, file_id, "test_schema", change_id)
2086
- }
2087
-
2088
- fn row_with_schema(
2089
- entity_pk: &str,
2090
- file_id: Option<&str>,
2091
- schema_key: &str,
2092
- change_id: &str,
2093
- ) -> MaterializedTrackedStateRow {
2094
- row_with_schema_and_value(entity_pk, file_id, schema_key, change_id, "value")
2095
- }
2096
-
2097
- fn row_with_value(
2098
- entity_pk: &str,
2099
- file_id: Option<&str>,
2100
- change_id: &str,
2101
- value: &str,
2102
- ) -> MaterializedTrackedStateRow {
2103
- row_with_schema_and_value(entity_pk, file_id, "test_schema", change_id, value)
2104
- }
2105
-
2106
- fn row_with_times(
2107
- entity_pk: &str,
2108
- file_id: Option<&str>,
2109
- change_id: &str,
2110
- value: &str,
2111
- created_at: &str,
2112
- updated_at: &str,
2113
- ) -> MaterializedTrackedStateRow {
2114
- let mut row = row_with_value(entity_pk, file_id, change_id, value);
2115
- row.created_at = created_at.to_string();
2116
- row.updated_at = updated_at.to_string();
2117
- row
2118
- }
2119
-
2120
- fn row_with_schema_and_value(
2121
- entity_pk: &str,
2122
- file_id: Option<&str>,
2123
- schema_key: &str,
2124
- change_id: &str,
2125
- value: &str,
2126
- ) -> MaterializedTrackedStateRow {
2127
- MaterializedTrackedStateRow {
2128
- entity_pk: EntityPk::single(entity_pk),
2129
- schema_key: schema_key.to_string(),
2130
- file_id: file_id.map(str::to_string),
2131
- snapshot_content: Some(format!("{{\"value\":\"{value}\"}}")),
2132
- metadata: None,
2133
- deleted: false,
2134
- created_at: "2026-01-01T00:00:00Z".to_string(),
2135
- updated_at: "2026-01-01T00:00:00Z".to_string(),
2136
- change_id: change_id.to_string(),
2137
- commit_id: change_id.replace("change", "commit"),
2138
- }
2139
- }
2140
- }