@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,1436 @@
|
|
|
1
|
+
use std::collections::{BTreeMap, BTreeSet};
|
|
2
|
+
use std::sync::Mutex;
|
|
3
|
+
|
|
4
|
+
use crate::functions::{FunctionProvider, FunctionProviderHandle};
|
|
5
|
+
#[cfg(test)]
|
|
6
|
+
use crate::live_state::LiveStateRowRequest;
|
|
7
|
+
use crate::live_state::{LiveStateRow, LiveStateRowIdentity, LiveStateScanRequest};
|
|
8
|
+
use crate::transaction::types::{
|
|
9
|
+
LogicalPrimaryKey, StageAdoptedChange, StageFileData, StageRow, StageRowOrigin, StageWrite,
|
|
10
|
+
StageWriteMode, StageWriteOperation, StageWriteOutcome,
|
|
11
|
+
};
|
|
12
|
+
use crate::transaction::types::{StagedAdoptedStateRow, StagedCommitMembers, StagedStateRow};
|
|
13
|
+
use crate::GLOBAL_VERSION_ID;
|
|
14
|
+
use crate::{LixError, NullableKeyFilter};
|
|
15
|
+
|
|
16
|
+
/// Transaction-local writes decoded by DataFusion provider hooks.
|
|
17
|
+
///
|
|
18
|
+
/// This is the engine2 seam between SQL execution and transaction ownership:
|
|
19
|
+
/// write frontends stage decoded writes here, the transaction normalizes them into
|
|
20
|
+
/// stable `StagedStateRow`s, reads build a `StagedStateRowOverlay` from those rows,
|
|
21
|
+
/// and commit later drains the same rows.
|
|
22
|
+
pub(crate) struct TransactionStagedWrites {
|
|
23
|
+
functions: FunctionProviderHandle,
|
|
24
|
+
rows: Mutex<BTreeMap<StagedStateRowIdentity, StagedStateRow>>,
|
|
25
|
+
adopted_rows: Mutex<BTreeMap<StagedStateRowIdentity, StagedAdoptedStateRow>>,
|
|
26
|
+
insert_identities: Mutex<BTreeMap<LiveStateRowIdentity, Option<StageRowOrigin>>>,
|
|
27
|
+
commit_members_by_version: Mutex<BTreeMap<String, StagedCommitMembers>>,
|
|
28
|
+
extra_commit_parents_by_version: Mutex<BTreeMap<String, Vec<String>>>,
|
|
29
|
+
file_data_writes: Mutex<Vec<StageFileData>>,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// Drained transaction-local writes ready for commit.
|
|
33
|
+
pub(crate) struct StagedWriteSet {
|
|
34
|
+
pub(crate) state_rows: Vec<StagedStateRow>,
|
|
35
|
+
pub(crate) adopted_rows: Vec<StagedAdoptedStateRow>,
|
|
36
|
+
pub(crate) insert_identities: BTreeMap<LiveStateRowIdentity, Option<StageRowOrigin>>,
|
|
37
|
+
pub(crate) commit_members_by_version: BTreeMap<String, StagedCommitMembers>,
|
|
38
|
+
pub(crate) extra_commit_parents_by_version: BTreeMap<String, Vec<String>>,
|
|
39
|
+
pub(crate) file_data_writes: Vec<StageFileData>,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
impl StagedWriteSet {
|
|
43
|
+
pub(crate) fn state_rows_for_validation(&self) -> Vec<StagedStateRow> {
|
|
44
|
+
self.state_rows
|
|
45
|
+
.iter()
|
|
46
|
+
.cloned()
|
|
47
|
+
.chain(self.adopted_rows.iter().map(StagedStateRow::from))
|
|
48
|
+
.collect()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
impl TransactionStagedWrites {
|
|
53
|
+
pub(crate) fn new(functions: FunctionProviderHandle) -> Self {
|
|
54
|
+
Self {
|
|
55
|
+
functions,
|
|
56
|
+
rows: Mutex::new(BTreeMap::new()),
|
|
57
|
+
adopted_rows: Mutex::new(BTreeMap::new()),
|
|
58
|
+
insert_identities: Mutex::new(BTreeMap::new()),
|
|
59
|
+
commit_members_by_version: Mutex::new(BTreeMap::new()),
|
|
60
|
+
extra_commit_parents_by_version: Mutex::new(BTreeMap::new()),
|
|
61
|
+
file_data_writes: Mutex::new(Vec::new()),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Drains staged writes for commit.
|
|
66
|
+
pub(crate) fn drain(&self) -> Result<StagedWriteSet, LixError> {
|
|
67
|
+
let mut rows_guard = self.rows.lock().map_err(|_| {
|
|
68
|
+
LixError::new(
|
|
69
|
+
"LIX_ERROR_UNKNOWN",
|
|
70
|
+
"failed to acquire transaction staged writes lock",
|
|
71
|
+
)
|
|
72
|
+
})?;
|
|
73
|
+
let mut adopted_rows_guard = self.adopted_rows.lock().map_err(|_| {
|
|
74
|
+
LixError::new(
|
|
75
|
+
"LIX_ERROR_UNKNOWN",
|
|
76
|
+
"failed to acquire transaction staged adopted writes lock",
|
|
77
|
+
)
|
|
78
|
+
})?;
|
|
79
|
+
let mut file_data_guard = self.file_data_writes.lock().map_err(|_| {
|
|
80
|
+
LixError::new(
|
|
81
|
+
"LIX_ERROR_UNKNOWN",
|
|
82
|
+
"failed to acquire transaction staged file data lock",
|
|
83
|
+
)
|
|
84
|
+
})?;
|
|
85
|
+
let mut insert_identities_guard = self.insert_identities.lock().map_err(|_| {
|
|
86
|
+
LixError::new(
|
|
87
|
+
"LIX_ERROR_UNKNOWN",
|
|
88
|
+
"failed to acquire transaction staged insert identity lock",
|
|
89
|
+
)
|
|
90
|
+
})?;
|
|
91
|
+
let mut commit_members_guard = self.commit_members_by_version.lock().map_err(|_| {
|
|
92
|
+
LixError::new(
|
|
93
|
+
"LIX_ERROR_UNKNOWN",
|
|
94
|
+
"failed to acquire transaction staged commit membership lock",
|
|
95
|
+
)
|
|
96
|
+
})?;
|
|
97
|
+
let mut extra_parents_guard =
|
|
98
|
+
self.extra_commit_parents_by_version.lock().map_err(|_| {
|
|
99
|
+
LixError::new(
|
|
100
|
+
"LIX_ERROR_UNKNOWN",
|
|
101
|
+
"failed to acquire transaction staged extra commit parents lock",
|
|
102
|
+
)
|
|
103
|
+
})?;
|
|
104
|
+
Ok(StagedWriteSet {
|
|
105
|
+
state_rows: std::mem::take(&mut *rows_guard).into_values().collect(),
|
|
106
|
+
adopted_rows: std::mem::take(&mut *adopted_rows_guard)
|
|
107
|
+
.into_values()
|
|
108
|
+
.collect(),
|
|
109
|
+
insert_identities: std::mem::take(&mut *insert_identities_guard),
|
|
110
|
+
commit_members_by_version: std::mem::take(&mut *commit_members_guard),
|
|
111
|
+
extra_commit_parents_by_version: std::mem::take(&mut *extra_parents_guard),
|
|
112
|
+
file_data_writes: std::mem::take(&mut *file_data_guard),
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// Records an additional parent for the commit generated for `version_id`.
|
|
117
|
+
///
|
|
118
|
+
/// Normal writes parent the new commit to the version's previous head.
|
|
119
|
+
/// Merges add the source version head as an extra parent so the commit graph
|
|
120
|
+
/// preserves branch ancestry while tracked-state roots still apply source
|
|
121
|
+
/// rows onto the target root.
|
|
122
|
+
pub(crate) fn add_commit_parent(
|
|
123
|
+
&self,
|
|
124
|
+
version_id: String,
|
|
125
|
+
parent_commit_id: String,
|
|
126
|
+
) -> Result<(), LixError> {
|
|
127
|
+
let mut guard = self.extra_commit_parents_by_version.lock().map_err(|_| {
|
|
128
|
+
LixError::new(
|
|
129
|
+
"LIX_ERROR_UNKNOWN",
|
|
130
|
+
"failed to acquire transaction staged extra commit parents lock",
|
|
131
|
+
)
|
|
132
|
+
})?;
|
|
133
|
+
let parents = guard.entry(version_id).or_default();
|
|
134
|
+
if !parents.contains(&parent_commit_id) {
|
|
135
|
+
parents.push(parent_commit_id);
|
|
136
|
+
}
|
|
137
|
+
Ok(())
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
pub(crate) fn staged_commit_id(&self, version_id: &str) -> Result<Option<String>, LixError> {
|
|
141
|
+
let guard = self.commit_members_by_version.lock().map_err(|_| {
|
|
142
|
+
LixError::new(
|
|
143
|
+
"LIX_ERROR_UNKNOWN",
|
|
144
|
+
"failed to acquire transaction staged commit membership lock",
|
|
145
|
+
)
|
|
146
|
+
})?;
|
|
147
|
+
Ok(guard
|
|
148
|
+
.get(version_id)
|
|
149
|
+
.map(|members| members.commit_id.clone()))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/// Stages a commit for `version_id` even if no tracked state rows changed.
|
|
153
|
+
///
|
|
154
|
+
/// Merge uses this to record graph ancestry for convergent merges where the
|
|
155
|
+
/// target already has the same final state as the source, but the source
|
|
156
|
+
/// head is not reachable from the target head.
|
|
157
|
+
pub(crate) fn stage_empty_commit(&self, version_id: String) -> Result<String, LixError> {
|
|
158
|
+
let mut functions = self.functions.clone();
|
|
159
|
+
let mut guard = self.commit_members_by_version.lock().map_err(|_| {
|
|
160
|
+
LixError::new(
|
|
161
|
+
"LIX_ERROR_UNKNOWN",
|
|
162
|
+
"failed to acquire transaction staged commit membership lock",
|
|
163
|
+
)
|
|
164
|
+
})?;
|
|
165
|
+
let members = guard.entry(version_id).or_insert_with(|| {
|
|
166
|
+
StagedCommitMembers::new(
|
|
167
|
+
functions.uuid_v7(),
|
|
168
|
+
functions.uuid_v7(),
|
|
169
|
+
functions.uuid_v7(),
|
|
170
|
+
functions.timestamp(),
|
|
171
|
+
)
|
|
172
|
+
});
|
|
173
|
+
members.allow_empty();
|
|
174
|
+
Ok(members.commit_id.clone())
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/// Builds the transaction-local read overlay from currently staged writes.
|
|
178
|
+
pub(crate) fn staging_overlay(&self) -> Result<StagedStateRowOverlay, LixError> {
|
|
179
|
+
let guard = self.rows.lock().map_err(|_| {
|
|
180
|
+
LixError::new(
|
|
181
|
+
"LIX_ERROR_UNKNOWN",
|
|
182
|
+
"failed to acquire transaction staged writes lock",
|
|
183
|
+
)
|
|
184
|
+
})?;
|
|
185
|
+
let adopted_guard = self.adopted_rows.lock().map_err(|_| {
|
|
186
|
+
LixError::new(
|
|
187
|
+
"LIX_ERROR_UNKNOWN",
|
|
188
|
+
"failed to acquire transaction staged adopted writes lock",
|
|
189
|
+
)
|
|
190
|
+
})?;
|
|
191
|
+
Ok(StagedStateRowOverlay::new(
|
|
192
|
+
guard.clone(),
|
|
193
|
+
adopted_guard.clone(),
|
|
194
|
+
))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/// Stages one decoded write batch into this transaction.
|
|
198
|
+
///
|
|
199
|
+
/// This is the single hydration boundary for engine2 writes:
|
|
200
|
+
/// frontends hand us `StageRow`s, and this method assigns timestamps,
|
|
201
|
+
/// change ids, commit ids, and commit membership before commit routing ever
|
|
202
|
+
/// sees the rows.
|
|
203
|
+
pub(crate) fn stage_write(&self, write: StageWrite) -> Result<StageWriteOutcome, LixError> {
|
|
204
|
+
let (mode, count) = match &write {
|
|
205
|
+
StageWrite::Rows { mode, rows } => (Some(*mode), rows.len() as u64),
|
|
206
|
+
StageWrite::RowsWithFileData { mode, count, .. } => (Some(*mode), *count),
|
|
207
|
+
StageWrite::AdoptedChanges { changes } => (None, changes.len() as u64),
|
|
208
|
+
};
|
|
209
|
+
let mut functions = self.functions.clone();
|
|
210
|
+
let (rows, adopted_rows, file_data_writes) =
|
|
211
|
+
self.state_rows_from_stage_write(write, &mut functions)?;
|
|
212
|
+
for row in &rows {
|
|
213
|
+
validate_commit_membership_support(row)?;
|
|
214
|
+
}
|
|
215
|
+
for row in &adopted_rows {
|
|
216
|
+
validate_adopted_commit_membership_support(row)?;
|
|
217
|
+
}
|
|
218
|
+
reject_duplicate_present_rows_in_batch(&rows)?;
|
|
219
|
+
let mut guard = self.rows.lock().map_err(|_| {
|
|
220
|
+
LixError::new(
|
|
221
|
+
"LIX_ERROR_UNKNOWN",
|
|
222
|
+
"failed to acquire transaction staged writes lock",
|
|
223
|
+
)
|
|
224
|
+
})?;
|
|
225
|
+
let mut adopted_guard = self.adopted_rows.lock().map_err(|_| {
|
|
226
|
+
LixError::new(
|
|
227
|
+
"LIX_ERROR_UNKNOWN",
|
|
228
|
+
"failed to acquire transaction staged adopted writes lock",
|
|
229
|
+
)
|
|
230
|
+
})?;
|
|
231
|
+
let mut commit_members_guard = self.commit_members_by_version.lock().map_err(|_| {
|
|
232
|
+
LixError::new(
|
|
233
|
+
"LIX_ERROR_UNKNOWN",
|
|
234
|
+
"failed to acquire transaction staged commit membership lock",
|
|
235
|
+
)
|
|
236
|
+
})?;
|
|
237
|
+
let mut insert_identities_guard = self.insert_identities.lock().map_err(|_| {
|
|
238
|
+
LixError::new(
|
|
239
|
+
"LIX_ERROR_UNKNOWN",
|
|
240
|
+
"failed to acquire transaction staged insert identity lock",
|
|
241
|
+
)
|
|
242
|
+
})?;
|
|
243
|
+
for mut row in rows {
|
|
244
|
+
let identity = StagedStateRowIdentity::from(&row);
|
|
245
|
+
if mode == Some(StageWriteMode::Insert)
|
|
246
|
+
&& (guard.contains_key(&identity)
|
|
247
|
+
|| guard.contains_key(&identity.opposite_untracked())
|
|
248
|
+
|| adopted_guard.contains_key(&identity))
|
|
249
|
+
{
|
|
250
|
+
return Err(duplicate_insert_identity_error(&row));
|
|
251
|
+
}
|
|
252
|
+
if adopted_guard.contains_key(&identity) {
|
|
253
|
+
return Err(conflicting_adopted_identity_error(&row));
|
|
254
|
+
}
|
|
255
|
+
if let Some(previous) = guard.remove(&identity.opposite_untracked()) {
|
|
256
|
+
remove_row_from_commit_members(&mut commit_members_guard, &previous);
|
|
257
|
+
}
|
|
258
|
+
if let Some(previous) = guard.remove(&identity) {
|
|
259
|
+
remove_row_from_commit_members(&mut commit_members_guard, &previous);
|
|
260
|
+
}
|
|
261
|
+
add_row_to_commit_members(&mut commit_members_guard, &mut row, &mut functions);
|
|
262
|
+
let identity = StagedStateRowIdentity::from(&row);
|
|
263
|
+
if mode == Some(StageWriteMode::Insert) {
|
|
264
|
+
insert_identities_guard.insert(
|
|
265
|
+
live_state_identity_from_staged_row(&row),
|
|
266
|
+
row.origin.clone(),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
guard.insert(identity, row);
|
|
270
|
+
}
|
|
271
|
+
for mut row in adopted_rows {
|
|
272
|
+
let identity = StagedStateRowIdentity::from(&row);
|
|
273
|
+
if guard.contains_key(&identity) || adopted_guard.contains_key(&identity) {
|
|
274
|
+
return Err(conflicting_adopted_projection_error(&row));
|
|
275
|
+
}
|
|
276
|
+
add_adopted_row_to_commit_members(&mut commit_members_guard, &mut row, &mut functions);
|
|
277
|
+
let identity = StagedStateRowIdentity::from(&row);
|
|
278
|
+
adopted_guard.insert(identity, row);
|
|
279
|
+
}
|
|
280
|
+
if !file_data_writes.is_empty() {
|
|
281
|
+
self.file_data_writes
|
|
282
|
+
.lock()
|
|
283
|
+
.map_err(|_| {
|
|
284
|
+
LixError::new(
|
|
285
|
+
"LIX_ERROR_UNKNOWN",
|
|
286
|
+
"failed to acquire transaction staged file data lock",
|
|
287
|
+
)
|
|
288
|
+
})?
|
|
289
|
+
.extend(file_data_writes);
|
|
290
|
+
}
|
|
291
|
+
Ok(StageWriteOutcome { count })
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
fn state_rows_from_stage_write(
|
|
295
|
+
&self,
|
|
296
|
+
write: StageWrite,
|
|
297
|
+
functions: &mut dyn FunctionProvider,
|
|
298
|
+
) -> Result<
|
|
299
|
+
(
|
|
300
|
+
Vec<StagedStateRow>,
|
|
301
|
+
Vec<StagedAdoptedStateRow>,
|
|
302
|
+
Vec<StageFileData>,
|
|
303
|
+
),
|
|
304
|
+
LixError,
|
|
305
|
+
> {
|
|
306
|
+
let mut state_rows = Vec::new();
|
|
307
|
+
let mut adopted_rows = Vec::new();
|
|
308
|
+
let mut file_data_writes = Vec::new();
|
|
309
|
+
match write {
|
|
310
|
+
StageWrite::Rows { rows, .. } => {
|
|
311
|
+
self.push_state_rows(&mut state_rows, rows, functions)?;
|
|
312
|
+
}
|
|
313
|
+
StageWrite::RowsWithFileData {
|
|
314
|
+
rows, file_data, ..
|
|
315
|
+
} => {
|
|
316
|
+
self.push_state_rows(&mut state_rows, rows, functions)?;
|
|
317
|
+
file_data_writes.extend(file_data);
|
|
318
|
+
}
|
|
319
|
+
StageWrite::AdoptedChanges { changes } => {
|
|
320
|
+
self.push_adopted_rows(&mut adopted_rows, changes)?;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
Ok((state_rows, adopted_rows, file_data_writes))
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
fn push_state_rows(
|
|
327
|
+
&self,
|
|
328
|
+
state_rows: &mut Vec<StagedStateRow>,
|
|
329
|
+
rows: Vec<StageRow>,
|
|
330
|
+
functions: &mut dyn FunctionProvider,
|
|
331
|
+
) -> Result<(), LixError> {
|
|
332
|
+
state_rows.reserve(rows.len());
|
|
333
|
+
for row in rows {
|
|
334
|
+
state_rows.push(hydrate_state_write_row(row, functions)?);
|
|
335
|
+
}
|
|
336
|
+
Ok(())
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
fn push_adopted_rows(
|
|
340
|
+
&self,
|
|
341
|
+
adopted_rows: &mut Vec<StagedAdoptedStateRow>,
|
|
342
|
+
changes: Vec<StageAdoptedChange>,
|
|
343
|
+
) -> Result<(), LixError> {
|
|
344
|
+
adopted_rows.reserve(changes.len());
|
|
345
|
+
for change in changes {
|
|
346
|
+
adopted_rows.push(hydrate_adopted_state_row(change)?);
|
|
347
|
+
}
|
|
348
|
+
Ok(())
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/// Read overlay derived from staged transaction writes.
|
|
353
|
+
pub(crate) struct StagedStateRowOverlay {
|
|
354
|
+
rows: BTreeMap<StagedStateRowIdentity, StagedStateRow>,
|
|
355
|
+
adopted_rows: BTreeMap<StagedStateRowIdentity, StagedAdoptedStateRow>,
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
impl StagedStateRowOverlay {
|
|
359
|
+
fn new(
|
|
360
|
+
rows: BTreeMap<StagedStateRowIdentity, StagedStateRow>,
|
|
361
|
+
adopted_rows: BTreeMap<StagedStateRowIdentity, StagedAdoptedStateRow>,
|
|
362
|
+
) -> Self {
|
|
363
|
+
Self { rows, adopted_rows }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/// Returns staged rows visible for a scan request.
|
|
367
|
+
pub(crate) fn scan(&self, request: &LiveStateScanRequest) -> Vec<LiveStateRow> {
|
|
368
|
+
self.rows
|
|
369
|
+
.values()
|
|
370
|
+
.filter(|row| staged_row_matches_scan(row, request))
|
|
371
|
+
.map(LiveStateRow::from)
|
|
372
|
+
.chain(
|
|
373
|
+
self.adopted_rows
|
|
374
|
+
.values()
|
|
375
|
+
.filter(|row| adopted_row_matches_scan(row, request))
|
|
376
|
+
.map(LiveStateRow::from),
|
|
377
|
+
)
|
|
378
|
+
.collect()
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/// Returns staged identities that should suppress base live-state rows.
|
|
382
|
+
///
|
|
383
|
+
/// Tombstones also suppress base live-state rows, even when the caller is not
|
|
384
|
+
/// asking to see tombstone rows.
|
|
385
|
+
pub(crate) fn identities_matching_scan(
|
|
386
|
+
&self,
|
|
387
|
+
request: &LiveStateScanRequest,
|
|
388
|
+
) -> BTreeSet<StagedStateRowIdentity> {
|
|
389
|
+
self.rows
|
|
390
|
+
.values()
|
|
391
|
+
.filter(|row| staged_row_identity_matches_scan(row, request))
|
|
392
|
+
.map(StagedStateRowIdentity::from)
|
|
393
|
+
.chain(
|
|
394
|
+
self.adopted_rows
|
|
395
|
+
.values()
|
|
396
|
+
.filter(|row| adopted_row_identity_matches_scan(row, request))
|
|
397
|
+
.map(StagedStateRowIdentity::from),
|
|
398
|
+
)
|
|
399
|
+
.collect()
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/// Returns a staged exact-row answer, if this transaction has one.
|
|
403
|
+
#[cfg(test)]
|
|
404
|
+
pub(crate) fn load_exact(&self, request: &LiveStateRowRequest) -> Option<StagedExactRow> {
|
|
405
|
+
let untracked_identity = StagedStateRowIdentity::from_exact_request(request, true)?;
|
|
406
|
+
if let Some(row) = self.rows.get(&untracked_identity) {
|
|
407
|
+
return Some(if row.snapshot_content.is_none() {
|
|
408
|
+
StagedExactRow::Tombstone
|
|
409
|
+
} else {
|
|
410
|
+
StagedExactRow::Row(LiveStateRow::from(row))
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
let identity = StagedStateRowIdentity::from_exact_request(request, false)?;
|
|
415
|
+
if let Some(row) = self.rows.get(&identity) {
|
|
416
|
+
return Some(if row.snapshot_content.is_none() {
|
|
417
|
+
StagedExactRow::Tombstone
|
|
418
|
+
} else {
|
|
419
|
+
StagedExactRow::Row(LiveStateRow::from(row))
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
self.adopted_rows.get(&identity).map(|row| {
|
|
423
|
+
if row.snapshot_content.is_none() {
|
|
424
|
+
StagedExactRow::Tombstone
|
|
425
|
+
} else {
|
|
426
|
+
StagedExactRow::Row(LiveStateRow::from(row))
|
|
427
|
+
}
|
|
428
|
+
})
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
#[cfg(test)]
|
|
433
|
+
pub(crate) enum StagedExactRow {
|
|
434
|
+
Row(LiveStateRow),
|
|
435
|
+
Tombstone,
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
|
439
|
+
pub(crate) struct StagedStateRowIdentity {
|
|
440
|
+
untracked: bool,
|
|
441
|
+
schema_key: String,
|
|
442
|
+
entity_id: crate::entity_identity::EntityIdentity,
|
|
443
|
+
file_id: Option<String>,
|
|
444
|
+
version_id: String,
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
impl StagedStateRowIdentity {
|
|
448
|
+
fn from_staged_row(row: &StagedStateRow) -> Self {
|
|
449
|
+
Self {
|
|
450
|
+
untracked: row.untracked,
|
|
451
|
+
schema_key: row.schema_key.clone(),
|
|
452
|
+
entity_id: row.entity_id.clone(),
|
|
453
|
+
file_id: row.file_id.clone(),
|
|
454
|
+
version_id: row.version_id.clone(),
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
#[cfg(test)]
|
|
459
|
+
fn from_exact_request(request: &LiveStateRowRequest, untracked: bool) -> Option<Self> {
|
|
460
|
+
let file_id = match &request.file_id {
|
|
461
|
+
NullableKeyFilter::Null => None,
|
|
462
|
+
NullableKeyFilter::Value(value) => Some(value.clone()),
|
|
463
|
+
// Exact overlay lookup requires a concrete row identity.
|
|
464
|
+
NullableKeyFilter::Any => return None,
|
|
465
|
+
};
|
|
466
|
+
Some(Self {
|
|
467
|
+
untracked,
|
|
468
|
+
schema_key: request.schema_key.clone(),
|
|
469
|
+
entity_id: request.entity_id.clone(),
|
|
470
|
+
file_id,
|
|
471
|
+
version_id: request.version_id.clone(),
|
|
472
|
+
})
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
fn opposite_untracked(&self) -> Self {
|
|
476
|
+
Self {
|
|
477
|
+
untracked: !self.untracked,
|
|
478
|
+
schema_key: self.schema_key.clone(),
|
|
479
|
+
entity_id: self.entity_id.clone(),
|
|
480
|
+
file_id: self.file_id.clone(),
|
|
481
|
+
version_id: self.version_id.clone(),
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
impl From<&StagedStateRow> for StagedStateRowIdentity {
|
|
487
|
+
fn from(row: &StagedStateRow) -> Self {
|
|
488
|
+
Self::from_staged_row(row)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
impl From<&StagedAdoptedStateRow> for StagedStateRowIdentity {
|
|
493
|
+
fn from(row: &StagedAdoptedStateRow) -> Self {
|
|
494
|
+
Self {
|
|
495
|
+
untracked: false,
|
|
496
|
+
schema_key: row.schema_key.clone(),
|
|
497
|
+
entity_id: row.entity_id.clone(),
|
|
498
|
+
file_id: row.file_id.clone(),
|
|
499
|
+
version_id: row.version_id.clone(),
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
impl From<&LiveStateRow> for StagedStateRowIdentity {
|
|
505
|
+
fn from(row: &LiveStateRow) -> Self {
|
|
506
|
+
Self {
|
|
507
|
+
untracked: row.untracked,
|
|
508
|
+
schema_key: row.schema_key.clone(),
|
|
509
|
+
entity_id: row.entity_id.clone(),
|
|
510
|
+
file_id: row.file_id.clone(),
|
|
511
|
+
version_id: row.version_id.clone(),
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
fn hydrate_state_write_row(
|
|
517
|
+
row: StageRow,
|
|
518
|
+
functions: &mut dyn FunctionProvider,
|
|
519
|
+
) -> Result<StagedStateRow, LixError> {
|
|
520
|
+
let updated_at = row.updated_at.unwrap_or_else(|| functions.timestamp());
|
|
521
|
+
Ok(StagedStateRow {
|
|
522
|
+
entity_id: row.entity_id.ok_or_else(|| {
|
|
523
|
+
LixError::new(
|
|
524
|
+
"LIX_ERROR_UNKNOWN",
|
|
525
|
+
"normalized staged row is missing entity_id",
|
|
526
|
+
)
|
|
527
|
+
})?,
|
|
528
|
+
schema_key: row.schema_key,
|
|
529
|
+
file_id: row.file_id,
|
|
530
|
+
snapshot_content: row.snapshot_content,
|
|
531
|
+
metadata: row.metadata,
|
|
532
|
+
origin: row.origin,
|
|
533
|
+
schema_version: row.schema_version,
|
|
534
|
+
created_at: row.created_at.unwrap_or_else(|| updated_at.clone()),
|
|
535
|
+
updated_at,
|
|
536
|
+
global: row.global,
|
|
537
|
+
change_id: if row.untracked {
|
|
538
|
+
row.change_id
|
|
539
|
+
} else {
|
|
540
|
+
Some(row.change_id.unwrap_or_else(|| functions.uuid_v7()))
|
|
541
|
+
},
|
|
542
|
+
commit_id: row.commit_id,
|
|
543
|
+
untracked: row.untracked,
|
|
544
|
+
version_id: row.version_id,
|
|
545
|
+
})
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
fn hydrate_adopted_state_row(
|
|
549
|
+
change: StageAdoptedChange,
|
|
550
|
+
) -> Result<StagedAdoptedStateRow, LixError> {
|
|
551
|
+
if change.change_id != change.projected_row.change_id {
|
|
552
|
+
return Err(LixError::new(
|
|
553
|
+
"LIX_ERROR_UNKNOWN",
|
|
554
|
+
format!(
|
|
555
|
+
"adopted change '{}' does not match projected row change_id '{}'",
|
|
556
|
+
change.change_id, change.projected_row.change_id
|
|
557
|
+
),
|
|
558
|
+
));
|
|
559
|
+
}
|
|
560
|
+
let row = change.projected_row;
|
|
561
|
+
Ok(StagedAdoptedStateRow {
|
|
562
|
+
entity_id: row.entity_id,
|
|
563
|
+
schema_key: row.schema_key,
|
|
564
|
+
file_id: row.file_id,
|
|
565
|
+
snapshot_content: row.snapshot_content,
|
|
566
|
+
metadata: row.metadata,
|
|
567
|
+
schema_version: row.schema_version,
|
|
568
|
+
created_at: row.created_at,
|
|
569
|
+
updated_at: row.updated_at,
|
|
570
|
+
global: change.version_id == GLOBAL_VERSION_ID,
|
|
571
|
+
change_id: change.change_id,
|
|
572
|
+
commit_id: String::new(),
|
|
573
|
+
version_id: change.version_id,
|
|
574
|
+
})
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
fn validate_commit_membership_support(row: &StagedStateRow) -> Result<(), LixError> {
|
|
578
|
+
if row.global && row.version_id != GLOBAL_VERSION_ID {
|
|
579
|
+
return Err(LixError::new(
|
|
580
|
+
"LIX_ERROR_UNKNOWN",
|
|
581
|
+
"engine2 global staged rows must use the global version id",
|
|
582
|
+
));
|
|
583
|
+
}
|
|
584
|
+
Ok(())
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
fn validate_adopted_commit_membership_support(row: &StagedAdoptedStateRow) -> Result<(), LixError> {
|
|
588
|
+
if row.global && row.version_id != GLOBAL_VERSION_ID {
|
|
589
|
+
return Err(LixError::new(
|
|
590
|
+
"LIX_ERROR_UNKNOWN",
|
|
591
|
+
"engine2 global adopted rows must use the global version id",
|
|
592
|
+
));
|
|
593
|
+
}
|
|
594
|
+
Ok(())
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
fn reject_duplicate_present_rows_in_batch(rows: &[StagedStateRow]) -> Result<(), LixError> {
|
|
598
|
+
let mut pending_present_rows = BTreeMap::<StagedStateRowIdentity, &StagedStateRow>::new();
|
|
599
|
+
for row in rows {
|
|
600
|
+
let identity = StagedStateRowIdentity::from(row);
|
|
601
|
+
if row.snapshot_content.is_none() {
|
|
602
|
+
pending_present_rows.remove(&identity);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
if let Some(previous) = pending_present_rows.insert(identity, row) {
|
|
606
|
+
return Err(duplicate_staged_present_row_error(row, previous));
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
Ok(())
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
fn duplicate_staged_present_row_error(row: &StagedStateRow, previous: &StagedStateRow) -> LixError {
|
|
613
|
+
let message = logical_primary_key_violation_message(row.origin.as_ref())
|
|
614
|
+
.unwrap_or_else(|| {
|
|
615
|
+
format!(
|
|
616
|
+
"primary-key constraint violation on schema '{}' version '{}': duplicate staged rows for entity_id '{}' in version '{}'",
|
|
617
|
+
row.schema_key,
|
|
618
|
+
row.schema_version,
|
|
619
|
+
previous
|
|
620
|
+
.entity_id
|
|
621
|
+
.as_string()
|
|
622
|
+
.unwrap_or_else(|_| "<invalid entity_id>".to_string()),
|
|
623
|
+
row.version_id
|
|
624
|
+
)
|
|
625
|
+
});
|
|
626
|
+
LixError::new(LixError::CODE_UNIQUE, message)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
pub(crate) fn duplicate_insert_identity_message(
|
|
630
|
+
schema_key: &str,
|
|
631
|
+
schema_version: &str,
|
|
632
|
+
entity_id: &crate::entity_identity::EntityIdentity,
|
|
633
|
+
version_id: Option<&str>,
|
|
634
|
+
origin: Option<&StageRowOrigin>,
|
|
635
|
+
) -> String {
|
|
636
|
+
if let Some(message) = logical_primary_key_violation_message(origin) {
|
|
637
|
+
return message;
|
|
638
|
+
}
|
|
639
|
+
let entity_id = entity_id
|
|
640
|
+
.as_string()
|
|
641
|
+
.unwrap_or_else(|_| "<invalid entity_id>".to_string());
|
|
642
|
+
match version_id {
|
|
643
|
+
Some(version_id) => format!(
|
|
644
|
+
"primary-key constraint violation on schema '{schema_key}' version '{schema_version}': INSERT would duplicate entity_id '{entity_id}' in version '{version_id}'"
|
|
645
|
+
),
|
|
646
|
+
None => format!(
|
|
647
|
+
"primary-key constraint violation on schema '{schema_key}' version '{schema_version}': INSERT would duplicate entity_id '{entity_id}'"
|
|
648
|
+
),
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
fn duplicate_insert_identity_error(row: &StagedStateRow) -> LixError {
|
|
653
|
+
let message = duplicate_insert_identity_message(
|
|
654
|
+
&row.schema_key,
|
|
655
|
+
&row.schema_version,
|
|
656
|
+
&row.entity_id,
|
|
657
|
+
Some(&row.version_id),
|
|
658
|
+
row.origin.as_ref(),
|
|
659
|
+
);
|
|
660
|
+
LixError::new(LixError::CODE_UNIQUE, message)
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
fn logical_primary_key_violation_message(origin: Option<&StageRowOrigin>) -> Option<String> {
|
|
664
|
+
let origin = origin?;
|
|
665
|
+
if origin.operation != StageWriteOperation::Insert {
|
|
666
|
+
return None;
|
|
667
|
+
}
|
|
668
|
+
let primary_key = origin.primary_key.as_ref()?;
|
|
669
|
+
Some(format!(
|
|
670
|
+
"primary-key constraint violation on table '{}': INSERT would duplicate {}",
|
|
671
|
+
origin.surface,
|
|
672
|
+
format_logical_primary_key(primary_key)
|
|
673
|
+
))
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
fn format_logical_primary_key(primary_key: &LogicalPrimaryKey) -> String {
|
|
677
|
+
primary_key
|
|
678
|
+
.columns
|
|
679
|
+
.iter()
|
|
680
|
+
.enumerate()
|
|
681
|
+
.map(|(index, column)| {
|
|
682
|
+
let value = primary_key
|
|
683
|
+
.values
|
|
684
|
+
.get(index)
|
|
685
|
+
.map(String::as_str)
|
|
686
|
+
.unwrap_or("<missing>");
|
|
687
|
+
format!("{column} '{value}'")
|
|
688
|
+
})
|
|
689
|
+
.collect::<Vec<_>>()
|
|
690
|
+
.join(", ")
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
fn conflicting_adopted_identity_error(row: &StagedStateRow) -> LixError {
|
|
694
|
+
LixError::new(
|
|
695
|
+
LixError::CODE_UNIQUE,
|
|
696
|
+
format!(
|
|
697
|
+
"transaction cannot stage a new row and an adopted projection for schema '{}' entity_id '{}' in version '{}'",
|
|
698
|
+
row.schema_key,
|
|
699
|
+
row.entity_id
|
|
700
|
+
.as_string()
|
|
701
|
+
.unwrap_or_else(|_| "<invalid entity_id>".to_string()),
|
|
702
|
+
row.version_id
|
|
703
|
+
),
|
|
704
|
+
)
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
fn conflicting_adopted_projection_error(row: &StagedAdoptedStateRow) -> LixError {
|
|
708
|
+
LixError::new(
|
|
709
|
+
LixError::CODE_UNIQUE,
|
|
710
|
+
format!(
|
|
711
|
+
"transaction cannot stage duplicate adopted projections for schema '{}' entity_id '{}' in version '{}'",
|
|
712
|
+
row.schema_key,
|
|
713
|
+
row.entity_id
|
|
714
|
+
.as_string()
|
|
715
|
+
.unwrap_or_else(|_| "<invalid entity_id>".to_string()),
|
|
716
|
+
row.version_id
|
|
717
|
+
),
|
|
718
|
+
)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
fn live_state_identity_from_staged_row(row: &StagedStateRow) -> LiveStateRowIdentity {
|
|
722
|
+
LiveStateRowIdentity {
|
|
723
|
+
version_id: row.version_id.clone(),
|
|
724
|
+
schema_key: row.schema_key.clone(),
|
|
725
|
+
entity_id: row.entity_id.clone(),
|
|
726
|
+
file_id: row.file_id.clone(),
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
fn add_row_to_commit_members(
|
|
731
|
+
members_by_version: &mut BTreeMap<String, StagedCommitMembers>,
|
|
732
|
+
row: &mut StagedStateRow,
|
|
733
|
+
functions: &mut dyn FunctionProvider,
|
|
734
|
+
) {
|
|
735
|
+
if row.untracked {
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
let change_id = row
|
|
739
|
+
.change_id
|
|
740
|
+
.clone()
|
|
741
|
+
.expect("tracked staged rows must carry change_id for commit membership");
|
|
742
|
+
let members = members_by_version
|
|
743
|
+
.entry(row.version_id.clone())
|
|
744
|
+
.or_insert_with(|| {
|
|
745
|
+
StagedCommitMembers::new(
|
|
746
|
+
functions.uuid_v7(),
|
|
747
|
+
functions.uuid_v7(),
|
|
748
|
+
functions.uuid_v7(),
|
|
749
|
+
functions.timestamp(),
|
|
750
|
+
)
|
|
751
|
+
});
|
|
752
|
+
row.commit_id = Some(members.commit_id.clone());
|
|
753
|
+
members.add_change_id(change_id);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
fn add_adopted_row_to_commit_members(
|
|
757
|
+
members_by_version: &mut BTreeMap<String, StagedCommitMembers>,
|
|
758
|
+
row: &mut StagedAdoptedStateRow,
|
|
759
|
+
functions: &mut dyn FunctionProvider,
|
|
760
|
+
) {
|
|
761
|
+
let members = members_by_version
|
|
762
|
+
.entry(row.version_id.clone())
|
|
763
|
+
.or_insert_with(|| {
|
|
764
|
+
StagedCommitMembers::new(
|
|
765
|
+
functions.uuid_v7(),
|
|
766
|
+
functions.uuid_v7(),
|
|
767
|
+
functions.uuid_v7(),
|
|
768
|
+
functions.timestamp(),
|
|
769
|
+
)
|
|
770
|
+
});
|
|
771
|
+
row.commit_id = members.commit_id.clone();
|
|
772
|
+
members.add_change_id(row.change_id.clone());
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
fn remove_row_from_commit_members(
|
|
776
|
+
members_by_version: &mut BTreeMap<String, StagedCommitMembers>,
|
|
777
|
+
row: &StagedStateRow,
|
|
778
|
+
) {
|
|
779
|
+
if row.untracked {
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
let Some(members) = members_by_version.get_mut(&row.version_id) else {
|
|
783
|
+
return;
|
|
784
|
+
};
|
|
785
|
+
let Some(change_id) = row.change_id.as_deref() else {
|
|
786
|
+
return;
|
|
787
|
+
};
|
|
788
|
+
members.remove_change_id(change_id);
|
|
789
|
+
if members.is_empty() {
|
|
790
|
+
members_by_version.remove(&row.version_id);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
fn staged_row_matches_scan(row: &StagedStateRow, request: &LiveStateScanRequest) -> bool {
|
|
795
|
+
staged_row_identity_matches_scan(row, request)
|
|
796
|
+
&& (row.snapshot_content.is_some() || request.filter.include_tombstones)
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
fn adopted_row_matches_scan(row: &StagedAdoptedStateRow, request: &LiveStateScanRequest) -> bool {
|
|
800
|
+
adopted_row_identity_matches_scan(row, request)
|
|
801
|
+
&& (row.snapshot_content.is_some() || request.filter.include_tombstones)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
fn adopted_row_identity_matches_scan(
|
|
805
|
+
row: &StagedAdoptedStateRow,
|
|
806
|
+
request: &LiveStateScanRequest,
|
|
807
|
+
) -> bool {
|
|
808
|
+
if !request.filter.schema_keys.is_empty()
|
|
809
|
+
&& !request.filter.schema_keys.contains(&row.schema_key)
|
|
810
|
+
{
|
|
811
|
+
return false;
|
|
812
|
+
}
|
|
813
|
+
if !request.filter.entity_ids.is_empty() && !request.filter.entity_ids.contains(&row.entity_id)
|
|
814
|
+
{
|
|
815
|
+
return false;
|
|
816
|
+
}
|
|
817
|
+
if !request.filter.version_ids.is_empty()
|
|
818
|
+
&& !request.filter.version_ids.contains(&row.version_id)
|
|
819
|
+
{
|
|
820
|
+
return false;
|
|
821
|
+
}
|
|
822
|
+
nullable_key_matches_filters(&row.file_id, &request.filter.file_ids)
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
fn staged_row_identity_matches_scan(row: &StagedStateRow, request: &LiveStateScanRequest) -> bool {
|
|
826
|
+
if !request.filter.schema_keys.is_empty()
|
|
827
|
+
&& !request.filter.schema_keys.contains(&row.schema_key)
|
|
828
|
+
{
|
|
829
|
+
return false;
|
|
830
|
+
}
|
|
831
|
+
if !request.filter.entity_ids.is_empty() && !request.filter.entity_ids.contains(&row.entity_id)
|
|
832
|
+
{
|
|
833
|
+
return false;
|
|
834
|
+
}
|
|
835
|
+
if !request.filter.version_ids.is_empty()
|
|
836
|
+
&& !request.filter.version_ids.contains(&row.version_id)
|
|
837
|
+
{
|
|
838
|
+
return false;
|
|
839
|
+
}
|
|
840
|
+
nullable_key_matches_filters(&row.file_id, &request.filter.file_ids)
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
fn nullable_key_matches_filters(
|
|
844
|
+
value: &Option<String>,
|
|
845
|
+
filters: &[NullableKeyFilter<String>],
|
|
846
|
+
) -> bool {
|
|
847
|
+
filters.is_empty()
|
|
848
|
+
|| filters
|
|
849
|
+
.iter()
|
|
850
|
+
.any(|filter| nullable_key_matches_filter(value, filter))
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
fn nullable_key_matches_filter(value: &Option<String>, filter: &NullableKeyFilter<String>) -> bool {
|
|
854
|
+
match filter {
|
|
855
|
+
NullableKeyFilter::Any => true,
|
|
856
|
+
NullableKeyFilter::Null => value.is_none(),
|
|
857
|
+
NullableKeyFilter::Value(expected) => value.as_ref() == Some(expected),
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
#[cfg(test)]
|
|
862
|
+
mod tests {
|
|
863
|
+
use super::*;
|
|
864
|
+
use crate::functions::SharedFunctionProvider;
|
|
865
|
+
use crate::live_state::{LiveStateFilter, LiveStateRowRequest};
|
|
866
|
+
|
|
867
|
+
#[tokio::test]
|
|
868
|
+
async fn staging_overlay_uses_last_staged_row_for_exact_load() {
|
|
869
|
+
let staged_writes = test_staged_writes();
|
|
870
|
+
|
|
871
|
+
staged_writes
|
|
872
|
+
.stage_write(StageWrite::Rows {
|
|
873
|
+
mode: StageWriteMode::Replace,
|
|
874
|
+
rows: vec![state_row("sql2-duplicate-key", "first")],
|
|
875
|
+
})
|
|
876
|
+
.expect("initial row should stage");
|
|
877
|
+
staged_writes
|
|
878
|
+
.stage_write(StageWrite::Rows {
|
|
879
|
+
mode: StageWriteMode::Replace,
|
|
880
|
+
rows: vec![state_row("sql2-duplicate-key", "second")],
|
|
881
|
+
})
|
|
882
|
+
.expect("staging rows should succeed");
|
|
883
|
+
|
|
884
|
+
let overlay = staged_writes
|
|
885
|
+
.staging_overlay()
|
|
886
|
+
.expect("overlay should build from staged rows");
|
|
887
|
+
let row = overlay
|
|
888
|
+
.load_exact(&LiveStateRowRequest {
|
|
889
|
+
schema_key: "lix_key_value".to_string(),
|
|
890
|
+
version_id: "global".to_string(),
|
|
891
|
+
entity_id: crate::entity_identity::EntityIdentity::single("sql2-duplicate-key"),
|
|
892
|
+
file_id: NullableKeyFilter::Null,
|
|
893
|
+
})
|
|
894
|
+
.expect("staged row should be visible");
|
|
895
|
+
|
|
896
|
+
let StagedExactRow::Row(row) = row else {
|
|
897
|
+
panic!("latest staged row should not be a tombstone");
|
|
898
|
+
};
|
|
899
|
+
assert_eq!(
|
|
900
|
+
row.snapshot_content.as_deref(),
|
|
901
|
+
Some("{\"key\":\"sql2-duplicate-key\",\"value\":\"second\"}")
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
#[tokio::test]
|
|
906
|
+
async fn staging_overlay_scan_returns_only_latest_row_per_identity() {
|
|
907
|
+
let staged_writes = test_staged_writes();
|
|
908
|
+
|
|
909
|
+
staged_writes
|
|
910
|
+
.stage_write(StageWrite::Rows {
|
|
911
|
+
mode: StageWriteMode::Replace,
|
|
912
|
+
rows: vec![state_row("sql2-duplicate-key", "first")],
|
|
913
|
+
})
|
|
914
|
+
.expect("initial row should stage");
|
|
915
|
+
staged_writes
|
|
916
|
+
.stage_write(StageWrite::Rows {
|
|
917
|
+
mode: StageWriteMode::Replace,
|
|
918
|
+
rows: vec![state_row("sql2-duplicate-key", "second")],
|
|
919
|
+
})
|
|
920
|
+
.expect("staging rows should succeed");
|
|
921
|
+
|
|
922
|
+
let overlay = staged_writes
|
|
923
|
+
.staging_overlay()
|
|
924
|
+
.expect("overlay should build from staged rows");
|
|
925
|
+
let rows = overlay.scan(&scan_request_for_key("sql2-duplicate-key", false));
|
|
926
|
+
|
|
927
|
+
assert_eq!(rows.len(), 1);
|
|
928
|
+
assert_eq!(
|
|
929
|
+
rows[0].snapshot_content.as_deref(),
|
|
930
|
+
Some("{\"key\":\"sql2-duplicate-key\",\"value\":\"second\"}")
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
#[tokio::test]
|
|
935
|
+
async fn staging_overlay_delete_hides_prior_staged_insert() {
|
|
936
|
+
let staged_writes = test_staged_writes();
|
|
937
|
+
|
|
938
|
+
staged_writes
|
|
939
|
+
.stage_write(StageWrite::Rows {
|
|
940
|
+
mode: StageWriteMode::Replace,
|
|
941
|
+
rows: vec![
|
|
942
|
+
state_row("sql2-delete-key", "visible"),
|
|
943
|
+
tombstone_row("sql2-delete-key"),
|
|
944
|
+
],
|
|
945
|
+
})
|
|
946
|
+
.expect("staging rows should succeed");
|
|
947
|
+
|
|
948
|
+
let overlay = staged_writes
|
|
949
|
+
.staging_overlay()
|
|
950
|
+
.expect("overlay should build from staged rows");
|
|
951
|
+
let exact = overlay
|
|
952
|
+
.load_exact(&exact_request_for_key("sql2-delete-key"))
|
|
953
|
+
.expect("staged tombstone should answer exact load");
|
|
954
|
+
assert!(matches!(exact, StagedExactRow::Tombstone));
|
|
955
|
+
assert!(overlay
|
|
956
|
+
.scan(&scan_request_for_key("sql2-delete-key", false))
|
|
957
|
+
.is_empty());
|
|
958
|
+
|
|
959
|
+
let tombstones = overlay.scan(&scan_request_for_key("sql2-delete-key", true));
|
|
960
|
+
assert_eq!(tombstones.len(), 1);
|
|
961
|
+
assert_eq!(tombstones[0].snapshot_content, None);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
#[tokio::test]
|
|
965
|
+
async fn staging_overlay_insert_after_delete_resurrects_row() {
|
|
966
|
+
let staged_writes = test_staged_writes();
|
|
967
|
+
|
|
968
|
+
staged_writes
|
|
969
|
+
.stage_write(StageWrite::Rows {
|
|
970
|
+
mode: StageWriteMode::Replace,
|
|
971
|
+
rows: vec![
|
|
972
|
+
tombstone_row("sql2-resurrect-key"),
|
|
973
|
+
state_row("sql2-resurrect-key", "visible-again"),
|
|
974
|
+
],
|
|
975
|
+
})
|
|
976
|
+
.expect("staging rows should succeed");
|
|
977
|
+
|
|
978
|
+
let overlay = staged_writes
|
|
979
|
+
.staging_overlay()
|
|
980
|
+
.expect("overlay should build from staged rows");
|
|
981
|
+
let exact = overlay
|
|
982
|
+
.load_exact(&exact_request_for_key("sql2-resurrect-key"))
|
|
983
|
+
.expect("staged row should answer exact load");
|
|
984
|
+
|
|
985
|
+
let StagedExactRow::Row(row) = exact else {
|
|
986
|
+
panic!("latest staged row should be visible");
|
|
987
|
+
};
|
|
988
|
+
assert_eq!(
|
|
989
|
+
row.snapshot_content.as_deref(),
|
|
990
|
+
Some("{\"key\":\"sql2-resurrect-key\",\"value\":\"visible-again\"}")
|
|
991
|
+
);
|
|
992
|
+
assert_eq!(
|
|
993
|
+
overlay
|
|
994
|
+
.scan(&scan_request_for_key("sql2-resurrect-key", false))
|
|
995
|
+
.len(),
|
|
996
|
+
1
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
#[tokio::test]
|
|
1001
|
+
async fn staged_writes_drain_returns_coalesced_latest_rows() {
|
|
1002
|
+
let staged_writes = test_staged_writes();
|
|
1003
|
+
|
|
1004
|
+
staged_writes
|
|
1005
|
+
.stage_write(StageWrite::Rows {
|
|
1006
|
+
mode: StageWriteMode::Replace,
|
|
1007
|
+
rows: vec![
|
|
1008
|
+
state_row("sql2-key-a", "first"),
|
|
1009
|
+
state_row("sql2-key-b", "only"),
|
|
1010
|
+
],
|
|
1011
|
+
})
|
|
1012
|
+
.expect("initial rows should stage");
|
|
1013
|
+
staged_writes
|
|
1014
|
+
.stage_write(StageWrite::Rows {
|
|
1015
|
+
mode: StageWriteMode::Replace,
|
|
1016
|
+
rows: vec![state_row("sql2-key-a", "second")],
|
|
1017
|
+
})
|
|
1018
|
+
.expect("staging rows should succeed");
|
|
1019
|
+
|
|
1020
|
+
let drained = staged_writes.drain().expect("drain should succeed");
|
|
1021
|
+
|
|
1022
|
+
assert_eq!(drained.state_rows.len(), 2);
|
|
1023
|
+
assert!(drained.state_rows.iter().any(|row| {
|
|
1024
|
+
row.entity_id == crate::entity_identity::EntityIdentity::single("sql2-key-a")
|
|
1025
|
+
&& row.snapshot_content.as_deref()
|
|
1026
|
+
== Some("{\"key\":\"sql2-key-a\",\"value\":\"second\"}")
|
|
1027
|
+
}));
|
|
1028
|
+
assert!(drained.state_rows.iter().any(|row| {
|
|
1029
|
+
row.entity_id == crate::entity_identity::EntityIdentity::single("sql2-key-b")
|
|
1030
|
+
&& row.snapshot_content.as_deref()
|
|
1031
|
+
== Some("{\"key\":\"sql2-key-b\",\"value\":\"only\"}")
|
|
1032
|
+
}));
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
#[tokio::test]
|
|
1036
|
+
async fn staged_writes_drain_preserves_file_data_payloads() {
|
|
1037
|
+
let staged_writes = test_staged_writes();
|
|
1038
|
+
|
|
1039
|
+
staged_writes
|
|
1040
|
+
.stage_write(StageWrite::RowsWithFileData {
|
|
1041
|
+
mode: StageWriteMode::Replace,
|
|
1042
|
+
rows: vec![state_row("file-readme", "descriptor")],
|
|
1043
|
+
file_data: vec![StageFileData {
|
|
1044
|
+
file_id: "file-readme".to_string(),
|
|
1045
|
+
version_id: "global".to_string(),
|
|
1046
|
+
untracked: true,
|
|
1047
|
+
data: b"hello".to_vec(),
|
|
1048
|
+
}],
|
|
1049
|
+
count: 1,
|
|
1050
|
+
})
|
|
1051
|
+
.expect("staging rows with file data should succeed");
|
|
1052
|
+
|
|
1053
|
+
let drained = staged_writes.drain().expect("drain should succeed");
|
|
1054
|
+
|
|
1055
|
+
assert_eq!(drained.state_rows.len(), 1);
|
|
1056
|
+
assert_eq!(drained.file_data_writes.len(), 1);
|
|
1057
|
+
assert_eq!(drained.file_data_writes[0].file_id, "file-readme");
|
|
1058
|
+
assert_eq!(drained.file_data_writes[0].data, b"hello");
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
#[tokio::test]
|
|
1062
|
+
async fn staged_writes_track_commit_members_for_tracked_global_rows() {
|
|
1063
|
+
let staged_writes = test_staged_writes();
|
|
1064
|
+
|
|
1065
|
+
staged_writes
|
|
1066
|
+
.stage_write(StageWrite::Rows {
|
|
1067
|
+
mode: StageWriteMode::Replace,
|
|
1068
|
+
rows: vec![state_row("tracked-key", "value").with_tracked()],
|
|
1069
|
+
})
|
|
1070
|
+
.expect("tracked global row should stage");
|
|
1071
|
+
|
|
1072
|
+
let drained = staged_writes.drain().expect("drain should succeed");
|
|
1073
|
+
let members = drained
|
|
1074
|
+
.commit_members_by_version
|
|
1075
|
+
.get("global")
|
|
1076
|
+
.expect("global commit members should exist");
|
|
1077
|
+
assert_eq!(
|
|
1078
|
+
members.change_ids.iter().cloned().collect::<Vec<_>>(),
|
|
1079
|
+
vec!["test-uuid-1".to_string()]
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
#[tokio::test]
|
|
1084
|
+
async fn staged_writes_do_not_track_untracked_rows_as_commit_members() {
|
|
1085
|
+
let staged_writes = test_staged_writes();
|
|
1086
|
+
|
|
1087
|
+
staged_writes
|
|
1088
|
+
.stage_write(StageWrite::Rows {
|
|
1089
|
+
mode: StageWriteMode::Replace,
|
|
1090
|
+
rows: vec![state_row("untracked-key", "value")],
|
|
1091
|
+
})
|
|
1092
|
+
.expect("untracked row should stage");
|
|
1093
|
+
|
|
1094
|
+
let drained = staged_writes.drain().expect("drain should succeed");
|
|
1095
|
+
assert!(drained.commit_members_by_version.is_empty());
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
#[tokio::test]
|
|
1099
|
+
async fn staged_writes_replace_commit_member_on_tracked_overwrite() {
|
|
1100
|
+
let staged_writes = test_staged_writes();
|
|
1101
|
+
|
|
1102
|
+
staged_writes
|
|
1103
|
+
.stage_write(StageWrite::Rows {
|
|
1104
|
+
mode: StageWriteMode::Replace,
|
|
1105
|
+
rows: vec![state_row("overwrite-key", "first")
|
|
1106
|
+
.with_tracked()
|
|
1107
|
+
.with_change_id("change-first")],
|
|
1108
|
+
})
|
|
1109
|
+
.expect("initial tracked row should stage");
|
|
1110
|
+
staged_writes
|
|
1111
|
+
.stage_write(StageWrite::Rows {
|
|
1112
|
+
mode: StageWriteMode::Replace,
|
|
1113
|
+
rows: vec![state_row("overwrite-key", "second")
|
|
1114
|
+
.with_tracked()
|
|
1115
|
+
.with_change_id("change-second")],
|
|
1116
|
+
})
|
|
1117
|
+
.expect("tracked overwrite should stage");
|
|
1118
|
+
|
|
1119
|
+
let drained = staged_writes.drain().expect("drain should succeed");
|
|
1120
|
+
let members = drained
|
|
1121
|
+
.commit_members_by_version
|
|
1122
|
+
.get("global")
|
|
1123
|
+
.expect("global commit members should exist");
|
|
1124
|
+
assert_eq!(
|
|
1125
|
+
members.change_ids.iter().cloned().collect::<Vec<_>>(),
|
|
1126
|
+
vec!["change-second".to_string()]
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
#[tokio::test]
|
|
1131
|
+
async fn staged_writes_untracked_overwrite_removes_tracked_commit_member() {
|
|
1132
|
+
let staged_writes = test_staged_writes();
|
|
1133
|
+
|
|
1134
|
+
staged_writes
|
|
1135
|
+
.stage_write(StageWrite::Rows {
|
|
1136
|
+
mode: StageWriteMode::Replace,
|
|
1137
|
+
rows: vec![
|
|
1138
|
+
state_row("tracked-to-untracked-key", "tracked")
|
|
1139
|
+
.with_tracked()
|
|
1140
|
+
.with_change_id("change-tracked"),
|
|
1141
|
+
state_row("tracked-to-untracked-key", "untracked")
|
|
1142
|
+
.with_change_id("change-untracked"),
|
|
1143
|
+
],
|
|
1144
|
+
})
|
|
1145
|
+
.expect("untracked overwrite should stage");
|
|
1146
|
+
|
|
1147
|
+
let drained = staged_writes.drain().expect("drain should succeed");
|
|
1148
|
+
assert_eq!(drained.state_rows.len(), 1);
|
|
1149
|
+
assert_eq!(
|
|
1150
|
+
drained.state_rows[0].change_id.as_deref(),
|
|
1151
|
+
Some("change-untracked")
|
|
1152
|
+
);
|
|
1153
|
+
assert!(drained.commit_members_by_version.is_empty());
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
#[tokio::test]
|
|
1157
|
+
async fn staged_writes_reject_duplicate_present_rows_in_one_batch() {
|
|
1158
|
+
let staged_writes = test_staged_writes();
|
|
1159
|
+
|
|
1160
|
+
let error = staged_writes
|
|
1161
|
+
.stage_write(StageWrite::Rows {
|
|
1162
|
+
mode: StageWriteMode::Replace,
|
|
1163
|
+
rows: vec![
|
|
1164
|
+
state_row("duplicate-present-key", "first"),
|
|
1165
|
+
state_row("duplicate-present-key", "second"),
|
|
1166
|
+
],
|
|
1167
|
+
})
|
|
1168
|
+
.expect_err("same-batch duplicate present rows should fail");
|
|
1169
|
+
|
|
1170
|
+
assert_eq!(error.code, LixError::CODE_UNIQUE);
|
|
1171
|
+
assert!(
|
|
1172
|
+
error.message.contains("primary-key constraint violation"),
|
|
1173
|
+
"error should explain the duplicate primary key: {error:?}"
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
#[tokio::test]
|
|
1178
|
+
async fn staged_writes_track_active_version_members_separately() {
|
|
1179
|
+
let staged_writes = test_staged_writes();
|
|
1180
|
+
|
|
1181
|
+
staged_writes
|
|
1182
|
+
.stage_write(StageWrite::Rows {
|
|
1183
|
+
mode: StageWriteMode::Replace,
|
|
1184
|
+
rows: vec![state_row("active-version-key", "value")
|
|
1185
|
+
.with_tracked()
|
|
1186
|
+
.with_version("version-a")],
|
|
1187
|
+
})
|
|
1188
|
+
.expect("active-version tracked staging should accumulate members");
|
|
1189
|
+
|
|
1190
|
+
let drained = staged_writes.drain().expect("drain should succeed");
|
|
1191
|
+
let members = drained
|
|
1192
|
+
.commit_members_by_version
|
|
1193
|
+
.get("version-a")
|
|
1194
|
+
.expect("active-version commit members should exist");
|
|
1195
|
+
assert_eq!(
|
|
1196
|
+
members.change_ids.iter().cloned().collect::<Vec<_>>(),
|
|
1197
|
+
vec!["test-uuid-1".to_string()]
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
#[tokio::test]
|
|
1202
|
+
async fn staged_writes_reject_global_rows_with_non_global_version_id() {
|
|
1203
|
+
let staged_writes = test_staged_writes();
|
|
1204
|
+
|
|
1205
|
+
let error = staged_writes
|
|
1206
|
+
.stage_write(StageWrite::Rows {
|
|
1207
|
+
mode: StageWriteMode::Replace,
|
|
1208
|
+
rows: vec![{
|
|
1209
|
+
let mut row = state_row("invalid-global-key", "value");
|
|
1210
|
+
row.version_id = "version-a".to_string();
|
|
1211
|
+
row
|
|
1212
|
+
}],
|
|
1213
|
+
})
|
|
1214
|
+
.expect_err("global row with non-global version should fail");
|
|
1215
|
+
|
|
1216
|
+
assert!(error
|
|
1217
|
+
.message
|
|
1218
|
+
.contains("global staged rows must use the global version id"));
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
#[tokio::test]
|
|
1222
|
+
async fn staging_overlay_identity_matches_live_state_conflict_key() {
|
|
1223
|
+
let staged_writes = test_staged_writes();
|
|
1224
|
+
|
|
1225
|
+
staged_writes
|
|
1226
|
+
.stage_write(StageWrite::Rows {
|
|
1227
|
+
mode: StageWriteMode::Replace,
|
|
1228
|
+
rows: vec![
|
|
1229
|
+
state_row("shared-entity", "other-schema-version").with_schema_version("2")
|
|
1230
|
+
],
|
|
1231
|
+
})
|
|
1232
|
+
.expect("initial same-identity row should stage");
|
|
1233
|
+
staged_writes
|
|
1234
|
+
.stage_write(StageWrite::Rows {
|
|
1235
|
+
mode: StageWriteMode::Replace,
|
|
1236
|
+
rows: vec![
|
|
1237
|
+
state_row("shared-entity", "base"),
|
|
1238
|
+
state_row("shared-entity", "other-version").with_version("version-b"),
|
|
1239
|
+
state_row("shared-entity", "other-schema").with_schema("other_schema"),
|
|
1240
|
+
state_row("shared-entity", "other-file").with_file_id("file-a"),
|
|
1241
|
+
state_row("shared-entity", "tracked").with_tracked(),
|
|
1242
|
+
],
|
|
1243
|
+
})
|
|
1244
|
+
.expect("staging rows should succeed");
|
|
1245
|
+
|
|
1246
|
+
let overlay = staged_writes
|
|
1247
|
+
.staging_overlay()
|
|
1248
|
+
.expect("overlay should build from staged rows");
|
|
1249
|
+
let rows = overlay.scan(&LiveStateScanRequest {
|
|
1250
|
+
filter: LiveStateFilter {
|
|
1251
|
+
entity_ids: vec![crate::entity_identity::EntityIdentity::single(
|
|
1252
|
+
"shared-entity",
|
|
1253
|
+
)],
|
|
1254
|
+
include_tombstones: true,
|
|
1255
|
+
..LiveStateFilter::default()
|
|
1256
|
+
},
|
|
1257
|
+
..LiveStateScanRequest::default()
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
assert_eq!(rows.len(), 4);
|
|
1261
|
+
assert!(rows.iter().any(|row| {
|
|
1262
|
+
row.snapshot_content.as_deref()
|
|
1263
|
+
== Some("{\"key\":\"shared-entity\",\"value\":\"tracked\"}")
|
|
1264
|
+
}));
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
#[tokio::test]
|
|
1268
|
+
async fn staged_writes_use_injected_function_provider_for_row_metadata() {
|
|
1269
|
+
let staged_writes = test_staged_writes();
|
|
1270
|
+
|
|
1271
|
+
staged_writes
|
|
1272
|
+
.stage_write(StageWrite::Rows {
|
|
1273
|
+
mode: StageWriteMode::Replace,
|
|
1274
|
+
rows: vec![state_row("sql2-functions-key", "value").with_tracked()],
|
|
1275
|
+
})
|
|
1276
|
+
.expect("staging rows should succeed");
|
|
1277
|
+
|
|
1278
|
+
let drained = staged_writes.drain().expect("drain should succeed");
|
|
1279
|
+
assert_eq!(drained.state_rows.len(), 1);
|
|
1280
|
+
assert_eq!(
|
|
1281
|
+
drained.state_rows[0].change_id.as_deref(),
|
|
1282
|
+
Some("test-uuid-1")
|
|
1283
|
+
);
|
|
1284
|
+
assert_eq!(
|
|
1285
|
+
drained.state_rows[0].created_at.as_str(),
|
|
1286
|
+
"test-timestamp-1"
|
|
1287
|
+
);
|
|
1288
|
+
assert_eq!(
|
|
1289
|
+
drained.state_rows[0].updated_at.as_str(),
|
|
1290
|
+
"test-timestamp-1"
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
#[tokio::test]
|
|
1295
|
+
async fn staged_writes_stamp_tracked_rows_with_commit_id_during_staging() {
|
|
1296
|
+
let staged_writes = test_staged_writes();
|
|
1297
|
+
|
|
1298
|
+
staged_writes
|
|
1299
|
+
.stage_write(StageWrite::Rows {
|
|
1300
|
+
mode: StageWriteMode::Replace,
|
|
1301
|
+
rows: vec![state_row("tracked-commit-key", "value").with_tracked()],
|
|
1302
|
+
})
|
|
1303
|
+
.expect("tracked row should stage");
|
|
1304
|
+
|
|
1305
|
+
let drained = staged_writes.drain().expect("drain should succeed");
|
|
1306
|
+
assert_eq!(drained.state_rows.len(), 1);
|
|
1307
|
+
assert_eq!(
|
|
1308
|
+
drained.state_rows[0].commit_id.as_deref(),
|
|
1309
|
+
Some("test-uuid-2")
|
|
1310
|
+
);
|
|
1311
|
+
assert_eq!(
|
|
1312
|
+
drained
|
|
1313
|
+
.commit_members_by_version
|
|
1314
|
+
.get("global")
|
|
1315
|
+
.expect("global commit members should exist")
|
|
1316
|
+
.commit_id,
|
|
1317
|
+
"test-uuid-2"
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
fn test_staged_writes() -> TransactionStagedWrites {
|
|
1322
|
+
TransactionStagedWrites::new(SharedFunctionProvider::new(Box::new(
|
|
1323
|
+
TestFunctionProvider::default(),
|
|
1324
|
+
)
|
|
1325
|
+
as Box<dyn FunctionProvider + Send>))
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
#[derive(Default)]
|
|
1329
|
+
struct TestFunctionProvider {
|
|
1330
|
+
uuid_count: usize,
|
|
1331
|
+
timestamp_count: usize,
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
impl FunctionProvider for TestFunctionProvider {
|
|
1335
|
+
fn uuid_v7(&mut self) -> String {
|
|
1336
|
+
self.uuid_count += 1;
|
|
1337
|
+
format!("test-uuid-{}", self.uuid_count)
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
fn timestamp(&mut self) -> String {
|
|
1341
|
+
self.timestamp_count += 1;
|
|
1342
|
+
format!("test-timestamp-{}", self.timestamp_count)
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
fn state_row(key: &str, value: &str) -> StageRow {
|
|
1347
|
+
StageRow {
|
|
1348
|
+
entity_id: Some(crate::entity_identity::EntityIdentity::single(key)),
|
|
1349
|
+
schema_key: "lix_key_value".to_string(),
|
|
1350
|
+
file_id: None,
|
|
1351
|
+
snapshot_content: Some(format!("{{\"key\":\"{key}\",\"value\":\"{value}\"}}")),
|
|
1352
|
+
metadata: None,
|
|
1353
|
+
origin: None,
|
|
1354
|
+
schema_version: "1".to_string(),
|
|
1355
|
+
created_at: None,
|
|
1356
|
+
updated_at: None,
|
|
1357
|
+
global: true,
|
|
1358
|
+
change_id: None,
|
|
1359
|
+
commit_id: None,
|
|
1360
|
+
untracked: true,
|
|
1361
|
+
version_id: "global".to_string(),
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
fn tombstone_row(key: &str) -> StageRow {
|
|
1366
|
+
StageRow {
|
|
1367
|
+
snapshot_content: None,
|
|
1368
|
+
..state_row(key, "deleted")
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
fn exact_request_for_key(key: &str) -> LiveStateRowRequest {
|
|
1373
|
+
LiveStateRowRequest {
|
|
1374
|
+
schema_key: "lix_key_value".to_string(),
|
|
1375
|
+
version_id: "global".to_string(),
|
|
1376
|
+
entity_id: crate::entity_identity::EntityIdentity::single(key),
|
|
1377
|
+
file_id: NullableKeyFilter::Null,
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
fn scan_request_for_key(key: &str, include_tombstones: bool) -> LiveStateScanRequest {
|
|
1382
|
+
LiveStateScanRequest {
|
|
1383
|
+
filter: LiveStateFilter {
|
|
1384
|
+
schema_keys: vec!["lix_key_value".to_string()],
|
|
1385
|
+
entity_ids: vec![crate::entity_identity::EntityIdentity::single(key)],
|
|
1386
|
+
version_ids: vec!["global".to_string()],
|
|
1387
|
+
file_ids: vec![NullableKeyFilter::Null],
|
|
1388
|
+
include_tombstones,
|
|
1389
|
+
..LiveStateFilter::default()
|
|
1390
|
+
},
|
|
1391
|
+
..LiveStateScanRequest::default()
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
trait StateRowTestExt {
|
|
1396
|
+
fn with_schema(self, schema_key: &str) -> Self;
|
|
1397
|
+
fn with_schema_version(self, schema_version: &str) -> Self;
|
|
1398
|
+
fn with_file_id(self, file_id: &str) -> Self;
|
|
1399
|
+
fn with_tracked(self) -> Self;
|
|
1400
|
+
fn with_version(self, version_id: &str) -> Self;
|
|
1401
|
+
fn with_change_id(self, change_id: &str) -> Self;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
impl StateRowTestExt for StageRow {
|
|
1405
|
+
fn with_schema(mut self, schema_key: &str) -> Self {
|
|
1406
|
+
self.schema_key = schema_key.to_string();
|
|
1407
|
+
self
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
fn with_schema_version(mut self, schema_version: &str) -> Self {
|
|
1411
|
+
self.schema_version = schema_version.to_string();
|
|
1412
|
+
self
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
fn with_file_id(mut self, file_id: &str) -> Self {
|
|
1416
|
+
self.file_id = Some(file_id.to_string());
|
|
1417
|
+
self
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
fn with_tracked(mut self) -> Self {
|
|
1421
|
+
self.untracked = false;
|
|
1422
|
+
self
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
fn with_version(mut self, version_id: &str) -> Self {
|
|
1426
|
+
self.version_id = version_id.to_string();
|
|
1427
|
+
self.global = version_id == GLOBAL_VERSION_ID;
|
|
1428
|
+
self
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
fn with_change_id(mut self, change_id: &str) -> Self {
|
|
1432
|
+
self.change_id = Some(change_id.to_string());
|
|
1433
|
+
self
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
}
|