@lix-js/sdk 0.6.0-preview.3 → 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.
- package/README.md +1 -1
- package/SKILL.md +105 -65
- package/dist/engine-wasm/index.js +4 -4
- package/dist/engine-wasm/wasm/lix_engine.d.ts +30 -6
- package/dist/engine-wasm/wasm/lix_engine.js +187 -117
- package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
- package/dist/engine-wasm/wasm/lix_engine.wasm.d.ts +14 -8
- package/dist/generated/builtin-schemas.d.ts +69 -69
- package/dist/generated/builtin-schemas.js +94 -94
- package/dist/open-lix.d.ts +42 -28
- package/dist/open-lix.js +49 -10
- package/dist/sqlite/index.js +86 -30
- package/dist-engine-src/README.md +3 -3
- package/dist-engine-src/src/backend/capabilities.rs +67 -0
- package/dist-engine-src/src/backend/conformance/baseline.rs +1127 -0
- package/dist-engine-src/src/backend/conformance/factory.rs +93 -0
- package/dist-engine-src/src/backend/conformance/failure_tests.rs +608 -0
- package/dist-engine-src/src/backend/conformance/fixtures.rs +26 -0
- package/dist-engine-src/src/backend/conformance/mod.rs +75 -0
- package/dist-engine-src/src/backend/conformance/model.rs +28 -0
- package/dist-engine-src/src/backend/conformance/model_based.rs +257 -0
- package/dist-engine-src/src/backend/conformance/persistence.rs +204 -0
- package/dist-engine-src/src/backend/conformance/projection.rs +21 -0
- package/dist-engine-src/src/backend/conformance/pushdown.rs +24 -0
- package/dist-engine-src/src/backend/conformance/runner.rs +90 -0
- package/dist-engine-src/src/backend/conformance/scan.rs +24 -0
- package/dist-engine-src/src/backend/conformance/write.rs +16 -0
- package/dist-engine-src/src/backend/error.rs +94 -0
- package/dist-engine-src/src/backend/in_memory.rs +670 -0
- package/dist-engine-src/src/backend/mod.rs +36 -9
- package/dist-engine-src/src/backend/predicate.rs +80 -0
- package/dist-engine-src/src/backend/traits.rs +260 -0
- package/dist-engine-src/src/backend/types.rs +224 -81
- package/dist-engine-src/src/binary_cas/context.rs +8 -8
- package/dist-engine-src/src/binary_cas/kv.rs +234 -259
- package/dist-engine-src/src/{version → branch}/context.rs +12 -12
- package/dist-engine-src/src/branch/lifecycle.rs +221 -0
- package/dist-engine-src/src/branch/mod.rs +13 -0
- package/dist-engine-src/src/branch/refs.rs +321 -0
- package/dist-engine-src/src/branch/stage_rows.rs +67 -0
- package/dist-engine-src/src/branch/types.rs +21 -0
- package/dist-engine-src/src/catalog/context.rs +18 -18
- package/dist-engine-src/src/catalog/snapshot.rs +8 -8
- package/dist-engine-src/src/changelog/bench_support.rs +785 -0
- package/dist-engine-src/src/changelog/change.rs +1 -0
- package/dist-engine-src/src/changelog/codec.rs +497 -0
- package/dist-engine-src/src/changelog/commit.rs +1 -0
- package/dist-engine-src/src/changelog/context.rs +1614 -0
- package/dist-engine-src/src/changelog/mod.rs +29 -0
- package/dist-engine-src/src/changelog/store.rs +163 -0
- package/dist-engine-src/src/changelog/test_support.rs +54 -0
- package/dist-engine-src/src/changelog/types.rs +213 -0
- package/dist-engine-src/src/commit_graph/context.rs +317 -274
- package/dist-engine-src/src/commit_graph/mod.rs +2 -4
- package/dist-engine-src/src/commit_graph/types.rs +22 -42
- package/dist-engine-src/src/commit_graph/walker.rs +133 -103
- package/dist-engine-src/src/common/error.rs +52 -18
- package/dist-engine-src/src/common/identity.rs +2 -2
- package/dist-engine-src/src/common/mod.rs +1 -1
- package/dist-engine-src/src/domain.rs +42 -46
- package/dist-engine-src/src/engine.rs +74 -96
- package/dist-engine-src/src/{entity_identity.rs → entity_pk.rs} +89 -92
- package/dist-engine-src/src/functions/context.rs +56 -52
- package/dist-engine-src/src/functions/state.rs +51 -52
- package/dist-engine-src/src/init.rs +288 -154
- package/dist-engine-src/src/json_store/context.rs +15 -266
- package/dist-engine-src/src/json_store/mod.rs +26 -0
- package/dist-engine-src/src/json_store/store.rs +103 -718
- package/dist-engine-src/src/json_store/types.rs +4 -9
- package/dist-engine-src/src/lib.rs +49 -19
- package/dist-engine-src/src/live_state/context.rs +654 -790
- package/dist-engine-src/src/live_state/mod.rs +9 -3
- package/dist-engine-src/src/live_state/overlay.rs +4 -4
- package/dist-engine-src/src/live_state/types.rs +30 -21
- package/dist-engine-src/src/live_state/visibility.rs +514 -71
- package/dist-engine-src/src/plugin/install.rs +48 -48
- package/dist-engine-src/src/plugin/manifest.rs +7 -7
- package/dist-engine-src/src/plugin/materializer.rs +0 -275
- package/dist-engine-src/src/plugin/plugin_manifest.json +4 -3
- package/dist-engine-src/src/schema/builtin/lix_binary_blob_ref.json +2 -2
- package/dist-engine-src/src/schema/builtin/lix_branch_descriptor.json +34 -0
- package/dist-engine-src/src/schema/builtin/lix_branch_ref.json +48 -0
- package/dist-engine-src/src/schema/builtin/lix_change.json +3 -3
- package/dist-engine-src/src/schema/builtin/lix_commit.json +1 -1
- package/dist-engine-src/src/schema/builtin/lix_label_assignment.json +6 -6
- package/dist-engine-src/src/schema/builtin/mod.rs +18 -20
- package/dist-engine-src/src/schema/compatibility.rs +11 -11
- package/dist-engine-src/src/schema/definition.json +2 -2
- package/dist-engine-src/src/schema/definition.rs +5 -5
- package/dist-engine-src/src/schema/key.rs +3 -3
- package/dist-engine-src/src/schema/mod.rs +1 -1
- package/dist-engine-src/src/schema/tests.rs +18 -18
- package/dist-engine-src/src/session/context.rs +819 -124
- package/dist-engine-src/src/session/create_branch.rs +94 -0
- package/dist-engine-src/src/session/execute.rs +260 -57
- package/dist-engine-src/src/session/merge/analysis.rs +9 -3
- package/dist-engine-src/src/session/merge/{version.rs → branch.rs} +119 -129
- package/dist-engine-src/src/session/merge/conflicts.rs +2 -2
- package/dist-engine-src/src/session/merge/mod.rs +5 -6
- package/dist-engine-src/src/session/merge/stats.rs +7 -11
- package/dist-engine-src/src/session/mod.rs +19 -16
- package/dist-engine-src/src/session/switch_branch.rs +113 -0
- package/dist-engine-src/src/session/transaction.rs +557 -0
- package/dist-engine-src/src/sql2/bind/classify.rs +102 -0
- package/dist-engine-src/src/sql2/bind/error.rs +5 -0
- package/dist-engine-src/src/sql2/bind/expr.rs +29 -0
- package/dist-engine-src/src/sql2/bind/mod.rs +12 -0
- package/dist-engine-src/src/sql2/{udfs/public_call.rs → bind/public_udf.rs} +98 -3
- package/dist-engine-src/src/sql2/bind/read.rs +65 -0
- package/dist-engine-src/src/sql2/bind/statement.rs +2236 -0
- package/dist-engine-src/src/sql2/bind/table.rs +273 -0
- package/dist-engine-src/src/sql2/bind/write.rs +86 -0
- package/dist-engine-src/src/sql2/branch_scope.rs +436 -0
- package/dist-engine-src/src/sql2/catalog/capability.rs +20 -0
- package/dist-engine-src/src/sql2/catalog/entity_surface.rs +296 -0
- package/dist-engine-src/src/sql2/catalog/mod.rs +15 -0
- package/dist-engine-src/src/sql2/catalog/registry.rs +556 -0
- package/dist-engine-src/src/sql2/catalog/schema.rs +88 -0
- package/dist-engine-src/src/sql2/catalog/surface.rs +41 -0
- package/dist-engine-src/src/sql2/change_materialization.rs +122 -0
- package/dist-engine-src/src/sql2/context.rs +36 -30
- package/dist-engine-src/src/sql2/error.rs +4 -5
- package/dist-engine-src/src/sql2/exec/bound_public_write.rs +1593 -0
- package/dist-engine-src/src/sql2/exec/datafusion.rs +5266 -0
- package/dist-engine-src/src/sql2/exec/fast_write.rs +82 -0
- package/dist-engine-src/src/sql2/exec/mod.rs +24 -0
- package/dist-engine-src/src/sql2/exec/write.rs +661 -0
- package/dist-engine-src/src/sql2/filesystem_planner.rs +72 -77
- package/dist-engine-src/src/sql2/filesystem_visibility.rs +21 -21
- package/dist-engine-src/src/sql2/history_projection.rs +8 -8
- package/dist-engine-src/src/sql2/history_route.rs +35 -31
- package/dist-engine-src/src/sql2/mod.rs +30 -24
- package/dist-engine-src/src/sql2/optimize/datafusion.rs +1 -0
- package/dist-engine-src/src/sql2/optimize/mod.rs +2 -0
- package/dist-engine-src/src/sql2/optimize/simple_write.rs +116 -0
- package/dist-engine-src/src/sql2/parse/mod.rs +69 -0
- package/dist-engine-src/src/sql2/parse/normalize.rs +1 -0
- package/dist-engine-src/src/sql2/plan/branch_scope.rs +24 -0
- package/dist-engine-src/src/sql2/plan/mod.rs +5 -0
- package/dist-engine-src/src/sql2/plan/predicate.rs +22 -0
- package/dist-engine-src/src/sql2/plan/write.rs +147 -0
- package/dist-engine-src/src/sql2/predicate_typecheck.rs +258 -0
- package/dist-engine-src/src/sql2/{version_provider.rs → providers/branch.rs} +218 -214
- package/dist-engine-src/src/sql2/{change_provider.rs → providers/change.rs} +156 -42
- package/dist-engine-src/src/sql2/{directory_provider.rs → providers/directory.rs} +291 -322
- package/dist-engine-src/src/sql2/{directory_history_provider.rs → providers/directory_history.rs} +56 -42
- package/dist-engine-src/src/sql2/providers/entity.rs +1484 -0
- package/dist-engine-src/src/sql2/{entity_history_provider.rs → providers/entity_history.rs} +43 -31
- package/dist-engine-src/src/sql2/{file_provider.rs → providers/file.rs} +323 -316
- package/dist-engine-src/src/sql2/{file_history_provider.rs → providers/file_history.rs} +60 -46
- package/dist-engine-src/src/sql2/{history_provider.rs → providers/history.rs} +46 -32
- package/dist-engine-src/src/sql2/{lix_state_provider.rs → providers/lix_state.rs} +359 -329
- package/dist-engine-src/src/sql2/providers/mod.rs +508 -0
- package/dist-engine-src/src/sql2/read_only.rs +2 -2
- package/dist-engine-src/src/sql2/session.rs +47 -96
- package/dist-engine-src/src/sql2/storage/constraints.rs +1 -0
- package/dist-engine-src/src/sql2/storage/mod.rs +1 -0
- package/dist-engine-src/src/sql2/test_support/differential.rs +712 -0
- package/dist-engine-src/src/sql2/test_support/generators.rs +354 -0
- package/dist-engine-src/src/sql2/test_support/mod.rs +2 -0
- package/dist-engine-src/src/sql2/udfs/{lix_active_version_commit_id.rs → lix_active_branch_commit_id.rs} +7 -7
- package/dist-engine-src/src/sql2/udfs/mod.rs +3 -6
- package/dist-engine-src/src/sql2/write_normalization.rs +45 -22
- package/dist-engine-src/src/storage/conformance.rs +399 -0
- package/dist-engine-src/src/storage/context.rs +552 -288
- package/dist-engine-src/src/storage/mod.rs +48 -10
- package/dist-engine-src/src/storage/point.rs +440 -0
- package/dist-engine-src/src/storage/read_scope.rs +43 -64
- package/dist-engine-src/src/storage/reader.rs +867 -0
- package/dist-engine-src/src/storage/scan.rs +784 -0
- package/dist-engine-src/src/storage/spaces.rs +236 -0
- package/dist-engine-src/src/storage/stats.rs +80 -0
- package/dist-engine-src/src/storage/write_set.rs +962 -0
- package/dist-engine-src/src/storage_bench.rs +136 -4828
- package/dist-engine-src/src/test_support.rs +360 -138
- package/dist-engine-src/src/tracked_state/bench_support.rs +394 -0
- package/dist-engine-src/src/tracked_state/codec.rs +155 -1057
- package/dist-engine-src/src/tracked_state/commit_root_rebuild.rs +358 -0
- package/dist-engine-src/src/tracked_state/context.rs +1927 -993
- package/dist-engine-src/src/tracked_state/diff.rs +1715 -261
- package/dist-engine-src/src/tracked_state/merge.rs +74 -88
- package/dist-engine-src/src/tracked_state/mod.rs +19 -16
- package/dist-engine-src/src/tracked_state/{materialization.rs → row_materialization.rs} +50 -178
- package/dist-engine-src/src/tracked_state/storage.rs +243 -191
- package/dist-engine-src/src/tracked_state/tree.rs +247 -371
- package/dist-engine-src/src/tracked_state/types.rs +49 -42
- package/dist-engine-src/src/transaction/bench_support.rs +407 -0
- package/dist-engine-src/src/transaction/commit.rs +821 -713
- package/dist-engine-src/src/transaction/context.rs +705 -600
- package/dist-engine-src/src/transaction/mod.rs +13 -2
- package/dist-engine-src/src/transaction/normalization.rs +63 -76
- package/dist-engine-src/src/transaction/prep.rs +13 -13
- package/dist-engine-src/src/transaction/schema_resolver.rs +19 -5
- package/dist-engine-src/src/transaction/staging.rs +228 -434
- package/dist-engine-src/src/transaction/types.rs +41 -98
- package/dist-engine-src/src/transaction/validation.rs +382 -446
- package/dist-engine-src/src/untracked_state/codec.rs +337 -29
- package/dist-engine-src/src/untracked_state/context.rs +7 -7
- package/dist-engine-src/src/untracked_state/materialization.rs +2 -2
- package/dist-engine-src/src/untracked_state/mod.rs +1 -1
- package/dist-engine-src/src/untracked_state/storage.rs +659 -157
- package/dist-engine-src/src/untracked_state/types.rs +21 -21
- package/package.json +71 -68
- package/dist-engine-src/src/backend/kv.rs +0 -358
- package/dist-engine-src/src/backend/testing.rs +0 -658
- package/dist-engine-src/src/commit_store/codec.rs +0 -887
- package/dist-engine-src/src/commit_store/context.rs +0 -944
- package/dist-engine-src/src/commit_store/materialization.rs +0 -84
- package/dist-engine-src/src/commit_store/mod.rs +0 -16
- package/dist-engine-src/src/commit_store/storage.rs +0 -600
- package/dist-engine-src/src/commit_store/types.rs +0 -215
- package/dist-engine-src/src/schema/builtin/lix_version_descriptor.json +0 -34
- package/dist-engine-src/src/schema/builtin/lix_version_ref.json +0 -48
- package/dist-engine-src/src/session/create_version.rs +0 -88
- package/dist-engine-src/src/session/merge/apply.rs +0 -23
- package/dist-engine-src/src/session/optimization9_sql2_bench.rs +0 -100
- package/dist-engine-src/src/session/switch_version.rs +0 -109
- package/dist-engine-src/src/sql2/classify.rs +0 -182
- package/dist-engine-src/src/sql2/entity_provider.rs +0 -3211
- package/dist-engine-src/src/sql2/execute.rs +0 -3440
- package/dist-engine-src/src/sql2/public_bind/assignment.rs +0 -46
- package/dist-engine-src/src/sql2/public_bind/capability.rs +0 -41
- package/dist-engine-src/src/sql2/public_bind/dml.rs +0 -166
- package/dist-engine-src/src/sql2/public_bind/mod.rs +0 -25
- package/dist-engine-src/src/sql2/public_bind/table.rs +0 -168
- package/dist-engine-src/src/sql2/version_scope.rs +0 -394
- package/dist-engine-src/src/storage/types.rs +0 -501
- package/dist-engine-src/src/tracked_state/by_file_index.rs +0 -98
- package/dist-engine-src/src/tracked_state/materializer.rs +0 -488
- package/dist-engine-src/src/transaction/live_state_overlay.rs +0 -35
- package/dist-engine-src/src/version/lifecycle.rs +0 -221
- package/dist-engine-src/src/version/mod.rs +0 -13
- package/dist-engine-src/src/version/refs.rs +0 -330
- package/dist-engine-src/src/version/stage_rows.rs +0 -67
- package/dist-engine-src/src/version/types.rs +0 -21
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
use crate::
|
|
2
|
-
use crate::
|
|
3
|
-
use crate::tracked_state::{
|
|
4
|
-
|
|
1
|
+
use crate::entity_pk::EntityPk;
|
|
2
|
+
use crate::json_store::JsonRef;
|
|
3
|
+
use crate::tracked_state::types::{
|
|
4
|
+
TrackedStateIndexValue, TrackedStateKey, TrackedStateTreeScanRequest,
|
|
5
5
|
};
|
|
6
|
+
use crate::tracked_state::{TrackedStateFilter, TrackedStateStoreReader};
|
|
6
7
|
use crate::LixError;
|
|
7
8
|
|
|
8
9
|
/// Filter for comparing two tracked-state commit roots.
|
|
@@ -26,21 +27,40 @@ pub(crate) struct TrackedStateDiffEntry {
|
|
|
26
27
|
///
|
|
27
28
|
/// This can be a tombstone. Callers that need user-visible semantics
|
|
28
29
|
/// should use `visible_before()` instead of inspecting this directly.
|
|
29
|
-
pub(crate) before: Option<
|
|
30
|
+
pub(crate) before: Option<TrackedStateDiffRow>,
|
|
30
31
|
/// Raw row in the right root.
|
|
31
32
|
///
|
|
32
33
|
/// This can be a tombstone. Keeping the raw tombstone is what lets merge
|
|
33
34
|
/// apply deletes without reloading the source root.
|
|
34
|
-
pub(crate) after: Option<
|
|
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,
|
|
35
55
|
}
|
|
36
56
|
|
|
37
57
|
/// Root-local tracked-state identity.
|
|
38
58
|
///
|
|
39
|
-
/// Entity
|
|
59
|
+
/// Entity pk used by merge/diff logic.
|
|
40
60
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
|
41
61
|
pub(crate) struct TrackedStateDiffIdentity {
|
|
42
62
|
pub(crate) schema_key: String,
|
|
43
|
-
pub(crate)
|
|
63
|
+
pub(crate) entity_pk: EntityPk,
|
|
44
64
|
pub(crate) file_id: Option<String>,
|
|
45
65
|
}
|
|
46
66
|
|
|
@@ -60,69 +80,64 @@ pub(crate) async fn diff_commits<S>(
|
|
|
60
80
|
request: &TrackedStateDiffRequest,
|
|
61
81
|
) -> Result<TrackedStateDiff, LixError>
|
|
62
82
|
where
|
|
63
|
-
S: crate::storage::
|
|
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,
|
|
64
98
|
{
|
|
65
99
|
let scan_request = scan_request_for_diff(request);
|
|
66
100
|
let tree_entries = reader
|
|
67
101
|
.diff_tree_entries_at_commits(left_commit_id, right_commit_id, &scan_request)
|
|
68
102
|
.await?;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
let
|
|
91
|
-
|
|
92
|
-
for pending_entry in pending_entries {
|
|
93
|
-
let before = materialized_row_at(pending_entry.before_index, &before_rows)?;
|
|
94
|
-
let after = materialized_row_at(pending_entry.after_index, &after_rows)?;
|
|
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 {
|
|
95
126
|
let identity = match before.as_ref().or(after.as_ref()) {
|
|
96
|
-
Some(row) => TrackedStateDiffIdentity::
|
|
127
|
+
Some(row) => TrackedStateDiffIdentity::from(row),
|
|
97
128
|
None => continue,
|
|
98
129
|
};
|
|
130
|
+
if identity.schema_key == "lix_commit" {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
99
133
|
let Some(entry) = classify_diff(identity, before, after) else {
|
|
100
134
|
continue;
|
|
101
135
|
};
|
|
102
136
|
entries.push(entry);
|
|
103
137
|
}
|
|
104
138
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
fn materialized_row_at(
|
|
109
|
-
index: Option<usize>,
|
|
110
|
-
rows: &[MaterializedTrackedStateRow],
|
|
111
|
-
) -> Result<Option<MaterializedTrackedStateRow>, LixError> {
|
|
112
|
-
let Some(index) = index else {
|
|
113
|
-
return Ok(None);
|
|
114
|
-
};
|
|
115
|
-
rows.get(index).cloned().map(Some).ok_or_else(|| {
|
|
116
|
-
LixError::new(
|
|
117
|
-
LixError::CODE_INTERNAL_ERROR,
|
|
118
|
-
"tracked_state diff materialization returned fewer rows than planned",
|
|
119
|
-
)
|
|
120
|
-
})
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
struct PendingDiffEntry {
|
|
124
|
-
before_index: Option<usize>,
|
|
125
|
-
after_index: Option<usize>,
|
|
139
|
+
let diff = TrackedStateDiff { entries };
|
|
140
|
+
Ok(diff)
|
|
126
141
|
}
|
|
127
142
|
|
|
128
143
|
fn scan_request_for_diff(request: &TrackedStateDiffRequest) -> TrackedStateTreeScanRequest {
|
|
@@ -130,7 +145,7 @@ fn scan_request_for_diff(request: &TrackedStateDiffRequest) -> TrackedStateTreeS
|
|
|
130
145
|
filter.include_tombstones = true;
|
|
131
146
|
TrackedStateTreeScanRequest {
|
|
132
147
|
schema_keys: filter.schema_keys,
|
|
133
|
-
|
|
148
|
+
entity_pks: filter.entity_pks,
|
|
134
149
|
file_ids: filter.file_ids,
|
|
135
150
|
include_tombstones: true,
|
|
136
151
|
limit: None,
|
|
@@ -139,8 +154,8 @@ fn scan_request_for_diff(request: &TrackedStateDiffRequest) -> TrackedStateTreeS
|
|
|
139
154
|
|
|
140
155
|
fn classify_diff(
|
|
141
156
|
identity: TrackedStateDiffIdentity,
|
|
142
|
-
before: Option<
|
|
143
|
-
after: Option<
|
|
157
|
+
before: Option<TrackedStateDiffRow>,
|
|
158
|
+
after: Option<TrackedStateDiffRow>,
|
|
144
159
|
) -> Option<TrackedStateDiffEntry> {
|
|
145
160
|
match (is_live_row(before.as_ref()), is_live_row(after.as_ref())) {
|
|
146
161
|
(None, None) => None,
|
|
@@ -166,24 +181,57 @@ fn classify_diff(
|
|
|
166
181
|
}
|
|
167
182
|
}
|
|
168
183
|
|
|
169
|
-
fn is_live_row(row: Option<&
|
|
170
|
-
row.filter(|row| row.
|
|
184
|
+
fn is_live_row(row: Option<&TrackedStateDiffRow>) -> Option<&TrackedStateDiffRow> {
|
|
185
|
+
row.filter(|row| !row.deleted)
|
|
171
186
|
}
|
|
172
187
|
|
|
173
|
-
fn tracked_row_payload_eq(
|
|
174
|
-
left
|
|
175
|
-
right: &MaterializedTrackedStateRow,
|
|
176
|
-
) -> bool {
|
|
177
|
-
left.snapshot_content == right.snapshot_content && left.metadata == right.metadata
|
|
188
|
+
fn tracked_row_payload_eq(left: &TrackedStateDiffRow, right: &TrackedStateDiffRow) -> bool {
|
|
189
|
+
left.snapshot_ref == right.snapshot_ref && left.metadata_ref == right.metadata_ref
|
|
178
190
|
}
|
|
179
191
|
|
|
180
192
|
impl TrackedStateDiffIdentity {
|
|
181
|
-
fn
|
|
182
|
-
|
|
193
|
+
fn from(row: &TrackedStateDiffRow) -> Self {
|
|
194
|
+
Self {
|
|
183
195
|
schema_key: row.schema_key.clone(),
|
|
184
|
-
|
|
196
|
+
entity_pk: row.entity_pk.clone(),
|
|
185
197
|
file_id: row.file_id.clone(),
|
|
186
|
-
}
|
|
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
|
+
)
|
|
187
235
|
}
|
|
188
236
|
}
|
|
189
237
|
|
|
@@ -199,35 +247,33 @@ impl TrackedStateDiffEntry {
|
|
|
199
247
|
}
|
|
200
248
|
|
|
201
249
|
#[cfg(test)]
|
|
202
|
-
pub(crate) fn visible_before(&self) -> Option<&
|
|
203
|
-
self.before
|
|
204
|
-
.as_ref()
|
|
205
|
-
.filter(|row| row.snapshot_content.is_some())
|
|
250
|
+
pub(crate) fn visible_before(&self) -> Option<&TrackedStateDiffRow> {
|
|
251
|
+
self.before.as_ref().filter(|row| !row.deleted)
|
|
206
252
|
}
|
|
207
253
|
|
|
208
254
|
#[cfg(test)]
|
|
209
|
-
pub(crate) fn visible_after(&self) -> Option<&
|
|
210
|
-
self.after
|
|
211
|
-
.as_ref()
|
|
212
|
-
.filter(|row| row.snapshot_content.is_some())
|
|
255
|
+
pub(crate) fn visible_after(&self) -> Option<&TrackedStateDiffRow> {
|
|
256
|
+
self.after.as_ref().filter(|row| !row.deleted)
|
|
213
257
|
}
|
|
214
258
|
}
|
|
215
259
|
|
|
216
260
|
#[cfg(test)]
|
|
217
261
|
mod tests {
|
|
218
|
-
use std::sync::Arc;
|
|
219
|
-
|
|
220
262
|
use super::*;
|
|
221
|
-
use crate::
|
|
222
|
-
use crate::storage::{
|
|
223
|
-
use crate::tracked_state::
|
|
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};
|
|
224
270
|
use crate::NullableKeyFilter;
|
|
225
271
|
|
|
226
272
|
#[tokio::test]
|
|
227
273
|
async fn diff_commits_reports_added_rows() {
|
|
228
274
|
let (storage, tracked_state) = seed_roots(&[], &[row("entity-a", None, "after")]).await;
|
|
229
275
|
|
|
230
|
-
let diff = diff(storage
|
|
276
|
+
let diff = diff(&storage, &tracked_state).await;
|
|
231
277
|
|
|
232
278
|
assert_eq!(
|
|
233
279
|
kinds(&diff),
|
|
@@ -249,7 +295,7 @@ mod tests {
|
|
|
249
295
|
async fn diff_commits_reports_removed_rows_when_right_side_is_absent() {
|
|
250
296
|
let (storage, tracked_state) = seed_roots(&[row("entity-a", None, "before")], &[]).await;
|
|
251
297
|
|
|
252
|
-
let diff = diff(storage
|
|
298
|
+
let diff = diff(&storage, &tracked_state).await;
|
|
253
299
|
|
|
254
300
|
assert_eq!(
|
|
255
301
|
kinds(&diff),
|
|
@@ -275,7 +321,7 @@ mod tests {
|
|
|
275
321
|
)
|
|
276
322
|
.await;
|
|
277
323
|
|
|
278
|
-
let diff = diff(storage
|
|
324
|
+
let diff = diff(&storage, &tracked_state).await;
|
|
279
325
|
|
|
280
326
|
assert_eq!(
|
|
281
327
|
kinds(&diff),
|
|
@@ -287,10 +333,7 @@ mod tests {
|
|
|
287
333
|
Some("delete")
|
|
288
334
|
);
|
|
289
335
|
assert!(
|
|
290
|
-
entry
|
|
291
|
-
.after
|
|
292
|
-
.as_ref()
|
|
293
|
-
.is_some_and(|row| row.snapshot_content.is_none()),
|
|
336
|
+
entry.after.as_ref().is_some_and(|row| row.deleted),
|
|
294
337
|
"removed diff should preserve the right-side tombstone for merge"
|
|
295
338
|
);
|
|
296
339
|
assert!(entry.before_is_live());
|
|
@@ -305,7 +348,7 @@ mod tests {
|
|
|
305
348
|
)
|
|
306
349
|
.await;
|
|
307
350
|
|
|
308
|
-
let diff = diff(storage
|
|
351
|
+
let diff = diff(&storage, &tracked_state).await;
|
|
309
352
|
|
|
310
353
|
assert_eq!(
|
|
311
354
|
kinds(&diff),
|
|
@@ -317,10 +360,7 @@ mod tests {
|
|
|
317
360
|
Some("delete")
|
|
318
361
|
);
|
|
319
362
|
assert!(
|
|
320
|
-
entry
|
|
321
|
-
.before
|
|
322
|
-
.as_ref()
|
|
323
|
-
.is_some_and(|row| row.snapshot_content.is_none()),
|
|
363
|
+
entry.before.as_ref().is_some_and(|row| row.deleted),
|
|
324
364
|
"added diff should preserve the left-side tombstone for merge"
|
|
325
365
|
);
|
|
326
366
|
assert!(!entry.before_is_live());
|
|
@@ -335,7 +375,7 @@ mod tests {
|
|
|
335
375
|
)
|
|
336
376
|
.await;
|
|
337
377
|
|
|
338
|
-
let diff = diff(storage
|
|
378
|
+
let diff = diff(&storage, &tracked_state).await;
|
|
339
379
|
|
|
340
380
|
assert_eq!(
|
|
341
381
|
kinds(&diff),
|
|
@@ -353,23 +393,27 @@ mod tests {
|
|
|
353
393
|
)
|
|
354
394
|
.await;
|
|
355
395
|
|
|
356
|
-
let diff = diff(storage
|
|
396
|
+
let diff = diff(&storage, &tracked_state).await;
|
|
357
397
|
|
|
358
398
|
assert!(diff.entries.is_empty());
|
|
359
399
|
}
|
|
360
400
|
|
|
361
401
|
#[tokio::test]
|
|
362
402
|
async fn diff_commits_distinguishes_same_entity_with_different_file_id() {
|
|
363
|
-
let (storage, tracked_state) =
|
|
403
|
+
let (storage, tracked_state) = seed_parent_child_delta(
|
|
364
404
|
&[row("entity-a", Some("file-a"), "before-a")],
|
|
365
|
-
&[
|
|
366
|
-
row("entity-a", Some("file-a"), "before-a"),
|
|
367
|
-
row("entity-a", Some("file-b"), "after-b"),
|
|
368
|
-
],
|
|
405
|
+
&[row("entity-a", Some("file-b"), "after-b")],
|
|
369
406
|
)
|
|
370
407
|
.await;
|
|
371
408
|
|
|
372
|
-
let
|
|
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");
|
|
373
417
|
|
|
374
418
|
assert_eq!(diff.entries.len(), 1);
|
|
375
419
|
assert_eq!(diff.entries[0].identity.file_id.as_deref(), Some("file-b"));
|
|
@@ -386,7 +430,10 @@ mod tests {
|
|
|
386
430
|
],
|
|
387
431
|
)
|
|
388
432
|
.await;
|
|
389
|
-
let
|
|
433
|
+
let read = storage
|
|
434
|
+
.begin_read(StorageReadOptions::default())
|
|
435
|
+
.expect("read should open");
|
|
436
|
+
let mut reader = tracked_state.reader(read);
|
|
390
437
|
let diff = reader
|
|
391
438
|
.diff_commits(
|
|
392
439
|
"left",
|
|
@@ -394,9 +441,7 @@ mod tests {
|
|
|
394
441
|
&TrackedStateDiffRequest {
|
|
395
442
|
filter: TrackedStateFilter {
|
|
396
443
|
schema_keys: vec!["schema-b".to_string()],
|
|
397
|
-
|
|
398
|
-
"entity-b",
|
|
399
|
-
)],
|
|
444
|
+
entity_pks: vec![crate::entity_pk::EntityPk::single("entity-b")],
|
|
400
445
|
file_ids: vec![NullableKeyFilter::Value("file-b".to_string())],
|
|
401
446
|
..Default::default()
|
|
402
447
|
},
|
|
@@ -412,200 +457,1492 @@ mod tests {
|
|
|
412
457
|
}
|
|
413
458
|
|
|
414
459
|
#[tokio::test]
|
|
415
|
-
async fn
|
|
416
|
-
let
|
|
417
|
-
let
|
|
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());
|
|
418
570
|
let tracked_state = TrackedStateContext::new();
|
|
419
|
-
|
|
420
|
-
.begin_write_transaction()
|
|
571
|
+
write_root_committed_for_test(&storage, &tracked_state, "left", None, &[])
|
|
421
572
|
.await
|
|
422
|
-
.expect("
|
|
423
|
-
|
|
424
|
-
|
|
573
|
+
.expect("left root should write");
|
|
574
|
+
write_root_committed_for_test(
|
|
575
|
+
&storage,
|
|
425
576
|
&tracked_state,
|
|
426
577
|
"parent",
|
|
427
578
|
None,
|
|
428
|
-
&[
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
+
)],
|
|
432
587
|
)
|
|
433
588
|
.await
|
|
434
|
-
.expect("parent should write");
|
|
435
|
-
|
|
436
|
-
|
|
589
|
+
.expect("parent root should write");
|
|
590
|
+
write_root_committed_for_test(
|
|
591
|
+
&storage,
|
|
437
592
|
&tracked_state,
|
|
438
593
|
"child",
|
|
439
594
|
Some("parent"),
|
|
440
|
-
&[
|
|
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
|
+
)],
|
|
441
603
|
)
|
|
442
604
|
.await
|
|
443
|
-
.expect("child should write");
|
|
444
|
-
tx.commit().await.expect("transaction should commit");
|
|
605
|
+
.expect("child root should write");
|
|
445
606
|
|
|
446
|
-
let
|
|
447
|
-
.
|
|
448
|
-
.
|
|
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())
|
|
449
613
|
.await
|
|
450
|
-
.expect("
|
|
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;
|
|
451
635
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
diff.entries[0]
|
|
465
|
-
.after
|
|
466
|
-
.as_ref()
|
|
467
|
-
.and_then(|row| row.snapshot_content.as_deref()),
|
|
468
|
-
Some("{\"value\":\"after\"}")
|
|
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}"
|
|
469
648
|
);
|
|
470
649
|
}
|
|
471
650
|
|
|
472
651
|
#[tokio::test]
|
|
473
|
-
async fn
|
|
474
|
-
let
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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")],
|
|
480
661
|
)
|
|
481
|
-
.await
|
|
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");
|
|
482
673
|
|
|
483
|
-
let
|
|
484
|
-
.
|
|
485
|
-
.
|
|
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())
|
|
486
680
|
.await
|
|
487
|
-
.expect("diff should load");
|
|
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);
|
|
488
731
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
.
|
|
503
|
-
|
|
504
|
-
.and_then(|row| row.snapshot_content.as_deref()),
|
|
505
|
-
Some("{\"value\":\"before\"}")
|
|
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}"
|
|
506
747
|
);
|
|
507
748
|
}
|
|
508
749
|
|
|
509
750
|
#[tokio::test]
|
|
510
|
-
async fn
|
|
511
|
-
let
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
+
}],
|
|
517
798
|
)
|
|
518
799
|
.await;
|
|
519
800
|
|
|
520
|
-
let
|
|
521
|
-
.
|
|
522
|
-
.
|
|
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())
|
|
523
807
|
.await
|
|
524
|
-
.
|
|
808
|
+
.expect_err("stale ancestor winner must be rejected");
|
|
525
809
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
);
|
|
530
|
-
assert!(diff.entries[0].before_is_live());
|
|
531
|
-
assert!(!diff.entries[0].after_is_live());
|
|
532
|
-
assert_eq!(
|
|
533
|
-
diff.entries[0]
|
|
534
|
-
.after
|
|
535
|
-
.as_ref()
|
|
536
|
-
.map(|row| row.change_id.as_str()),
|
|
537
|
-
Some("child-delete")
|
|
810
|
+
assert!(
|
|
811
|
+
is_commit_root_validation_error(&error),
|
|
812
|
+
"unexpected error: {error}"
|
|
538
813
|
);
|
|
539
814
|
}
|
|
540
815
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
tracked_state
|
|
546
|
-
.reader(storage)
|
|
547
|
-
.diff_commits("left", "right", &TrackedStateDiffRequest::default())
|
|
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, &[])
|
|
548
821
|
.await
|
|
549
|
-
.expect("
|
|
550
|
-
|
|
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");
|
|
551
837
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
let tracked_state = TrackedStateContext::new();
|
|
559
|
-
let mut tx = storage
|
|
560
|
-
.begin_write_transaction()
|
|
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())
|
|
561
844
|
.await
|
|
562
|
-
.expect("
|
|
563
|
-
|
|
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
|
+
)
|
|
564
864
|
.await
|
|
565
|
-
.expect("
|
|
566
|
-
|
|
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())
|
|
567
906
|
.await
|
|
568
|
-
.
|
|
569
|
-
|
|
570
|
-
(
|
|
907
|
+
.expect_err("unreachable valid change must be rejected");
|
|
908
|
+
|
|
909
|
+
assert!(
|
|
910
|
+
is_commit_root_validation_error(&error),
|
|
911
|
+
"unexpected error: {error}"
|
|
912
|
+
);
|
|
571
913
|
}
|
|
572
914
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
) -> (StorageContext, TrackedStateContext) {
|
|
577
|
-
let backend = Arc::new(UnitTestBackend::new());
|
|
578
|
-
let storage = StorageContext::new(backend.clone());
|
|
915
|
+
#[tokio::test]
|
|
916
|
+
async fn diff_commits_rejects_second_parent_row_without_commit_root_proof() {
|
|
917
|
+
let storage = StorageContext::new(InMemoryStorageBackend::new());
|
|
579
918
|
let tracked_state = TrackedStateContext::new();
|
|
580
|
-
|
|
581
|
-
.begin_write_transaction()
|
|
919
|
+
write_root_committed_for_test(&storage, &tracked_state, "left", None, &[])
|
|
582
920
|
.await
|
|
583
|
-
.expect("
|
|
584
|
-
|
|
921
|
+
.expect("left root should write");
|
|
922
|
+
write_root_committed_for_test(&storage, &tracked_state, "target", None, &[])
|
|
585
923
|
.await
|
|
586
|
-
.expect("
|
|
587
|
-
|
|
588
|
-
|
|
924
|
+
.expect("target root should write");
|
|
925
|
+
write_root_committed_for_test(
|
|
926
|
+
&storage,
|
|
589
927
|
&tracked_state,
|
|
590
|
-
"
|
|
591
|
-
|
|
592
|
-
|
|
928
|
+
"source",
|
|
929
|
+
None,
|
|
930
|
+
&[row_with_value("entity-a", None, "source-change", "value")],
|
|
593
931
|
)
|
|
594
932
|
.await
|
|
595
|
-
.expect("
|
|
596
|
-
tx.commit().await.expect("transaction should commit");
|
|
597
|
-
(storage, tracked_state)
|
|
598
|
-
}
|
|
933
|
+
.expect("source root should write");
|
|
599
934
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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>,
|
|
605
1941
|
rows: &[MaterializedTrackedStateRow],
|
|
606
1942
|
) -> Result<(), LixError> {
|
|
607
1943
|
crate::test_support::stage_tracked_root_from_materialized(
|
|
608
|
-
|
|
1944
|
+
read,
|
|
1945
|
+
writes,
|
|
609
1946
|
tracked_state,
|
|
610
1947
|
commit_id,
|
|
611
1948
|
parent_commit_id,
|
|
@@ -614,6 +1951,62 @@ mod tests {
|
|
|
614
1951
|
.await
|
|
615
1952
|
}
|
|
616
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
|
+
|
|
617
2010
|
fn kinds(diff: &TrackedStateDiff) -> Vec<(String, TrackedStateDiffKind)> {
|
|
618
2011
|
diff.entries
|
|
619
2012
|
.iter()
|
|
@@ -621,7 +2014,7 @@ mod tests {
|
|
|
621
2014
|
(
|
|
622
2015
|
entry
|
|
623
2016
|
.identity
|
|
624
|
-
.
|
|
2017
|
+
.entity_pk
|
|
625
2018
|
.as_single_string_owned()
|
|
626
2019
|
.expect("identity"),
|
|
627
2020
|
entry.kind,
|
|
@@ -630,48 +2023,109 @@ mod tests {
|
|
|
630
2023
|
.collect()
|
|
631
2024
|
}
|
|
632
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
|
+
|
|
633
2073
|
fn tombstone(
|
|
634
|
-
|
|
2074
|
+
entity_pk: &str,
|
|
635
2075
|
file_id: Option<&str>,
|
|
636
2076
|
change_id: &str,
|
|
637
2077
|
) -> MaterializedTrackedStateRow {
|
|
638
|
-
let mut row = row(
|
|
2078
|
+
let mut row = row(entity_pk, file_id, change_id);
|
|
639
2079
|
row.snapshot_content = None;
|
|
640
2080
|
row.deleted = true;
|
|
641
2081
|
row
|
|
642
2082
|
}
|
|
643
2083
|
|
|
644
|
-
fn row(
|
|
645
|
-
row_with_schema(
|
|
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)
|
|
646
2086
|
}
|
|
647
2087
|
|
|
648
2088
|
fn row_with_schema(
|
|
649
|
-
|
|
2089
|
+
entity_pk: &str,
|
|
650
2090
|
file_id: Option<&str>,
|
|
651
2091
|
schema_key: &str,
|
|
652
2092
|
change_id: &str,
|
|
653
2093
|
) -> MaterializedTrackedStateRow {
|
|
654
|
-
row_with_schema_and_value(
|
|
2094
|
+
row_with_schema_and_value(entity_pk, file_id, schema_key, change_id, "value")
|
|
655
2095
|
}
|
|
656
2096
|
|
|
657
2097
|
fn row_with_value(
|
|
658
|
-
|
|
2098
|
+
entity_pk: &str,
|
|
659
2099
|
file_id: Option<&str>,
|
|
660
2100
|
change_id: &str,
|
|
661
2101
|
value: &str,
|
|
662
2102
|
) -> MaterializedTrackedStateRow {
|
|
663
|
-
row_with_schema_and_value(
|
|
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
|
|
664
2118
|
}
|
|
665
2119
|
|
|
666
2120
|
fn row_with_schema_and_value(
|
|
667
|
-
|
|
2121
|
+
entity_pk: &str,
|
|
668
2122
|
file_id: Option<&str>,
|
|
669
2123
|
schema_key: &str,
|
|
670
2124
|
change_id: &str,
|
|
671
2125
|
value: &str,
|
|
672
2126
|
) -> MaterializedTrackedStateRow {
|
|
673
2127
|
MaterializedTrackedStateRow {
|
|
674
|
-
|
|
2128
|
+
entity_pk: EntityPk::single(entity_pk),
|
|
675
2129
|
schema_key: schema_key.to_string(),
|
|
676
2130
|
file_id: file_id.map(str::to_string),
|
|
677
2131
|
snapshot_content: Some(format!("{{\"value\":\"{value}\"}}")),
|