@lix-js/sdk 0.6.0-preview.0 → 0.6.0-preview.2
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 +9 -0
- package/SKILL.md +468 -0
- package/dist/engine-wasm/index.d.ts +15 -11
- package/dist/engine-wasm/index.js +105 -38
- package/dist/engine-wasm/wasm/lix_engine.d.ts +14 -2
- package/dist/engine-wasm/wasm/lix_engine.js +18 -17
- package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
- package/dist/engine-wasm/wasm/lix_engine.wasm.d.ts +2 -1
- package/dist/generated/builtin-schemas.d.ts +31 -41
- package/dist/generated/builtin-schemas.js +52 -56
- package/dist/open-lix.d.ts +141 -24
- package/dist/open-lix.js +199 -35
- package/dist/sqlite/index.js +99 -22
- package/dist-engine-src/README.md +18 -0
- package/dist-engine-src/src/backend/kv.rs +358 -0
- package/dist-engine-src/src/backend/mod.rs +12 -0
- package/dist-engine-src/src/backend/testing.rs +658 -0
- package/dist-engine-src/src/backend/types.rs +96 -0
- package/dist-engine-src/src/binary_cas/chunking.rs +31 -0
- package/dist-engine-src/src/binary_cas/codec.rs +346 -0
- package/dist-engine-src/src/binary_cas/context.rs +139 -0
- package/dist-engine-src/src/binary_cas/kv.rs +1063 -0
- package/dist-engine-src/src/binary_cas/mod.rs +11 -0
- package/dist-engine-src/src/binary_cas/types.rs +127 -0
- package/dist-engine-src/src/cel/context.rs +86 -0
- package/dist-engine-src/src/cel/error.rs +19 -0
- package/dist-engine-src/src/cel/mod.rs +8 -0
- package/dist-engine-src/src/cel/provider.rs +9 -0
- package/dist-engine-src/src/cel/runtime.rs +167 -0
- package/dist-engine-src/src/cel/value.rs +50 -0
- package/dist-engine-src/src/changelog/codec.rs +321 -0
- package/dist-engine-src/src/changelog/context.rs +92 -0
- package/dist-engine-src/src/changelog/materialization.rs +121 -0
- package/dist-engine-src/src/changelog/mod.rs +13 -0
- package/dist-engine-src/src/changelog/reader.rs +20 -0
- package/dist-engine-src/src/changelog/storage.rs +220 -0
- package/dist-engine-src/src/changelog/types.rs +38 -0
- package/dist-engine-src/src/commit_graph/context.rs +1588 -0
- package/dist-engine-src/src/commit_graph/mod.rs +12 -0
- package/dist-engine-src/src/commit_graph/types.rs +145 -0
- package/dist-engine-src/src/commit_graph/walker.rs +780 -0
- package/dist-engine-src/src/common/error.rs +313 -0
- package/dist-engine-src/src/common/fingerprint.rs +3 -0
- package/dist-engine-src/src/common/fs_path.rs +1336 -0
- package/dist-engine-src/src/common/identity.rs +135 -0
- package/dist-engine-src/src/common/metadata.rs +35 -0
- package/dist-engine-src/src/common/mod.rs +23 -0
- package/dist-engine-src/src/common/types.rs +105 -0
- package/dist-engine-src/src/common/wire.rs +222 -0
- package/dist-engine-src/src/engine.rs +239 -0
- package/dist-engine-src/src/entity_identity.rs +285 -0
- package/dist-engine-src/src/functions/context.rs +327 -0
- package/dist-engine-src/src/functions/deterministic.rs +113 -0
- package/dist-engine-src/src/functions/mod.rs +18 -0
- package/dist-engine-src/src/functions/provider.rs +130 -0
- package/dist-engine-src/src/functions/state.rs +363 -0
- package/dist-engine-src/src/functions/types.rs +37 -0
- package/dist-engine-src/src/init.rs +505 -0
- package/dist-engine-src/src/json_store/compression.rs +77 -0
- package/dist-engine-src/src/json_store/context.rs +129 -0
- package/dist-engine-src/src/json_store/encoded.rs +15 -0
- package/dist-engine-src/src/json_store/mod.rs +9 -0
- package/dist-engine-src/src/json_store/store.rs +236 -0
- package/dist-engine-src/src/json_store/types.rs +52 -0
- package/dist-engine-src/src/lib.rs +61 -0
- package/dist-engine-src/src/live_state/context.rs +2241 -0
- package/dist-engine-src/src/live_state/mod.rs +15 -0
- package/dist-engine-src/src/live_state/overlay.rs +75 -0
- package/dist-engine-src/src/live_state/reader.rs +23 -0
- package/dist-engine-src/src/live_state/types.rs +239 -0
- package/dist-engine-src/src/live_state/visibility.rs +218 -0
- package/dist-engine-src/src/plugin/archive.rs +441 -0
- package/dist-engine-src/src/plugin/component.rs +183 -0
- package/dist-engine-src/src/plugin/install.rs +637 -0
- package/dist-engine-src/src/plugin/manifest.rs +516 -0
- package/dist-engine-src/src/plugin/materializer.rs +477 -0
- package/dist-engine-src/src/plugin/mod.rs +33 -0
- package/dist-engine-src/src/plugin/plugin_manifest.json +119 -0
- package/dist-engine-src/src/plugin/storage.rs +74 -0
- package/dist-engine-src/src/schema/annotations/defaults.rs +280 -0
- package/dist-engine-src/src/schema/annotations/mod.rs +1 -0
- package/dist-engine-src/src/schema/builtin/lix_account.json +22 -0
- package/dist-engine-src/src/schema/builtin/lix_active_account.json +30 -0
- package/dist-engine-src/src/schema/builtin/lix_binary_blob_ref.json +30 -0
- package/dist-engine-src/src/schema/builtin/lix_change.json +62 -0
- package/dist-engine-src/src/schema/builtin/lix_change_author.json +46 -0
- package/dist-engine-src/src/schema/builtin/lix_change_set.json +18 -0
- package/dist-engine-src/src/schema/builtin/lix_change_set_element.json +75 -0
- package/dist-engine-src/src/schema/builtin/lix_commit.json +62 -0
- package/dist-engine-src/src/schema/builtin/lix_commit_edge.json +46 -0
- package/dist-engine-src/src/schema/builtin/lix_directory_descriptor.json +53 -0
- package/dist-engine-src/src/schema/builtin/lix_entity_label.json +63 -0
- package/dist-engine-src/src/schema/builtin/lix_file_descriptor.json +53 -0
- package/dist-engine-src/src/schema/builtin/lix_key_value.json +41 -0
- package/dist-engine-src/src/schema/builtin/lix_label.json +22 -0
- package/dist-engine-src/src/schema/builtin/lix_registered_schema.json +31 -0
- package/dist-engine-src/src/schema/builtin/lix_version_descriptor.json +35 -0
- package/dist-engine-src/src/schema/builtin/lix_version_ref.json +49 -0
- package/dist-engine-src/src/schema/builtin/mod.rs +271 -0
- package/dist-engine-src/src/schema/definition.json +157 -0
- package/dist-engine-src/src/schema/definition.rs +636 -0
- package/dist-engine-src/src/schema/key.rs +206 -0
- package/dist-engine-src/src/schema/mod.rs +20 -0
- package/dist-engine-src/src/schema/seed.rs +14 -0
- package/dist-engine-src/src/schema/tests.rs +739 -0
- package/dist-engine-src/src/schema_registry.rs +294 -0
- package/dist-engine-src/src/session/context.rs +366 -0
- package/dist-engine-src/src/session/create_version.rs +80 -0
- package/dist-engine-src/src/session/execute.rs +447 -0
- package/dist-engine-src/src/session/merge/analysis.rs +102 -0
- package/dist-engine-src/src/session/merge/apply.rs +23 -0
- package/dist-engine-src/src/session/merge/conflicts.rs +62 -0
- package/dist-engine-src/src/session/merge/mod.rs +11 -0
- package/dist-engine-src/src/session/merge/stats.rs +65 -0
- package/dist-engine-src/src/session/merge/version.rs +437 -0
- package/dist-engine-src/src/session/mod.rs +25 -0
- package/dist-engine-src/src/session/switch_version.rs +121 -0
- package/dist-engine-src/src/sql2/change_provider.rs +337 -0
- package/dist-engine-src/src/sql2/classify.rs +147 -0
- package/dist-engine-src/src/sql2/commit_derived_provider.rs +591 -0
- package/dist-engine-src/src/sql2/context.rs +307 -0
- package/dist-engine-src/src/sql2/directory_history_provider.rs +623 -0
- package/dist-engine-src/src/sql2/directory_provider.rs +2405 -0
- package/dist-engine-src/src/sql2/dml.rs +148 -0
- package/dist-engine-src/src/sql2/entity_history_provider.rs +444 -0
- package/dist-engine-src/src/sql2/entity_provider.rs +2700 -0
- package/dist-engine-src/src/sql2/error.rs +196 -0
- package/dist-engine-src/src/sql2/execute.rs +3379 -0
- package/dist-engine-src/src/sql2/file_history_provider.rs +902 -0
- package/dist-engine-src/src/sql2/file_provider.rs +3254 -0
- package/dist-engine-src/src/sql2/filesystem_planner.rs +1526 -0
- package/dist-engine-src/src/sql2/filesystem_predicates.rs +159 -0
- package/dist-engine-src/src/sql2/filesystem_visibility.rs +369 -0
- package/dist-engine-src/src/sql2/history_projection.rs +80 -0
- package/dist-engine-src/src/sql2/history_provider.rs +418 -0
- package/dist-engine-src/src/sql2/history_route.rs +643 -0
- package/dist-engine-src/src/sql2/lix_state_provider.rs +2430 -0
- package/dist-engine-src/src/sql2/mod.rs +43 -0
- package/dist-engine-src/src/sql2/read_only.rs +65 -0
- package/dist-engine-src/src/sql2/record_batch.rs +17 -0
- package/dist-engine-src/src/sql2/result_metadata.rs +29 -0
- package/dist-engine-src/src/sql2/runtime.rs +60 -0
- package/dist-engine-src/src/sql2/session.rs +135 -0
- package/dist-engine-src/src/sql2/udfs/common.rs +295 -0
- package/dist-engine-src/src/sql2/udfs/lix_active_version_commit_id.rs +53 -0
- package/dist-engine-src/src/sql2/udfs/lix_empty_blob.rs +47 -0
- package/dist-engine-src/src/sql2/udfs/lix_json.rs +100 -0
- package/dist-engine-src/src/sql2/udfs/lix_json_get.rs +99 -0
- package/dist-engine-src/src/sql2/udfs/lix_json_get_text.rs +99 -0
- package/dist-engine-src/src/sql2/udfs/lix_text_decode.rs +82 -0
- package/dist-engine-src/src/sql2/udfs/lix_text_encode.rs +85 -0
- package/dist-engine-src/src/sql2/udfs/lix_uuid_v7.rs +76 -0
- package/dist-engine-src/src/sql2/udfs/mod.rs +82 -0
- package/dist-engine-src/src/sql2/version_provider.rs +1187 -0
- package/dist-engine-src/src/sql2/version_scope.rs +394 -0
- package/dist-engine-src/src/sql2/write_normalization.rs +345 -0
- package/dist-engine-src/src/storage/context.rs +356 -0
- package/dist-engine-src/src/storage/mod.rs +14 -0
- package/dist-engine-src/src/storage/read_scope.rs +88 -0
- package/dist-engine-src/src/storage/types.rs +501 -0
- package/dist-engine-src/src/storage_bench.rs +3406 -0
- package/dist-engine-src/src/test_support.rs +81 -0
- package/dist-engine-src/src/tracked_state/by_file_index.rs +102 -0
- package/dist-engine-src/src/tracked_state/codec.rs +747 -0
- package/dist-engine-src/src/tracked_state/context.rs +983 -0
- package/dist-engine-src/src/tracked_state/diff.rs +494 -0
- package/dist-engine-src/src/tracked_state/materialization.rs +141 -0
- package/dist-engine-src/src/tracked_state/merge.rs +474 -0
- package/dist-engine-src/src/tracked_state/mod.rs +31 -0
- package/dist-engine-src/src/tracked_state/rebuild.rs +771 -0
- package/dist-engine-src/src/tracked_state/storage.rs +243 -0
- package/dist-engine-src/src/tracked_state/tree.rs +2744 -0
- package/dist-engine-src/src/tracked_state/tree_types.rs +176 -0
- package/dist-engine-src/src/tracked_state/types.rs +61 -0
- package/dist-engine-src/src/transaction/commit.rs +1224 -0
- package/dist-engine-src/src/transaction/context.rs +1307 -0
- package/dist-engine-src/src/transaction/live_state_overlay.rs +34 -0
- package/dist-engine-src/src/transaction/mod.rs +11 -0
- package/dist-engine-src/src/transaction/normalization.rs +1026 -0
- package/dist-engine-src/src/transaction/schema_resolver.rs +127 -0
- package/dist-engine-src/src/transaction/staging.rs +1436 -0
- package/dist-engine-src/src/transaction/types.rs +351 -0
- package/dist-engine-src/src/transaction/validation.rs +4811 -0
- package/dist-engine-src/src/untracked_state/codec.rs +363 -0
- package/dist-engine-src/src/untracked_state/context.rs +82 -0
- package/dist-engine-src/src/untracked_state/materialization.rs +157 -0
- package/dist-engine-src/src/untracked_state/mod.rs +17 -0
- package/dist-engine-src/src/untracked_state/storage.rs +348 -0
- package/dist-engine-src/src/untracked_state/types.rs +96 -0
- package/dist-engine-src/src/version/context.rs +52 -0
- package/dist-engine-src/src/version/mod.rs +12 -0
- package/dist-engine-src/src/version/refs.rs +421 -0
- package/dist-engine-src/src/version/stage_rows.rs +71 -0
- package/dist-engine-src/src/version/types.rs +21 -0
- package/dist-engine-src/src/wasm/mod.rs +60 -0
- package/package.json +68 -63
|
@@ -0,0 +1,1588 @@
|
|
|
1
|
+
use std::collections::{BTreeMap, BTreeSet};
|
|
2
|
+
|
|
3
|
+
use crate::changelog::{
|
|
4
|
+
materialize_change, CanonicalChange, ChangelogContext, ChangelogStoreReader,
|
|
5
|
+
MaterializedCanonicalChange,
|
|
6
|
+
};
|
|
7
|
+
use crate::commit_graph::walker::{best_common_ancestors, walk_reachable_commits};
|
|
8
|
+
use crate::commit_graph::{
|
|
9
|
+
CommitGraphChangeHistoryEntry, CommitGraphChangeHistoryRequest, CommitGraphChangeSet,
|
|
10
|
+
CommitGraphChangeSetElement, CommitGraphCommit, CommitGraphEdge, CommitGraphEntity,
|
|
11
|
+
CommitGraphReader, ReachableCommitGraphCommit,
|
|
12
|
+
};
|
|
13
|
+
use crate::entity_identity::EntityIdentity;
|
|
14
|
+
use crate::json_store::{JsonStoreContext, JsonStoreReader};
|
|
15
|
+
use crate::storage::StorageReader;
|
|
16
|
+
use crate::storage::{ScopedStorageReader, StorageReadScope};
|
|
17
|
+
use crate::LixError;
|
|
18
|
+
|
|
19
|
+
const COMMIT_SCHEMA_KEY: &str = "lix_commit";
|
|
20
|
+
|
|
21
|
+
/// Read model for resolving changelog commits into entity state at a head.
|
|
22
|
+
///
|
|
23
|
+
/// This module does not own durable storage. It reads immutable changelog facts
|
|
24
|
+
/// through a caller-provided KV store and applies commit graph rules on top.
|
|
25
|
+
#[derive(Clone)]
|
|
26
|
+
pub(crate) struct CommitGraphContext {
|
|
27
|
+
changelog: ChangelogContext,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
impl CommitGraphContext {
|
|
31
|
+
pub(crate) fn new(changelog: ChangelogContext) -> Self {
|
|
32
|
+
Self { changelog }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Creates a graph reader over a caller-provided KV store.
|
|
36
|
+
pub(crate) fn reader<S>(&self, store: S) -> CommitGraphStoreReader<S>
|
|
37
|
+
where
|
|
38
|
+
S: StorageReader,
|
|
39
|
+
{
|
|
40
|
+
let read_scope = StorageReadScope::new(store);
|
|
41
|
+
CommitGraphStoreReader {
|
|
42
|
+
changelog_reader: self.changelog.reader(read_scope.store()),
|
|
43
|
+
json_reader: JsonStoreContext::new().reader(read_scope.store()),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// Commit-graph reader that resolves changelog entities at a commit head.
|
|
49
|
+
pub(crate) struct CommitGraphStoreReader<S>
|
|
50
|
+
where
|
|
51
|
+
S: StorageReader,
|
|
52
|
+
{
|
|
53
|
+
changelog_reader: ChangelogStoreReader<ScopedStorageReader<S>>,
|
|
54
|
+
json_reader: JsonStoreReader<ScopedStorageReader<S>>,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
impl<S> CommitGraphStoreReader<S>
|
|
58
|
+
where
|
|
59
|
+
S: StorageReader,
|
|
60
|
+
{
|
|
61
|
+
/// Returns the canonical entities that are effective at `head_commit_id`.
|
|
62
|
+
///
|
|
63
|
+
/// Reachable commits are visited nearest-first. For each commit, the commit
|
|
64
|
+
/// row itself is visible, then introduced/adopted `change_ids` are visited
|
|
65
|
+
/// in reverse order so later writes in the same commit win.
|
|
66
|
+
pub(crate) async fn entities_at(
|
|
67
|
+
&mut self,
|
|
68
|
+
head_commit_id: &str,
|
|
69
|
+
) -> Result<Vec<CommitGraphEntity>, LixError> {
|
|
70
|
+
let commits = self.reachable_commits(head_commit_id).await?;
|
|
71
|
+
self.select_entities(commits).await
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Loads and parses a `lix_commit` canonical change by commit id.
|
|
75
|
+
pub(crate) async fn load_commit(
|
|
76
|
+
&mut self,
|
|
77
|
+
commit_id: &str,
|
|
78
|
+
) -> Result<Option<CommitGraphCommit>, LixError> {
|
|
79
|
+
let Some(change) = self.find_commit_change(commit_id).await? else {
|
|
80
|
+
return Ok(None);
|
|
81
|
+
};
|
|
82
|
+
parse_commit_change(change).map(Some)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// Loads every commit fact from the changelog.
|
|
86
|
+
///
|
|
87
|
+
/// This is used by global commit surfaces where the caller wants the durable
|
|
88
|
+
/// graph facts themselves, not reachability from a particular version head.
|
|
89
|
+
pub(crate) async fn all_commits(&mut self) -> Result<Vec<CommitGraphCommit>, LixError> {
|
|
90
|
+
let changes = self
|
|
91
|
+
.changelog_reader
|
|
92
|
+
.scan_changes(&crate::changelog::ChangelogScanRequest { limit: None })
|
|
93
|
+
.await?;
|
|
94
|
+
let mut commits = Vec::new();
|
|
95
|
+
for change in changes
|
|
96
|
+
.into_iter()
|
|
97
|
+
.filter(|change| change.schema_key == COMMIT_SCHEMA_KEY)
|
|
98
|
+
{
|
|
99
|
+
commits.push(parse_commit_change(self.materialize_change(change).await?)?);
|
|
100
|
+
}
|
|
101
|
+
commits.sort_by(|left, right| left.commit_id.cmp(&right.commit_id));
|
|
102
|
+
Ok(commits)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// Walks from `head_commit_id` through parent commits and records nearest depth.
|
|
106
|
+
pub(crate) async fn reachable_commits(
|
|
107
|
+
&mut self,
|
|
108
|
+
head_commit_id: &str,
|
|
109
|
+
) -> Result<Vec<ReachableCommitGraphCommit>, LixError> {
|
|
110
|
+
walk_reachable_commits(self, head_commit_id).await
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/// Returns the best common ancestors shared by two commit heads.
|
|
114
|
+
///
|
|
115
|
+
/// This is the commit-DAG primitive. It can return more than one commit in
|
|
116
|
+
/// criss-cross histories. Merge code should layer an explicit merge-base
|
|
117
|
+
/// policy on top when it needs exactly one base for a three-way merge.
|
|
118
|
+
pub(crate) async fn best_common_ancestors(
|
|
119
|
+
&mut self,
|
|
120
|
+
left_commit_id: &str,
|
|
121
|
+
right_commit_id: &str,
|
|
122
|
+
) -> Result<Vec<CommitGraphCommit>, LixError> {
|
|
123
|
+
best_common_ancestors(self, left_commit_id, right_commit_id).await
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Resolves the single commit base to use for a three-way merge.
|
|
127
|
+
///
|
|
128
|
+
/// This is merge policy layered over `best_common_ancestors(...)`. Histories
|
|
129
|
+
/// with no shared base or multiple equally good bases are rejected for now
|
|
130
|
+
/// so merge code cannot accidentally hide unsupported graph semantics.
|
|
131
|
+
pub(crate) async fn merge_base(
|
|
132
|
+
&mut self,
|
|
133
|
+
left_commit_id: &str,
|
|
134
|
+
right_commit_id: &str,
|
|
135
|
+
) -> Result<CommitGraphCommit, LixError> {
|
|
136
|
+
let ancestors = self
|
|
137
|
+
.best_common_ancestors(left_commit_id, right_commit_id)
|
|
138
|
+
.await?;
|
|
139
|
+
match ancestors.as_slice() {
|
|
140
|
+
[] => Err(LixError::new(
|
|
141
|
+
"LIX_ERROR_UNKNOWN",
|
|
142
|
+
format!(
|
|
143
|
+
"commit_graph found no common history between '{left_commit_id}' and '{right_commit_id}'"
|
|
144
|
+
),
|
|
145
|
+
)),
|
|
146
|
+
[base] => Ok(base.clone()),
|
|
147
|
+
_ => Err(LixError::ambiguous_merge_base(
|
|
148
|
+
left_commit_id,
|
|
149
|
+
right_commit_id,
|
|
150
|
+
ancestors
|
|
151
|
+
.iter()
|
|
152
|
+
.map(|ancestor| ancestor.commit_id.clone())
|
|
153
|
+
.collect(),
|
|
154
|
+
)),
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/// Derives parent/child edges from parsed commits.
|
|
159
|
+
pub(crate) fn commit_edges(&self, commits: &[CommitGraphCommit]) -> Vec<CommitGraphEdge> {
|
|
160
|
+
commits
|
|
161
|
+
.iter()
|
|
162
|
+
.flat_map(|commit| {
|
|
163
|
+
commit
|
|
164
|
+
.parent_commit_ids
|
|
165
|
+
.iter()
|
|
166
|
+
.map(|parent_commit_id| CommitGraphEdge {
|
|
167
|
+
parent_commit_id: parent_commit_id.clone(),
|
|
168
|
+
child_commit_id: commit.commit_id.clone(),
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
.collect()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// Derives one change-set row for each parsed commit.
|
|
175
|
+
pub(crate) fn change_sets(&self, commits: &[CommitGraphCommit]) -> Vec<CommitGraphChangeSet> {
|
|
176
|
+
commits
|
|
177
|
+
.iter()
|
|
178
|
+
.map(|commit| CommitGraphChangeSet {
|
|
179
|
+
id: commit.change_set_id.clone(),
|
|
180
|
+
commit_id: commit.commit_id.clone(),
|
|
181
|
+
})
|
|
182
|
+
.collect()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/// Loads the canonical changes introduced/adopted by each commit's change set.
|
|
186
|
+
pub(crate) async fn change_set_elements(
|
|
187
|
+
&mut self,
|
|
188
|
+
commits: &[CommitGraphCommit],
|
|
189
|
+
) -> Result<Vec<CommitGraphChangeSetElement>, LixError> {
|
|
190
|
+
let mut elements = Vec::new();
|
|
191
|
+
for commit in commits {
|
|
192
|
+
for change_id in &commit.change_ids {
|
|
193
|
+
let change = self
|
|
194
|
+
.load_member_change(change_id, &commit.commit_id)
|
|
195
|
+
.await?;
|
|
196
|
+
elements.push(CommitGraphChangeSetElement {
|
|
197
|
+
change_set_id: commit.change_set_id.clone(),
|
|
198
|
+
change,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
Ok(elements)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/// Returns canonical changes reachable from `start_commit_id`.
|
|
206
|
+
///
|
|
207
|
+
/// This is the primitive history API. It reports the commit/depth where
|
|
208
|
+
/// each matching canonical change was introduced or adopted during graph
|
|
209
|
+
/// traversal and leaves row shaping to callers such as SQL providers.
|
|
210
|
+
pub(crate) async fn change_history_from_commit(
|
|
211
|
+
&mut self,
|
|
212
|
+
start_commit_id: &str,
|
|
213
|
+
request: &CommitGraphChangeHistoryRequest,
|
|
214
|
+
) -> Result<Vec<CommitGraphChangeHistoryEntry>, LixError> {
|
|
215
|
+
let commits = self.reachable_commits(start_commit_id).await?;
|
|
216
|
+
let mut entries = Vec::new();
|
|
217
|
+
let mut seen_change_ids = BTreeSet::new();
|
|
218
|
+
|
|
219
|
+
for reachable in commits {
|
|
220
|
+
if !depth_matches(reachable.depth, request) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let commit_id = reachable.commit.commit_id;
|
|
225
|
+
for change_id in reachable.commit.change_ids {
|
|
226
|
+
if !seen_change_ids.insert(change_id.clone()) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
let change = self
|
|
230
|
+
.load_member_canonical_change(&change_id, &commit_id)
|
|
231
|
+
.await?;
|
|
232
|
+
if change_matches_history_request(&change, request) {
|
|
233
|
+
entries.push(CommitGraphChangeHistoryEntry {
|
|
234
|
+
change,
|
|
235
|
+
observed_commit_id: commit_id.clone(),
|
|
236
|
+
start_commit_id: start_commit_id.to_string(),
|
|
237
|
+
depth: reachable.depth,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
Ok(entries)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async fn load_member_canonical_change(
|
|
247
|
+
&mut self,
|
|
248
|
+
change_id: &str,
|
|
249
|
+
source_commit_id: &str,
|
|
250
|
+
) -> Result<CanonicalChange, LixError> {
|
|
251
|
+
self.changelog_reader
|
|
252
|
+
.load_change(change_id)
|
|
253
|
+
.await?
|
|
254
|
+
.ok_or_else(|| {
|
|
255
|
+
LixError::new(
|
|
256
|
+
"LIX_ERROR_UNKNOWN",
|
|
257
|
+
format!(
|
|
258
|
+
"commit_graph commit '{source_commit_id}' references missing change '{change_id}'"
|
|
259
|
+
),
|
|
260
|
+
)
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/// Selects the first reachable change for each canonical entity identity.
|
|
265
|
+
async fn select_entities(
|
|
266
|
+
&mut self,
|
|
267
|
+
commits: Vec<ReachableCommitGraphCommit>,
|
|
268
|
+
) -> Result<Vec<CommitGraphEntity>, LixError> {
|
|
269
|
+
let mut order = Vec::new();
|
|
270
|
+
let mut entities = BTreeMap::new();
|
|
271
|
+
|
|
272
|
+
for reachable in commits {
|
|
273
|
+
let depth = reachable.depth;
|
|
274
|
+
let source_commit_id = reachable.commit.commit_id;
|
|
275
|
+
|
|
276
|
+
observe_change(
|
|
277
|
+
&mut order,
|
|
278
|
+
&mut entities,
|
|
279
|
+
reachable.commit.change,
|
|
280
|
+
source_commit_id.clone(),
|
|
281
|
+
depth,
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
for change_id in reachable.commit.change_ids.iter().rev() {
|
|
285
|
+
let change = self
|
|
286
|
+
.load_member_change(change_id, &source_commit_id)
|
|
287
|
+
.await?;
|
|
288
|
+
observe_change(
|
|
289
|
+
&mut order,
|
|
290
|
+
&mut entities,
|
|
291
|
+
change,
|
|
292
|
+
source_commit_id.clone(),
|
|
293
|
+
depth,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
Ok(order
|
|
299
|
+
.into_iter()
|
|
300
|
+
.filter_map(|identity| {
|
|
301
|
+
entities
|
|
302
|
+
.remove(&identity)
|
|
303
|
+
.map(|accumulator| accumulator.entity)
|
|
304
|
+
})
|
|
305
|
+
.collect())
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async fn load_member_change(
|
|
309
|
+
&mut self,
|
|
310
|
+
change_id: &str,
|
|
311
|
+
source_commit_id: &str,
|
|
312
|
+
) -> Result<MaterializedCanonicalChange, LixError> {
|
|
313
|
+
let change = self
|
|
314
|
+
.changelog_reader
|
|
315
|
+
.load_change(change_id)
|
|
316
|
+
.await?
|
|
317
|
+
.ok_or_else(|| {
|
|
318
|
+
LixError::new(
|
|
319
|
+
"LIX_ERROR_UNKNOWN",
|
|
320
|
+
format!(
|
|
321
|
+
"commit_graph commit '{source_commit_id}' references missing change '{change_id}'"
|
|
322
|
+
),
|
|
323
|
+
)
|
|
324
|
+
})?;
|
|
325
|
+
self.materialize_change(change).await
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async fn materialize_change(
|
|
329
|
+
&mut self,
|
|
330
|
+
change: CanonicalChange,
|
|
331
|
+
) -> Result<MaterializedCanonicalChange, LixError> {
|
|
332
|
+
materialize_change(&mut self.json_reader, change).await
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async fn find_commit_change(
|
|
336
|
+
&mut self,
|
|
337
|
+
commit_id: &str,
|
|
338
|
+
) -> Result<Option<MaterializedCanonicalChange>, LixError> {
|
|
339
|
+
let changes = self
|
|
340
|
+
.changelog_reader
|
|
341
|
+
.scan_changes(&crate::changelog::ChangelogScanRequest { limit: None })
|
|
342
|
+
.await?;
|
|
343
|
+
let Some(change) = changes.into_iter().find(|change| {
|
|
344
|
+
change.schema_key == COMMIT_SCHEMA_KEY
|
|
345
|
+
&& change
|
|
346
|
+
.entity_id
|
|
347
|
+
.as_string()
|
|
348
|
+
.is_ok_and(|entity_id| entity_id == commit_id)
|
|
349
|
+
}) else {
|
|
350
|
+
return Ok(None);
|
|
351
|
+
};
|
|
352
|
+
self.materialize_change(change).await.map(Some)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
#[async_trait::async_trait]
|
|
357
|
+
impl<S> CommitGraphReader for CommitGraphStoreReader<S>
|
|
358
|
+
where
|
|
359
|
+
S: StorageReader,
|
|
360
|
+
{
|
|
361
|
+
async fn load_commit(
|
|
362
|
+
&mut self,
|
|
363
|
+
commit_id: &str,
|
|
364
|
+
) -> Result<Option<CommitGraphCommit>, LixError> {
|
|
365
|
+
CommitGraphStoreReader::load_commit(self, commit_id).await
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async fn all_commits(&mut self) -> Result<Vec<CommitGraphCommit>, LixError> {
|
|
369
|
+
CommitGraphStoreReader::all_commits(self).await
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async fn reachable_commits(
|
|
373
|
+
&mut self,
|
|
374
|
+
head_commit_id: &str,
|
|
375
|
+
) -> Result<Vec<ReachableCommitGraphCommit>, LixError> {
|
|
376
|
+
CommitGraphStoreReader::reachable_commits(self, head_commit_id).await
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async fn best_common_ancestors(
|
|
380
|
+
&mut self,
|
|
381
|
+
left_commit_id: &str,
|
|
382
|
+
right_commit_id: &str,
|
|
383
|
+
) -> Result<Vec<CommitGraphCommit>, LixError> {
|
|
384
|
+
CommitGraphStoreReader::best_common_ancestors(self, left_commit_id, right_commit_id).await
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async fn merge_base(
|
|
388
|
+
&mut self,
|
|
389
|
+
left_commit_id: &str,
|
|
390
|
+
right_commit_id: &str,
|
|
391
|
+
) -> Result<CommitGraphCommit, LixError> {
|
|
392
|
+
CommitGraphStoreReader::merge_base(self, left_commit_id, right_commit_id).await
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
fn commit_edges(&self, commits: &[CommitGraphCommit]) -> Vec<CommitGraphEdge> {
|
|
396
|
+
CommitGraphStoreReader::commit_edges(self, commits)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
fn change_sets(&self, commits: &[CommitGraphCommit]) -> Vec<CommitGraphChangeSet> {
|
|
400
|
+
CommitGraphStoreReader::change_sets(self, commits)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async fn change_set_elements(
|
|
404
|
+
&mut self,
|
|
405
|
+
commits: &[CommitGraphCommit],
|
|
406
|
+
) -> Result<Vec<CommitGraphChangeSetElement>, LixError> {
|
|
407
|
+
CommitGraphStoreReader::change_set_elements(self, commits).await
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async fn change_history_from_commit(
|
|
411
|
+
&mut self,
|
|
412
|
+
start_commit_id: &str,
|
|
413
|
+
request: &CommitGraphChangeHistoryRequest,
|
|
414
|
+
) -> Result<Vec<CommitGraphChangeHistoryEntry>, LixError> {
|
|
415
|
+
CommitGraphStoreReader::change_history_from_commit(self, start_commit_id, request).await
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
fn depth_matches(depth: u32, request: &CommitGraphChangeHistoryRequest) -> bool {
|
|
420
|
+
request.min_depth.map_or(true, |min| depth >= min)
|
|
421
|
+
&& request.max_depth.map_or(true, |max| depth <= max)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
fn change_matches_history_request(
|
|
425
|
+
change: &CanonicalChange,
|
|
426
|
+
request: &CommitGraphChangeHistoryRequest,
|
|
427
|
+
) -> bool {
|
|
428
|
+
(request.include_tombstones || change.snapshot_ref.is_some())
|
|
429
|
+
&& (request.entity_ids.is_empty() || request.entity_ids.contains(&change.entity_id))
|
|
430
|
+
&& (request.schema_keys.is_empty() || request.schema_keys.contains(&change.schema_key))
|
|
431
|
+
&& (request.file_ids.is_empty()
|
|
432
|
+
|| change
|
|
433
|
+
.file_id
|
|
434
|
+
.as_ref()
|
|
435
|
+
.is_some_and(|file_id| request.file_ids.contains(file_id)))
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
fn observe_change(
|
|
439
|
+
order: &mut Vec<CanonicalEntityIdentity>,
|
|
440
|
+
entities: &mut BTreeMap<CanonicalEntityIdentity, EntityAccumulator>,
|
|
441
|
+
change: MaterializedCanonicalChange,
|
|
442
|
+
source_commit_id: String,
|
|
443
|
+
depth: u32,
|
|
444
|
+
) {
|
|
445
|
+
let identity = CanonicalEntityIdentity::from_change(&change);
|
|
446
|
+
if let Some(accumulator) = entities.get_mut(&identity) {
|
|
447
|
+
// TODO: represent unresolved parent-parent merge conflicts instead of
|
|
448
|
+
// collapsing them through deterministic traversal order. A head commit
|
|
449
|
+
// change for the same identity should remain the explicit resolution.
|
|
450
|
+
accumulator.entity.created_at = change.created_at.clone();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
order.push(identity.clone());
|
|
455
|
+
entities.insert(
|
|
456
|
+
identity,
|
|
457
|
+
EntityAccumulator {
|
|
458
|
+
entity: CommitGraphEntity {
|
|
459
|
+
created_at: change.created_at.clone(),
|
|
460
|
+
updated_at: change.created_at.clone(),
|
|
461
|
+
change,
|
|
462
|
+
source_commit_id,
|
|
463
|
+
depth,
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
#[derive(Debug)]
|
|
470
|
+
struct EntityAccumulator {
|
|
471
|
+
entity: CommitGraphEntity,
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
|
475
|
+
struct CanonicalEntityIdentity {
|
|
476
|
+
entity_id: EntityIdentity,
|
|
477
|
+
schema_key: String,
|
|
478
|
+
file_id: Option<String>,
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
impl CanonicalEntityIdentity {
|
|
482
|
+
fn from_change(change: &MaterializedCanonicalChange) -> Self {
|
|
483
|
+
Self {
|
|
484
|
+
entity_id: change.entity_id.clone(),
|
|
485
|
+
schema_key: change.schema_key.clone(),
|
|
486
|
+
file_id: change.file_id.clone(),
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
fn parse_commit_change(
|
|
492
|
+
change: crate::changelog::MaterializedCanonicalChange,
|
|
493
|
+
) -> Result<CommitGraphCommit, LixError> {
|
|
494
|
+
let change_entity_id = change.entity_id.as_string()?;
|
|
495
|
+
if change.schema_key != COMMIT_SCHEMA_KEY {
|
|
496
|
+
return Err(LixError::new(
|
|
497
|
+
"LIX_ERROR_UNKNOWN",
|
|
498
|
+
format!(
|
|
499
|
+
"commit_graph expected schema_key '{COMMIT_SCHEMA_KEY}' but got '{}'",
|
|
500
|
+
change.schema_key
|
|
501
|
+
),
|
|
502
|
+
));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
let snapshot_content = change.snapshot_content.as_deref().ok_or_else(|| {
|
|
506
|
+
LixError::new(
|
|
507
|
+
"LIX_ERROR_UNKNOWN",
|
|
508
|
+
format!(
|
|
509
|
+
"commit_graph commit '{}' is missing snapshot_content",
|
|
510
|
+
change_entity_id
|
|
511
|
+
),
|
|
512
|
+
)
|
|
513
|
+
})?;
|
|
514
|
+
let snapshot =
|
|
515
|
+
serde_json::from_str::<serde_json::Value>(snapshot_content).map_err(|error| {
|
|
516
|
+
LixError::new(
|
|
517
|
+
"LIX_ERROR_UNKNOWN",
|
|
518
|
+
format!(
|
|
519
|
+
"commit_graph commit '{}' snapshot_content is invalid JSON: {error}",
|
|
520
|
+
change_entity_id
|
|
521
|
+
),
|
|
522
|
+
)
|
|
523
|
+
})?;
|
|
524
|
+
|
|
525
|
+
let commit_id = required_string(&snapshot, "id", &change_entity_id)?;
|
|
526
|
+
if commit_id != change_entity_id {
|
|
527
|
+
return Err(LixError::new(
|
|
528
|
+
"LIX_ERROR_UNKNOWN",
|
|
529
|
+
format!(
|
|
530
|
+
"commit_graph commit change entity_id '{}' does not match snapshot id '{}'",
|
|
531
|
+
change_entity_id, commit_id
|
|
532
|
+
),
|
|
533
|
+
));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
let change_ids = required_string_array(&snapshot, "change_ids", &change_entity_id)?;
|
|
537
|
+
let author_account_ids =
|
|
538
|
+
optional_string_array(&snapshot, "author_account_ids", &change_entity_id)?;
|
|
539
|
+
let parent_commit_ids =
|
|
540
|
+
required_string_array(&snapshot, "parent_commit_ids", &change_entity_id)?;
|
|
541
|
+
let change_set_id = required_string(&snapshot, "change_set_id", &change_entity_id)?;
|
|
542
|
+
|
|
543
|
+
Ok(CommitGraphCommit {
|
|
544
|
+
change,
|
|
545
|
+
commit_id,
|
|
546
|
+
change_set_id,
|
|
547
|
+
change_ids,
|
|
548
|
+
author_account_ids,
|
|
549
|
+
parent_commit_ids,
|
|
550
|
+
})
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
fn required_string(
|
|
554
|
+
snapshot: &serde_json::Value,
|
|
555
|
+
field: &str,
|
|
556
|
+
commit_id: &str,
|
|
557
|
+
) -> Result<String, LixError> {
|
|
558
|
+
snapshot
|
|
559
|
+
.get(field)
|
|
560
|
+
.and_then(serde_json::Value::as_str)
|
|
561
|
+
.filter(|value| !value.is_empty())
|
|
562
|
+
.map(str::to_string)
|
|
563
|
+
.ok_or_else(|| {
|
|
564
|
+
LixError::new(
|
|
565
|
+
"LIX_ERROR_UNKNOWN",
|
|
566
|
+
format!("commit_graph commit '{commit_id}' requires string field '{field}'"),
|
|
567
|
+
)
|
|
568
|
+
})
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
fn required_string_array(
|
|
572
|
+
snapshot: &serde_json::Value,
|
|
573
|
+
field: &str,
|
|
574
|
+
commit_id: &str,
|
|
575
|
+
) -> Result<Vec<String>, LixError> {
|
|
576
|
+
let values = snapshot
|
|
577
|
+
.get(field)
|
|
578
|
+
.and_then(serde_json::Value::as_array)
|
|
579
|
+
.ok_or_else(|| {
|
|
580
|
+
LixError::new(
|
|
581
|
+
"LIX_ERROR_UNKNOWN",
|
|
582
|
+
format!("commit_graph commit '{commit_id}' requires array field '{field}'"),
|
|
583
|
+
)
|
|
584
|
+
})?;
|
|
585
|
+
|
|
586
|
+
values
|
|
587
|
+
.iter()
|
|
588
|
+
.map(|value| {
|
|
589
|
+
value.as_str().filter(|value| !value.is_empty()).map(str::to_string).ok_or_else(|| {
|
|
590
|
+
LixError::new(
|
|
591
|
+
"LIX_ERROR_UNKNOWN",
|
|
592
|
+
format!(
|
|
593
|
+
"commit_graph commit '{commit_id}' field '{field}' must contain only non-empty strings"
|
|
594
|
+
),
|
|
595
|
+
)
|
|
596
|
+
})
|
|
597
|
+
})
|
|
598
|
+
.collect()
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
fn optional_string_array(
|
|
602
|
+
snapshot: &serde_json::Value,
|
|
603
|
+
field: &str,
|
|
604
|
+
commit_id: &str,
|
|
605
|
+
) -> Result<Vec<String>, LixError> {
|
|
606
|
+
match snapshot.get(field) {
|
|
607
|
+
Some(_) => required_string_array(snapshot, field, commit_id),
|
|
608
|
+
None => Ok(Vec::new()),
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
#[cfg(test)]
|
|
613
|
+
mod tests {
|
|
614
|
+
use std::sync::Arc;
|
|
615
|
+
|
|
616
|
+
use serde_json::json;
|
|
617
|
+
|
|
618
|
+
use crate::backend::testing::UnitTestBackend;
|
|
619
|
+
use crate::changelog::{
|
|
620
|
+
canonicalize_materialized_change, ChangelogContext, MaterializedCanonicalChange,
|
|
621
|
+
};
|
|
622
|
+
use crate::commit_graph::{CommitGraphChangeHistoryRequest, CommitGraphContext};
|
|
623
|
+
use crate::json_store::JsonStoreContext;
|
|
624
|
+
use crate::storage::{StorageContext, StorageWriteSet};
|
|
625
|
+
|
|
626
|
+
#[tokio::test]
|
|
627
|
+
async fn load_commit_parses_commit_snapshot() {
|
|
628
|
+
let backend = Arc::new(UnitTestBackend::new());
|
|
629
|
+
let storage = StorageContext::new(backend.clone());
|
|
630
|
+
let changelog = ChangelogContext::new();
|
|
631
|
+
append_changes(
|
|
632
|
+
storage.clone(),
|
|
633
|
+
&changelog,
|
|
634
|
+
&[commit_change(
|
|
635
|
+
"commit-1-change",
|
|
636
|
+
"commit-1",
|
|
637
|
+
&["change-1", "change-2"],
|
|
638
|
+
&["parent-1"],
|
|
639
|
+
)],
|
|
640
|
+
)
|
|
641
|
+
.await;
|
|
642
|
+
|
|
643
|
+
let graph = CommitGraphContext::new(changelog);
|
|
644
|
+
let mut reader = graph.reader(storage);
|
|
645
|
+
let commit = reader
|
|
646
|
+
.load_commit("commit-1")
|
|
647
|
+
.await
|
|
648
|
+
.expect("commit load should succeed")
|
|
649
|
+
.expect("commit should exist");
|
|
650
|
+
|
|
651
|
+
assert_eq!(commit.commit_id, "commit-1");
|
|
652
|
+
assert_eq!(commit.change_set_id, "change-set-1");
|
|
653
|
+
assert_eq!(commit.change_ids, vec!["change-1", "change-2"]);
|
|
654
|
+
assert_eq!(commit.parent_commit_ids, vec!["parent-1"]);
|
|
655
|
+
assert_eq!(commit.change.id, "commit-1-change");
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
#[tokio::test]
|
|
659
|
+
async fn load_commit_returns_none_for_missing_commit() {
|
|
660
|
+
let backend = Arc::new(UnitTestBackend::new());
|
|
661
|
+
let storage = StorageContext::new(backend.clone());
|
|
662
|
+
let graph = CommitGraphContext::new(ChangelogContext::new());
|
|
663
|
+
let mut reader = graph.reader(storage);
|
|
664
|
+
|
|
665
|
+
let commit = reader
|
|
666
|
+
.load_commit("missing")
|
|
667
|
+
.await
|
|
668
|
+
.expect("commit load should succeed");
|
|
669
|
+
|
|
670
|
+
assert_eq!(commit, None);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
#[tokio::test]
|
|
674
|
+
async fn load_commit_rejects_malformed_snapshot() {
|
|
675
|
+
let backend = Arc::new(UnitTestBackend::new());
|
|
676
|
+
let storage = StorageContext::new(backend.clone());
|
|
677
|
+
let changelog = ChangelogContext::new();
|
|
678
|
+
append_changes(
|
|
679
|
+
storage.clone(),
|
|
680
|
+
&changelog,
|
|
681
|
+
&[MaterializedCanonicalChange {
|
|
682
|
+
id: "commit-1-change".to_string(),
|
|
683
|
+
entity_id: crate::entity_identity::EntityIdentity::single("commit-1"),
|
|
684
|
+
schema_key: super::COMMIT_SCHEMA_KEY.to_string(),
|
|
685
|
+
schema_version: "1".to_string(),
|
|
686
|
+
file_id: None,
|
|
687
|
+
snapshot_content: Some("{\"id\":\"commit-1\"}".to_string()),
|
|
688
|
+
metadata: None,
|
|
689
|
+
created_at: "2026-01-01T00:00:00Z".to_string(),
|
|
690
|
+
}],
|
|
691
|
+
)
|
|
692
|
+
.await;
|
|
693
|
+
|
|
694
|
+
let graph = CommitGraphContext::new(changelog);
|
|
695
|
+
let mut reader = graph.reader(storage);
|
|
696
|
+
let error = reader
|
|
697
|
+
.load_commit("commit-1")
|
|
698
|
+
.await
|
|
699
|
+
.expect_err("malformed commit should fail");
|
|
700
|
+
|
|
701
|
+
assert!(error.message.contains("change_ids"));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
#[tokio::test]
|
|
705
|
+
async fn all_commits_returns_parsed_commits_sorted_by_id() {
|
|
706
|
+
let backend = Arc::new(UnitTestBackend::new());
|
|
707
|
+
let storage = StorageContext::new(backend.clone());
|
|
708
|
+
let changelog = ChangelogContext::new();
|
|
709
|
+
append_changes(
|
|
710
|
+
storage.clone(),
|
|
711
|
+
&changelog,
|
|
712
|
+
&[
|
|
713
|
+
commit_change("commit-b-change", "commit-b", &[], &[]),
|
|
714
|
+
entity_change("change-1", "entity-1", "example", "{}"),
|
|
715
|
+
commit_change("commit-a-change", "commit-a", &[], &[]),
|
|
716
|
+
],
|
|
717
|
+
)
|
|
718
|
+
.await;
|
|
719
|
+
|
|
720
|
+
let graph = CommitGraphContext::new(changelog);
|
|
721
|
+
let mut reader = graph.reader(storage);
|
|
722
|
+
let commits = reader
|
|
723
|
+
.all_commits()
|
|
724
|
+
.await
|
|
725
|
+
.expect("commit scan should succeed");
|
|
726
|
+
|
|
727
|
+
assert_eq!(
|
|
728
|
+
commits
|
|
729
|
+
.iter()
|
|
730
|
+
.map(|commit| commit.commit_id.as_str())
|
|
731
|
+
.collect::<Vec<_>>(),
|
|
732
|
+
vec!["commit-a", "commit-b"]
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
#[tokio::test]
|
|
737
|
+
async fn entities_at_walks_ancestors_and_computes_nearest_depth() {
|
|
738
|
+
let backend = Arc::new(UnitTestBackend::new());
|
|
739
|
+
let storage = StorageContext::new(backend.clone());
|
|
740
|
+
let changelog = ChangelogContext::new();
|
|
741
|
+
append_changes(
|
|
742
|
+
storage.clone(),
|
|
743
|
+
&changelog,
|
|
744
|
+
&[
|
|
745
|
+
commit_change("commit-root-change", "commit-root", &[], &[]),
|
|
746
|
+
commit_change("commit-left-change", "commit-left", &[], &["commit-root"]),
|
|
747
|
+
commit_change("commit-right-change", "commit-right", &[], &["commit-root"]),
|
|
748
|
+
commit_change(
|
|
749
|
+
"commit-head-change",
|
|
750
|
+
"commit-head",
|
|
751
|
+
&[],
|
|
752
|
+
&["commit-left", "commit-right"],
|
|
753
|
+
),
|
|
754
|
+
],
|
|
755
|
+
)
|
|
756
|
+
.await;
|
|
757
|
+
|
|
758
|
+
let graph = CommitGraphContext::new(changelog);
|
|
759
|
+
let mut reader = graph.reader(storage);
|
|
760
|
+
let entities = reader
|
|
761
|
+
.entities_at("commit-head")
|
|
762
|
+
.await
|
|
763
|
+
.expect("ancestor traversal should succeed");
|
|
764
|
+
|
|
765
|
+
let depths = entities
|
|
766
|
+
.into_iter()
|
|
767
|
+
.map(|entity| (entity.source_commit_id, entity.depth))
|
|
768
|
+
.collect::<Vec<_>>();
|
|
769
|
+
assert_eq!(
|
|
770
|
+
depths,
|
|
771
|
+
vec![
|
|
772
|
+
("commit-head".to_string(), 0),
|
|
773
|
+
("commit-left".to_string(), 1),
|
|
774
|
+
("commit-right".to_string(), 1),
|
|
775
|
+
("commit-root".to_string(), 2),
|
|
776
|
+
]
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
#[tokio::test]
|
|
781
|
+
async fn commit_edges_are_derived_from_parent_commit_ids() {
|
|
782
|
+
let graph = CommitGraphContext::new(ChangelogContext::new());
|
|
783
|
+
let reader = graph.reader(StorageContext::new(Arc::new(UnitTestBackend::new())));
|
|
784
|
+
let commits = vec![parsed_commit(
|
|
785
|
+
"commit-head",
|
|
786
|
+
&[],
|
|
787
|
+
&["commit-left", "commit-right"],
|
|
788
|
+
)];
|
|
789
|
+
|
|
790
|
+
let edges = reader.commit_edges(&commits);
|
|
791
|
+
|
|
792
|
+
assert_eq!(
|
|
793
|
+
edges
|
|
794
|
+
.iter()
|
|
795
|
+
.map(|edge| (
|
|
796
|
+
edge.parent_commit_id.as_str(),
|
|
797
|
+
edge.child_commit_id.as_str()
|
|
798
|
+
))
|
|
799
|
+
.collect::<Vec<_>>(),
|
|
800
|
+
vec![
|
|
801
|
+
("commit-left", "commit-head"),
|
|
802
|
+
("commit-right", "commit-head")
|
|
803
|
+
]
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
#[tokio::test]
|
|
808
|
+
async fn change_sets_are_derived_from_commits() {
|
|
809
|
+
let graph = CommitGraphContext::new(ChangelogContext::new());
|
|
810
|
+
let reader = graph.reader(StorageContext::new(Arc::new(UnitTestBackend::new())));
|
|
811
|
+
let commits = vec![parsed_commit("commit-1", &[], &[])];
|
|
812
|
+
|
|
813
|
+
let change_sets = reader.change_sets(&commits);
|
|
814
|
+
|
|
815
|
+
assert_eq!(change_sets.len(), 1);
|
|
816
|
+
assert_eq!(change_sets[0].id, "change-set-1");
|
|
817
|
+
assert_eq!(change_sets[0].commit_id, "commit-1");
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
#[tokio::test]
|
|
821
|
+
async fn change_set_elements_load_member_changes() {
|
|
822
|
+
let backend = Arc::new(UnitTestBackend::new());
|
|
823
|
+
let storage = StorageContext::new(backend.clone());
|
|
824
|
+
let changelog = ChangelogContext::new();
|
|
825
|
+
append_changes(
|
|
826
|
+
storage.clone(),
|
|
827
|
+
&changelog,
|
|
828
|
+
&[
|
|
829
|
+
entity_change("change-1", "entity-1", "example", "{}"),
|
|
830
|
+
commit_change("commit-1-change", "commit-1", &["change-1"], &[]),
|
|
831
|
+
],
|
|
832
|
+
)
|
|
833
|
+
.await;
|
|
834
|
+
|
|
835
|
+
let graph = CommitGraphContext::new(changelog);
|
|
836
|
+
let mut reader = graph.reader(storage);
|
|
837
|
+
let commits = reader
|
|
838
|
+
.all_commits()
|
|
839
|
+
.await
|
|
840
|
+
.expect("commit scan should succeed");
|
|
841
|
+
let elements = reader
|
|
842
|
+
.change_set_elements(&commits)
|
|
843
|
+
.await
|
|
844
|
+
.expect("change-set elements should load");
|
|
845
|
+
|
|
846
|
+
assert_eq!(elements.len(), 1);
|
|
847
|
+
assert_eq!(elements[0].change_set_id, "change-set-1");
|
|
848
|
+
assert_eq!(elements[0].change.id, "change-1");
|
|
849
|
+
assert_eq!(
|
|
850
|
+
elements[0]
|
|
851
|
+
.change
|
|
852
|
+
.entity_id
|
|
853
|
+
.as_string()
|
|
854
|
+
.expect("entity id should project"),
|
|
855
|
+
"entity-1"
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
#[tokio::test]
|
|
860
|
+
async fn change_history_from_commit_reports_matching_canonical_changes_with_depth() {
|
|
861
|
+
let backend = Arc::new(UnitTestBackend::new());
|
|
862
|
+
let storage = StorageContext::new(backend.clone());
|
|
863
|
+
let changelog = ChangelogContext::new();
|
|
864
|
+
append_changes(
|
|
865
|
+
storage.clone(),
|
|
866
|
+
&changelog,
|
|
867
|
+
&[
|
|
868
|
+
entity_change("change-root", "entity-root", "test_schema", "{}"),
|
|
869
|
+
entity_change("change-head", "entity-head", "test_schema", "{}"),
|
|
870
|
+
commit_change("commit-root-change", "commit-root", &["change-root"], &[]),
|
|
871
|
+
commit_change(
|
|
872
|
+
"commit-head-change",
|
|
873
|
+
"commit-head",
|
|
874
|
+
&["change-head"],
|
|
875
|
+
&["commit-root"],
|
|
876
|
+
),
|
|
877
|
+
],
|
|
878
|
+
)
|
|
879
|
+
.await;
|
|
880
|
+
|
|
881
|
+
let graph = CommitGraphContext::new(changelog);
|
|
882
|
+
let mut reader = graph.reader(storage);
|
|
883
|
+
let history = reader
|
|
884
|
+
.change_history_from_commit(
|
|
885
|
+
"commit-head",
|
|
886
|
+
&CommitGraphChangeHistoryRequest {
|
|
887
|
+
schema_keys: vec!["test_schema".to_string()],
|
|
888
|
+
include_tombstones: true,
|
|
889
|
+
..CommitGraphChangeHistoryRequest::default()
|
|
890
|
+
},
|
|
891
|
+
)
|
|
892
|
+
.await
|
|
893
|
+
.expect("history should resolve");
|
|
894
|
+
|
|
895
|
+
assert_eq!(
|
|
896
|
+
history
|
|
897
|
+
.iter()
|
|
898
|
+
.map(|entry| (
|
|
899
|
+
entry.change.id.as_str(),
|
|
900
|
+
entry.observed_commit_id.as_str(),
|
|
901
|
+
entry.start_commit_id.as_str(),
|
|
902
|
+
entry.depth
|
|
903
|
+
))
|
|
904
|
+
.collect::<Vec<_>>(),
|
|
905
|
+
vec![
|
|
906
|
+
("change-head", "commit-head", "commit-head", 0),
|
|
907
|
+
("change-root", "commit-root", "commit-head", 1),
|
|
908
|
+
]
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
#[tokio::test]
|
|
913
|
+
async fn change_history_from_commit_filters_depth_entity_file_and_tombstones() {
|
|
914
|
+
let backend = Arc::new(UnitTestBackend::new());
|
|
915
|
+
let storage = StorageContext::new(backend.clone());
|
|
916
|
+
let changelog = ChangelogContext::new();
|
|
917
|
+
append_changes(
|
|
918
|
+
storage.clone(),
|
|
919
|
+
&changelog,
|
|
920
|
+
&[
|
|
921
|
+
entity_change_with_file(
|
|
922
|
+
"change-file-a",
|
|
923
|
+
"entity-1",
|
|
924
|
+
"test_schema",
|
|
925
|
+
Some("file-a"),
|
|
926
|
+
"{}",
|
|
927
|
+
),
|
|
928
|
+
entity_tombstone("change-tombstone", "entity-1", "test_schema"),
|
|
929
|
+
entity_change_with_file(
|
|
930
|
+
"change-file-b",
|
|
931
|
+
"entity-2",
|
|
932
|
+
"test_schema",
|
|
933
|
+
Some("file-b"),
|
|
934
|
+
"{}",
|
|
935
|
+
),
|
|
936
|
+
commit_change("commit-root-change", "commit-root", &["change-file-a"], &[]),
|
|
937
|
+
commit_change(
|
|
938
|
+
"commit-head-change",
|
|
939
|
+
"commit-head",
|
|
940
|
+
&["change-tombstone", "change-file-b"],
|
|
941
|
+
&["commit-root"],
|
|
942
|
+
),
|
|
943
|
+
],
|
|
944
|
+
)
|
|
945
|
+
.await;
|
|
946
|
+
|
|
947
|
+
let graph = CommitGraphContext::new(changelog);
|
|
948
|
+
let mut reader = graph.reader(storage);
|
|
949
|
+
let history = reader
|
|
950
|
+
.change_history_from_commit(
|
|
951
|
+
"commit-head",
|
|
952
|
+
&CommitGraphChangeHistoryRequest {
|
|
953
|
+
entity_ids: vec![crate::entity_identity::EntityIdentity::single("entity-1")],
|
|
954
|
+
file_ids: vec!["file-a".to_string()],
|
|
955
|
+
min_depth: Some(1),
|
|
956
|
+
max_depth: Some(1),
|
|
957
|
+
include_tombstones: false,
|
|
958
|
+
..CommitGraphChangeHistoryRequest::default()
|
|
959
|
+
},
|
|
960
|
+
)
|
|
961
|
+
.await
|
|
962
|
+
.expect("history should resolve");
|
|
963
|
+
|
|
964
|
+
assert_eq!(history.len(), 1);
|
|
965
|
+
assert_eq!(history[0].change.id, "change-file-a");
|
|
966
|
+
assert_eq!(history[0].depth, 1);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
#[tokio::test]
|
|
970
|
+
async fn change_history_from_commit_includes_tombstones_when_requested() {
|
|
971
|
+
let backend = Arc::new(UnitTestBackend::new());
|
|
972
|
+
let storage = StorageContext::new(backend.clone());
|
|
973
|
+
let changelog = ChangelogContext::new();
|
|
974
|
+
append_changes(
|
|
975
|
+
storage.clone(),
|
|
976
|
+
&changelog,
|
|
977
|
+
&[
|
|
978
|
+
entity_tombstone("change-deleted", "entity-1", "test_schema"),
|
|
979
|
+
commit_change(
|
|
980
|
+
"commit-head-change",
|
|
981
|
+
"commit-head",
|
|
982
|
+
&["change-deleted"],
|
|
983
|
+
&[],
|
|
984
|
+
),
|
|
985
|
+
],
|
|
986
|
+
)
|
|
987
|
+
.await;
|
|
988
|
+
|
|
989
|
+
let graph = CommitGraphContext::new(changelog);
|
|
990
|
+
let mut reader = graph.reader(storage);
|
|
991
|
+
let hidden = reader
|
|
992
|
+
.change_history_from_commit("commit-head", &CommitGraphChangeHistoryRequest::default())
|
|
993
|
+
.await
|
|
994
|
+
.expect("history should resolve");
|
|
995
|
+
let visible = reader
|
|
996
|
+
.change_history_from_commit(
|
|
997
|
+
"commit-head",
|
|
998
|
+
&CommitGraphChangeHistoryRequest {
|
|
999
|
+
include_tombstones: true,
|
|
1000
|
+
..CommitGraphChangeHistoryRequest::default()
|
|
1001
|
+
},
|
|
1002
|
+
)
|
|
1003
|
+
.await
|
|
1004
|
+
.expect("history should resolve");
|
|
1005
|
+
|
|
1006
|
+
assert!(hidden.is_empty());
|
|
1007
|
+
assert_eq!(visible.len(), 1);
|
|
1008
|
+
assert_eq!(visible[0].change.id, "change-deleted");
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
#[tokio::test]
|
|
1012
|
+
async fn entities_at_selects_nearest_member_change_for_identity() {
|
|
1013
|
+
let backend = Arc::new(UnitTestBackend::new());
|
|
1014
|
+
let storage = StorageContext::new(backend.clone());
|
|
1015
|
+
let changelog = ChangelogContext::new();
|
|
1016
|
+
append_changes(
|
|
1017
|
+
storage.clone(),
|
|
1018
|
+
&changelog,
|
|
1019
|
+
&[
|
|
1020
|
+
entity_change(
|
|
1021
|
+
"change-old",
|
|
1022
|
+
"entity-1",
|
|
1023
|
+
"test_schema",
|
|
1024
|
+
"{\"value\":\"old\"}",
|
|
1025
|
+
),
|
|
1026
|
+
entity_change(
|
|
1027
|
+
"change-new",
|
|
1028
|
+
"entity-1",
|
|
1029
|
+
"test_schema",
|
|
1030
|
+
"{\"value\":\"new\"}",
|
|
1031
|
+
),
|
|
1032
|
+
commit_change("commit-root-change", "commit-root", &["change-old"], &[]),
|
|
1033
|
+
commit_change(
|
|
1034
|
+
"commit-head-change",
|
|
1035
|
+
"commit-head",
|
|
1036
|
+
&["change-new"],
|
|
1037
|
+
&["commit-root"],
|
|
1038
|
+
),
|
|
1039
|
+
],
|
|
1040
|
+
)
|
|
1041
|
+
.await;
|
|
1042
|
+
|
|
1043
|
+
let graph = CommitGraphContext::new(changelog);
|
|
1044
|
+
let mut reader = graph.reader(storage);
|
|
1045
|
+
let entities = reader
|
|
1046
|
+
.entities_at("commit-head")
|
|
1047
|
+
.await
|
|
1048
|
+
.expect("entities should resolve");
|
|
1049
|
+
|
|
1050
|
+
assert_eq!(
|
|
1051
|
+
entity_ids_for_schema(&entities, "test_schema"),
|
|
1052
|
+
vec![("change-new".to_string(), "commit-head".to_string(), 0)]
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
#[tokio::test]
|
|
1057
|
+
async fn entities_at_reports_created_at_from_oldest_reachable_change() {
|
|
1058
|
+
let backend = Arc::new(UnitTestBackend::new());
|
|
1059
|
+
let storage = StorageContext::new(backend.clone());
|
|
1060
|
+
let changelog = ChangelogContext::new();
|
|
1061
|
+
append_changes(
|
|
1062
|
+
storage.clone(),
|
|
1063
|
+
&changelog,
|
|
1064
|
+
&[
|
|
1065
|
+
entity_change_at(
|
|
1066
|
+
"change-created",
|
|
1067
|
+
"entity-1",
|
|
1068
|
+
"test_schema",
|
|
1069
|
+
"{\"value\":\"created\"}",
|
|
1070
|
+
"2026-01-01T00:00:00Z",
|
|
1071
|
+
),
|
|
1072
|
+
entity_change_at(
|
|
1073
|
+
"change-updated",
|
|
1074
|
+
"entity-1",
|
|
1075
|
+
"test_schema",
|
|
1076
|
+
"{\"value\":\"updated\"}",
|
|
1077
|
+
"2026-01-02T00:00:00Z",
|
|
1078
|
+
),
|
|
1079
|
+
commit_change(
|
|
1080
|
+
"commit-root-change",
|
|
1081
|
+
"commit-root",
|
|
1082
|
+
&["change-created"],
|
|
1083
|
+
&[],
|
|
1084
|
+
),
|
|
1085
|
+
commit_change(
|
|
1086
|
+
"commit-head-change",
|
|
1087
|
+
"commit-head",
|
|
1088
|
+
&["change-updated"],
|
|
1089
|
+
&["commit-root"],
|
|
1090
|
+
),
|
|
1091
|
+
],
|
|
1092
|
+
)
|
|
1093
|
+
.await;
|
|
1094
|
+
|
|
1095
|
+
let graph = CommitGraphContext::new(changelog);
|
|
1096
|
+
let mut reader = graph.reader(storage);
|
|
1097
|
+
let entities = reader
|
|
1098
|
+
.entities_at("commit-head")
|
|
1099
|
+
.await
|
|
1100
|
+
.expect("entities should resolve");
|
|
1101
|
+
let entity = entities
|
|
1102
|
+
.iter()
|
|
1103
|
+
.find(|entity| entity.change.schema_key == "test_schema")
|
|
1104
|
+
.expect("test entity should resolve");
|
|
1105
|
+
|
|
1106
|
+
assert_eq!(entity.change.id, "change-updated");
|
|
1107
|
+
assert_eq!(entity.created_at, "2026-01-01T00:00:00Z");
|
|
1108
|
+
assert_eq!(entity.updated_at, "2026-01-02T00:00:00Z");
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
#[tokio::test]
|
|
1112
|
+
async fn entities_at_uses_reverse_change_order_within_commit() {
|
|
1113
|
+
let backend = Arc::new(UnitTestBackend::new());
|
|
1114
|
+
let storage = StorageContext::new(backend.clone());
|
|
1115
|
+
let changelog = ChangelogContext::new();
|
|
1116
|
+
append_changes(
|
|
1117
|
+
storage.clone(),
|
|
1118
|
+
&changelog,
|
|
1119
|
+
&[
|
|
1120
|
+
entity_change(
|
|
1121
|
+
"change-first",
|
|
1122
|
+
"entity-1",
|
|
1123
|
+
"test_schema",
|
|
1124
|
+
"{\"value\":\"first\"}",
|
|
1125
|
+
),
|
|
1126
|
+
entity_change(
|
|
1127
|
+
"change-last",
|
|
1128
|
+
"entity-1",
|
|
1129
|
+
"test_schema",
|
|
1130
|
+
"{\"value\":\"last\"}",
|
|
1131
|
+
),
|
|
1132
|
+
commit_change(
|
|
1133
|
+
"commit-head-change",
|
|
1134
|
+
"commit-head",
|
|
1135
|
+
&["change-first", "change-last"],
|
|
1136
|
+
&[],
|
|
1137
|
+
),
|
|
1138
|
+
],
|
|
1139
|
+
)
|
|
1140
|
+
.await;
|
|
1141
|
+
|
|
1142
|
+
let graph = CommitGraphContext::new(changelog);
|
|
1143
|
+
let mut reader = graph.reader(storage);
|
|
1144
|
+
let entities = reader
|
|
1145
|
+
.entities_at("commit-head")
|
|
1146
|
+
.await
|
|
1147
|
+
.expect("entities should resolve");
|
|
1148
|
+
|
|
1149
|
+
assert_eq!(
|
|
1150
|
+
entity_ids_for_schema(&entities, "test_schema"),
|
|
1151
|
+
vec![("change-last".to_string(), "commit-head".to_string(), 0)]
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
#[tokio::test]
|
|
1156
|
+
async fn entities_at_head_change_overrides_both_merge_parents() {
|
|
1157
|
+
let backend = Arc::new(UnitTestBackend::new());
|
|
1158
|
+
let storage = StorageContext::new(backend.clone());
|
|
1159
|
+
let changelog = ChangelogContext::new();
|
|
1160
|
+
append_changes(
|
|
1161
|
+
storage.clone(),
|
|
1162
|
+
&changelog,
|
|
1163
|
+
&[
|
|
1164
|
+
entity_change(
|
|
1165
|
+
"change-left",
|
|
1166
|
+
"entity-1",
|
|
1167
|
+
"test_schema",
|
|
1168
|
+
"{\"value\":\"left\"}",
|
|
1169
|
+
),
|
|
1170
|
+
entity_change(
|
|
1171
|
+
"change-right",
|
|
1172
|
+
"entity-1",
|
|
1173
|
+
"test_schema",
|
|
1174
|
+
"{\"value\":\"right\"}",
|
|
1175
|
+
),
|
|
1176
|
+
entity_change(
|
|
1177
|
+
"change-resolved",
|
|
1178
|
+
"entity-1",
|
|
1179
|
+
"test_schema",
|
|
1180
|
+
"{\"value\":\"resolved\"}",
|
|
1181
|
+
),
|
|
1182
|
+
commit_change("commit-left-change", "commit-left", &["change-left"], &[]),
|
|
1183
|
+
commit_change(
|
|
1184
|
+
"commit-right-change",
|
|
1185
|
+
"commit-right",
|
|
1186
|
+
&["change-right"],
|
|
1187
|
+
&[],
|
|
1188
|
+
),
|
|
1189
|
+
commit_change(
|
|
1190
|
+
"commit-head-change",
|
|
1191
|
+
"commit-head",
|
|
1192
|
+
&["change-resolved"],
|
|
1193
|
+
&["commit-left", "commit-right"],
|
|
1194
|
+
),
|
|
1195
|
+
],
|
|
1196
|
+
)
|
|
1197
|
+
.await;
|
|
1198
|
+
|
|
1199
|
+
let graph = CommitGraphContext::new(changelog);
|
|
1200
|
+
let mut reader = graph.reader(storage);
|
|
1201
|
+
let entities = reader
|
|
1202
|
+
.entities_at("commit-head")
|
|
1203
|
+
.await
|
|
1204
|
+
.expect("entities should resolve");
|
|
1205
|
+
|
|
1206
|
+
assert_eq!(
|
|
1207
|
+
entity_ids_for_schema(&entities, "test_schema"),
|
|
1208
|
+
vec![("change-resolved".to_string(), "commit-head".to_string(), 0)]
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
#[tokio::test]
|
|
1213
|
+
async fn entities_at_distinguishes_same_entity_with_different_file_id() {
|
|
1214
|
+
let backend = Arc::new(UnitTestBackend::new());
|
|
1215
|
+
let storage = StorageContext::new(backend.clone());
|
|
1216
|
+
let changelog = ChangelogContext::new();
|
|
1217
|
+
append_changes(
|
|
1218
|
+
storage.clone(),
|
|
1219
|
+
&changelog,
|
|
1220
|
+
&[
|
|
1221
|
+
entity_change_with_file(
|
|
1222
|
+
"change-file-a",
|
|
1223
|
+
"entity-1",
|
|
1224
|
+
"test_schema",
|
|
1225
|
+
Some("file-a"),
|
|
1226
|
+
"{\"value\":\"file-a\"}",
|
|
1227
|
+
),
|
|
1228
|
+
entity_change_with_file(
|
|
1229
|
+
"change-file-b",
|
|
1230
|
+
"entity-1",
|
|
1231
|
+
"test_schema",
|
|
1232
|
+
Some("file-b"),
|
|
1233
|
+
"{\"value\":\"file-b\"}",
|
|
1234
|
+
),
|
|
1235
|
+
commit_change(
|
|
1236
|
+
"commit-head-change",
|
|
1237
|
+
"commit-head",
|
|
1238
|
+
&["change-file-a", "change-file-b"],
|
|
1239
|
+
&[],
|
|
1240
|
+
),
|
|
1241
|
+
],
|
|
1242
|
+
)
|
|
1243
|
+
.await;
|
|
1244
|
+
|
|
1245
|
+
let graph = CommitGraphContext::new(changelog);
|
|
1246
|
+
let mut reader = graph.reader(storage);
|
|
1247
|
+
let entities = reader
|
|
1248
|
+
.entities_at("commit-head")
|
|
1249
|
+
.await
|
|
1250
|
+
.expect("entities should resolve");
|
|
1251
|
+
|
|
1252
|
+
assert_eq!(
|
|
1253
|
+
entity_ids_for_schema(&entities, "test_schema"),
|
|
1254
|
+
vec![
|
|
1255
|
+
("change-file-b".to_string(), "commit-head".to_string(), 0),
|
|
1256
|
+
("change-file-a".to_string(), "commit-head".to_string(), 0),
|
|
1257
|
+
]
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
#[tokio::test]
|
|
1262
|
+
async fn entities_at_uses_latest_change_for_same_entity_identity() {
|
|
1263
|
+
let backend = Arc::new(UnitTestBackend::new());
|
|
1264
|
+
let storage = StorageContext::new(backend.clone());
|
|
1265
|
+
let changelog = ChangelogContext::new();
|
|
1266
|
+
append_changes(
|
|
1267
|
+
storage.clone(),
|
|
1268
|
+
&changelog,
|
|
1269
|
+
&[
|
|
1270
|
+
entity_change_with_file(
|
|
1271
|
+
"change-entity-a",
|
|
1272
|
+
"entity-1",
|
|
1273
|
+
"test_schema",
|
|
1274
|
+
None,
|
|
1275
|
+
"{\"value\":\"a\"}",
|
|
1276
|
+
),
|
|
1277
|
+
entity_change_with_file(
|
|
1278
|
+
"change-entity-b",
|
|
1279
|
+
"entity-1",
|
|
1280
|
+
"test_schema",
|
|
1281
|
+
None,
|
|
1282
|
+
"{\"value\":\"b\"}",
|
|
1283
|
+
),
|
|
1284
|
+
commit_change(
|
|
1285
|
+
"commit-head-change",
|
|
1286
|
+
"commit-head",
|
|
1287
|
+
&["change-entity-a", "change-entity-b"],
|
|
1288
|
+
&[],
|
|
1289
|
+
),
|
|
1290
|
+
],
|
|
1291
|
+
)
|
|
1292
|
+
.await;
|
|
1293
|
+
|
|
1294
|
+
let graph = CommitGraphContext::new(changelog);
|
|
1295
|
+
let mut reader = graph.reader(storage);
|
|
1296
|
+
let entities = reader
|
|
1297
|
+
.entities_at("commit-head")
|
|
1298
|
+
.await
|
|
1299
|
+
.expect("entities should resolve");
|
|
1300
|
+
let entity = entities
|
|
1301
|
+
.iter()
|
|
1302
|
+
.find(|entity| entity.change.schema_key == "test_schema")
|
|
1303
|
+
.expect("entity should resolve");
|
|
1304
|
+
|
|
1305
|
+
assert_eq!(
|
|
1306
|
+
entity_ids_for_schema(&entities, "test_schema"),
|
|
1307
|
+
vec![("change-entity-b".to_string(), "commit-head".to_string(), 0)]
|
|
1308
|
+
);
|
|
1309
|
+
assert_eq!(
|
|
1310
|
+
entity.change.snapshot_content.as_deref(),
|
|
1311
|
+
Some("{\"value\":\"b\"}")
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
#[tokio::test]
|
|
1316
|
+
async fn entities_at_head_tombstone_hides_parent_entity() {
|
|
1317
|
+
let backend = Arc::new(UnitTestBackend::new());
|
|
1318
|
+
let storage = StorageContext::new(backend.clone());
|
|
1319
|
+
let changelog = ChangelogContext::new();
|
|
1320
|
+
append_changes(
|
|
1321
|
+
storage.clone(),
|
|
1322
|
+
&changelog,
|
|
1323
|
+
&[
|
|
1324
|
+
entity_change(
|
|
1325
|
+
"change-created",
|
|
1326
|
+
"entity-1",
|
|
1327
|
+
"test_schema",
|
|
1328
|
+
"{\"value\":\"created\"}",
|
|
1329
|
+
),
|
|
1330
|
+
entity_tombstone("change-deleted", "entity-1", "test_schema"),
|
|
1331
|
+
commit_change(
|
|
1332
|
+
"commit-root-change",
|
|
1333
|
+
"commit-root",
|
|
1334
|
+
&["change-created"],
|
|
1335
|
+
&[],
|
|
1336
|
+
),
|
|
1337
|
+
commit_change(
|
|
1338
|
+
"commit-head-change",
|
|
1339
|
+
"commit-head",
|
|
1340
|
+
&["change-deleted"],
|
|
1341
|
+
&["commit-root"],
|
|
1342
|
+
),
|
|
1343
|
+
],
|
|
1344
|
+
)
|
|
1345
|
+
.await;
|
|
1346
|
+
|
|
1347
|
+
let graph = CommitGraphContext::new(changelog);
|
|
1348
|
+
let mut reader = graph.reader(storage);
|
|
1349
|
+
let entities = reader
|
|
1350
|
+
.entities_at("commit-head")
|
|
1351
|
+
.await
|
|
1352
|
+
.expect("entities should resolve");
|
|
1353
|
+
let entity = entities
|
|
1354
|
+
.iter()
|
|
1355
|
+
.find(|entity| entity.change.schema_key == "test_schema")
|
|
1356
|
+
.expect("tombstone entity should resolve");
|
|
1357
|
+
|
|
1358
|
+
assert_eq!(
|
|
1359
|
+
entity_ids_for_schema(&entities, "test_schema"),
|
|
1360
|
+
vec![("change-deleted".to_string(), "commit-head".to_string(), 0)]
|
|
1361
|
+
);
|
|
1362
|
+
assert_eq!(entity.change.snapshot_content, None);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
#[tokio::test]
|
|
1366
|
+
async fn entities_at_includes_reachable_commit_rows() {
|
|
1367
|
+
let backend = Arc::new(UnitTestBackend::new());
|
|
1368
|
+
let storage = StorageContext::new(backend.clone());
|
|
1369
|
+
let changelog = ChangelogContext::new();
|
|
1370
|
+
append_changes(
|
|
1371
|
+
storage.clone(),
|
|
1372
|
+
&changelog,
|
|
1373
|
+
&[
|
|
1374
|
+
commit_change("commit-root-change", "commit-root", &[], &[]),
|
|
1375
|
+
commit_change("commit-head-change", "commit-head", &[], &["commit-root"]),
|
|
1376
|
+
],
|
|
1377
|
+
)
|
|
1378
|
+
.await;
|
|
1379
|
+
|
|
1380
|
+
let graph = CommitGraphContext::new(changelog);
|
|
1381
|
+
let mut reader = graph.reader(storage);
|
|
1382
|
+
let entities = reader
|
|
1383
|
+
.entities_at("commit-head")
|
|
1384
|
+
.await
|
|
1385
|
+
.expect("entities should resolve");
|
|
1386
|
+
|
|
1387
|
+
assert_eq!(
|
|
1388
|
+
entity_ids_for_schema(&entities, super::COMMIT_SCHEMA_KEY),
|
|
1389
|
+
vec![
|
|
1390
|
+
(
|
|
1391
|
+
"commit-head-change".to_string(),
|
|
1392
|
+
"commit-head".to_string(),
|
|
1393
|
+
0
|
|
1394
|
+
),
|
|
1395
|
+
(
|
|
1396
|
+
"commit-root-change".to_string(),
|
|
1397
|
+
"commit-root".to_string(),
|
|
1398
|
+
1
|
|
1399
|
+
),
|
|
1400
|
+
]
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
#[tokio::test]
|
|
1405
|
+
async fn entities_at_errors_on_missing_member_change() {
|
|
1406
|
+
let backend = Arc::new(UnitTestBackend::new());
|
|
1407
|
+
let storage = StorageContext::new(backend.clone());
|
|
1408
|
+
let changelog = ChangelogContext::new();
|
|
1409
|
+
append_changes(
|
|
1410
|
+
storage.clone(),
|
|
1411
|
+
&changelog,
|
|
1412
|
+
&[commit_change(
|
|
1413
|
+
"commit-head-change",
|
|
1414
|
+
"commit-head",
|
|
1415
|
+
&["missing-change"],
|
|
1416
|
+
&[],
|
|
1417
|
+
)],
|
|
1418
|
+
)
|
|
1419
|
+
.await;
|
|
1420
|
+
|
|
1421
|
+
let graph = CommitGraphContext::new(changelog);
|
|
1422
|
+
let mut reader = graph.reader(storage);
|
|
1423
|
+
let error = reader
|
|
1424
|
+
.entities_at("commit-head")
|
|
1425
|
+
.await
|
|
1426
|
+
.expect_err("missing member change should fail");
|
|
1427
|
+
|
|
1428
|
+
assert!(error.message.contains("missing-change"));
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
async fn append_changes(
|
|
1432
|
+
storage: StorageContext,
|
|
1433
|
+
changelog: &ChangelogContext,
|
|
1434
|
+
changes: &[MaterializedCanonicalChange],
|
|
1435
|
+
) {
|
|
1436
|
+
let mut tx = storage
|
|
1437
|
+
.begin_write_transaction()
|
|
1438
|
+
.await
|
|
1439
|
+
.expect("transaction should open");
|
|
1440
|
+
let mut writes = StorageWriteSet::new();
|
|
1441
|
+
let canonical_changes = {
|
|
1442
|
+
let mut json_writer = JsonStoreContext::new().writer();
|
|
1443
|
+
changes
|
|
1444
|
+
.iter()
|
|
1445
|
+
.map(|change| {
|
|
1446
|
+
canonicalize_materialized_change(&mut writes, &mut json_writer, change)
|
|
1447
|
+
})
|
|
1448
|
+
.collect::<Result<Vec<_>, _>>()
|
|
1449
|
+
.expect("changes should canonicalize")
|
|
1450
|
+
};
|
|
1451
|
+
changelog
|
|
1452
|
+
.writer(&mut writes)
|
|
1453
|
+
.stage_changes(&canonical_changes)
|
|
1454
|
+
.expect("append should succeed");
|
|
1455
|
+
writes
|
|
1456
|
+
.apply(&mut tx.as_mut())
|
|
1457
|
+
.await
|
|
1458
|
+
.expect("writes should apply");
|
|
1459
|
+
tx.commit().await.expect("commit should succeed");
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
fn commit_change(
|
|
1463
|
+
change_id: &str,
|
|
1464
|
+
commit_id: &str,
|
|
1465
|
+
change_ids: &[&str],
|
|
1466
|
+
parent_commit_ids: &[&str],
|
|
1467
|
+
) -> MaterializedCanonicalChange {
|
|
1468
|
+
MaterializedCanonicalChange {
|
|
1469
|
+
id: change_id.to_string(),
|
|
1470
|
+
entity_id: crate::entity_identity::EntityIdentity::single(commit_id),
|
|
1471
|
+
schema_key: super::COMMIT_SCHEMA_KEY.to_string(),
|
|
1472
|
+
schema_version: "1".to_string(),
|
|
1473
|
+
file_id: None,
|
|
1474
|
+
snapshot_content: Some(
|
|
1475
|
+
serde_json::to_string(&json!({
|
|
1476
|
+
"id": commit_id,
|
|
1477
|
+
"change_set_id": "change-set-1",
|
|
1478
|
+
"change_ids": change_ids,
|
|
1479
|
+
"parent_commit_ids": parent_commit_ids,
|
|
1480
|
+
}))
|
|
1481
|
+
.expect("snapshot should serialize"),
|
|
1482
|
+
),
|
|
1483
|
+
metadata: None,
|
|
1484
|
+
created_at: "2026-01-01T00:00:00Z".to_string(),
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
fn parsed_commit(
|
|
1489
|
+
commit_id: &str,
|
|
1490
|
+
change_ids: &[&str],
|
|
1491
|
+
parent_commit_ids: &[&str],
|
|
1492
|
+
) -> crate::commit_graph::CommitGraphCommit {
|
|
1493
|
+
super::parse_commit_change(commit_change(
|
|
1494
|
+
&format!("{commit_id}-change"),
|
|
1495
|
+
commit_id,
|
|
1496
|
+
change_ids,
|
|
1497
|
+
parent_commit_ids,
|
|
1498
|
+
))
|
|
1499
|
+
.expect("commit helper should parse")
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
fn entity_change(
|
|
1503
|
+
change_id: &str,
|
|
1504
|
+
entity_id: &str,
|
|
1505
|
+
schema_key: &str,
|
|
1506
|
+
snapshot_content: &str,
|
|
1507
|
+
) -> MaterializedCanonicalChange {
|
|
1508
|
+
entity_change_at(
|
|
1509
|
+
change_id,
|
|
1510
|
+
entity_id,
|
|
1511
|
+
schema_key,
|
|
1512
|
+
snapshot_content,
|
|
1513
|
+
"2026-01-01T00:00:00Z",
|
|
1514
|
+
)
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
fn entity_change_at(
|
|
1518
|
+
change_id: &str,
|
|
1519
|
+
entity_id: &str,
|
|
1520
|
+
schema_key: &str,
|
|
1521
|
+
snapshot_content: &str,
|
|
1522
|
+
created_at: &str,
|
|
1523
|
+
) -> MaterializedCanonicalChange {
|
|
1524
|
+
MaterializedCanonicalChange {
|
|
1525
|
+
id: change_id.to_string(),
|
|
1526
|
+
entity_id: crate::entity_identity::EntityIdentity::single(entity_id),
|
|
1527
|
+
schema_key: schema_key.to_string(),
|
|
1528
|
+
schema_version: "1".to_string(),
|
|
1529
|
+
file_id: None,
|
|
1530
|
+
snapshot_content: Some(snapshot_content.to_string()),
|
|
1531
|
+
metadata: None,
|
|
1532
|
+
created_at: created_at.to_string(),
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
fn entity_change_with_file(
|
|
1537
|
+
change_id: &str,
|
|
1538
|
+
entity_id: &str,
|
|
1539
|
+
schema_key: &str,
|
|
1540
|
+
file_id: Option<&str>,
|
|
1541
|
+
snapshot_content: &str,
|
|
1542
|
+
) -> MaterializedCanonicalChange {
|
|
1543
|
+
MaterializedCanonicalChange {
|
|
1544
|
+
id: change_id.to_string(),
|
|
1545
|
+
entity_id: crate::entity_identity::EntityIdentity::single(entity_id),
|
|
1546
|
+
schema_key: schema_key.to_string(),
|
|
1547
|
+
schema_version: "1".to_string(),
|
|
1548
|
+
file_id: file_id.map(str::to_string),
|
|
1549
|
+
snapshot_content: Some(snapshot_content.to_string()),
|
|
1550
|
+
metadata: None,
|
|
1551
|
+
created_at: "2026-01-01T00:00:00Z".to_string(),
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
fn entity_tombstone(
|
|
1556
|
+
change_id: &str,
|
|
1557
|
+
entity_id: &str,
|
|
1558
|
+
schema_key: &str,
|
|
1559
|
+
) -> MaterializedCanonicalChange {
|
|
1560
|
+
MaterializedCanonicalChange {
|
|
1561
|
+
id: change_id.to_string(),
|
|
1562
|
+
entity_id: crate::entity_identity::EntityIdentity::single(entity_id),
|
|
1563
|
+
schema_key: schema_key.to_string(),
|
|
1564
|
+
schema_version: "1".to_string(),
|
|
1565
|
+
file_id: None,
|
|
1566
|
+
snapshot_content: None,
|
|
1567
|
+
metadata: None,
|
|
1568
|
+
created_at: "2026-01-02T00:00:00Z".to_string(),
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
fn entity_ids_for_schema(
|
|
1573
|
+
entities: &[crate::commit_graph::CommitGraphEntity],
|
|
1574
|
+
schema_key: &str,
|
|
1575
|
+
) -> Vec<(String, String, u32)> {
|
|
1576
|
+
entities
|
|
1577
|
+
.iter()
|
|
1578
|
+
.filter(|entity| entity.change.schema_key == schema_key)
|
|
1579
|
+
.map(|entity| {
|
|
1580
|
+
(
|
|
1581
|
+
entity.change.id.clone(),
|
|
1582
|
+
entity.source_commit_id.clone(),
|
|
1583
|
+
entity.depth,
|
|
1584
|
+
)
|
|
1585
|
+
})
|
|
1586
|
+
.collect()
|
|
1587
|
+
}
|
|
1588
|
+
}
|