@lix-js/sdk 0.6.0-preview.1 → 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/SKILL.md +305 -320
- package/dist/engine-wasm/wasm/lix_engine.d.ts +5 -0
- package/dist/engine-wasm/wasm/lix_engine.js +9 -13
- package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
- package/dist/engine-wasm/wasm/lix_engine.wasm.d.ts +1 -0
- package/dist/open-lix.d.ts +103 -14
- package/dist/open-lix.js +3 -0
- 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 -64
|
@@ -0,0 +1,4811 @@
|
|
|
1
|
+
use std::collections::{BTreeMap, BTreeSet};
|
|
2
|
+
|
|
3
|
+
use jsonschema::JSONSchema;
|
|
4
|
+
use serde_json::Value as JsonValue;
|
|
5
|
+
|
|
6
|
+
use crate::common::json_pointer_get;
|
|
7
|
+
use crate::entity_identity::{EntityIdentity, EntityIdentityError, EntityIdentityPart};
|
|
8
|
+
use crate::live_state::{
|
|
9
|
+
LiveStateFilter, LiveStateReader, LiveStateRow, LiveStateRowIdentity, LiveStateRowRequest,
|
|
10
|
+
LiveStateScanRequest,
|
|
11
|
+
};
|
|
12
|
+
use crate::schema::{
|
|
13
|
+
compile_lix_schema, format_lix_schema_validation_errors, schema_from_registered_snapshot,
|
|
14
|
+
};
|
|
15
|
+
#[cfg(test)]
|
|
16
|
+
use crate::schema::{
|
|
17
|
+
is_seed_schema_key, reject_unsupported_registered_schema_version, validate_lix_schema,
|
|
18
|
+
validate_lix_schema_definition, SchemaKey,
|
|
19
|
+
};
|
|
20
|
+
use crate::transaction::normalization::{SchemaCatalogKey, TransactionSchemaCatalog};
|
|
21
|
+
use crate::transaction::staging::duplicate_insert_identity_message;
|
|
22
|
+
use crate::transaction::staging::StagedWriteSet;
|
|
23
|
+
use crate::transaction::types::StagedStateRow;
|
|
24
|
+
use crate::version::{VERSION_DESCRIPTOR_SCHEMA_KEY, VERSION_REF_SCHEMA_KEY};
|
|
25
|
+
use crate::{validate_row_metadata, LixError, NullableKeyFilter};
|
|
26
|
+
|
|
27
|
+
const REGISTERED_SCHEMA_KEY: &str = "lix_registered_schema";
|
|
28
|
+
const DIRECTORY_DESCRIPTOR_SCHEMA_KEY: &str = "lix_directory_descriptor";
|
|
29
|
+
const FILE_DESCRIPTOR_SCHEMA_KEY: &str = "lix_file_descriptor";
|
|
30
|
+
const STATE_SURFACE_SCHEMA_KEY: &str = "lix_state";
|
|
31
|
+
const MAX_DIRECTORY_PARENT_DEPTH: usize = 1024;
|
|
32
|
+
|
|
33
|
+
/// Immutable view of the final transaction write set before persistence.
|
|
34
|
+
///
|
|
35
|
+
/// Validation intentionally runs after staging has coalesced overwrites and
|
|
36
|
+
/// hydrated generated fields, but before changelog, tracked-state, untracked
|
|
37
|
+
/// state, or binary CAS writes are flushed.
|
|
38
|
+
pub(crate) struct TransactionValidationInput<'a> {
|
|
39
|
+
staged_writes: &'a StagedWriteSet,
|
|
40
|
+
schema_catalog: &'a TransactionSchemaCatalog,
|
|
41
|
+
live_state: &'a dyn LiveStateReader,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
impl<'a> TransactionValidationInput<'a> {
|
|
45
|
+
pub(crate) fn new(
|
|
46
|
+
staged_writes: &'a StagedWriteSet,
|
|
47
|
+
schema_catalog: &'a TransactionSchemaCatalog,
|
|
48
|
+
live_state: &'a dyn LiveStateReader,
|
|
49
|
+
) -> Self {
|
|
50
|
+
Self {
|
|
51
|
+
staged_writes,
|
|
52
|
+
schema_catalog,
|
|
53
|
+
live_state,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#[cfg(test)]
|
|
58
|
+
fn from_visible_schemas_for_tests(
|
|
59
|
+
staged_writes: &'a StagedWriteSet,
|
|
60
|
+
visible_schemas: &'a [JsonValue],
|
|
61
|
+
live_state: &'a dyn LiveStateReader,
|
|
62
|
+
) -> Self {
|
|
63
|
+
let catalog = Box::leak(Box::new(
|
|
64
|
+
TransactionSchemaCatalog::from_visible_schemas(visible_schemas)
|
|
65
|
+
.expect("test schema catalog should build"),
|
|
66
|
+
));
|
|
67
|
+
Self::new(staged_writes, catalog, live_state)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// Validates the final transaction write set before durable persistence.
|
|
72
|
+
///
|
|
73
|
+
/// The validator owns semantic write correctness for every engine2 write
|
|
74
|
+
/// frontend. It builds one transaction-visible schema catalog, validates pending
|
|
75
|
+
/// schema registrations, checks exact schema existence, and validates each
|
|
76
|
+
/// non-tombstone snapshot against the compiled JSON Schema for its
|
|
77
|
+
/// `(schema_key, schema_version)`.
|
|
78
|
+
///
|
|
79
|
+
/// Cross-row constraints such as `x-lix-unique` and foreign keys should also
|
|
80
|
+
/// live here so they can share transaction-local indexes and see the final
|
|
81
|
+
/// coalesced staged write set.
|
|
82
|
+
pub(crate) async fn validate_staged_writes(
|
|
83
|
+
input: TransactionValidationInput<'_>,
|
|
84
|
+
) -> Result<(), LixError> {
|
|
85
|
+
validate_foreign_key_definitions(input.schema_catalog)?;
|
|
86
|
+
let schema_catalog = input.schema_catalog.clone();
|
|
87
|
+
let pending_file_descriptors =
|
|
88
|
+
PendingFileDescriptorIndex::from_staged_writes(input.staged_writes);
|
|
89
|
+
let staged_rows = input.staged_writes.state_rows_for_validation();
|
|
90
|
+
validate_registered_schema_identity_is_canonical(&input, &staged_rows).await?;
|
|
91
|
+
let mut compiled_schemas = CompiledSchemaCatalog::new(&schema_catalog);
|
|
92
|
+
let mut pending_constraints = PendingConstraintIndexes::default();
|
|
93
|
+
let mut staged_snapshots = Vec::new();
|
|
94
|
+
for row in &staged_rows {
|
|
95
|
+
validate_staged_row_shape(row)?;
|
|
96
|
+
validate_staged_row_metadata(row)?;
|
|
97
|
+
validate_schema_exists(row, &schema_catalog)?;
|
|
98
|
+
let snapshot = validate_snapshot_content(row, &mut compiled_schemas)?;
|
|
99
|
+
if let Some(snapshot) = snapshot.as_ref() {
|
|
100
|
+
let schema = schema_catalog
|
|
101
|
+
.schema(&row.schema_key, &row.schema_version)
|
|
102
|
+
.ok_or_else(|| {
|
|
103
|
+
LixError::new(
|
|
104
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
105
|
+
format!(
|
|
106
|
+
"schema '{}' version '{}' is not visible to this transaction",
|
|
107
|
+
row.schema_key, row.schema_version
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
})?;
|
|
111
|
+
validate_file_owner_reference(&input, &pending_file_descriptors, row).await?;
|
|
112
|
+
validate_primary_key_identity(row, schema, snapshot)?;
|
|
113
|
+
pending_constraints.remember_row(row, schema, snapshot)?;
|
|
114
|
+
pending_constraints.remember_foreign_key_references(
|
|
115
|
+
&schema_catalog,
|
|
116
|
+
row,
|
|
117
|
+
schema,
|
|
118
|
+
snapshot,
|
|
119
|
+
)?;
|
|
120
|
+
staged_snapshots.push((row, schema, snapshot.clone()));
|
|
121
|
+
} else {
|
|
122
|
+
pending_constraints.remember_tombstone(row);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
let unresolved_foreign_keys =
|
|
126
|
+
validate_pending_foreign_keys(&schema_catalog, &pending_constraints, &staged_snapshots)?;
|
|
127
|
+
validate_pending_delete_restrictions(&schema_catalog, &pending_constraints)?;
|
|
128
|
+
let unresolved_foreign_keys =
|
|
129
|
+
validate_committed_foreign_keys(&input, &pending_constraints, &unresolved_foreign_keys)
|
|
130
|
+
.await?;
|
|
131
|
+
reject_unresolved_foreign_keys(&unresolved_foreign_keys)?;
|
|
132
|
+
validate_committed_delete_restrictions(&input, &schema_catalog, &pending_constraints).await?;
|
|
133
|
+
validate_version_ref_delete_restrictions(&input, &pending_constraints).await?;
|
|
134
|
+
validate_committed_insert_identities(&input, &pending_constraints).await?;
|
|
135
|
+
validate_committed_unique_constraints(&input, &pending_constraints).await?;
|
|
136
|
+
validate_directory_descriptor_parent_graph(&input, &staged_rows).await?;
|
|
137
|
+
validate_filesystem_namespace(&input, &staged_rows).await?;
|
|
138
|
+
Ok(())
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
|
142
|
+
struct DirectoryDescriptorScope {
|
|
143
|
+
version_id: String,
|
|
144
|
+
schema_version: String,
|
|
145
|
+
untracked: bool,
|
|
146
|
+
file_id: Option<String>,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#[derive(Debug, Clone, serde::Deserialize)]
|
|
150
|
+
struct DirectoryDescriptorSnapshot {
|
|
151
|
+
id: String,
|
|
152
|
+
parent_id: Option<String>,
|
|
153
|
+
name: String,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#[derive(Debug, Clone, serde::Deserialize)]
|
|
157
|
+
struct FileDescriptorSnapshot {
|
|
158
|
+
directory_id: Option<String>,
|
|
159
|
+
name: String,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async fn validate_directory_descriptor_parent_graph(
|
|
163
|
+
input: &TransactionValidationInput<'_>,
|
|
164
|
+
staged_rows: &[StagedStateRow],
|
|
165
|
+
) -> Result<(), LixError> {
|
|
166
|
+
let scopes = staged_directory_descriptor_scopes(staged_rows);
|
|
167
|
+
for scope in scopes {
|
|
168
|
+
let mut parents = committed_directory_parent_map(input.live_state, &scope).await?;
|
|
169
|
+
apply_staged_directory_parent_rows(staged_rows, &scope, &mut parents)?;
|
|
170
|
+
validate_directory_parent_map(&scope, &parents)?;
|
|
171
|
+
}
|
|
172
|
+
Ok(())
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async fn validate_registered_schema_identity_is_canonical(
|
|
176
|
+
input: &TransactionValidationInput<'_>,
|
|
177
|
+
staged_rows: &[StagedStateRow],
|
|
178
|
+
) -> Result<(), LixError> {
|
|
179
|
+
let pending_schema_rows = staged_rows
|
|
180
|
+
.iter()
|
|
181
|
+
.filter(|row| row.schema_key == REGISTERED_SCHEMA_KEY && row.snapshot_content.is_some())
|
|
182
|
+
.collect::<Vec<_>>();
|
|
183
|
+
if pending_schema_rows.is_empty() {
|
|
184
|
+
return Ok(());
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let pending_entity_ids = pending_schema_rows
|
|
188
|
+
.iter()
|
|
189
|
+
.map(|row| row.entity_id.clone())
|
|
190
|
+
.collect::<Vec<_>>();
|
|
191
|
+
let committed_rows = input
|
|
192
|
+
.live_state
|
|
193
|
+
.scan_rows(&LiveStateScanRequest {
|
|
194
|
+
filter: LiveStateFilter {
|
|
195
|
+
schema_keys: vec![REGISTERED_SCHEMA_KEY.to_string()],
|
|
196
|
+
entity_ids: pending_entity_ids,
|
|
197
|
+
file_ids: vec![NullableKeyFilter::Null],
|
|
198
|
+
include_tombstones: false,
|
|
199
|
+
..LiveStateFilter::default()
|
|
200
|
+
},
|
|
201
|
+
..LiveStateScanRequest::default()
|
|
202
|
+
})
|
|
203
|
+
.await?;
|
|
204
|
+
|
|
205
|
+
for row in committed_rows {
|
|
206
|
+
let Some(snapshot_content) = row.snapshot_content.as_deref() else {
|
|
207
|
+
continue;
|
|
208
|
+
};
|
|
209
|
+
let snapshot = parse_registered_schema_snapshot(snapshot_content)?;
|
|
210
|
+
let Some(pending_row) = pending_schema_rows
|
|
211
|
+
.iter()
|
|
212
|
+
.find(|pending_row| pending_row.entity_id == row.entity_id)
|
|
213
|
+
else {
|
|
214
|
+
continue;
|
|
215
|
+
};
|
|
216
|
+
let pending_snapshot = parse_registered_schema_snapshot(
|
|
217
|
+
pending_row
|
|
218
|
+
.snapshot_content
|
|
219
|
+
.as_deref()
|
|
220
|
+
.expect("pending registered schema row has snapshot_content"),
|
|
221
|
+
)?;
|
|
222
|
+
if snapshot != pending_snapshot {
|
|
223
|
+
let (key, _) = schema_from_registered_snapshot(&pending_snapshot)?;
|
|
224
|
+
return Err(LixError::new(
|
|
225
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
226
|
+
format!(
|
|
227
|
+
"schema '{}' version '{}' is already registered with a different definition; schema identity must be canonical",
|
|
228
|
+
key.schema_key, key.schema_version
|
|
229
|
+
),
|
|
230
|
+
));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
Ok(())
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
fn parse_registered_schema_snapshot(snapshot_content: &str) -> Result<JsonValue, LixError> {
|
|
238
|
+
serde_json::from_str::<JsonValue>(snapshot_content).map_err(|error| {
|
|
239
|
+
LixError::new(
|
|
240
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
241
|
+
format!("registered schema snapshot_content is invalid JSON: {error}"),
|
|
242
|
+
)
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
fn staged_directory_descriptor_scopes(
|
|
247
|
+
staged_rows: &[StagedStateRow],
|
|
248
|
+
) -> BTreeSet<DirectoryDescriptorScope> {
|
|
249
|
+
staged_rows
|
|
250
|
+
.iter()
|
|
251
|
+
.filter(|row| row.schema_key == DIRECTORY_DESCRIPTOR_SCHEMA_KEY)
|
|
252
|
+
.map(|row| DirectoryDescriptorScope {
|
|
253
|
+
version_id: row.version_id.clone(),
|
|
254
|
+
schema_version: row.schema_version.clone(),
|
|
255
|
+
untracked: row.untracked,
|
|
256
|
+
file_id: row.file_id.clone(),
|
|
257
|
+
})
|
|
258
|
+
.collect()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async fn committed_directory_parent_map(
|
|
262
|
+
live_state: &dyn LiveStateReader,
|
|
263
|
+
scope: &DirectoryDescriptorScope,
|
|
264
|
+
) -> Result<BTreeMap<String, Option<String>>, LixError> {
|
|
265
|
+
let rows = live_state
|
|
266
|
+
.scan_rows(&LiveStateScanRequest {
|
|
267
|
+
filter: LiveStateFilter {
|
|
268
|
+
schema_keys: vec![DIRECTORY_DESCRIPTOR_SCHEMA_KEY.to_string()],
|
|
269
|
+
version_ids: vec![scope.version_id.clone()],
|
|
270
|
+
file_ids: vec![nullable_filter_from_option(&scope.file_id)],
|
|
271
|
+
include_tombstones: false,
|
|
272
|
+
..Default::default()
|
|
273
|
+
},
|
|
274
|
+
..Default::default()
|
|
275
|
+
})
|
|
276
|
+
.await?;
|
|
277
|
+
let mut parents = BTreeMap::new();
|
|
278
|
+
for row in rows {
|
|
279
|
+
if !committed_directory_row_is_in_scope(&row, scope) {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
let Some(snapshot_content) = row.snapshot_content.as_deref() else {
|
|
283
|
+
continue;
|
|
284
|
+
};
|
|
285
|
+
let snapshot = parse_directory_descriptor_snapshot(&row.schema_version, snapshot_content)?;
|
|
286
|
+
parents.insert(snapshot.id, snapshot.parent_id);
|
|
287
|
+
}
|
|
288
|
+
Ok(parents)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
fn committed_directory_row_is_in_scope(
|
|
292
|
+
row: &LiveStateRow,
|
|
293
|
+
scope: &DirectoryDescriptorScope,
|
|
294
|
+
) -> bool {
|
|
295
|
+
row.schema_key == DIRECTORY_DESCRIPTOR_SCHEMA_KEY
|
|
296
|
+
&& row.schema_version == scope.schema_version
|
|
297
|
+
&& row.untracked == scope.untracked
|
|
298
|
+
&& row.file_id == scope.file_id
|
|
299
|
+
&& committed_row_is_exact_version_scoped(row, &scope.version_id)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
fn apply_staged_directory_parent_rows(
|
|
303
|
+
staged_rows: &[StagedStateRow],
|
|
304
|
+
scope: &DirectoryDescriptorScope,
|
|
305
|
+
parents: &mut BTreeMap<String, Option<String>>,
|
|
306
|
+
) -> Result<(), LixError> {
|
|
307
|
+
for row in staged_rows {
|
|
308
|
+
if row.schema_key != DIRECTORY_DESCRIPTOR_SCHEMA_KEY
|
|
309
|
+
|| row.version_id != scope.version_id
|
|
310
|
+
|| row.schema_version != scope.schema_version
|
|
311
|
+
|| row.untracked != scope.untracked
|
|
312
|
+
|| row.file_id != scope.file_id
|
|
313
|
+
{
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
let id = row.entity_id.as_string()?;
|
|
317
|
+
let Some(snapshot_content) = row.snapshot_content.as_deref() else {
|
|
318
|
+
parents.remove(&id);
|
|
319
|
+
continue;
|
|
320
|
+
};
|
|
321
|
+
let snapshot = parse_directory_descriptor_snapshot(&row.schema_version, snapshot_content)?;
|
|
322
|
+
parents.insert(snapshot.id, snapshot.parent_id);
|
|
323
|
+
}
|
|
324
|
+
Ok(())
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
fn parse_directory_descriptor_snapshot(
|
|
328
|
+
schema_version: &str,
|
|
329
|
+
snapshot_content: &str,
|
|
330
|
+
) -> Result<DirectoryDescriptorSnapshot, LixError> {
|
|
331
|
+
serde_json::from_str::<DirectoryDescriptorSnapshot>(snapshot_content).map_err(|error| {
|
|
332
|
+
LixError::new(
|
|
333
|
+
LixError::CODE_SCHEMA_VALIDATION,
|
|
334
|
+
format!(
|
|
335
|
+
"lix_directory_descriptor version '{schema_version}' snapshot_content is invalid JSON: {error}"
|
|
336
|
+
),
|
|
337
|
+
)
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
|
342
|
+
struct FilesystemStorageScope {
|
|
343
|
+
version_id: String,
|
|
344
|
+
untracked: bool,
|
|
345
|
+
file_id: Option<String>,
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
|
349
|
+
struct FilesystemNamespaceIdentity {
|
|
350
|
+
schema_key: String,
|
|
351
|
+
entity_id: EntityIdentity,
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
355
|
+
enum FilesystemNamespaceOccupant {
|
|
356
|
+
Directory {
|
|
357
|
+
entity_id: EntityIdentity,
|
|
358
|
+
parent_id: Option<String>,
|
|
359
|
+
name: String,
|
|
360
|
+
},
|
|
361
|
+
File {
|
|
362
|
+
entity_id: EntityIdentity,
|
|
363
|
+
directory_id: Option<String>,
|
|
364
|
+
entry_name: String,
|
|
365
|
+
},
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
impl FilesystemNamespaceOccupant {
|
|
369
|
+
fn entity_id(&self) -> &EntityIdentity {
|
|
370
|
+
match self {
|
|
371
|
+
Self::Directory { entity_id, .. } | Self::File { entity_id, .. } => entity_id,
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
fn kind(&self) -> &'static str {
|
|
376
|
+
match self {
|
|
377
|
+
Self::Directory { .. } => "directory",
|
|
378
|
+
Self::File { .. } => "file",
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
fn parent_id(&self) -> &Option<String> {
|
|
383
|
+
match self {
|
|
384
|
+
Self::Directory { parent_id, .. } => parent_id,
|
|
385
|
+
Self::File { directory_id, .. } => directory_id,
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
fn entry_name(&self) -> &str {
|
|
390
|
+
match self {
|
|
391
|
+
Self::Directory { name, .. } => name,
|
|
392
|
+
Self::File { entry_name, .. } => entry_name,
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async fn validate_filesystem_namespace(
|
|
398
|
+
input: &TransactionValidationInput<'_>,
|
|
399
|
+
staged_rows: &[StagedStateRow],
|
|
400
|
+
) -> Result<(), LixError> {
|
|
401
|
+
// Filesystem namespace constraints are storage-scope local. Global rows are
|
|
402
|
+
// validated in the global scope and may be projected into version reads, but
|
|
403
|
+
// projected globals do not participate in version-local constraint checks.
|
|
404
|
+
let scopes = staged_filesystem_namespace_scopes(staged_rows);
|
|
405
|
+
for scope in scopes {
|
|
406
|
+
let mut occupants =
|
|
407
|
+
committed_filesystem_namespace_occupants(input.live_state, &scope).await?;
|
|
408
|
+
apply_staged_filesystem_namespace_rows(staged_rows, &scope, &mut occupants)?;
|
|
409
|
+
validate_filesystem_namespace_occupants(&scope, occupants)?;
|
|
410
|
+
}
|
|
411
|
+
Ok(())
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
fn staged_filesystem_namespace_scopes(
|
|
415
|
+
staged_rows: &[StagedStateRow],
|
|
416
|
+
) -> BTreeSet<FilesystemStorageScope> {
|
|
417
|
+
staged_rows
|
|
418
|
+
.iter()
|
|
419
|
+
.filter(|row| {
|
|
420
|
+
row.schema_key == DIRECTORY_DESCRIPTOR_SCHEMA_KEY
|
|
421
|
+
|| row.schema_key == FILE_DESCRIPTOR_SCHEMA_KEY
|
|
422
|
+
})
|
|
423
|
+
.map(|row| FilesystemStorageScope {
|
|
424
|
+
version_id: row.version_id.clone(),
|
|
425
|
+
untracked: row.untracked,
|
|
426
|
+
file_id: row.file_id.clone(),
|
|
427
|
+
})
|
|
428
|
+
.collect()
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async fn committed_filesystem_namespace_occupants(
|
|
432
|
+
live_state: &dyn LiveStateReader,
|
|
433
|
+
scope: &FilesystemStorageScope,
|
|
434
|
+
) -> Result<BTreeMap<FilesystemNamespaceIdentity, FilesystemNamespaceOccupant>, LixError> {
|
|
435
|
+
let rows = live_state
|
|
436
|
+
.scan_rows(&LiveStateScanRequest {
|
|
437
|
+
filter: LiveStateFilter {
|
|
438
|
+
schema_keys: vec![
|
|
439
|
+
DIRECTORY_DESCRIPTOR_SCHEMA_KEY.to_string(),
|
|
440
|
+
FILE_DESCRIPTOR_SCHEMA_KEY.to_string(),
|
|
441
|
+
],
|
|
442
|
+
version_ids: vec![scope.version_id.clone()],
|
|
443
|
+
file_ids: vec![nullable_filter_from_option(&scope.file_id)],
|
|
444
|
+
include_tombstones: false,
|
|
445
|
+
..Default::default()
|
|
446
|
+
},
|
|
447
|
+
..Default::default()
|
|
448
|
+
})
|
|
449
|
+
.await?;
|
|
450
|
+
let mut occupants = BTreeMap::new();
|
|
451
|
+
for row in rows {
|
|
452
|
+
if !committed_filesystem_row_is_in_scope(&row, scope) {
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if let Some((identity, occupant)) = filesystem_namespace_occupant_from_live_row(&row)? {
|
|
456
|
+
occupants.insert(identity, occupant);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
Ok(occupants)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
fn committed_filesystem_row_is_in_scope(
|
|
463
|
+
row: &LiveStateRow,
|
|
464
|
+
scope: &FilesystemStorageScope,
|
|
465
|
+
) -> bool {
|
|
466
|
+
(row.schema_key == DIRECTORY_DESCRIPTOR_SCHEMA_KEY
|
|
467
|
+
|| row.schema_key == FILE_DESCRIPTOR_SCHEMA_KEY)
|
|
468
|
+
&& row.untracked == scope.untracked
|
|
469
|
+
&& row.file_id == scope.file_id
|
|
470
|
+
&& committed_row_is_exact_version_scoped(row, &scope.version_id)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
fn apply_staged_filesystem_namespace_rows(
|
|
474
|
+
staged_rows: &[StagedStateRow],
|
|
475
|
+
scope: &FilesystemStorageScope,
|
|
476
|
+
occupants: &mut BTreeMap<FilesystemNamespaceIdentity, FilesystemNamespaceOccupant>,
|
|
477
|
+
) -> Result<(), LixError> {
|
|
478
|
+
for row in staged_rows {
|
|
479
|
+
if (row.schema_key != DIRECTORY_DESCRIPTOR_SCHEMA_KEY
|
|
480
|
+
&& row.schema_key != FILE_DESCRIPTOR_SCHEMA_KEY)
|
|
481
|
+
|| row.version_id != scope.version_id
|
|
482
|
+
|| row.untracked != scope.untracked
|
|
483
|
+
|| row.file_id != scope.file_id
|
|
484
|
+
{
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
let identity = FilesystemNamespaceIdentity {
|
|
488
|
+
schema_key: row.schema_key.clone(),
|
|
489
|
+
entity_id: row.entity_id.clone(),
|
|
490
|
+
};
|
|
491
|
+
let Some(snapshot_content) = row.snapshot_content.as_deref() else {
|
|
492
|
+
occupants.remove(&identity);
|
|
493
|
+
continue;
|
|
494
|
+
};
|
|
495
|
+
occupants.insert(
|
|
496
|
+
identity,
|
|
497
|
+
filesystem_namespace_occupant_from_staged_row(row, snapshot_content)?,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
Ok(())
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
fn filesystem_namespace_occupant_from_live_row(
|
|
504
|
+
row: &LiveStateRow,
|
|
505
|
+
) -> Result<Option<(FilesystemNamespaceIdentity, FilesystemNamespaceOccupant)>, LixError> {
|
|
506
|
+
let Some(snapshot_content) = row.snapshot_content.as_deref() else {
|
|
507
|
+
return Ok(None);
|
|
508
|
+
};
|
|
509
|
+
let identity = FilesystemNamespaceIdentity {
|
|
510
|
+
schema_key: row.schema_key.clone(),
|
|
511
|
+
entity_id: row.entity_id.clone(),
|
|
512
|
+
};
|
|
513
|
+
let occupant = match row.schema_key.as_str() {
|
|
514
|
+
DIRECTORY_DESCRIPTOR_SCHEMA_KEY => {
|
|
515
|
+
directory_namespace_occupant(&row.schema_version, &row.entity_id, snapshot_content)?
|
|
516
|
+
}
|
|
517
|
+
FILE_DESCRIPTOR_SCHEMA_KEY => {
|
|
518
|
+
file_namespace_occupant(&row.schema_version, &row.entity_id, snapshot_content)?
|
|
519
|
+
}
|
|
520
|
+
_ => return Ok(None),
|
|
521
|
+
};
|
|
522
|
+
Ok(Some((identity, occupant)))
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
fn filesystem_namespace_occupant_from_staged_row(
|
|
526
|
+
row: &StagedStateRow,
|
|
527
|
+
snapshot_content: &str,
|
|
528
|
+
) -> Result<FilesystemNamespaceOccupant, LixError> {
|
|
529
|
+
match row.schema_key.as_str() {
|
|
530
|
+
DIRECTORY_DESCRIPTOR_SCHEMA_KEY => {
|
|
531
|
+
directory_namespace_occupant(&row.schema_version, &row.entity_id, snapshot_content)
|
|
532
|
+
}
|
|
533
|
+
FILE_DESCRIPTOR_SCHEMA_KEY => {
|
|
534
|
+
file_namespace_occupant(&row.schema_version, &row.entity_id, snapshot_content)
|
|
535
|
+
}
|
|
536
|
+
_ => Err(LixError::new(
|
|
537
|
+
LixError::CODE_SCHEMA_VALIDATION,
|
|
538
|
+
format!(
|
|
539
|
+
"filesystem namespace validation cannot parse schema '{}'",
|
|
540
|
+
row.schema_key
|
|
541
|
+
),
|
|
542
|
+
)),
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
fn directory_namespace_occupant(
|
|
547
|
+
schema_version: &str,
|
|
548
|
+
entity_id: &EntityIdentity,
|
|
549
|
+
snapshot_content: &str,
|
|
550
|
+
) -> Result<FilesystemNamespaceOccupant, LixError> {
|
|
551
|
+
let snapshot = parse_directory_descriptor_snapshot(schema_version, snapshot_content)?;
|
|
552
|
+
Ok(FilesystemNamespaceOccupant::Directory {
|
|
553
|
+
entity_id: entity_id.clone(),
|
|
554
|
+
parent_id: snapshot.parent_id,
|
|
555
|
+
name: snapshot.name,
|
|
556
|
+
})
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
fn file_namespace_occupant(
|
|
560
|
+
schema_version: &str,
|
|
561
|
+
entity_id: &EntityIdentity,
|
|
562
|
+
snapshot_content: &str,
|
|
563
|
+
) -> Result<FilesystemNamespaceOccupant, LixError> {
|
|
564
|
+
let snapshot = serde_json::from_str::<FileDescriptorSnapshot>(snapshot_content).map_err(|error| {
|
|
565
|
+
LixError::new(
|
|
566
|
+
LixError::CODE_SCHEMA_VALIDATION,
|
|
567
|
+
format!(
|
|
568
|
+
"lix_file_descriptor version '{schema_version}' snapshot_content is invalid JSON: {error}"
|
|
569
|
+
),
|
|
570
|
+
)
|
|
571
|
+
})?;
|
|
572
|
+
Ok(FilesystemNamespaceOccupant::File {
|
|
573
|
+
entity_id: entity_id.clone(),
|
|
574
|
+
directory_id: snapshot.directory_id,
|
|
575
|
+
entry_name: snapshot.name,
|
|
576
|
+
})
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
fn validate_filesystem_namespace_occupants(
|
|
580
|
+
scope: &FilesystemStorageScope,
|
|
581
|
+
occupants: BTreeMap<FilesystemNamespaceIdentity, FilesystemNamespaceOccupant>,
|
|
582
|
+
) -> Result<(), LixError> {
|
|
583
|
+
let mut by_parent_and_name =
|
|
584
|
+
BTreeMap::<(Option<String>, String), FilesystemNamespaceOccupant>::new();
|
|
585
|
+
for occupant in occupants.into_values() {
|
|
586
|
+
let key = (
|
|
587
|
+
occupant.parent_id().clone(),
|
|
588
|
+
occupant.entry_name().to_string(),
|
|
589
|
+
);
|
|
590
|
+
if let Some(existing) = by_parent_and_name.insert(key.clone(), occupant.clone()) {
|
|
591
|
+
if existing != occupant {
|
|
592
|
+
return Err(filesystem_namespace_conflict_error(
|
|
593
|
+
scope, &key.0, &key.1, &existing, &occupant,
|
|
594
|
+
));
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
Ok(())
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
fn filesystem_namespace_conflict_error(
|
|
602
|
+
scope: &FilesystemStorageScope,
|
|
603
|
+
parent_id: &Option<String>,
|
|
604
|
+
entry_name: &str,
|
|
605
|
+
existing: &FilesystemNamespaceOccupant,
|
|
606
|
+
conflicting: &FilesystemNamespaceOccupant,
|
|
607
|
+
) -> LixError {
|
|
608
|
+
let parent = parent_id.as_deref().unwrap_or("<root>");
|
|
609
|
+
let existing_id = existing
|
|
610
|
+
.entity_id()
|
|
611
|
+
.as_string()
|
|
612
|
+
.unwrap_or_else(|_| "<non-string-entity-id>".to_string());
|
|
613
|
+
let conflicting_id = conflicting
|
|
614
|
+
.entity_id()
|
|
615
|
+
.as_string()
|
|
616
|
+
.unwrap_or_else(|_| "<non-string-entity-id>".to_string());
|
|
617
|
+
LixError::new(
|
|
618
|
+
LixError::CODE_UNIQUE,
|
|
619
|
+
format!(
|
|
620
|
+
"filesystem namespace conflict in version '{}' for parent {parent:?} entry {entry_name:?}: {} '{}' conflicts with {} '{}'",
|
|
621
|
+
scope.version_id,
|
|
622
|
+
existing.kind(),
|
|
623
|
+
existing_id,
|
|
624
|
+
conflicting.kind(),
|
|
625
|
+
conflicting_id
|
|
626
|
+
),
|
|
627
|
+
)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
fn validate_directory_parent_map(
|
|
631
|
+
scope: &DirectoryDescriptorScope,
|
|
632
|
+
parents: &BTreeMap<String, Option<String>>,
|
|
633
|
+
) -> Result<(), LixError> {
|
|
634
|
+
for directory_id in parents.keys() {
|
|
635
|
+
validate_directory_parent_chain(scope, parents, directory_id)?;
|
|
636
|
+
}
|
|
637
|
+
Ok(())
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
fn validate_directory_parent_chain(
|
|
641
|
+
scope: &DirectoryDescriptorScope,
|
|
642
|
+
parents: &BTreeMap<String, Option<String>>,
|
|
643
|
+
start_id: &str,
|
|
644
|
+
) -> Result<(), LixError> {
|
|
645
|
+
let mut current_id = start_id;
|
|
646
|
+
let mut seen = BTreeSet::<String>::new();
|
|
647
|
+
for depth in 0..=MAX_DIRECTORY_PARENT_DEPTH {
|
|
648
|
+
if !seen.insert(current_id.to_string()) {
|
|
649
|
+
return Err(directory_parent_cycle_error(scope, start_id, current_id));
|
|
650
|
+
}
|
|
651
|
+
let Some(parent_id) = parents.get(current_id) else {
|
|
652
|
+
return Err(directory_parent_missing_error(scope, start_id, current_id));
|
|
653
|
+
};
|
|
654
|
+
let Some(parent_id) = parent_id.as_deref() else {
|
|
655
|
+
return Ok(());
|
|
656
|
+
};
|
|
657
|
+
current_id = parent_id;
|
|
658
|
+
if depth == MAX_DIRECTORY_PARENT_DEPTH {
|
|
659
|
+
return Err(directory_parent_depth_error(scope, start_id));
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
Err(directory_parent_depth_error(scope, start_id))
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
fn directory_parent_cycle_error(
|
|
666
|
+
scope: &DirectoryDescriptorScope,
|
|
667
|
+
start_id: &str,
|
|
668
|
+
repeated_id: &str,
|
|
669
|
+
) -> LixError {
|
|
670
|
+
LixError::new(
|
|
671
|
+
LixError::CODE_CONSTRAINT_VIOLATION,
|
|
672
|
+
format!(
|
|
673
|
+
"lix_directory_descriptor parent_id cycle in version '{}': directory '{}' reaches ancestor '{}' twice",
|
|
674
|
+
scope.version_id, start_id, repeated_id
|
|
675
|
+
),
|
|
676
|
+
)
|
|
677
|
+
.with_hint("Set parent_id to null or to an existing directory outside the directory's descendants.")
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
fn directory_parent_missing_error(
|
|
681
|
+
scope: &DirectoryDescriptorScope,
|
|
682
|
+
start_id: &str,
|
|
683
|
+
missing_id: &str,
|
|
684
|
+
) -> LixError {
|
|
685
|
+
LixError::new(
|
|
686
|
+
LixError::CODE_FOREIGN_KEY,
|
|
687
|
+
format!(
|
|
688
|
+
"lix_directory_descriptor parent_id chain in version '{}' for directory '{}' references missing directory '{}'",
|
|
689
|
+
scope.version_id, start_id, missing_id
|
|
690
|
+
),
|
|
691
|
+
)
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
fn directory_parent_depth_error(scope: &DirectoryDescriptorScope, start_id: &str) -> LixError {
|
|
695
|
+
LixError::new(
|
|
696
|
+
LixError::CODE_CONSTRAINT_VIOLATION,
|
|
697
|
+
format!(
|
|
698
|
+
"lix_directory_descriptor parent_id chain in version '{}' for directory '{}' exceeds maximum depth {}",
|
|
699
|
+
scope.version_id, start_id, MAX_DIRECTORY_PARENT_DEPTH
|
|
700
|
+
),
|
|
701
|
+
)
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
async fn validate_committed_insert_identities(
|
|
705
|
+
input: &TransactionValidationInput<'_>,
|
|
706
|
+
pending_constraints: &PendingConstraintIndexes,
|
|
707
|
+
) -> Result<(), LixError> {
|
|
708
|
+
for (identity, origin) in &input.staged_writes.insert_identities {
|
|
709
|
+
let Some(committed_row) = input
|
|
710
|
+
.live_state
|
|
711
|
+
.load_row(&LiveStateRowRequest {
|
|
712
|
+
schema_key: identity.schema_key.clone(),
|
|
713
|
+
version_id: identity.version_id.clone(),
|
|
714
|
+
entity_id: identity.entity_id.clone(),
|
|
715
|
+
file_id: nullable_filter_from_option(&identity.file_id),
|
|
716
|
+
})
|
|
717
|
+
.await?
|
|
718
|
+
else {
|
|
719
|
+
continue;
|
|
720
|
+
};
|
|
721
|
+
if committed_row.snapshot_content.is_none()
|
|
722
|
+
|| !committed_row_is_exact_version_scoped(&committed_row, &identity.version_id)
|
|
723
|
+
|| pending_constraints.tombstones_identity(&committed_row)
|
|
724
|
+
{
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
return Err(LixError::new(
|
|
728
|
+
LixError::CODE_UNIQUE,
|
|
729
|
+
duplicate_insert_identity_message(
|
|
730
|
+
&identity.schema_key,
|
|
731
|
+
&committed_row.schema_version,
|
|
732
|
+
&identity.entity_id,
|
|
733
|
+
None,
|
|
734
|
+
origin.as_ref(),
|
|
735
|
+
),
|
|
736
|
+
));
|
|
737
|
+
}
|
|
738
|
+
Ok(())
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async fn validate_version_ref_delete_restrictions(
|
|
742
|
+
input: &TransactionValidationInput<'_>,
|
|
743
|
+
pending_constraints: &PendingConstraintIndexes,
|
|
744
|
+
) -> Result<(), LixError> {
|
|
745
|
+
for tombstone in &pending_constraints.tombstones {
|
|
746
|
+
if tombstone.identity.schema_key != VERSION_REF_SCHEMA_KEY {
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
let descriptor_identity = LiveStateRowIdentity {
|
|
751
|
+
version_id: tombstone.identity.version_id.clone(),
|
|
752
|
+
schema_key: VERSION_DESCRIPTOR_SCHEMA_KEY.to_string(),
|
|
753
|
+
entity_id: tombstone.identity.entity_id.clone(),
|
|
754
|
+
file_id: tombstone.identity.file_id.clone(),
|
|
755
|
+
};
|
|
756
|
+
if pending_constraints.tombstones_target_identity(&descriptor_identity) {
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
if pending_constraints.has_identity_target(&descriptor_identity) {
|
|
760
|
+
return Err(version_ref_delete_restriction_error(
|
|
761
|
+
&tombstone.identity,
|
|
762
|
+
&descriptor_identity,
|
|
763
|
+
)?);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
let Some(descriptor_row) = input
|
|
767
|
+
.live_state
|
|
768
|
+
.load_row(&LiveStateRowRequest {
|
|
769
|
+
schema_key: descriptor_identity.schema_key.clone(),
|
|
770
|
+
version_id: descriptor_identity.version_id.clone(),
|
|
771
|
+
entity_id: descriptor_identity.entity_id.clone(),
|
|
772
|
+
file_id: nullable_filter_from_option(&descriptor_identity.file_id),
|
|
773
|
+
})
|
|
774
|
+
.await?
|
|
775
|
+
else {
|
|
776
|
+
continue;
|
|
777
|
+
};
|
|
778
|
+
if descriptor_row.snapshot_content.is_some()
|
|
779
|
+
&& committed_row_is_exact_version_scoped(
|
|
780
|
+
&descriptor_row,
|
|
781
|
+
&descriptor_identity.version_id,
|
|
782
|
+
)
|
|
783
|
+
&& !pending_constraints.tombstones_identity(&descriptor_row)
|
|
784
|
+
{
|
|
785
|
+
return Err(version_ref_delete_restriction_error(
|
|
786
|
+
&tombstone.identity,
|
|
787
|
+
&descriptor_identity,
|
|
788
|
+
)?);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
Ok(())
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
fn version_ref_delete_restriction_error(
|
|
795
|
+
ref_identity: &LiveStateRowIdentity,
|
|
796
|
+
descriptor_identity: &LiveStateRowIdentity,
|
|
797
|
+
) -> Result<LixError, LixError> {
|
|
798
|
+
Ok(LixError::new(
|
|
799
|
+
LixError::CODE_FOREIGN_KEY,
|
|
800
|
+
format!(
|
|
801
|
+
"cannot delete '{}' row '{}' in version '{}' because matching '{}' row '{}' would remain without a version ref",
|
|
802
|
+
ref_identity.schema_key,
|
|
803
|
+
ref_identity.entity_id.as_string()?,
|
|
804
|
+
ref_identity.version_id,
|
|
805
|
+
descriptor_identity.schema_key,
|
|
806
|
+
descriptor_identity.entity_id.as_string()?,
|
|
807
|
+
),
|
|
808
|
+
))
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
812
|
+
enum PendingFileDescriptorState {
|
|
813
|
+
Present,
|
|
814
|
+
Tombstone,
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
#[derive(Debug, Clone, Default)]
|
|
818
|
+
struct PendingFileDescriptorIndex {
|
|
819
|
+
by_version_and_file_id: BTreeMap<(String, String), PendingFileDescriptorState>,
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
impl PendingFileDescriptorIndex {
|
|
823
|
+
fn from_staged_writes(staged_writes: &StagedWriteSet) -> Self {
|
|
824
|
+
let mut index = Self::default();
|
|
825
|
+
for row in staged_writes.state_rows_for_validation() {
|
|
826
|
+
if row.schema_key != FILE_DESCRIPTOR_SCHEMA_KEY || row.file_id.is_some() {
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
if let Ok(file_id) = row.entity_id.as_string() {
|
|
830
|
+
let state = if row.snapshot_content.is_some() {
|
|
831
|
+
PendingFileDescriptorState::Present
|
|
832
|
+
} else {
|
|
833
|
+
PendingFileDescriptorState::Tombstone
|
|
834
|
+
};
|
|
835
|
+
index
|
|
836
|
+
.by_version_and_file_id
|
|
837
|
+
.insert((row.version_id.clone(), file_id), state);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
index
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
fn state(&self, version_id: &str, file_id: &str) -> Option<PendingFileDescriptorState> {
|
|
844
|
+
self.by_version_and_file_id
|
|
845
|
+
.get(&(version_id.to_string(), file_id.to_string()))
|
|
846
|
+
.copied()
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
async fn validate_file_owner_reference(
|
|
851
|
+
input: &TransactionValidationInput<'_>,
|
|
852
|
+
pending_file_descriptors: &PendingFileDescriptorIndex,
|
|
853
|
+
row: &StagedStateRow,
|
|
854
|
+
) -> Result<(), LixError> {
|
|
855
|
+
let Some(file_id) = row.file_id.as_deref() else {
|
|
856
|
+
return Ok(());
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
if pending_file_descriptor_exists(pending_file_descriptors, &row.version_id, file_id) {
|
|
860
|
+
return Ok(());
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
if committed_file_descriptor_exists(input.live_state, &row.version_id, file_id).await? {
|
|
864
|
+
return Ok(());
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
Err(missing_file_owner_reference_error(row, file_id)?)
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
fn pending_file_descriptor_exists(
|
|
871
|
+
pending_file_descriptors: &PendingFileDescriptorIndex,
|
|
872
|
+
version_id: &str,
|
|
873
|
+
file_id: &str,
|
|
874
|
+
) -> bool {
|
|
875
|
+
matches!(
|
|
876
|
+
pending_file_descriptors.state(version_id, file_id),
|
|
877
|
+
Some(PendingFileDescriptorState::Present)
|
|
878
|
+
)
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async fn committed_file_descriptor_exists(
|
|
882
|
+
live_state: &dyn LiveStateReader,
|
|
883
|
+
version_id: &str,
|
|
884
|
+
file_id: &str,
|
|
885
|
+
) -> Result<bool, LixError> {
|
|
886
|
+
committed_file_descriptor_exists_in_exact_version(live_state, version_id, file_id).await
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
async fn committed_file_descriptor_exists_in_exact_version(
|
|
890
|
+
live_state: &dyn LiveStateReader,
|
|
891
|
+
version_id: &str,
|
|
892
|
+
file_id: &str,
|
|
893
|
+
) -> Result<bool, LixError> {
|
|
894
|
+
let Some(row) = live_state
|
|
895
|
+
.load_row(&LiveStateRowRequest {
|
|
896
|
+
schema_key: FILE_DESCRIPTOR_SCHEMA_KEY.to_string(),
|
|
897
|
+
version_id: version_id.to_string(),
|
|
898
|
+
entity_id: EntityIdentity::single(file_id),
|
|
899
|
+
file_id: NullableKeyFilter::Null,
|
|
900
|
+
})
|
|
901
|
+
.await?
|
|
902
|
+
else {
|
|
903
|
+
return Ok(false);
|
|
904
|
+
};
|
|
905
|
+
Ok(row.snapshot_content.is_some()
|
|
906
|
+
&& row.schema_key == FILE_DESCRIPTOR_SCHEMA_KEY
|
|
907
|
+
&& row.entity_id == EntityIdentity::single(file_id)
|
|
908
|
+
&& row.file_id.is_none()
|
|
909
|
+
&& committed_row_is_exact_version_scoped(&row, version_id))
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
fn missing_file_owner_reference_error(
|
|
913
|
+
row: &StagedStateRow,
|
|
914
|
+
file_id: &str,
|
|
915
|
+
) -> Result<LixError, LixError> {
|
|
916
|
+
Ok(LixError::new(
|
|
917
|
+
LixError::CODE_FILE_NOT_FOUND,
|
|
918
|
+
format!(
|
|
919
|
+
"file ownership validation failed for schema '{}': entity '{}' references missing file_id '{}' in effective file scope for version '{}'",
|
|
920
|
+
row.schema_key,
|
|
921
|
+
row.entity_id.as_string()?,
|
|
922
|
+
file_id,
|
|
923
|
+
row.version_id
|
|
924
|
+
),
|
|
925
|
+
)
|
|
926
|
+
.with_hint("Insert a row into lix_file with this id first, or use null for a global entity."))
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
fn validate_staged_row_shape(row: &StagedStateRow) -> Result<(), LixError> {
|
|
930
|
+
if row.schema_key.is_empty() {
|
|
931
|
+
return Err(LixError::new(
|
|
932
|
+
"LIX_ERROR_UNKNOWN",
|
|
933
|
+
"engine2 transaction validation requires non-empty schema_key",
|
|
934
|
+
));
|
|
935
|
+
}
|
|
936
|
+
if row.schema_version.is_empty() {
|
|
937
|
+
return Err(LixError::new(
|
|
938
|
+
"LIX_ERROR_UNKNOWN",
|
|
939
|
+
"engine2 transaction validation requires non-empty schema_version",
|
|
940
|
+
));
|
|
941
|
+
}
|
|
942
|
+
Ok(())
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
fn validate_staged_row_metadata(row: &StagedStateRow) -> Result<(), LixError> {
|
|
946
|
+
let Some(metadata) = row.metadata.as_ref() else {
|
|
947
|
+
return Ok(());
|
|
948
|
+
};
|
|
949
|
+
validate_row_metadata(
|
|
950
|
+
metadata.clone(),
|
|
951
|
+
format!(
|
|
952
|
+
"metadata for schema '{}' version '{}'",
|
|
953
|
+
row.schema_key, row.schema_version
|
|
954
|
+
),
|
|
955
|
+
)?;
|
|
956
|
+
Ok(())
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
fn validate_schema_exists(
|
|
960
|
+
row: &StagedStateRow,
|
|
961
|
+
schema_catalog: &TransactionSchemaCatalog,
|
|
962
|
+
) -> Result<(), LixError> {
|
|
963
|
+
if !schema_catalog.contains(&row.schema_key, &row.schema_version) {
|
|
964
|
+
return Err(LixError::new(
|
|
965
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
966
|
+
format!(
|
|
967
|
+
"schema '{}' version '{}' is not visible to this transaction",
|
|
968
|
+
row.schema_key, row.schema_version
|
|
969
|
+
),
|
|
970
|
+
));
|
|
971
|
+
}
|
|
972
|
+
Ok(())
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
fn validate_snapshot_content(
|
|
976
|
+
row: &StagedStateRow,
|
|
977
|
+
compiled_schemas: &mut CompiledSchemaCatalog<'_>,
|
|
978
|
+
) -> Result<Option<JsonValue>, LixError> {
|
|
979
|
+
let Some(snapshot_content) = row.snapshot_content.as_deref() else {
|
|
980
|
+
return Ok(None);
|
|
981
|
+
};
|
|
982
|
+
let snapshot = serde_json::from_str::<JsonValue>(snapshot_content).map_err(|error| {
|
|
983
|
+
LixError::new(
|
|
984
|
+
LixError::CODE_SCHEMA_VALIDATION,
|
|
985
|
+
format!(
|
|
986
|
+
"snapshot_content for schema '{}' version '{}' is invalid JSON: {error}",
|
|
987
|
+
row.schema_key, row.schema_version
|
|
988
|
+
),
|
|
989
|
+
)
|
|
990
|
+
})?;
|
|
991
|
+
let compiled_schema = compiled_schemas.compiled_schema(&row.schema_key, &row.schema_version)?;
|
|
992
|
+
if let Err(errors) = compiled_schema.validate(&snapshot) {
|
|
993
|
+
let details = format_lix_schema_validation_errors(errors);
|
|
994
|
+
return Err(LixError::new(
|
|
995
|
+
LixError::CODE_SCHEMA_VALIDATION,
|
|
996
|
+
format!(
|
|
997
|
+
"snapshot_content validation failed for schema '{}' version '{}': {details}",
|
|
998
|
+
row.schema_key, row.schema_version
|
|
999
|
+
),
|
|
1000
|
+
));
|
|
1001
|
+
}
|
|
1002
|
+
Ok(Some(snapshot))
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
fn validate_primary_key_identity(
|
|
1006
|
+
row: &StagedStateRow,
|
|
1007
|
+
schema: &JsonValue,
|
|
1008
|
+
snapshot: &JsonValue,
|
|
1009
|
+
) -> Result<(), LixError> {
|
|
1010
|
+
let Some(primary_key_paths) = pointer_groups(schema, "x-lix-primary-key")?
|
|
1011
|
+
.into_iter()
|
|
1012
|
+
.next()
|
|
1013
|
+
else {
|
|
1014
|
+
return Ok(());
|
|
1015
|
+
};
|
|
1016
|
+
let derived = EntityIdentity::from_primary_key_paths(snapshot, &primary_key_paths)
|
|
1017
|
+
.map_err(|error| primary_key_identity_error(row, &primary_key_paths, error))?;
|
|
1018
|
+
if row.entity_id != derived {
|
|
1019
|
+
return Err(LixError::new(
|
|
1020
|
+
LixError::CODE_UNIQUE,
|
|
1021
|
+
format!(
|
|
1022
|
+
"primary-key constraint violation on schema '{}' version '{}': entity_id '{}' does not match derived primary key '{}'",
|
|
1023
|
+
row.schema_key,
|
|
1024
|
+
row.schema_version,
|
|
1025
|
+
row.entity_id.as_string()?,
|
|
1026
|
+
derived.as_string()?
|
|
1027
|
+
),
|
|
1028
|
+
));
|
|
1029
|
+
}
|
|
1030
|
+
Ok(())
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
#[derive(Default)]
|
|
1034
|
+
struct PendingConstraintIndexes {
|
|
1035
|
+
unique_values: BTreeMap<PendingUniqueKey, EntityIdentity>,
|
|
1036
|
+
identity_targets: Vec<LiveStateRowIdentity>,
|
|
1037
|
+
fk_targets: BTreeMap<PendingForeignKeyTargetKey, Vec<EntityIdentity>>,
|
|
1038
|
+
fk_references: BTreeMap<PendingForeignKeyReferenceTarget, Vec<LiveStateRowIdentity>>,
|
|
1039
|
+
tombstones: Vec<PendingTombstone>,
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
impl PendingConstraintIndexes {
|
|
1043
|
+
fn remember_tombstone(&mut self, row: &StagedStateRow) {
|
|
1044
|
+
self.tombstones.push(PendingTombstone {
|
|
1045
|
+
identity: LiveStateRowIdentity {
|
|
1046
|
+
version_id: row.version_id.clone(),
|
|
1047
|
+
schema_key: row.schema_key.clone(),
|
|
1048
|
+
entity_id: row.entity_id.clone(),
|
|
1049
|
+
file_id: row.file_id.clone(),
|
|
1050
|
+
},
|
|
1051
|
+
schema_version: row.schema_version.clone(),
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
fn remember_row(
|
|
1056
|
+
&mut self,
|
|
1057
|
+
row: &StagedStateRow,
|
|
1058
|
+
schema: &JsonValue,
|
|
1059
|
+
snapshot: &JsonValue,
|
|
1060
|
+
) -> Result<(), LixError> {
|
|
1061
|
+
self.remember_identity_target(row);
|
|
1062
|
+
self.remember_primary_key_target(row, schema, snapshot)?;
|
|
1063
|
+
self.remember_unique_targets(row, schema, snapshot)?;
|
|
1064
|
+
Ok(())
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
fn remember_identity_target(&mut self, row: &StagedStateRow) {
|
|
1068
|
+
self.identity_targets.push(LiveStateRowIdentity {
|
|
1069
|
+
version_id: row.version_id.clone(),
|
|
1070
|
+
schema_key: row.schema_key.clone(),
|
|
1071
|
+
entity_id: row.entity_id.clone(),
|
|
1072
|
+
file_id: row.file_id.clone(),
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
fn remember_primary_key_target(
|
|
1077
|
+
&mut self,
|
|
1078
|
+
row: &StagedStateRow,
|
|
1079
|
+
schema: &JsonValue,
|
|
1080
|
+
snapshot: &JsonValue,
|
|
1081
|
+
) -> Result<(), LixError> {
|
|
1082
|
+
for primary_key_paths in pointer_groups(schema, "x-lix-primary-key")? {
|
|
1083
|
+
self.remember_fk_target(row, &primary_key_paths, snapshot);
|
|
1084
|
+
}
|
|
1085
|
+
Ok(())
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
fn remember_unique_targets(
|
|
1089
|
+
&mut self,
|
|
1090
|
+
row: &StagedStateRow,
|
|
1091
|
+
schema: &JsonValue,
|
|
1092
|
+
snapshot: &JsonValue,
|
|
1093
|
+
) -> Result<(), LixError> {
|
|
1094
|
+
for unique_paths in pointer_groups(schema, "x-lix-unique")? {
|
|
1095
|
+
let Some(value) = UniqueConstraintValue::from_snapshot(snapshot, &unique_paths) else {
|
|
1096
|
+
continue;
|
|
1097
|
+
};
|
|
1098
|
+
self.remember_fk_target(row, &unique_paths, snapshot);
|
|
1099
|
+
let key = PendingUniqueKey {
|
|
1100
|
+
schema_key: row.schema_key.clone(),
|
|
1101
|
+
schema_version: row.schema_version.clone(),
|
|
1102
|
+
version_id: row.version_id.clone(),
|
|
1103
|
+
untracked: row.untracked,
|
|
1104
|
+
file_id: row.file_id.clone(),
|
|
1105
|
+
pointer_group: unique_paths.clone(),
|
|
1106
|
+
value,
|
|
1107
|
+
};
|
|
1108
|
+
if let Some(existing_entity_id) = self
|
|
1109
|
+
.unique_values
|
|
1110
|
+
.insert(key.clone(), row.entity_id.clone())
|
|
1111
|
+
{
|
|
1112
|
+
if existing_entity_id != row.entity_id {
|
|
1113
|
+
return Err(LixError::new(
|
|
1114
|
+
LixError::CODE_UNIQUE,
|
|
1115
|
+
format!(
|
|
1116
|
+
"unique constraint violation on {}.{} for value {}: rows '{}' and '{}' conflict",
|
|
1117
|
+
row.schema_key,
|
|
1118
|
+
format_pointer_group(&key.pointer_group),
|
|
1119
|
+
key.value.display(),
|
|
1120
|
+
existing_entity_id.as_string()?,
|
|
1121
|
+
row.entity_id.as_string()?
|
|
1122
|
+
),
|
|
1123
|
+
));
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
Ok(())
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
fn remember_fk_target(
|
|
1131
|
+
&mut self,
|
|
1132
|
+
row: &StagedStateRow,
|
|
1133
|
+
pointer_group: &[Vec<String>],
|
|
1134
|
+
snapshot: &JsonValue,
|
|
1135
|
+
) {
|
|
1136
|
+
let Some(value) = UniqueConstraintValue::from_snapshot(snapshot, pointer_group) else {
|
|
1137
|
+
return;
|
|
1138
|
+
};
|
|
1139
|
+
self.fk_targets
|
|
1140
|
+
.entry(PendingForeignKeyTargetKey {
|
|
1141
|
+
schema_key: row.schema_key.clone(),
|
|
1142
|
+
schema_version: row.schema_version.clone(),
|
|
1143
|
+
version_id: row.version_id.clone(),
|
|
1144
|
+
file_id: row.file_id.clone(),
|
|
1145
|
+
pointer_group: pointer_group.to_vec(),
|
|
1146
|
+
value,
|
|
1147
|
+
})
|
|
1148
|
+
.or_default()
|
|
1149
|
+
.push(row.entity_id.clone());
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
fn remember_foreign_key_references(
|
|
1153
|
+
&mut self,
|
|
1154
|
+
schema_catalog: &TransactionSchemaCatalog,
|
|
1155
|
+
row: &StagedStateRow,
|
|
1156
|
+
schema: &JsonValue,
|
|
1157
|
+
snapshot: &JsonValue,
|
|
1158
|
+
) -> Result<(), LixError> {
|
|
1159
|
+
for foreign_key in foreign_key_definitions(schema)? {
|
|
1160
|
+
let Some(local_value) = UniqueConstraintValue::from_snapshot_non_null(
|
|
1161
|
+
snapshot,
|
|
1162
|
+
&foreign_key.local_properties,
|
|
1163
|
+
) else {
|
|
1164
|
+
continue;
|
|
1165
|
+
};
|
|
1166
|
+
let target = if foreign_key.referenced_schema_key == STATE_SURFACE_SCHEMA_KEY {
|
|
1167
|
+
PendingForeignKeyReferenceTarget::StateSurfaceIdentity(
|
|
1168
|
+
state_surface_target_identity(&row.version_id, &foreign_key, snapshot)?,
|
|
1169
|
+
)
|
|
1170
|
+
} else {
|
|
1171
|
+
let target_key = schema_catalog
|
|
1172
|
+
.schema_key_by_key(&foreign_key.referenced_schema_key)
|
|
1173
|
+
.ok_or_else(|| {
|
|
1174
|
+
LixError::new(
|
|
1175
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
1176
|
+
format!(
|
|
1177
|
+
"foreign key on schema '{}' references missing schema '{}'",
|
|
1178
|
+
row.schema_key, foreign_key.referenced_schema_key
|
|
1179
|
+
),
|
|
1180
|
+
)
|
|
1181
|
+
})?;
|
|
1182
|
+
PendingForeignKeyReferenceTarget::Key(PendingForeignKeyTargetKey {
|
|
1183
|
+
schema_key: target_key.schema_key,
|
|
1184
|
+
schema_version: target_key.schema_version,
|
|
1185
|
+
version_id: row.version_id.clone(),
|
|
1186
|
+
file_id: row.file_id.clone(),
|
|
1187
|
+
pointer_group: foreign_key.referenced_properties,
|
|
1188
|
+
value: local_value,
|
|
1189
|
+
})
|
|
1190
|
+
};
|
|
1191
|
+
self.fk_references
|
|
1192
|
+
.entry(target)
|
|
1193
|
+
.or_default()
|
|
1194
|
+
.push(LiveStateRowIdentity {
|
|
1195
|
+
version_id: row.version_id.clone(),
|
|
1196
|
+
schema_key: row.schema_key.clone(),
|
|
1197
|
+
entity_id: row.entity_id.clone(),
|
|
1198
|
+
file_id: row.file_id.clone(),
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
Ok(())
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
fn tombstones_identity(&self, row: &LiveStateRow) -> bool {
|
|
1205
|
+
let identity = LiveStateRowIdentity::from_row(row);
|
|
1206
|
+
self.tombstones
|
|
1207
|
+
.iter()
|
|
1208
|
+
.any(|tombstone| tombstone.identity == identity)
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
fn has_identity_target(&self, identity: &LiveStateRowIdentity) -> bool {
|
|
1212
|
+
self.identity_targets.contains(identity)
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
fn tombstones_target_identity(&self, identity: &LiveStateRowIdentity) -> bool {
|
|
1216
|
+
self.tombstones
|
|
1217
|
+
.iter()
|
|
1218
|
+
.any(|tombstone| tombstone.identity == *identity)
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
fn has_fk_target_key(&self, key: &PendingForeignKeyTargetKey) -> bool {
|
|
1222
|
+
self.fk_targets.contains_key(key)
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
fn active_references_to(
|
|
1226
|
+
&self,
|
|
1227
|
+
target: &PendingForeignKeyReferenceTarget,
|
|
1228
|
+
) -> Vec<&LiveStateRowIdentity> {
|
|
1229
|
+
self.fk_references
|
|
1230
|
+
.get(target)
|
|
1231
|
+
.into_iter()
|
|
1232
|
+
.flat_map(|references| references.iter())
|
|
1233
|
+
.filter(|source_identity| !self.tombstones_target_identity(source_identity))
|
|
1234
|
+
.collect()
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
#[cfg(test)]
|
|
1238
|
+
fn has_fk_reference_to_key(
|
|
1239
|
+
&self,
|
|
1240
|
+
schema_key: &str,
|
|
1241
|
+
schema_version: &str,
|
|
1242
|
+
version_id: &str,
|
|
1243
|
+
file_id: Option<&str>,
|
|
1244
|
+
pointer_group: &[&str],
|
|
1245
|
+
value: UniqueConstraintValue,
|
|
1246
|
+
) -> Result<bool, LixError> {
|
|
1247
|
+
let pointer_group = pointer_group
|
|
1248
|
+
.iter()
|
|
1249
|
+
.map(|pointer| parse_json_pointer(pointer))
|
|
1250
|
+
.collect::<Result<Vec<_>, _>>()?;
|
|
1251
|
+
let key = PendingForeignKeyReferenceTarget::Key(PendingForeignKeyTargetKey {
|
|
1252
|
+
schema_key: schema_key.to_string(),
|
|
1253
|
+
schema_version: schema_version.to_string(),
|
|
1254
|
+
version_id: version_id.to_string(),
|
|
1255
|
+
file_id: file_id.map(str::to_string),
|
|
1256
|
+
pointer_group,
|
|
1257
|
+
value,
|
|
1258
|
+
});
|
|
1259
|
+
Ok(self.fk_references.contains_key(&key))
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
#[cfg(test)]
|
|
1263
|
+
fn has_fk_reference_to_identity(&self, identity: LiveStateRowIdentity) -> bool {
|
|
1264
|
+
self.fk_references
|
|
1265
|
+
.contains_key(&PendingForeignKeyReferenceTarget::StateSurfaceIdentity(
|
|
1266
|
+
identity,
|
|
1267
|
+
))
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
#[cfg(test)]
|
|
1271
|
+
fn has_fk_target(
|
|
1272
|
+
&self,
|
|
1273
|
+
schema_key: &str,
|
|
1274
|
+
schema_version: &str,
|
|
1275
|
+
version_id: &str,
|
|
1276
|
+
file_id: Option<&str>,
|
|
1277
|
+
pointer_group: &[&str],
|
|
1278
|
+
value: UniqueConstraintValue,
|
|
1279
|
+
) -> Result<bool, LixError> {
|
|
1280
|
+
let pointer_group = pointer_group
|
|
1281
|
+
.iter()
|
|
1282
|
+
.map(|pointer| parse_json_pointer(pointer))
|
|
1283
|
+
.collect::<Result<Vec<_>, _>>()?;
|
|
1284
|
+
let key = PendingForeignKeyTargetKey {
|
|
1285
|
+
schema_key: schema_key.to_string(),
|
|
1286
|
+
schema_version: schema_version.to_string(),
|
|
1287
|
+
version_id: version_id.to_string(),
|
|
1288
|
+
file_id: file_id.map(str::to_string),
|
|
1289
|
+
pointer_group,
|
|
1290
|
+
value,
|
|
1291
|
+
};
|
|
1292
|
+
Ok(self.fk_targets.contains_key(&key))
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
1297
|
+
struct PendingTombstone {
|
|
1298
|
+
identity: LiveStateRowIdentity,
|
|
1299
|
+
schema_version: String,
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
|
1303
|
+
struct PendingUniqueKey {
|
|
1304
|
+
schema_key: String,
|
|
1305
|
+
schema_version: String,
|
|
1306
|
+
version_id: String,
|
|
1307
|
+
untracked: bool,
|
|
1308
|
+
file_id: Option<String>,
|
|
1309
|
+
pointer_group: Vec<Vec<String>>,
|
|
1310
|
+
value: UniqueConstraintValue,
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
|
1314
|
+
struct PendingForeignKeyTargetKey {
|
|
1315
|
+
schema_key: String,
|
|
1316
|
+
schema_version: String,
|
|
1317
|
+
version_id: String,
|
|
1318
|
+
file_id: Option<String>,
|
|
1319
|
+
pointer_group: Vec<Vec<String>>,
|
|
1320
|
+
value: UniqueConstraintValue,
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
|
1324
|
+
enum PendingForeignKeyReferenceTarget {
|
|
1325
|
+
Key(PendingForeignKeyTargetKey),
|
|
1326
|
+
StateSurfaceIdentity(LiveStateRowIdentity),
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
fn validate_pending_delete_restrictions(
|
|
1330
|
+
schema_catalog: &TransactionSchemaCatalog,
|
|
1331
|
+
pending_constraints: &PendingConstraintIndexes,
|
|
1332
|
+
) -> Result<(), LixError> {
|
|
1333
|
+
for tombstone in &pending_constraints.tombstones {
|
|
1334
|
+
let identity_target =
|
|
1335
|
+
PendingForeignKeyReferenceTarget::StateSurfaceIdentity(tombstone.identity.clone());
|
|
1336
|
+
reject_pending_delete_references(
|
|
1337
|
+
&tombstone.identity,
|
|
1338
|
+
&identity_target,
|
|
1339
|
+
pending_constraints.active_references_to(&identity_target),
|
|
1340
|
+
)?;
|
|
1341
|
+
|
|
1342
|
+
let Some(schema) =
|
|
1343
|
+
schema_catalog.schema(&tombstone.identity.schema_key, &tombstone.schema_version)
|
|
1344
|
+
else {
|
|
1345
|
+
continue;
|
|
1346
|
+
};
|
|
1347
|
+
for primary_key_paths in pointer_groups(schema, "x-lix-primary-key")? {
|
|
1348
|
+
let target = PendingForeignKeyReferenceTarget::Key(PendingForeignKeyTargetKey {
|
|
1349
|
+
schema_key: tombstone.identity.schema_key.clone(),
|
|
1350
|
+
schema_version: tombstone.schema_version.clone(),
|
|
1351
|
+
version_id: tombstone.identity.version_id.clone(),
|
|
1352
|
+
file_id: tombstone.identity.file_id.clone(),
|
|
1353
|
+
pointer_group: primary_key_paths,
|
|
1354
|
+
value: UniqueConstraintValue::from_entity_identity(&tombstone.identity.entity_id),
|
|
1355
|
+
});
|
|
1356
|
+
reject_pending_delete_references(
|
|
1357
|
+
&tombstone.identity,
|
|
1358
|
+
&target,
|
|
1359
|
+
pending_constraints.active_references_to(&target),
|
|
1360
|
+
)?;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
Ok(())
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
fn reject_pending_delete_references(
|
|
1367
|
+
deleted_identity: &LiveStateRowIdentity,
|
|
1368
|
+
target: &PendingForeignKeyReferenceTarget,
|
|
1369
|
+
references: Vec<&LiveStateRowIdentity>,
|
|
1370
|
+
) -> Result<(), LixError> {
|
|
1371
|
+
let Some(reference) = references.first() else {
|
|
1372
|
+
return Ok(());
|
|
1373
|
+
};
|
|
1374
|
+
Err(LixError::new(
|
|
1375
|
+
LixError::CODE_FOREIGN_KEY,
|
|
1376
|
+
format!(
|
|
1377
|
+
"cannot delete '{}' row '{}' in version '{}' because pending row '{}' references it{}",
|
|
1378
|
+
deleted_identity.schema_key,
|
|
1379
|
+
deleted_identity.entity_id.as_string()?,
|
|
1380
|
+
deleted_identity.version_id,
|
|
1381
|
+
reference.entity_id.as_string()?,
|
|
1382
|
+
pending_foreign_key_reference_target_description(target)?
|
|
1383
|
+
),
|
|
1384
|
+
))
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
fn pending_foreign_key_reference_target_description(
|
|
1388
|
+
target: &PendingForeignKeyReferenceTarget,
|
|
1389
|
+
) -> Result<String, LixError> {
|
|
1390
|
+
match target {
|
|
1391
|
+
PendingForeignKeyReferenceTarget::Key(target) => Ok(format!(
|
|
1392
|
+
" through '{}.{}' value {}",
|
|
1393
|
+
target.schema_key,
|
|
1394
|
+
format_pointer_group(&target.pointer_group),
|
|
1395
|
+
target.value.display()
|
|
1396
|
+
)),
|
|
1397
|
+
PendingForeignKeyReferenceTarget::StateSurfaceIdentity(target) => Ok(format!(
|
|
1398
|
+
" through '{}:{}'",
|
|
1399
|
+
target.schema_key,
|
|
1400
|
+
target.entity_id.as_string()?
|
|
1401
|
+
)),
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
async fn validate_committed_delete_restrictions(
|
|
1406
|
+
input: &TransactionValidationInput<'_>,
|
|
1407
|
+
schema_catalog: &TransactionSchemaCatalog,
|
|
1408
|
+
pending_constraints: &PendingConstraintIndexes,
|
|
1409
|
+
) -> Result<(), LixError> {
|
|
1410
|
+
for tombstone in &pending_constraints.tombstones {
|
|
1411
|
+
for (source_key, source_schema) in schema_catalog.schemas() {
|
|
1412
|
+
for foreign_key in foreign_key_definitions(source_schema)? {
|
|
1413
|
+
if foreign_key.referenced_schema_key == STATE_SURFACE_SCHEMA_KEY {
|
|
1414
|
+
validate_committed_state_surface_delete_restriction(
|
|
1415
|
+
input.live_state,
|
|
1416
|
+
pending_constraints,
|
|
1417
|
+
tombstone,
|
|
1418
|
+
source_key,
|
|
1419
|
+
&foreign_key,
|
|
1420
|
+
)
|
|
1421
|
+
.await?;
|
|
1422
|
+
} else if foreign_key.referenced_schema_key == tombstone.identity.schema_key {
|
|
1423
|
+
validate_committed_normal_delete_restriction(
|
|
1424
|
+
input.live_state,
|
|
1425
|
+
pending_constraints,
|
|
1426
|
+
tombstone,
|
|
1427
|
+
source_key,
|
|
1428
|
+
&foreign_key,
|
|
1429
|
+
)
|
|
1430
|
+
.await?;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
Ok(())
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
async fn validate_committed_normal_delete_restriction(
|
|
1439
|
+
live_state: &dyn LiveStateReader,
|
|
1440
|
+
pending_constraints: &PendingConstraintIndexes,
|
|
1441
|
+
tombstone: &PendingTombstone,
|
|
1442
|
+
source_key: &SchemaCatalogKey,
|
|
1443
|
+
foreign_key: &ForeignKeyDefinition,
|
|
1444
|
+
) -> Result<(), LixError> {
|
|
1445
|
+
let Some(deleted_value) =
|
|
1446
|
+
committed_deleted_row_value(live_state, tombstone, &foreign_key.referenced_properties)
|
|
1447
|
+
.await?
|
|
1448
|
+
else {
|
|
1449
|
+
return Ok(());
|
|
1450
|
+
};
|
|
1451
|
+
let rows = live_state
|
|
1452
|
+
.scan_rows(&LiveStateScanRequest {
|
|
1453
|
+
filter: LiveStateFilter {
|
|
1454
|
+
schema_keys: vec![source_key.schema_key.clone()],
|
|
1455
|
+
version_ids: vec![tombstone.identity.version_id.clone()],
|
|
1456
|
+
file_ids: vec![nullable_filter_from_option(&tombstone.identity.file_id)],
|
|
1457
|
+
include_tombstones: false,
|
|
1458
|
+
..Default::default()
|
|
1459
|
+
},
|
|
1460
|
+
..Default::default()
|
|
1461
|
+
})
|
|
1462
|
+
.await?;
|
|
1463
|
+
|
|
1464
|
+
for row in rows {
|
|
1465
|
+
if !committed_row_is_exact_version_scoped(&row, &tombstone.identity.version_id) {
|
|
1466
|
+
continue;
|
|
1467
|
+
}
|
|
1468
|
+
if row.schema_version != source_key.schema_version
|
|
1469
|
+
|| row.file_id != tombstone.identity.file_id
|
|
1470
|
+
|| pending_constraints.tombstones_identity(&row)
|
|
1471
|
+
{
|
|
1472
|
+
continue;
|
|
1473
|
+
}
|
|
1474
|
+
let Some(snapshot_content) = row.snapshot_content.as_deref() else {
|
|
1475
|
+
continue;
|
|
1476
|
+
};
|
|
1477
|
+
let snapshot = parse_committed_snapshot(&row, snapshot_content)?;
|
|
1478
|
+
if UniqueConstraintValue::from_snapshot_non_null(&snapshot, &foreign_key.local_properties)
|
|
1479
|
+
.as_ref()
|
|
1480
|
+
== Some(&deleted_value)
|
|
1481
|
+
{
|
|
1482
|
+
return Err(committed_delete_restriction_error(
|
|
1483
|
+
&tombstone.identity,
|
|
1484
|
+
&row,
|
|
1485
|
+
&foreign_key.local_properties,
|
|
1486
|
+
)?);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
Ok(())
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
async fn validate_committed_state_surface_delete_restriction(
|
|
1493
|
+
live_state: &dyn LiveStateReader,
|
|
1494
|
+
pending_constraints: &PendingConstraintIndexes,
|
|
1495
|
+
tombstone: &PendingTombstone,
|
|
1496
|
+
source_key: &SchemaCatalogKey,
|
|
1497
|
+
foreign_key: &ForeignKeyDefinition,
|
|
1498
|
+
) -> Result<(), LixError> {
|
|
1499
|
+
let rows = live_state
|
|
1500
|
+
.scan_rows(&LiveStateScanRequest {
|
|
1501
|
+
filter: LiveStateFilter {
|
|
1502
|
+
schema_keys: vec![source_key.schema_key.clone()],
|
|
1503
|
+
version_ids: vec![tombstone.identity.version_id.clone()],
|
|
1504
|
+
file_ids: vec![nullable_filter_from_option(&tombstone.identity.file_id)],
|
|
1505
|
+
include_tombstones: false,
|
|
1506
|
+
..Default::default()
|
|
1507
|
+
},
|
|
1508
|
+
..Default::default()
|
|
1509
|
+
})
|
|
1510
|
+
.await?;
|
|
1511
|
+
|
|
1512
|
+
for row in rows {
|
|
1513
|
+
if !committed_row_is_exact_version_scoped(&row, &tombstone.identity.version_id) {
|
|
1514
|
+
continue;
|
|
1515
|
+
}
|
|
1516
|
+
if row.schema_version != source_key.schema_version
|
|
1517
|
+
|| row.file_id != tombstone.identity.file_id
|
|
1518
|
+
|| pending_constraints.tombstones_identity(&row)
|
|
1519
|
+
{
|
|
1520
|
+
continue;
|
|
1521
|
+
}
|
|
1522
|
+
let Some(snapshot_content) = row.snapshot_content.as_deref() else {
|
|
1523
|
+
continue;
|
|
1524
|
+
};
|
|
1525
|
+
let snapshot = parse_committed_snapshot(&row, snapshot_content)?;
|
|
1526
|
+
if state_surface_target_identity(&row.version_id, foreign_key, &snapshot)?
|
|
1527
|
+
== tombstone.identity
|
|
1528
|
+
{
|
|
1529
|
+
return Err(committed_delete_restriction_error(
|
|
1530
|
+
&tombstone.identity,
|
|
1531
|
+
&row,
|
|
1532
|
+
&foreign_key.local_properties,
|
|
1533
|
+
)?);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
Ok(())
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
async fn committed_deleted_row_value(
|
|
1540
|
+
live_state: &dyn LiveStateReader,
|
|
1541
|
+
tombstone: &PendingTombstone,
|
|
1542
|
+
referenced_properties: &[Vec<String>],
|
|
1543
|
+
) -> Result<Option<UniqueConstraintValue>, LixError> {
|
|
1544
|
+
let Some(row) = live_state
|
|
1545
|
+
.load_row(&LiveStateRowRequest {
|
|
1546
|
+
schema_key: tombstone.identity.schema_key.clone(),
|
|
1547
|
+
version_id: tombstone.identity.version_id.clone(),
|
|
1548
|
+
entity_id: tombstone.identity.entity_id.clone(),
|
|
1549
|
+
file_id: nullable_filter_from_option(&tombstone.identity.file_id),
|
|
1550
|
+
})
|
|
1551
|
+
.await?
|
|
1552
|
+
else {
|
|
1553
|
+
return Ok(None);
|
|
1554
|
+
};
|
|
1555
|
+
if !committed_row_is_exact_version_scoped(&row, &tombstone.identity.version_id)
|
|
1556
|
+
|| row.schema_version != tombstone.schema_version
|
|
1557
|
+
|| row.file_id != tombstone.identity.file_id
|
|
1558
|
+
{
|
|
1559
|
+
return Ok(None);
|
|
1560
|
+
}
|
|
1561
|
+
let Some(snapshot_content) = row.snapshot_content.as_deref() else {
|
|
1562
|
+
return Ok(None);
|
|
1563
|
+
};
|
|
1564
|
+
let snapshot = parse_committed_snapshot(&row, snapshot_content)?;
|
|
1565
|
+
Ok(UniqueConstraintValue::from_snapshot(
|
|
1566
|
+
&snapshot,
|
|
1567
|
+
referenced_properties,
|
|
1568
|
+
))
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
fn committed_delete_restriction_error(
|
|
1572
|
+
deleted_identity: &LiveStateRowIdentity,
|
|
1573
|
+
referencing_row: &LiveStateRow,
|
|
1574
|
+
local_properties: &[Vec<String>],
|
|
1575
|
+
) -> Result<LixError, LixError> {
|
|
1576
|
+
Ok(LixError::new(
|
|
1577
|
+
LixError::CODE_FOREIGN_KEY,
|
|
1578
|
+
format!(
|
|
1579
|
+
"cannot delete '{}' row '{}' in version '{}' because committed row '{}' references it through {}",
|
|
1580
|
+
deleted_identity.schema_key,
|
|
1581
|
+
deleted_identity.entity_id.as_string()?,
|
|
1582
|
+
deleted_identity.version_id,
|
|
1583
|
+
referencing_row.entity_id.as_string()?,
|
|
1584
|
+
format_pointer_group(local_properties)
|
|
1585
|
+
),
|
|
1586
|
+
))
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
fn parse_committed_snapshot(
|
|
1590
|
+
row: &LiveStateRow,
|
|
1591
|
+
snapshot_content: &str,
|
|
1592
|
+
) -> Result<JsonValue, LixError> {
|
|
1593
|
+
serde_json::from_str::<JsonValue>(snapshot_content).map_err(|error| {
|
|
1594
|
+
LixError::new(
|
|
1595
|
+
LixError::CODE_SCHEMA_VALIDATION,
|
|
1596
|
+
format!(
|
|
1597
|
+
"committed snapshot_content for schema '{}' version '{}' is invalid JSON: {error}",
|
|
1598
|
+
row.schema_key, row.schema_version
|
|
1599
|
+
),
|
|
1600
|
+
)
|
|
1601
|
+
})
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
1605
|
+
struct UnresolvedForeignKeyCheck {
|
|
1606
|
+
source_identity: LiveStateRowIdentity,
|
|
1607
|
+
source_schema_key: String,
|
|
1608
|
+
source_pointer_group: Vec<Vec<String>>,
|
|
1609
|
+
target: UnresolvedForeignKeyTarget,
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
1613
|
+
enum UnresolvedForeignKeyTarget {
|
|
1614
|
+
Key(PendingForeignKeyTargetKey),
|
|
1615
|
+
StateSurfaceIdentity(LiveStateRowIdentity),
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
fn validate_pending_foreign_keys(
|
|
1619
|
+
schema_catalog: &TransactionSchemaCatalog,
|
|
1620
|
+
pending_constraints: &PendingConstraintIndexes,
|
|
1621
|
+
staged_snapshots: &[(&StagedStateRow, &JsonValue, JsonValue)],
|
|
1622
|
+
) -> Result<Vec<UnresolvedForeignKeyCheck>, LixError> {
|
|
1623
|
+
let mut unresolved = Vec::new();
|
|
1624
|
+
for (row, schema, snapshot) in staged_snapshots {
|
|
1625
|
+
for foreign_key in foreign_key_definitions(schema)? {
|
|
1626
|
+
let Some(local_value) = UniqueConstraintValue::from_snapshot_non_null(
|
|
1627
|
+
snapshot,
|
|
1628
|
+
&foreign_key.local_properties,
|
|
1629
|
+
) else {
|
|
1630
|
+
continue;
|
|
1631
|
+
};
|
|
1632
|
+
if foreign_key.referenced_schema_key == STATE_SURFACE_SCHEMA_KEY {
|
|
1633
|
+
if let Some(check) = validate_pending_state_surface_foreign_key(
|
|
1634
|
+
row,
|
|
1635
|
+
&foreign_key,
|
|
1636
|
+
snapshot,
|
|
1637
|
+
pending_constraints,
|
|
1638
|
+
)? {
|
|
1639
|
+
unresolved.push(check);
|
|
1640
|
+
}
|
|
1641
|
+
} else {
|
|
1642
|
+
if let Some(check) = validate_pending_normal_foreign_key(
|
|
1643
|
+
schema_catalog,
|
|
1644
|
+
row,
|
|
1645
|
+
&foreign_key,
|
|
1646
|
+
local_value,
|
|
1647
|
+
pending_constraints,
|
|
1648
|
+
)? {
|
|
1649
|
+
unresolved.push(check);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
Ok(unresolved)
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
fn validate_pending_normal_foreign_key(
|
|
1658
|
+
schema_catalog: &TransactionSchemaCatalog,
|
|
1659
|
+
row: &StagedStateRow,
|
|
1660
|
+
foreign_key: &ForeignKeyDefinition,
|
|
1661
|
+
local_value: UniqueConstraintValue,
|
|
1662
|
+
pending_constraints: &PendingConstraintIndexes,
|
|
1663
|
+
) -> Result<Option<UnresolvedForeignKeyCheck>, LixError> {
|
|
1664
|
+
let target_key = schema_catalog
|
|
1665
|
+
.schema_key_by_key(&foreign_key.referenced_schema_key)
|
|
1666
|
+
.ok_or_else(|| {
|
|
1667
|
+
LixError::new(
|
|
1668
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
1669
|
+
format!(
|
|
1670
|
+
"foreign key on schema '{}' references missing schema '{}'",
|
|
1671
|
+
row.schema_key, foreign_key.referenced_schema_key
|
|
1672
|
+
),
|
|
1673
|
+
)
|
|
1674
|
+
})?;
|
|
1675
|
+
let key = PendingForeignKeyTargetKey {
|
|
1676
|
+
schema_key: target_key.schema_key,
|
|
1677
|
+
schema_version: target_key.schema_version,
|
|
1678
|
+
version_id: row.version_id.clone(),
|
|
1679
|
+
file_id: row.file_id.clone(),
|
|
1680
|
+
pointer_group: foreign_key.referenced_properties.clone(),
|
|
1681
|
+
value: local_value,
|
|
1682
|
+
};
|
|
1683
|
+
if pending_constraints.has_fk_target_key(&key) {
|
|
1684
|
+
return Ok(None);
|
|
1685
|
+
}
|
|
1686
|
+
Ok(Some(UnresolvedForeignKeyCheck {
|
|
1687
|
+
source_identity: LiveStateRowIdentity {
|
|
1688
|
+
version_id: row.version_id.clone(),
|
|
1689
|
+
schema_key: row.schema_key.clone(),
|
|
1690
|
+
entity_id: row.entity_id.clone(),
|
|
1691
|
+
file_id: row.file_id.clone(),
|
|
1692
|
+
},
|
|
1693
|
+
source_schema_key: row.schema_key.clone(),
|
|
1694
|
+
source_pointer_group: foreign_key.local_properties.clone(),
|
|
1695
|
+
target: UnresolvedForeignKeyTarget::Key(key),
|
|
1696
|
+
}))
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
fn validate_pending_state_surface_foreign_key(
|
|
1700
|
+
row: &StagedStateRow,
|
|
1701
|
+
foreign_key: &ForeignKeyDefinition,
|
|
1702
|
+
snapshot: &JsonValue,
|
|
1703
|
+
pending_constraints: &PendingConstraintIndexes,
|
|
1704
|
+
) -> Result<Option<UnresolvedForeignKeyCheck>, LixError> {
|
|
1705
|
+
let target_identity = state_surface_target_identity(&row.version_id, foreign_key, snapshot)?;
|
|
1706
|
+
if pending_constraints.tombstones_target_identity(&target_identity) {
|
|
1707
|
+
return Err(LixError::new(
|
|
1708
|
+
LixError::CODE_FOREIGN_KEY,
|
|
1709
|
+
format!(
|
|
1710
|
+
"foreign key on {}.{} references target deleted in this transaction",
|
|
1711
|
+
row.schema_key,
|
|
1712
|
+
format_pointer_group(&foreign_key.local_properties)
|
|
1713
|
+
),
|
|
1714
|
+
));
|
|
1715
|
+
}
|
|
1716
|
+
if pending_constraints.has_identity_target(&target_identity) {
|
|
1717
|
+
return Ok(None);
|
|
1718
|
+
}
|
|
1719
|
+
Ok(Some(UnresolvedForeignKeyCheck {
|
|
1720
|
+
source_identity: LiveStateRowIdentity {
|
|
1721
|
+
version_id: row.version_id.clone(),
|
|
1722
|
+
schema_key: row.schema_key.clone(),
|
|
1723
|
+
entity_id: row.entity_id.clone(),
|
|
1724
|
+
file_id: row.file_id.clone(),
|
|
1725
|
+
},
|
|
1726
|
+
source_schema_key: row.schema_key.clone(),
|
|
1727
|
+
source_pointer_group: foreign_key.local_properties.clone(),
|
|
1728
|
+
target: UnresolvedForeignKeyTarget::StateSurfaceIdentity(target_identity),
|
|
1729
|
+
}))
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
async fn validate_committed_foreign_keys(
|
|
1733
|
+
input: &TransactionValidationInput<'_>,
|
|
1734
|
+
pending_constraints: &PendingConstraintIndexes,
|
|
1735
|
+
unresolved_checks: &[UnresolvedForeignKeyCheck],
|
|
1736
|
+
) -> Result<Vec<UnresolvedForeignKeyCheck>, LixError> {
|
|
1737
|
+
let mut still_unresolved = Vec::new();
|
|
1738
|
+
for check in unresolved_checks {
|
|
1739
|
+
let resolved = match &check.target {
|
|
1740
|
+
UnresolvedForeignKeyTarget::Key(target) => {
|
|
1741
|
+
committed_normal_foreign_key_target_exists(
|
|
1742
|
+
input.live_state,
|
|
1743
|
+
pending_constraints,
|
|
1744
|
+
target,
|
|
1745
|
+
)
|
|
1746
|
+
.await?
|
|
1747
|
+
}
|
|
1748
|
+
UnresolvedForeignKeyTarget::StateSurfaceIdentity(target_identity) => {
|
|
1749
|
+
committed_state_surface_foreign_key_target_exists(
|
|
1750
|
+
input.live_state,
|
|
1751
|
+
pending_constraints,
|
|
1752
|
+
target_identity,
|
|
1753
|
+
)
|
|
1754
|
+
.await?
|
|
1755
|
+
}
|
|
1756
|
+
};
|
|
1757
|
+
if !resolved {
|
|
1758
|
+
still_unresolved.push(check.clone());
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
Ok(still_unresolved)
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
fn reject_unresolved_foreign_keys(
|
|
1765
|
+
unresolved_checks: &[UnresolvedForeignKeyCheck],
|
|
1766
|
+
) -> Result<(), LixError> {
|
|
1767
|
+
let Some(check) = unresolved_checks.first() else {
|
|
1768
|
+
return Ok(());
|
|
1769
|
+
};
|
|
1770
|
+
Err(LixError::new(
|
|
1771
|
+
LixError::CODE_FOREIGN_KEY,
|
|
1772
|
+
format!(
|
|
1773
|
+
"foreign key on schema '{}' row '{}' via {} has no matching target in version '{}'{}",
|
|
1774
|
+
check.source_schema_key,
|
|
1775
|
+
check.source_identity.entity_id.as_string()?,
|
|
1776
|
+
format_pointer_group(&check.source_pointer_group),
|
|
1777
|
+
check.source_identity.version_id,
|
|
1778
|
+
unresolved_foreign_key_target_description(&check.target)?
|
|
1779
|
+
),
|
|
1780
|
+
))
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
fn unresolved_foreign_key_target_description(
|
|
1784
|
+
target: &UnresolvedForeignKeyTarget,
|
|
1785
|
+
) -> Result<String, LixError> {
|
|
1786
|
+
match target {
|
|
1787
|
+
UnresolvedForeignKeyTarget::Key(target) => Ok(format!(
|
|
1788
|
+
" for target '{}.{}' value {}",
|
|
1789
|
+
target.schema_key,
|
|
1790
|
+
format_pointer_group(&target.pointer_group),
|
|
1791
|
+
target.value.display()
|
|
1792
|
+
)),
|
|
1793
|
+
UnresolvedForeignKeyTarget::StateSurfaceIdentity(target) => Ok(format!(
|
|
1794
|
+
" for target '{}:{}'",
|
|
1795
|
+
target.schema_key,
|
|
1796
|
+
target.entity_id.as_string()?
|
|
1797
|
+
)),
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
async fn committed_normal_foreign_key_target_exists(
|
|
1802
|
+
live_state: &dyn LiveStateReader,
|
|
1803
|
+
pending_constraints: &PendingConstraintIndexes,
|
|
1804
|
+
target: &PendingForeignKeyTargetKey,
|
|
1805
|
+
) -> Result<bool, LixError> {
|
|
1806
|
+
let rows = live_state
|
|
1807
|
+
.scan_rows(&LiveStateScanRequest {
|
|
1808
|
+
filter: LiveStateFilter {
|
|
1809
|
+
schema_keys: vec![target.schema_key.clone()],
|
|
1810
|
+
version_ids: vec![target.version_id.clone()],
|
|
1811
|
+
file_ids: vec![nullable_filter_from_option(&target.file_id)],
|
|
1812
|
+
include_tombstones: false,
|
|
1813
|
+
..Default::default()
|
|
1814
|
+
},
|
|
1815
|
+
..Default::default()
|
|
1816
|
+
})
|
|
1817
|
+
.await?;
|
|
1818
|
+
|
|
1819
|
+
for row in rows {
|
|
1820
|
+
if !committed_row_is_exact_version_scoped(&row, &target.version_id) {
|
|
1821
|
+
continue;
|
|
1822
|
+
}
|
|
1823
|
+
if pending_constraints.tombstones_identity(&row) {
|
|
1824
|
+
continue;
|
|
1825
|
+
}
|
|
1826
|
+
if row.schema_key != target.schema_key
|
|
1827
|
+
|| row.schema_version != target.schema_version
|
|
1828
|
+
|| row.file_id != target.file_id
|
|
1829
|
+
{
|
|
1830
|
+
continue;
|
|
1831
|
+
}
|
|
1832
|
+
let Some(snapshot_content) = row.snapshot_content.as_deref() else {
|
|
1833
|
+
continue;
|
|
1834
|
+
};
|
|
1835
|
+
let snapshot = serde_json::from_str::<JsonValue>(snapshot_content).map_err(|error| {
|
|
1836
|
+
LixError::new(
|
|
1837
|
+
LixError::CODE_SCHEMA_VALIDATION,
|
|
1838
|
+
format!(
|
|
1839
|
+
"committed snapshot_content for schema '{}' version '{}' is invalid JSON: {error}",
|
|
1840
|
+
row.schema_key, row.schema_version
|
|
1841
|
+
),
|
|
1842
|
+
)
|
|
1843
|
+
})?;
|
|
1844
|
+
if UniqueConstraintValue::from_snapshot(&snapshot, &target.pointer_group).as_ref()
|
|
1845
|
+
== Some(&target.value)
|
|
1846
|
+
{
|
|
1847
|
+
return Ok(true);
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
Ok(false)
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
async fn committed_state_surface_foreign_key_target_exists(
|
|
1854
|
+
live_state: &dyn LiveStateReader,
|
|
1855
|
+
pending_constraints: &PendingConstraintIndexes,
|
|
1856
|
+
target_identity: &LiveStateRowIdentity,
|
|
1857
|
+
) -> Result<bool, LixError> {
|
|
1858
|
+
let Some(row) = live_state
|
|
1859
|
+
.load_row(&LiveStateRowRequest {
|
|
1860
|
+
schema_key: target_identity.schema_key.clone(),
|
|
1861
|
+
version_id: target_identity.version_id.clone(),
|
|
1862
|
+
entity_id: target_identity.entity_id.clone(),
|
|
1863
|
+
file_id: nullable_filter_from_option(&target_identity.file_id),
|
|
1864
|
+
})
|
|
1865
|
+
.await?
|
|
1866
|
+
else {
|
|
1867
|
+
return Ok(false);
|
|
1868
|
+
};
|
|
1869
|
+
if pending_constraints.tombstones_identity(&row) {
|
|
1870
|
+
return Ok(false);
|
|
1871
|
+
}
|
|
1872
|
+
Ok(
|
|
1873
|
+
committed_row_is_exact_version_scoped(&row, &target_identity.version_id)
|
|
1874
|
+
&& row.schema_key == target_identity.schema_key
|
|
1875
|
+
&& row.entity_id == target_identity.entity_id
|
|
1876
|
+
&& row.file_id == target_identity.file_id,
|
|
1877
|
+
)
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
fn state_surface_target_identity(
|
|
1881
|
+
version_id: &str,
|
|
1882
|
+
foreign_key: &ForeignKeyDefinition,
|
|
1883
|
+
snapshot: &JsonValue,
|
|
1884
|
+
) -> Result<LiveStateRowIdentity, LixError> {
|
|
1885
|
+
let entity_id =
|
|
1886
|
+
state_surface_local_value_for_referenced_pointer(foreign_key, snapshot, "/entity_id")?;
|
|
1887
|
+
let schema_key =
|
|
1888
|
+
state_surface_local_value_for_referenced_pointer(foreign_key, snapshot, "/schema_key")?;
|
|
1889
|
+
let file_id = state_surface_optional_local_value_for_referenced_pointer(
|
|
1890
|
+
foreign_key,
|
|
1891
|
+
snapshot,
|
|
1892
|
+
"/file_id",
|
|
1893
|
+
)?;
|
|
1894
|
+
Ok(LiveStateRowIdentity {
|
|
1895
|
+
version_id: version_id.to_string(),
|
|
1896
|
+
schema_key,
|
|
1897
|
+
entity_id: EntityIdentity::from_string(&entity_id).map_err(|error| {
|
|
1898
|
+
LixError::new(
|
|
1899
|
+
LixError::CODE_FOREIGN_KEY,
|
|
1900
|
+
format!("state-surface foreign key entity_id is invalid: {error}"),
|
|
1901
|
+
)
|
|
1902
|
+
})?,
|
|
1903
|
+
file_id,
|
|
1904
|
+
})
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
fn state_surface_local_value_for_referenced_pointer(
|
|
1908
|
+
foreign_key: &ForeignKeyDefinition,
|
|
1909
|
+
snapshot: &JsonValue,
|
|
1910
|
+
referenced_pointer: &str,
|
|
1911
|
+
) -> Result<String, LixError> {
|
|
1912
|
+
state_surface_optional_local_value_for_referenced_pointer(
|
|
1913
|
+
foreign_key,
|
|
1914
|
+
snapshot,
|
|
1915
|
+
referenced_pointer,
|
|
1916
|
+
)?
|
|
1917
|
+
.ok_or_else(|| {
|
|
1918
|
+
LixError::new(
|
|
1919
|
+
LixError::CODE_FOREIGN_KEY,
|
|
1920
|
+
format!("state-surface foreign key target '{referenced_pointer}' is missing"),
|
|
1921
|
+
)
|
|
1922
|
+
})
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
fn state_surface_optional_local_value_for_referenced_pointer(
|
|
1926
|
+
foreign_key: &ForeignKeyDefinition,
|
|
1927
|
+
snapshot: &JsonValue,
|
|
1928
|
+
referenced_pointer: &str,
|
|
1929
|
+
) -> Result<Option<String>, LixError> {
|
|
1930
|
+
let referenced_pointer = parse_json_pointer(referenced_pointer)?;
|
|
1931
|
+
let Some(index) = foreign_key
|
|
1932
|
+
.referenced_properties
|
|
1933
|
+
.iter()
|
|
1934
|
+
.position(|pointer| pointer == &referenced_pointer)
|
|
1935
|
+
else {
|
|
1936
|
+
return Ok(None);
|
|
1937
|
+
};
|
|
1938
|
+
let local_pointer = &foreign_key.local_properties[index];
|
|
1939
|
+
let Some(value) = json_pointer_get(snapshot, local_pointer) else {
|
|
1940
|
+
return Ok(None);
|
|
1941
|
+
};
|
|
1942
|
+
if value.is_null() {
|
|
1943
|
+
return Ok(None);
|
|
1944
|
+
}
|
|
1945
|
+
value
|
|
1946
|
+
.as_str()
|
|
1947
|
+
.map(|value| Some(value.to_string()))
|
|
1948
|
+
.ok_or_else(|| {
|
|
1949
|
+
LixError::new(
|
|
1950
|
+
LixError::CODE_FOREIGN_KEY,
|
|
1951
|
+
format!(
|
|
1952
|
+
"state-surface foreign key value at '{}' must be a string",
|
|
1953
|
+
format_json_pointer(local_pointer)
|
|
1954
|
+
),
|
|
1955
|
+
)
|
|
1956
|
+
})
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
async fn validate_committed_unique_constraints(
|
|
1960
|
+
input: &TransactionValidationInput<'_>,
|
|
1961
|
+
pending_constraints: &PendingConstraintIndexes,
|
|
1962
|
+
) -> Result<(), LixError> {
|
|
1963
|
+
for (key, pending_entity_id) in &pending_constraints.unique_values {
|
|
1964
|
+
let committed_rows = input
|
|
1965
|
+
.live_state
|
|
1966
|
+
.scan_rows(&LiveStateScanRequest {
|
|
1967
|
+
filter: LiveStateFilter {
|
|
1968
|
+
schema_keys: vec![key.schema_key.clone()],
|
|
1969
|
+
version_ids: vec![key.version_id.clone()],
|
|
1970
|
+
file_ids: vec![nullable_filter_from_option(&key.file_id)],
|
|
1971
|
+
include_tombstones: false,
|
|
1972
|
+
..Default::default()
|
|
1973
|
+
},
|
|
1974
|
+
..Default::default()
|
|
1975
|
+
})
|
|
1976
|
+
.await?;
|
|
1977
|
+
|
|
1978
|
+
for committed_row in committed_rows {
|
|
1979
|
+
if !committed_row_is_in_exact_validation_scope(&committed_row, key) {
|
|
1980
|
+
continue;
|
|
1981
|
+
}
|
|
1982
|
+
if committed_row.entity_id == *pending_entity_id {
|
|
1983
|
+
continue;
|
|
1984
|
+
}
|
|
1985
|
+
if pending_constraints.tombstones_identity(&committed_row) {
|
|
1986
|
+
continue;
|
|
1987
|
+
}
|
|
1988
|
+
let Some(snapshot_content) = committed_row.snapshot_content.as_deref() else {
|
|
1989
|
+
continue;
|
|
1990
|
+
};
|
|
1991
|
+
let snapshot = serde_json::from_str::<JsonValue>(snapshot_content).map_err(|error| {
|
|
1992
|
+
LixError::new(
|
|
1993
|
+
LixError::CODE_SCHEMA_VALIDATION,
|
|
1994
|
+
format!(
|
|
1995
|
+
"committed snapshot_content for schema '{}' version '{}' is invalid JSON: {error}",
|
|
1996
|
+
committed_row.schema_key, committed_row.schema_version
|
|
1997
|
+
),
|
|
1998
|
+
)
|
|
1999
|
+
})?;
|
|
2000
|
+
let Some(committed_value) =
|
|
2001
|
+
UniqueConstraintValue::from_snapshot(&snapshot, &key.pointer_group)
|
|
2002
|
+
else {
|
|
2003
|
+
continue;
|
|
2004
|
+
};
|
|
2005
|
+
if committed_value == key.value {
|
|
2006
|
+
return Err(LixError::new(
|
|
2007
|
+
LixError::CODE_UNIQUE,
|
|
2008
|
+
format!(
|
|
2009
|
+
"unique constraint violation on {}.{} for value {}: committed row '{}' conflicts with staged row '{}'",
|
|
2010
|
+
key.schema_key,
|
|
2011
|
+
format_pointer_group(&key.pointer_group),
|
|
2012
|
+
key.value.display(),
|
|
2013
|
+
committed_row.entity_id.as_string()?,
|
|
2014
|
+
pending_entity_id.as_string()?
|
|
2015
|
+
),
|
|
2016
|
+
));
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
Ok(())
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
fn nullable_filter_from_option(value: &Option<String>) -> NullableKeyFilter<String> {
|
|
2024
|
+
match value {
|
|
2025
|
+
Some(value) => NullableKeyFilter::Value(value.clone()),
|
|
2026
|
+
None => NullableKeyFilter::Null,
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
fn committed_row_is_in_exact_validation_scope(row: &LiveStateRow, key: &PendingUniqueKey) -> bool {
|
|
2031
|
+
// LiveStateReader may return serving projections such as global rows
|
|
2032
|
+
// projected into a requested version. Constraint validation is root-local:
|
|
2033
|
+
// only rows authored in the exact version participate.
|
|
2034
|
+
row.version_id == key.version_id
|
|
2035
|
+
&& row.schema_key == key.schema_key
|
|
2036
|
+
&& row.schema_version == key.schema_version
|
|
2037
|
+
&& row.untracked == key.untracked
|
|
2038
|
+
&& row.file_id == key.file_id
|
|
2039
|
+
&& committed_row_is_exact_version_scoped(row, &key.version_id)
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
fn committed_row_is_exact_version_scoped(row: &LiveStateRow, version_id: &str) -> bool {
|
|
2043
|
+
row.version_id == version_id && row.global == (row.version_id == crate::GLOBAL_VERSION_ID)
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
|
2047
|
+
struct UniqueConstraintValue(Vec<String>);
|
|
2048
|
+
|
|
2049
|
+
impl UniqueConstraintValue {
|
|
2050
|
+
#[cfg(test)]
|
|
2051
|
+
fn string_values<const N: usize>(values: [&str; N]) -> Self {
|
|
2052
|
+
Self(
|
|
2053
|
+
values
|
|
2054
|
+
.into_iter()
|
|
2055
|
+
.map(|value| format!("{value:?}"))
|
|
2056
|
+
.collect(),
|
|
2057
|
+
)
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
fn from_entity_identity(identity: &EntityIdentity) -> Self {
|
|
2061
|
+
Self(
|
|
2062
|
+
identity
|
|
2063
|
+
.parts
|
|
2064
|
+
.iter()
|
|
2065
|
+
.map(|part| match part {
|
|
2066
|
+
EntityIdentityPart::String(value) => format!("{value:?}"),
|
|
2067
|
+
EntityIdentityPart::Bool(value) => value.to_string(),
|
|
2068
|
+
EntityIdentityPart::Number(value) => value.clone(),
|
|
2069
|
+
})
|
|
2070
|
+
.collect(),
|
|
2071
|
+
)
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
fn from_snapshot(snapshot: &JsonValue, pointers: &[Vec<String>]) -> Option<Self> {
|
|
2075
|
+
let mut values = Vec::with_capacity(pointers.len());
|
|
2076
|
+
for pointer in pointers {
|
|
2077
|
+
let value = json_pointer_get(snapshot, pointer)?;
|
|
2078
|
+
values.push(stable_unique_value(value));
|
|
2079
|
+
}
|
|
2080
|
+
Some(Self(values))
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
fn from_snapshot_non_null(snapshot: &JsonValue, pointers: &[Vec<String>]) -> Option<Self> {
|
|
2084
|
+
let mut values = Vec::with_capacity(pointers.len());
|
|
2085
|
+
for pointer in pointers {
|
|
2086
|
+
let value = json_pointer_get(snapshot, pointer)?;
|
|
2087
|
+
if value.is_null() {
|
|
2088
|
+
return None;
|
|
2089
|
+
}
|
|
2090
|
+
values.push(stable_unique_value(value));
|
|
2091
|
+
}
|
|
2092
|
+
Some(Self(values))
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
fn display(&self) -> String {
|
|
2096
|
+
if let [value] = self.0.as_slice() {
|
|
2097
|
+
return value.clone();
|
|
2098
|
+
}
|
|
2099
|
+
format!("({})", self.0.join(", "))
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
fn stable_unique_value(value: &JsonValue) -> String {
|
|
2104
|
+
match value {
|
|
2105
|
+
JsonValue::String(value) => format!("{value:?}"),
|
|
2106
|
+
JsonValue::Number(value) => value.to_string(),
|
|
2107
|
+
JsonValue::Bool(value) => value.to_string(),
|
|
2108
|
+
JsonValue::Null => "null".to_string(),
|
|
2109
|
+
JsonValue::Array(_) | JsonValue::Object(_) => value.to_string(),
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
fn pointer_groups(schema: &JsonValue, field: &str) -> Result<Vec<Vec<Vec<String>>>, LixError> {
|
|
2114
|
+
let Some(value) = schema.get(field) else {
|
|
2115
|
+
return Ok(Vec::new());
|
|
2116
|
+
};
|
|
2117
|
+
let groups = if field == "x-lix-primary-key" {
|
|
2118
|
+
vec![value]
|
|
2119
|
+
} else {
|
|
2120
|
+
value
|
|
2121
|
+
.as_array()
|
|
2122
|
+
.map(|groups| groups.iter().collect())
|
|
2123
|
+
.unwrap_or_default()
|
|
2124
|
+
};
|
|
2125
|
+
groups
|
|
2126
|
+
.into_iter()
|
|
2127
|
+
.map(|group| {
|
|
2128
|
+
let group = group.as_array().ok_or_else(|| {
|
|
2129
|
+
LixError::new(
|
|
2130
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2131
|
+
format!("schema {field} must contain arrays of JSON Pointers"),
|
|
2132
|
+
)
|
|
2133
|
+
})?;
|
|
2134
|
+
group
|
|
2135
|
+
.iter()
|
|
2136
|
+
.enumerate()
|
|
2137
|
+
.map(|(index, pointer)| {
|
|
2138
|
+
let pointer = pointer.as_str().ok_or_else(|| {
|
|
2139
|
+
LixError::new(
|
|
2140
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2141
|
+
format!("schema {field} entry at index {index} must be a string"),
|
|
2142
|
+
)
|
|
2143
|
+
})?;
|
|
2144
|
+
parse_json_pointer(pointer)
|
|
2145
|
+
})
|
|
2146
|
+
.collect::<Result<Vec<_>, _>>()
|
|
2147
|
+
})
|
|
2148
|
+
.collect()
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
fn parse_json_pointer(pointer: &str) -> Result<Vec<String>, LixError> {
|
|
2152
|
+
if pointer.is_empty() {
|
|
2153
|
+
return Ok(Vec::new());
|
|
2154
|
+
}
|
|
2155
|
+
if !pointer.starts_with('/') {
|
|
2156
|
+
return Err(LixError::new(
|
|
2157
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2158
|
+
format!("invalid JSON pointer '{pointer}'"),
|
|
2159
|
+
));
|
|
2160
|
+
}
|
|
2161
|
+
pointer[1..]
|
|
2162
|
+
.split('/')
|
|
2163
|
+
.map(unescape_json_pointer_segment)
|
|
2164
|
+
.collect()
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
fn unescape_json_pointer_segment(segment: &str) -> Result<String, LixError> {
|
|
2168
|
+
let mut output = String::new();
|
|
2169
|
+
let mut chars = segment.chars();
|
|
2170
|
+
while let Some(ch) = chars.next() {
|
|
2171
|
+
if ch == '~' {
|
|
2172
|
+
match chars.next() {
|
|
2173
|
+
Some('0') => output.push('~'),
|
|
2174
|
+
Some('1') => output.push('/'),
|
|
2175
|
+
_ => {
|
|
2176
|
+
return Err(LixError::new(
|
|
2177
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2178
|
+
"invalid JSON pointer escape",
|
|
2179
|
+
));
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
} else {
|
|
2183
|
+
output.push(ch);
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
Ok(output)
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
fn format_pointer_group(group: &[Vec<String>]) -> String {
|
|
2190
|
+
let pointers = group
|
|
2191
|
+
.iter()
|
|
2192
|
+
.map(|pointer| format_json_pointer(pointer))
|
|
2193
|
+
.collect::<Vec<_>>();
|
|
2194
|
+
if let [pointer] = pointers.as_slice() {
|
|
2195
|
+
pointer.clone()
|
|
2196
|
+
} else {
|
|
2197
|
+
format!("({})", pointers.join(", "))
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
fn format_json_pointer(pointer: &[String]) -> String {
|
|
2202
|
+
if pointer.is_empty() {
|
|
2203
|
+
return String::new();
|
|
2204
|
+
}
|
|
2205
|
+
format!(
|
|
2206
|
+
"/{}",
|
|
2207
|
+
pointer
|
|
2208
|
+
.iter()
|
|
2209
|
+
.map(|segment| segment.replace('~', "~0").replace('/', "~1"))
|
|
2210
|
+
.collect::<Vec<_>>()
|
|
2211
|
+
.join("/")
|
|
2212
|
+
)
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
fn primary_key_identity_error(
|
|
2216
|
+
row: &StagedStateRow,
|
|
2217
|
+
primary_key_paths: &[Vec<String>],
|
|
2218
|
+
error: EntityIdentityError,
|
|
2219
|
+
) -> LixError {
|
|
2220
|
+
let reason = match error {
|
|
2221
|
+
EntityIdentityError::EmptyPrimaryKey => "empty x-lix-primary-key".to_string(),
|
|
2222
|
+
EntityIdentityError::EmptyPrimaryKeyPath { index } => {
|
|
2223
|
+
format!("empty x-lix-primary-key pointer at index {index}")
|
|
2224
|
+
}
|
|
2225
|
+
EntityIdentityError::MissingPrimaryKeyValue { index } => {
|
|
2226
|
+
let pointer = format_json_pointer(&primary_key_paths[index]);
|
|
2227
|
+
format!("missing value at primary-key pointer '{pointer}'")
|
|
2228
|
+
}
|
|
2229
|
+
EntityIdentityError::NullPrimaryKeyValue { index } => {
|
|
2230
|
+
let pointer = format_json_pointer(&primary_key_paths[index]);
|
|
2231
|
+
format!("null value at primary-key pointer '{pointer}'")
|
|
2232
|
+
}
|
|
2233
|
+
EntityIdentityError::EmptyPrimaryKeyValue { index } => {
|
|
2234
|
+
let pointer = format_json_pointer(&primary_key_paths[index]);
|
|
2235
|
+
format!("empty value at primary-key pointer '{pointer}'")
|
|
2236
|
+
}
|
|
2237
|
+
EntityIdentityError::UnsupportedPrimaryKeyValue { index } => {
|
|
2238
|
+
let pointer = format_json_pointer(&primary_key_paths[index]);
|
|
2239
|
+
format!("unsupported non-scalar value at primary-key pointer '{pointer}'")
|
|
2240
|
+
}
|
|
2241
|
+
EntityIdentityError::InvalidEncodedEntityIdentity => {
|
|
2242
|
+
"invalid encoded entity identity".to_string()
|
|
2243
|
+
}
|
|
2244
|
+
};
|
|
2245
|
+
LixError::new(
|
|
2246
|
+
LixError::CODE_UNIQUE,
|
|
2247
|
+
format!(
|
|
2248
|
+
"primary-key constraint violation on schema '{}' version '{}': {reason}",
|
|
2249
|
+
row.schema_key, row.schema_version
|
|
2250
|
+
),
|
|
2251
|
+
)
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
#[derive(Debug, Clone)]
|
|
2255
|
+
struct ForeignKeyDefinition {
|
|
2256
|
+
local_properties: Vec<Vec<String>>,
|
|
2257
|
+
referenced_schema_key: String,
|
|
2258
|
+
referenced_properties: Vec<Vec<String>>,
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
fn foreign_key_definitions(schema: &JsonValue) -> Result<Vec<ForeignKeyDefinition>, LixError> {
|
|
2262
|
+
let Some(value) = schema.get("x-lix-foreign-keys") else {
|
|
2263
|
+
return Ok(Vec::new());
|
|
2264
|
+
};
|
|
2265
|
+
let Some(foreign_keys) = value.as_array() else {
|
|
2266
|
+
return Err(LixError::new(
|
|
2267
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2268
|
+
"schema x-lix-foreign-keys must be an array",
|
|
2269
|
+
));
|
|
2270
|
+
};
|
|
2271
|
+
|
|
2272
|
+
foreign_keys
|
|
2273
|
+
.iter()
|
|
2274
|
+
.enumerate()
|
|
2275
|
+
.map(|(index, foreign_key)| {
|
|
2276
|
+
let object = foreign_key.as_object().ok_or_else(|| {
|
|
2277
|
+
LixError::new(
|
|
2278
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2279
|
+
format!("x-lix-foreign-keys[{index}] must be an object"),
|
|
2280
|
+
)
|
|
2281
|
+
})?;
|
|
2282
|
+
let references = object
|
|
2283
|
+
.get("references")
|
|
2284
|
+
.and_then(JsonValue::as_object)
|
|
2285
|
+
.ok_or_else(|| {
|
|
2286
|
+
LixError::new(
|
|
2287
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2288
|
+
format!("x-lix-foreign-keys[{index}].references must be an object"),
|
|
2289
|
+
)
|
|
2290
|
+
})?;
|
|
2291
|
+
let referenced_schema_key = references
|
|
2292
|
+
.get("schemaKey")
|
|
2293
|
+
.and_then(JsonValue::as_str)
|
|
2294
|
+
.ok_or_else(|| {
|
|
2295
|
+
LixError::new(
|
|
2296
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2297
|
+
format!("x-lix-foreign-keys[{index}].references.schemaKey must be a string"),
|
|
2298
|
+
)
|
|
2299
|
+
})?
|
|
2300
|
+
.to_string();
|
|
2301
|
+
let local_properties = pointer_array(
|
|
2302
|
+
object.get("properties"),
|
|
2303
|
+
&format!("x-lix-foreign-keys[{index}].properties"),
|
|
2304
|
+
)?;
|
|
2305
|
+
let referenced_properties = pointer_array(
|
|
2306
|
+
references.get("properties"),
|
|
2307
|
+
&format!("x-lix-foreign-keys[{index}].references.properties"),
|
|
2308
|
+
)?;
|
|
2309
|
+
if local_properties.len() != referenced_properties.len() {
|
|
2310
|
+
return Err(LixError::new(
|
|
2311
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2312
|
+
format!(
|
|
2313
|
+
"x-lix-foreign-keys[{index}] must have the same number of local and referenced properties"
|
|
2314
|
+
),
|
|
2315
|
+
));
|
|
2316
|
+
}
|
|
2317
|
+
Ok(ForeignKeyDefinition {
|
|
2318
|
+
local_properties,
|
|
2319
|
+
referenced_schema_key,
|
|
2320
|
+
referenced_properties,
|
|
2321
|
+
})
|
|
2322
|
+
})
|
|
2323
|
+
.collect()
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
fn pointer_array(value: Option<&JsonValue>, label: &str) -> Result<Vec<Vec<String>>, LixError> {
|
|
2327
|
+
let Some(value) = value else {
|
|
2328
|
+
return Err(LixError::new(
|
|
2329
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2330
|
+
format!("{label} is required"),
|
|
2331
|
+
));
|
|
2332
|
+
};
|
|
2333
|
+
let Some(values) = value.as_array() else {
|
|
2334
|
+
return Err(LixError::new(
|
|
2335
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2336
|
+
format!("{label} must be an array"),
|
|
2337
|
+
));
|
|
2338
|
+
};
|
|
2339
|
+
if values.is_empty() {
|
|
2340
|
+
return Err(LixError::new(
|
|
2341
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2342
|
+
format!("{label} must not be empty"),
|
|
2343
|
+
));
|
|
2344
|
+
}
|
|
2345
|
+
values
|
|
2346
|
+
.iter()
|
|
2347
|
+
.enumerate()
|
|
2348
|
+
.map(|(index, pointer)| {
|
|
2349
|
+
let pointer = pointer.as_str().ok_or_else(|| {
|
|
2350
|
+
LixError::new(
|
|
2351
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2352
|
+
format!("{label}[{index}] must be a string"),
|
|
2353
|
+
)
|
|
2354
|
+
})?;
|
|
2355
|
+
parse_json_pointer(pointer)
|
|
2356
|
+
})
|
|
2357
|
+
.collect()
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
fn validate_foreign_key_definition(
|
|
2361
|
+
catalog: &TransactionSchemaCatalog,
|
|
2362
|
+
source_key: &SchemaCatalogKey,
|
|
2363
|
+
source_schema: &JsonValue,
|
|
2364
|
+
foreign_key: ForeignKeyDefinition,
|
|
2365
|
+
) -> Result<(), LixError> {
|
|
2366
|
+
for pointer in &foreign_key.local_properties {
|
|
2367
|
+
validate_schema_field_pointer(source_schema, pointer).map_err(|detail| {
|
|
2368
|
+
LixError::new(
|
|
2369
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2370
|
+
format!(
|
|
2371
|
+
"foreign key on schema '{}' references missing local property '{}': {detail}",
|
|
2372
|
+
source_key.schema_key,
|
|
2373
|
+
format_json_pointer(pointer)
|
|
2374
|
+
),
|
|
2375
|
+
)
|
|
2376
|
+
})?;
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
let target_schema = catalog
|
|
2380
|
+
.schema_by_key(&foreign_key.referenced_schema_key)
|
|
2381
|
+
.or_else(|| {
|
|
2382
|
+
(foreign_key.referenced_schema_key == STATE_SURFACE_SCHEMA_KEY)
|
|
2383
|
+
.then_some(state_surface_foreign_key_schema())
|
|
2384
|
+
})
|
|
2385
|
+
.ok_or_else(|| {
|
|
2386
|
+
LixError::new(
|
|
2387
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2388
|
+
format!(
|
|
2389
|
+
"foreign key on schema '{}' references missing schema '{}'",
|
|
2390
|
+
source_key.schema_key, foreign_key.referenced_schema_key
|
|
2391
|
+
),
|
|
2392
|
+
)
|
|
2393
|
+
})?;
|
|
2394
|
+
|
|
2395
|
+
for pointer in &foreign_key.referenced_properties {
|
|
2396
|
+
validate_schema_field_pointer(target_schema, pointer).map_err(|detail| {
|
|
2397
|
+
LixError::new(
|
|
2398
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2399
|
+
format!(
|
|
2400
|
+
"foreign key on schema '{}' references missing target property '{}.{}': {detail}",
|
|
2401
|
+
source_key.schema_key,
|
|
2402
|
+
foreign_key.referenced_schema_key,
|
|
2403
|
+
format_json_pointer(pointer)
|
|
2404
|
+
),
|
|
2405
|
+
)
|
|
2406
|
+
})?;
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
if foreign_key.referenced_schema_key == STATE_SURFACE_SCHEMA_KEY {
|
|
2410
|
+
validate_state_surface_foreign_key_target(source_key, &foreign_key)?;
|
|
2411
|
+
} else if !referenced_properties_are_keyed(target_schema, &foreign_key.referenced_properties)? {
|
|
2412
|
+
return Err(LixError::new(
|
|
2413
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2414
|
+
format!(
|
|
2415
|
+
"foreign key on schema '{}' references '{}.{}', but referenced properties must match the target primary key or a unique constraint",
|
|
2416
|
+
source_key.schema_key,
|
|
2417
|
+
foreign_key.referenced_schema_key,
|
|
2418
|
+
format_pointer_group(&foreign_key.referenced_properties)
|
|
2419
|
+
),
|
|
2420
|
+
));
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
Ok(())
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
fn state_surface_foreign_key_schema() -> &'static JsonValue {
|
|
2427
|
+
crate::schema::lix_state_surface_schema_definition()
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
fn validate_state_surface_foreign_key_target(
|
|
2431
|
+
source_key: &SchemaCatalogKey,
|
|
2432
|
+
foreign_key: &ForeignKeyDefinition,
|
|
2433
|
+
) -> Result<(), LixError> {
|
|
2434
|
+
for required_pointer in ["/entity_id", "/schema_key"] {
|
|
2435
|
+
let required_pointer = parse_json_pointer(required_pointer)?;
|
|
2436
|
+
if !foreign_key
|
|
2437
|
+
.referenced_properties
|
|
2438
|
+
.iter()
|
|
2439
|
+
.any(|pointer| pointer == &required_pointer)
|
|
2440
|
+
{
|
|
2441
|
+
return Err(LixError::new(
|
|
2442
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2443
|
+
format!(
|
|
2444
|
+
"foreign key on schema '{}' references lix_state and must include '{}'",
|
|
2445
|
+
source_key.schema_key,
|
|
2446
|
+
format_json_pointer(&required_pointer)
|
|
2447
|
+
),
|
|
2448
|
+
));
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
Ok(())
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
fn validate_schema_field_pointer(schema: &JsonValue, pointer: &[String]) -> Result<(), String> {
|
|
2455
|
+
if pointer.is_empty() {
|
|
2456
|
+
return Err("empty pointer does not name a field".to_string());
|
|
2457
|
+
}
|
|
2458
|
+
let mut current = schema;
|
|
2459
|
+
for segment in pointer {
|
|
2460
|
+
let properties = current
|
|
2461
|
+
.get("properties")
|
|
2462
|
+
.and_then(JsonValue::as_object)
|
|
2463
|
+
.ok_or_else(|| {
|
|
2464
|
+
format!(
|
|
2465
|
+
"schema segment before '{}' has no object properties",
|
|
2466
|
+
segment
|
|
2467
|
+
)
|
|
2468
|
+
})?;
|
|
2469
|
+
current = properties
|
|
2470
|
+
.get(segment)
|
|
2471
|
+
.ok_or_else(|| format!("property '{}' does not exist", segment))?;
|
|
2472
|
+
}
|
|
2473
|
+
Ok(())
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
fn referenced_properties_are_keyed(
|
|
2477
|
+
target_schema: &JsonValue,
|
|
2478
|
+
referenced_properties: &[Vec<String>],
|
|
2479
|
+
) -> Result<bool, LixError> {
|
|
2480
|
+
if let Some(primary_key) = pointer_groups(target_schema, "x-lix-primary-key")?
|
|
2481
|
+
.into_iter()
|
|
2482
|
+
.next()
|
|
2483
|
+
{
|
|
2484
|
+
if primary_key == referenced_properties {
|
|
2485
|
+
return Ok(true);
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
Ok(pointer_groups(target_schema, "x-lix-unique")?
|
|
2489
|
+
.into_iter()
|
|
2490
|
+
.any(|unique_group| unique_group == referenced_properties))
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
fn validate_foreign_key_definitions(catalog: &TransactionSchemaCatalog) -> Result<(), LixError> {
|
|
2494
|
+
for (key, schema) in catalog.schemas() {
|
|
2495
|
+
let foreign_keys = foreign_key_definitions(schema)?;
|
|
2496
|
+
for foreign_key in foreign_keys {
|
|
2497
|
+
validate_foreign_key_definition(catalog, key, schema, foreign_key)?;
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
Ok(())
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
/// Per-transaction compiled schema cache.
|
|
2504
|
+
///
|
|
2505
|
+
/// Compilation is lazy and keyed by exact `(schema_key, schema_version)`, so a
|
|
2506
|
+
/// transaction that writes many rows for one schema pays the JSON Schema
|
|
2507
|
+
/// compilation cost only once.
|
|
2508
|
+
struct CompiledSchemaCatalog<'a> {
|
|
2509
|
+
schema_catalog: &'a TransactionSchemaCatalog,
|
|
2510
|
+
compiled_by_key: BTreeMap<SchemaCatalogKey, JSONSchema>,
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
impl<'a> CompiledSchemaCatalog<'a> {
|
|
2514
|
+
fn new(schema_catalog: &'a TransactionSchemaCatalog) -> Self {
|
|
2515
|
+
Self {
|
|
2516
|
+
schema_catalog,
|
|
2517
|
+
compiled_by_key: BTreeMap::new(),
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
fn compiled_schema(
|
|
2522
|
+
&mut self,
|
|
2523
|
+
schema_key: &str,
|
|
2524
|
+
schema_version: &str,
|
|
2525
|
+
) -> Result<&JSONSchema, LixError> {
|
|
2526
|
+
let key = SchemaCatalogKey {
|
|
2527
|
+
schema_key: schema_key.to_string(),
|
|
2528
|
+
schema_version: schema_version.to_string(),
|
|
2529
|
+
};
|
|
2530
|
+
if !self.compiled_by_key.contains_key(&key) {
|
|
2531
|
+
let schema = self
|
|
2532
|
+
.schema_catalog
|
|
2533
|
+
.schema(schema_key, schema_version)
|
|
2534
|
+
.ok_or_else(|| {
|
|
2535
|
+
LixError::new(
|
|
2536
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2537
|
+
format!(
|
|
2538
|
+
"schema '{schema_key}' version '{schema_version}' is not visible to this transaction"
|
|
2539
|
+
),
|
|
2540
|
+
)
|
|
2541
|
+
})?;
|
|
2542
|
+
let compiled = compile_lix_schema(schema)?;
|
|
2543
|
+
self.compiled_by_key.insert(key.clone(), compiled);
|
|
2544
|
+
}
|
|
2545
|
+
self.compiled_by_key.get(&key).ok_or_else(|| {
|
|
2546
|
+
LixError::new(
|
|
2547
|
+
LixError::CODE_UNKNOWN,
|
|
2548
|
+
format!(
|
|
2549
|
+
"compiled schema cache lookup failed for schema '{schema_key}' version '{schema_version}'"
|
|
2550
|
+
),
|
|
2551
|
+
)
|
|
2552
|
+
})
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
#[cfg(test)]
|
|
2556
|
+
fn compiled_count(&self) -> usize {
|
|
2557
|
+
self.compiled_by_key.len()
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
#[cfg(test)]
|
|
2562
|
+
fn validate_pending_registered_schema(
|
|
2563
|
+
row: &StagedStateRow,
|
|
2564
|
+
registered_schema_definition: &JsonValue,
|
|
2565
|
+
) -> Result<(SchemaKey, JsonValue), LixError> {
|
|
2566
|
+
let snapshot_content = row.snapshot_content.as_deref().ok_or_else(|| {
|
|
2567
|
+
LixError::new(
|
|
2568
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2569
|
+
"registered schema write requires snapshot_content",
|
|
2570
|
+
)
|
|
2571
|
+
})?;
|
|
2572
|
+
let snapshot = serde_json::from_str::<JsonValue>(snapshot_content).map_err(|error| {
|
|
2573
|
+
LixError::new(
|
|
2574
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2575
|
+
format!("pending registered schema snapshot_content is invalid JSON: {error}"),
|
|
2576
|
+
)
|
|
2577
|
+
})?;
|
|
2578
|
+
if !snapshot.get("value").is_some_and(JsonValue::is_object) {
|
|
2579
|
+
validate_lix_schema(registered_schema_definition, &snapshot)?;
|
|
2580
|
+
}
|
|
2581
|
+
// A registered-schema row stores the schema definition under `value`.
|
|
2582
|
+
// Validate both layers: the outer row must match the builtin
|
|
2583
|
+
// `lix_registered_schema` schema, and the inner definition must be a valid
|
|
2584
|
+
// Lix schema before it can extend the transaction-visible catalog.
|
|
2585
|
+
let (key, schema) = schema_from_registered_snapshot(&snapshot)?;
|
|
2586
|
+
reject_unsupported_registered_schema_version(&key)?;
|
|
2587
|
+
reject_seed_schema_registration(&key)?;
|
|
2588
|
+
validate_lix_schema_definition(&schema)?;
|
|
2589
|
+
validate_lix_schema(registered_schema_definition, &snapshot)?;
|
|
2590
|
+
Ok((key, schema))
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
#[cfg(test)]
|
|
2594
|
+
fn reject_seed_schema_registration(key: &SchemaKey) -> Result<(), LixError> {
|
|
2595
|
+
if is_seed_schema_key(&key.schema_key) {
|
|
2596
|
+
return Err(LixError::new(
|
|
2597
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2598
|
+
format!(
|
|
2599
|
+
"schema '{}' is a system schema and cannot be registered at runtime",
|
|
2600
|
+
key.schema_key
|
|
2601
|
+
),
|
|
2602
|
+
));
|
|
2603
|
+
}
|
|
2604
|
+
Ok(())
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
#[cfg(test)]
|
|
2608
|
+
mod tests {
|
|
2609
|
+
use async_trait::async_trait;
|
|
2610
|
+
use serde_json::json;
|
|
2611
|
+
|
|
2612
|
+
use super::*;
|
|
2613
|
+
use crate::live_state::{LiveStateRow, LiveStateRowRequest, LiveStateScanRequest};
|
|
2614
|
+
use crate::schema::{schema_key_from_definition, seed_schema_definition};
|
|
2615
|
+
|
|
2616
|
+
struct EmptyLiveStateReader;
|
|
2617
|
+
|
|
2618
|
+
#[async_trait]
|
|
2619
|
+
impl LiveStateReader for EmptyLiveStateReader {
|
|
2620
|
+
async fn scan_rows(
|
|
2621
|
+
&self,
|
|
2622
|
+
request: &LiveStateScanRequest,
|
|
2623
|
+
) -> Result<Vec<LiveStateRow>, LixError> {
|
|
2624
|
+
Ok(test_file_descriptor_rows()
|
|
2625
|
+
.into_iter()
|
|
2626
|
+
.filter(|row| live_state_row_matches_scan(row, request))
|
|
2627
|
+
.collect())
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
async fn load_row(
|
|
2631
|
+
&self,
|
|
2632
|
+
request: &LiveStateRowRequest,
|
|
2633
|
+
) -> Result<Option<LiveStateRow>, LixError> {
|
|
2634
|
+
Ok(test_file_descriptor_rows()
|
|
2635
|
+
.into_iter()
|
|
2636
|
+
.find(|row| live_state_row_matches_load(row, request)))
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
fn validation_input<'a>(
|
|
2641
|
+
staged_writes: &'a StagedWriteSet,
|
|
2642
|
+
visible_schemas: &'a [JsonValue],
|
|
2643
|
+
) -> TransactionValidationInput<'a> {
|
|
2644
|
+
let catalog = Box::leak(Box::new(
|
|
2645
|
+
catalog_from_transaction_parts_unchecked(staged_writes, visible_schemas)
|
|
2646
|
+
.expect("test schema catalog should build"),
|
|
2647
|
+
));
|
|
2648
|
+
TransactionValidationInput::new(staged_writes, catalog, &EmptyLiveStateReader)
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
fn catalog_from_transaction_input(
|
|
2652
|
+
input: &TransactionValidationInput<'_>,
|
|
2653
|
+
) -> Result<TransactionSchemaCatalog, LixError> {
|
|
2654
|
+
let catalog = input.schema_catalog.clone();
|
|
2655
|
+
validate_foreign_key_definitions(&catalog)?;
|
|
2656
|
+
Ok(catalog)
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
fn catalog_from_transaction_parts(
|
|
2660
|
+
staged_writes: &StagedWriteSet,
|
|
2661
|
+
visible_schemas: &[JsonValue],
|
|
2662
|
+
) -> Result<TransactionSchemaCatalog, LixError> {
|
|
2663
|
+
let catalog = catalog_from_transaction_parts_unchecked(staged_writes, visible_schemas)?;
|
|
2664
|
+
let mut pending_keys =
|
|
2665
|
+
BTreeMap::<SchemaCatalogKey, crate::entity_identity::EntityIdentity>::new();
|
|
2666
|
+
for row in staged_writes
|
|
2667
|
+
.state_rows_for_validation()
|
|
2668
|
+
.iter()
|
|
2669
|
+
.filter(|row| row.schema_key == REGISTERED_SCHEMA_KEY)
|
|
2670
|
+
{
|
|
2671
|
+
let snapshot_content = row.snapshot_content.as_deref().ok_or_else(|| {
|
|
2672
|
+
LixError::new(
|
|
2673
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2674
|
+
"registered schema write requires snapshot_content",
|
|
2675
|
+
)
|
|
2676
|
+
})?;
|
|
2677
|
+
let snapshot =
|
|
2678
|
+
serde_json::from_str::<JsonValue>(snapshot_content).map_err(|error| {
|
|
2679
|
+
LixError::new(
|
|
2680
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2681
|
+
format!(
|
|
2682
|
+
"pending registered schema snapshot_content is invalid JSON: {error}"
|
|
2683
|
+
),
|
|
2684
|
+
)
|
|
2685
|
+
})?;
|
|
2686
|
+
let (key, _) = schema_from_registered_snapshot(&snapshot)?;
|
|
2687
|
+
let catalog_key = SchemaCatalogKey::from_schema_key(key);
|
|
2688
|
+
if let Some(existing_entity_id) =
|
|
2689
|
+
pending_keys.insert(catalog_key.clone(), row.entity_id.clone())
|
|
2690
|
+
{
|
|
2691
|
+
return Err(LixError::new(
|
|
2692
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2693
|
+
format!(
|
|
2694
|
+
"duplicate pending registered schema '{}' version '{}' in transaction: rows '{}' and '{}'",
|
|
2695
|
+
catalog_key.schema_key,
|
|
2696
|
+
catalog_key.schema_version,
|
|
2697
|
+
existing_entity_id.as_string()?,
|
|
2698
|
+
row.entity_id.as_string()?
|
|
2699
|
+
),
|
|
2700
|
+
));
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
validate_foreign_key_definitions(&catalog)?;
|
|
2704
|
+
Ok(catalog)
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
fn catalog_from_transaction_parts_unchecked(
|
|
2708
|
+
staged_writes: &StagedWriteSet,
|
|
2709
|
+
visible_schemas: &[JsonValue],
|
|
2710
|
+
) -> Result<TransactionSchemaCatalog, LixError> {
|
|
2711
|
+
let mut catalog = TransactionSchemaCatalog::from_visible_schemas(visible_schemas)?;
|
|
2712
|
+
let staged_rows = staged_writes.state_rows_for_validation();
|
|
2713
|
+
for row in staged_rows
|
|
2714
|
+
.iter()
|
|
2715
|
+
.filter(|row| row.schema_key == REGISTERED_SCHEMA_KEY)
|
|
2716
|
+
{
|
|
2717
|
+
let registered_schema_definition = catalog
|
|
2718
|
+
.schema(REGISTERED_SCHEMA_KEY, "1")
|
|
2719
|
+
.cloned()
|
|
2720
|
+
.ok_or_else(|| {
|
|
2721
|
+
LixError::new(
|
|
2722
|
+
LixError::CODE_SCHEMA_DEFINITION,
|
|
2723
|
+
"lix_registered_schema schema is not visible to this transaction",
|
|
2724
|
+
)
|
|
2725
|
+
})?;
|
|
2726
|
+
let (key, schema) =
|
|
2727
|
+
validate_pending_registered_schema(row, ®istered_schema_definition)?;
|
|
2728
|
+
catalog.insert_schema(key, schema);
|
|
2729
|
+
}
|
|
2730
|
+
Ok(catalog)
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
struct StaticLiveStateReader {
|
|
2734
|
+
rows: Vec<LiveStateRow>,
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
#[async_trait]
|
|
2738
|
+
impl LiveStateReader for StaticLiveStateReader {
|
|
2739
|
+
async fn scan_rows(
|
|
2740
|
+
&self,
|
|
2741
|
+
request: &LiveStateScanRequest,
|
|
2742
|
+
) -> Result<Vec<LiveStateRow>, LixError> {
|
|
2743
|
+
Ok(self
|
|
2744
|
+
.rows
|
|
2745
|
+
.iter()
|
|
2746
|
+
.cloned()
|
|
2747
|
+
.chain(test_file_descriptor_rows())
|
|
2748
|
+
.filter(|row| {
|
|
2749
|
+
request.filter.schema_keys.is_empty()
|
|
2750
|
+
|| request.filter.schema_keys.contains(&row.schema_key)
|
|
2751
|
+
})
|
|
2752
|
+
.filter(|row| {
|
|
2753
|
+
request.filter.version_ids.is_empty()
|
|
2754
|
+
|| request.filter.version_ids.contains(&row.version_id)
|
|
2755
|
+
})
|
|
2756
|
+
.filter(|row| {
|
|
2757
|
+
request.filter.file_ids.is_empty()
|
|
2758
|
+
|| request
|
|
2759
|
+
.filter
|
|
2760
|
+
.file_ids
|
|
2761
|
+
.iter()
|
|
2762
|
+
.any(|filter| filter.matches(row.file_id.as_ref()))
|
|
2763
|
+
})
|
|
2764
|
+
.collect())
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
async fn load_row(
|
|
2768
|
+
&self,
|
|
2769
|
+
request: &LiveStateRowRequest,
|
|
2770
|
+
) -> Result<Option<LiveStateRow>, LixError> {
|
|
2771
|
+
Ok(self
|
|
2772
|
+
.rows
|
|
2773
|
+
.iter()
|
|
2774
|
+
.cloned()
|
|
2775
|
+
.chain(test_file_descriptor_rows())
|
|
2776
|
+
.find(|row| {
|
|
2777
|
+
row.schema_key == request.schema_key
|
|
2778
|
+
&& row.version_id == request.version_id
|
|
2779
|
+
&& row.entity_id == request.entity_id
|
|
2780
|
+
&& request.file_id.matches(row.file_id.as_ref())
|
|
2781
|
+
}))
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
struct StrictEmptyLiveStateReader;
|
|
2786
|
+
|
|
2787
|
+
#[async_trait]
|
|
2788
|
+
impl LiveStateReader for StrictEmptyLiveStateReader {
|
|
2789
|
+
async fn scan_rows(
|
|
2790
|
+
&self,
|
|
2791
|
+
_request: &LiveStateScanRequest,
|
|
2792
|
+
) -> Result<Vec<LiveStateRow>, LixError> {
|
|
2793
|
+
Ok(Vec::new())
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
async fn load_row(
|
|
2797
|
+
&self,
|
|
2798
|
+
_request: &LiveStateRowRequest,
|
|
2799
|
+
) -> Result<Option<LiveStateRow>, LixError> {
|
|
2800
|
+
Ok(None)
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
struct StrictStaticLiveStateReader {
|
|
2805
|
+
rows: Vec<LiveStateRow>,
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
#[async_trait]
|
|
2809
|
+
impl LiveStateReader for StrictStaticLiveStateReader {
|
|
2810
|
+
async fn scan_rows(
|
|
2811
|
+
&self,
|
|
2812
|
+
request: &LiveStateScanRequest,
|
|
2813
|
+
) -> Result<Vec<LiveStateRow>, LixError> {
|
|
2814
|
+
Ok(self
|
|
2815
|
+
.rows
|
|
2816
|
+
.iter()
|
|
2817
|
+
.filter(|row| live_state_row_matches_scan(row, request))
|
|
2818
|
+
.cloned()
|
|
2819
|
+
.collect())
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
async fn load_row(
|
|
2823
|
+
&self,
|
|
2824
|
+
request: &LiveStateRowRequest,
|
|
2825
|
+
) -> Result<Option<LiveStateRow>, LixError> {
|
|
2826
|
+
Ok(self
|
|
2827
|
+
.rows
|
|
2828
|
+
.iter()
|
|
2829
|
+
.find(|row| live_state_row_matches_load(row, request))
|
|
2830
|
+
.cloned())
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
#[test]
|
|
2835
|
+
fn schema_catalog_indexes_visible_schemas_by_key_and_version() {
|
|
2836
|
+
let visible_schemas = vec![json!({
|
|
2837
|
+
"x-lix-key": "visible_schema",
|
|
2838
|
+
"x-lix-version": "1",
|
|
2839
|
+
"type": "object",
|
|
2840
|
+
})];
|
|
2841
|
+
let staged_writes = empty_staged_write_set();
|
|
2842
|
+
let input = validation_input(&staged_writes, &visible_schemas);
|
|
2843
|
+
|
|
2844
|
+
let catalog = catalog_from_transaction_input(&input).expect("schema catalog should build");
|
|
2845
|
+
|
|
2846
|
+
assert_eq!(catalog.len(), 1);
|
|
2847
|
+
assert!(catalog.contains("visible_schema", "1"));
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
#[test]
|
|
2851
|
+
fn schema_catalog_includes_pending_registered_schema_rows() {
|
|
2852
|
+
let visible_schemas = vec![
|
|
2853
|
+
registered_schema(),
|
|
2854
|
+
json!({
|
|
2855
|
+
"x-lix-key": "visible_schema",
|
|
2856
|
+
"x-lix-version": "1",
|
|
2857
|
+
"type": "object",
|
|
2858
|
+
}),
|
|
2859
|
+
];
|
|
2860
|
+
let staged_writes = StagedWriteSet {
|
|
2861
|
+
state_rows: vec![pending_registered_schema_row("pending_schema", "1")],
|
|
2862
|
+
adopted_rows: Vec::new(),
|
|
2863
|
+
..empty_staged_write_set()
|
|
2864
|
+
};
|
|
2865
|
+
let input = validation_input(&staged_writes, &visible_schemas);
|
|
2866
|
+
|
|
2867
|
+
let catalog = catalog_from_transaction_input(&input).expect("schema catalog should build");
|
|
2868
|
+
|
|
2869
|
+
assert_eq!(catalog.len(), 3);
|
|
2870
|
+
assert!(catalog.contains("visible_schema", "1"));
|
|
2871
|
+
assert!(catalog.contains("pending_schema", "1"));
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
#[test]
|
|
2875
|
+
fn schema_catalog_pending_schema_overrides_same_visible_identity() {
|
|
2876
|
+
let visible_schemas = vec![
|
|
2877
|
+
registered_schema(),
|
|
2878
|
+
json!({
|
|
2879
|
+
"x-lix-key": "same_schema",
|
|
2880
|
+
"x-lix-version": "1",
|
|
2881
|
+
"type": "object",
|
|
2882
|
+
"properties": {
|
|
2883
|
+
"old": { "type": "string" }
|
|
2884
|
+
}
|
|
2885
|
+
}),
|
|
2886
|
+
];
|
|
2887
|
+
let staged_writes = StagedWriteSet {
|
|
2888
|
+
state_rows: vec![pending_registered_schema_row("same_schema", "1")],
|
|
2889
|
+
adopted_rows: Vec::new(),
|
|
2890
|
+
..empty_staged_write_set()
|
|
2891
|
+
};
|
|
2892
|
+
let input = validation_input(&staged_writes, &visible_schemas);
|
|
2893
|
+
|
|
2894
|
+
let catalog = catalog_from_transaction_input(&input).expect("schema catalog should build");
|
|
2895
|
+
|
|
2896
|
+
assert_eq!(catalog.len(), 2);
|
|
2897
|
+
assert!(catalog.contains("same_schema", "1"));
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
#[test]
|
|
2901
|
+
fn pending_registered_schema_requires_snapshot_content() {
|
|
2902
|
+
let mut row = pending_registered_schema_row("missing_snapshot", "1");
|
|
2903
|
+
row.snapshot_content = None;
|
|
2904
|
+
|
|
2905
|
+
let error = validate_pending_registered_schema(&row, ®istered_schema())
|
|
2906
|
+
.expect_err("registered schema writes require snapshot_content");
|
|
2907
|
+
|
|
2908
|
+
assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
#[test]
|
|
2912
|
+
fn pending_registered_schema_rejects_invalid_snapshot_json() {
|
|
2913
|
+
let mut row = pending_registered_schema_row("invalid_json", "1");
|
|
2914
|
+
row.snapshot_content = Some("{not-json".to_string());
|
|
2915
|
+
|
|
2916
|
+
let error = validate_pending_registered_schema(&row, ®istered_schema())
|
|
2917
|
+
.expect_err("invalid JSON should fail");
|
|
2918
|
+
|
|
2919
|
+
assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
#[test]
|
|
2923
|
+
fn pending_registered_schema_uses_builtin_schema_for_outer_value_shape() {
|
|
2924
|
+
let mut row = pending_registered_schema_row("missing_value", "1");
|
|
2925
|
+
row.snapshot_content = Some(json!({}).to_string());
|
|
2926
|
+
|
|
2927
|
+
let error = validate_pending_registered_schema(&row, ®istered_schema())
|
|
2928
|
+
.expect_err("builtin lix_registered_schema validation should fail");
|
|
2929
|
+
|
|
2930
|
+
assert_eq!(error.code, LixError::CODE_SCHEMA_VALIDATION);
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
#[test]
|
|
2934
|
+
fn pending_registered_schema_rejects_malformed_nested_lix_schema_definition() {
|
|
2935
|
+
let mut row = pending_registered_schema_row("bad_schema_version", "v1");
|
|
2936
|
+
row.snapshot_content = Some(
|
|
2937
|
+
json!({
|
|
2938
|
+
"value": {
|
|
2939
|
+
"x-lix-key": "bad_schema_version",
|
|
2940
|
+
"x-lix-version": "v1",
|
|
2941
|
+
"type": "object",
|
|
2942
|
+
"properties": {
|
|
2943
|
+
"id": { "type": "string" }
|
|
2944
|
+
},
|
|
2945
|
+
"required": ["id"],
|
|
2946
|
+
"additionalProperties": false,
|
|
2947
|
+
}
|
|
2948
|
+
})
|
|
2949
|
+
.to_string(),
|
|
2950
|
+
);
|
|
2951
|
+
|
|
2952
|
+
let error = validate_pending_registered_schema(&row, ®istered_schema())
|
|
2953
|
+
.expect_err("nested Lix schema definition should be rejected");
|
|
2954
|
+
|
|
2955
|
+
assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
#[test]
|
|
2959
|
+
fn schema_catalog_rejects_duplicate_pending_registered_schema_identity() {
|
|
2960
|
+
let mut duplicate = pending_registered_schema_row("duplicate_schema", "1");
|
|
2961
|
+
duplicate.entity_id = registered_schema_entity_id("duplicate_schema_duplicate", "1");
|
|
2962
|
+
let staged_writes = StagedWriteSet {
|
|
2963
|
+
state_rows: vec![
|
|
2964
|
+
pending_registered_schema_row("duplicate_schema", "1"),
|
|
2965
|
+
duplicate,
|
|
2966
|
+
],
|
|
2967
|
+
..empty_staged_write_set()
|
|
2968
|
+
};
|
|
2969
|
+
let visible_schemas = vec![registered_schema()];
|
|
2970
|
+
|
|
2971
|
+
let error = catalog_from_transaction_parts(&staged_writes, &visible_schemas)
|
|
2972
|
+
.expect_err("duplicate pending schema keys should fail");
|
|
2973
|
+
|
|
2974
|
+
assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
#[test]
|
|
2978
|
+
fn schema_catalog_allows_pending_foreign_key_to_pending_schema() {
|
|
2979
|
+
let staged_writes = StagedWriteSet {
|
|
2980
|
+
state_rows: vec![
|
|
2981
|
+
pending_registered_schema_from_definition(fk_parent_schema()),
|
|
2982
|
+
pending_registered_schema_from_definition(fk_child_schema()),
|
|
2983
|
+
],
|
|
2984
|
+
..empty_staged_write_set()
|
|
2985
|
+
};
|
|
2986
|
+
let visible_schemas = vec![registered_schema()];
|
|
2987
|
+
let input = validation_input(&staged_writes, &visible_schemas);
|
|
2988
|
+
|
|
2989
|
+
let catalog = catalog_from_transaction_input(&input)
|
|
2990
|
+
.expect("pending parent schema should satisfy pending child foreign key");
|
|
2991
|
+
|
|
2992
|
+
assert!(catalog.contains("fk_parent_schema", "1"));
|
|
2993
|
+
assert!(catalog.contains("fk_child_schema", "1"));
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
#[test]
|
|
2997
|
+
fn schema_catalog_rejects_foreign_key_missing_target_schema() {
|
|
2998
|
+
let staged_writes = StagedWriteSet {
|
|
2999
|
+
state_rows: vec![pending_registered_schema_from_definition(fk_child_schema())],
|
|
3000
|
+
adopted_rows: Vec::new(),
|
|
3001
|
+
..empty_staged_write_set()
|
|
3002
|
+
};
|
|
3003
|
+
let visible_schemas = vec![registered_schema()];
|
|
3004
|
+
let input = validation_input(&staged_writes, &visible_schemas);
|
|
3005
|
+
|
|
3006
|
+
let error = catalog_from_transaction_input(&input)
|
|
3007
|
+
.expect_err("missing referenced schema should fail");
|
|
3008
|
+
|
|
3009
|
+
assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
|
|
3010
|
+
}
|
|
3011
|
+
|
|
3012
|
+
#[test]
|
|
3013
|
+
fn schema_catalog_rejects_foreign_key_missing_local_field() {
|
|
3014
|
+
let mut child = fk_child_schema();
|
|
3015
|
+
child["x-lix-foreign-keys"][0]["properties"] = json!(["/missing_parent_id"]);
|
|
3016
|
+
let staged_writes = StagedWriteSet {
|
|
3017
|
+
state_rows: vec![
|
|
3018
|
+
pending_registered_schema_from_definition(fk_parent_schema()),
|
|
3019
|
+
pending_registered_schema_from_definition(child),
|
|
3020
|
+
],
|
|
3021
|
+
..empty_staged_write_set()
|
|
3022
|
+
};
|
|
3023
|
+
let visible_schemas = vec![registered_schema()];
|
|
3024
|
+
let input = validation_input(&staged_writes, &visible_schemas);
|
|
3025
|
+
|
|
3026
|
+
let error =
|
|
3027
|
+
catalog_from_transaction_input(&input).expect_err("missing local FK field should fail");
|
|
3028
|
+
|
|
3029
|
+
assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
#[test]
|
|
3033
|
+
fn schema_catalog_rejects_foreign_key_missing_referenced_field() {
|
|
3034
|
+
let mut child = fk_child_schema();
|
|
3035
|
+
child["x-lix-foreign-keys"][0]["references"]["properties"] = json!(["/missing_id"]);
|
|
3036
|
+
let staged_writes = StagedWriteSet {
|
|
3037
|
+
state_rows: vec![
|
|
3038
|
+
pending_registered_schema_from_definition(fk_parent_schema()),
|
|
3039
|
+
pending_registered_schema_from_definition(child),
|
|
3040
|
+
],
|
|
3041
|
+
..empty_staged_write_set()
|
|
3042
|
+
};
|
|
3043
|
+
let visible_schemas = vec![registered_schema()];
|
|
3044
|
+
let input = validation_input(&staged_writes, &visible_schemas);
|
|
3045
|
+
|
|
3046
|
+
let error = catalog_from_transaction_input(&input)
|
|
3047
|
+
.expect_err("missing referenced FK field should fail");
|
|
3048
|
+
|
|
3049
|
+
assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
#[test]
|
|
3053
|
+
fn schema_catalog_rejects_foreign_key_to_non_unique_target_field() {
|
|
3054
|
+
let mut parent = fk_parent_schema();
|
|
3055
|
+
parent["properties"]["name"] = json!({ "type": "string" });
|
|
3056
|
+
let mut child = fk_child_schema();
|
|
3057
|
+
child["x-lix-foreign-keys"][0]["references"]["properties"] = json!(["/name"]);
|
|
3058
|
+
let staged_writes = StagedWriteSet {
|
|
3059
|
+
state_rows: vec![
|
|
3060
|
+
pending_registered_schema_from_definition(parent),
|
|
3061
|
+
pending_registered_schema_from_definition(child),
|
|
3062
|
+
],
|
|
3063
|
+
..empty_staged_write_set()
|
|
3064
|
+
};
|
|
3065
|
+
let visible_schemas = vec![registered_schema()];
|
|
3066
|
+
let input = validation_input(&staged_writes, &visible_schemas);
|
|
3067
|
+
|
|
3068
|
+
let error = catalog_from_transaction_input(&input)
|
|
3069
|
+
.expect_err("FK target must be primary-key or unique");
|
|
3070
|
+
|
|
3071
|
+
assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
#[test]
|
|
3075
|
+
fn schema_catalog_allows_state_surface_foreign_key_target() {
|
|
3076
|
+
let staged_writes = StagedWriteSet {
|
|
3077
|
+
state_rows: vec![pending_registered_schema_from_definition(
|
|
3078
|
+
state_surface_ref_schema(),
|
|
3079
|
+
)],
|
|
3080
|
+
..empty_staged_write_set()
|
|
3081
|
+
};
|
|
3082
|
+
let visible_schemas = vec![registered_schema()];
|
|
3083
|
+
let input = validation_input(&staged_writes, &visible_schemas);
|
|
3084
|
+
|
|
3085
|
+
let catalog = catalog_from_transaction_input(&input)
|
|
3086
|
+
.expect("lix_state should validate as a state-surface FK target");
|
|
3087
|
+
|
|
3088
|
+
assert!(catalog.contains("state_surface_ref_schema", "1"));
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
#[test]
|
|
3092
|
+
fn schema_catalog_rejects_state_surface_foreign_key_without_schema_key() {
|
|
3093
|
+
let mut schema = state_surface_ref_schema();
|
|
3094
|
+
schema["x-lix-foreign-keys"][0]["properties"] = json!(["/target_entity_id"]);
|
|
3095
|
+
schema["x-lix-foreign-keys"][0]["references"]["properties"] = json!(["/entity_id"]);
|
|
3096
|
+
let staged_writes = StagedWriteSet {
|
|
3097
|
+
state_rows: vec![pending_registered_schema_from_definition(schema)],
|
|
3098
|
+
adopted_rows: Vec::new(),
|
|
3099
|
+
..empty_staged_write_set()
|
|
3100
|
+
};
|
|
3101
|
+
let visible_schemas = vec![registered_schema()];
|
|
3102
|
+
let input = validation_input(&staged_writes, &visible_schemas);
|
|
3103
|
+
|
|
3104
|
+
let error = catalog_from_transaction_input(&input)
|
|
3105
|
+
.expect_err("lix_state FK target must include schema identity");
|
|
3106
|
+
|
|
3107
|
+
assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
#[tokio::test]
|
|
3111
|
+
async fn validation_rejects_unknown_schema_key() {
|
|
3112
|
+
let visible_schemas = vec![key_value_schema()];
|
|
3113
|
+
let staged_writes = StagedWriteSet {
|
|
3114
|
+
state_rows: vec![staged_row(
|
|
3115
|
+
"unknown_schema",
|
|
3116
|
+
"1",
|
|
3117
|
+
Some(json!({}).to_string()),
|
|
3118
|
+
)],
|
|
3119
|
+
..empty_staged_write_set()
|
|
3120
|
+
};
|
|
3121
|
+
|
|
3122
|
+
let error = validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
|
|
3123
|
+
.await
|
|
3124
|
+
.expect_err("unknown schema_key should fail");
|
|
3125
|
+
|
|
3126
|
+
assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
#[tokio::test]
|
|
3130
|
+
async fn validation_rejects_unknown_schema_version() {
|
|
3131
|
+
let visible_schemas = vec![key_value_schema()];
|
|
3132
|
+
let staged_writes = StagedWriteSet {
|
|
3133
|
+
state_rows: vec![staged_row(
|
|
3134
|
+
"lix_key_value",
|
|
3135
|
+
"2",
|
|
3136
|
+
Some(json!({ "key": "k", "value": "v" }).to_string()),
|
|
3137
|
+
)],
|
|
3138
|
+
..empty_staged_write_set()
|
|
3139
|
+
};
|
|
3140
|
+
|
|
3141
|
+
let error = validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
|
|
3142
|
+
.await
|
|
3143
|
+
.expect_err("unknown schema_version should fail");
|
|
3144
|
+
|
|
3145
|
+
assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
#[tokio::test]
|
|
3149
|
+
async fn validation_checks_schema_existence_for_tombstones() {
|
|
3150
|
+
let visible_schemas = vec![key_value_schema()];
|
|
3151
|
+
let staged_writes = StagedWriteSet {
|
|
3152
|
+
state_rows: vec![staged_row("unknown_schema", "1", None)],
|
|
3153
|
+
adopted_rows: Vec::new(),
|
|
3154
|
+
..empty_staged_write_set()
|
|
3155
|
+
};
|
|
3156
|
+
|
|
3157
|
+
let error = validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
|
|
3158
|
+
.await
|
|
3159
|
+
.expect_err("tombstone with unknown schema should fail");
|
|
3160
|
+
|
|
3161
|
+
assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
#[tokio::test]
|
|
3165
|
+
async fn validation_allows_pending_registered_schema_to_validate_later_rows() {
|
|
3166
|
+
let visible_schemas = vec![key_value_schema(), registered_schema()];
|
|
3167
|
+
let staged_writes = StagedWriteSet {
|
|
3168
|
+
state_rows: vec![
|
|
3169
|
+
pending_registered_schema_row("pending_schema", "1"),
|
|
3170
|
+
staged_row(
|
|
3171
|
+
"pending_schema",
|
|
3172
|
+
"1",
|
|
3173
|
+
Some(json!({ "id": "entity-1" }).to_string()),
|
|
3174
|
+
),
|
|
3175
|
+
],
|
|
3176
|
+
..empty_staged_write_set()
|
|
3177
|
+
};
|
|
3178
|
+
|
|
3179
|
+
validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
|
|
3180
|
+
.await
|
|
3181
|
+
.expect("pending registered schema should be visible to later staged rows");
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
#[tokio::test]
|
|
3185
|
+
async fn validation_validates_snapshot_content_against_schema() {
|
|
3186
|
+
let visible_schemas = vec![key_value_schema()];
|
|
3187
|
+
let staged_writes = StagedWriteSet {
|
|
3188
|
+
state_rows: vec![staged_row(
|
|
3189
|
+
"lix_key_value",
|
|
3190
|
+
"1",
|
|
3191
|
+
Some(json!({ "key": "k" }).to_string()),
|
|
3192
|
+
)],
|
|
3193
|
+
..empty_staged_write_set()
|
|
3194
|
+
};
|
|
3195
|
+
|
|
3196
|
+
let error = validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
|
|
3197
|
+
.await
|
|
3198
|
+
.expect_err("missing required snapshot field should fail");
|
|
3199
|
+
|
|
3200
|
+
assert_eq!(error.code, LixError::CODE_SCHEMA_VALIDATION);
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
#[tokio::test]
|
|
3204
|
+
async fn validation_rejects_invalid_snapshot_json() {
|
|
3205
|
+
let visible_schemas = vec![key_value_schema()];
|
|
3206
|
+
let staged_writes = StagedWriteSet {
|
|
3207
|
+
state_rows: vec![staged_row(
|
|
3208
|
+
"lix_key_value",
|
|
3209
|
+
"1",
|
|
3210
|
+
Some("{not-json".to_string()),
|
|
3211
|
+
)],
|
|
3212
|
+
..empty_staged_write_set()
|
|
3213
|
+
};
|
|
3214
|
+
|
|
3215
|
+
let error = validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
|
|
3216
|
+
.await
|
|
3217
|
+
.expect_err("invalid snapshot JSON should fail");
|
|
3218
|
+
|
|
3219
|
+
assert_eq!(error.code, LixError::CODE_SCHEMA_VALIDATION);
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
#[tokio::test]
|
|
3223
|
+
async fn validation_skips_snapshot_validation_for_tombstones() {
|
|
3224
|
+
let visible_schemas = vec![key_value_schema()];
|
|
3225
|
+
let staged_writes = StagedWriteSet {
|
|
3226
|
+
state_rows: vec![staged_row("lix_key_value", "1", None)],
|
|
3227
|
+
adopted_rows: Vec::new(),
|
|
3228
|
+
..empty_staged_write_set()
|
|
3229
|
+
};
|
|
3230
|
+
|
|
3231
|
+
validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
|
|
3232
|
+
.await
|
|
3233
|
+
.expect("tombstone should only require schema existence");
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
#[tokio::test]
|
|
3237
|
+
async fn validation_rejects_missing_file_owner_reference() {
|
|
3238
|
+
let visible_schemas = vec![unique_schema()];
|
|
3239
|
+
let staged_writes = StagedWriteSet {
|
|
3240
|
+
state_rows: vec![unique_row("post-1", "hello-world", "first")],
|
|
3241
|
+
adopted_rows: Vec::new(),
|
|
3242
|
+
..empty_staged_write_set()
|
|
3243
|
+
};
|
|
3244
|
+
|
|
3245
|
+
let error =
|
|
3246
|
+
validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
|
|
3247
|
+
&staged_writes,
|
|
3248
|
+
&visible_schemas,
|
|
3249
|
+
&StrictEmptyLiveStateReader,
|
|
3250
|
+
))
|
|
3251
|
+
.await
|
|
3252
|
+
.expect_err("non-null file_id should require a file descriptor");
|
|
3253
|
+
|
|
3254
|
+
assert_eq!(error.code, LixError::CODE_FILE_NOT_FOUND);
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
#[tokio::test]
|
|
3258
|
+
async fn validation_allows_pending_file_owner_reference() {
|
|
3259
|
+
let visible_schemas = vec![
|
|
3260
|
+
unique_schema(),
|
|
3261
|
+
file_descriptor_schema(),
|
|
3262
|
+
directory_descriptor_schema(),
|
|
3263
|
+
];
|
|
3264
|
+
let staged_writes = StagedWriteSet {
|
|
3265
|
+
state_rows: vec![
|
|
3266
|
+
staged_file_descriptor_row("file-a", "version-a"),
|
|
3267
|
+
unique_row("post-1", "hello-world", "first"),
|
|
3268
|
+
],
|
|
3269
|
+
..empty_staged_write_set()
|
|
3270
|
+
};
|
|
3271
|
+
|
|
3272
|
+
validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
|
|
3273
|
+
&staged_writes,
|
|
3274
|
+
&visible_schemas,
|
|
3275
|
+
&StrictEmptyLiveStateReader,
|
|
3276
|
+
))
|
|
3277
|
+
.await
|
|
3278
|
+
.expect("same-transaction file descriptor should satisfy file ownership");
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
#[tokio::test]
|
|
3282
|
+
async fn validation_allows_committed_file_owner_reference() {
|
|
3283
|
+
let visible_schemas = vec![unique_schema()];
|
|
3284
|
+
let staged_writes = StagedWriteSet {
|
|
3285
|
+
state_rows: vec![unique_row("post-1", "hello-world", "first")],
|
|
3286
|
+
adopted_rows: Vec::new(),
|
|
3287
|
+
..empty_staged_write_set()
|
|
3288
|
+
};
|
|
3289
|
+
let live_state = StaticLiveStateReader {
|
|
3290
|
+
rows: vec![committed_file_descriptor_row("file-a", "version-a")],
|
|
3291
|
+
};
|
|
3292
|
+
|
|
3293
|
+
validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
|
|
3294
|
+
&staged_writes,
|
|
3295
|
+
&visible_schemas,
|
|
3296
|
+
&live_state,
|
|
3297
|
+
))
|
|
3298
|
+
.await
|
|
3299
|
+
.expect("committed file descriptor should satisfy file ownership");
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3302
|
+
#[tokio::test]
|
|
3303
|
+
async fn validation_rejects_file_owner_reference_that_exists_only_in_global() {
|
|
3304
|
+
let visible_schemas = vec![unique_schema()];
|
|
3305
|
+
let staged_writes = StagedWriteSet {
|
|
3306
|
+
state_rows: vec![unique_row("post-1", "hello-world", "first")],
|
|
3307
|
+
adopted_rows: Vec::new(),
|
|
3308
|
+
..empty_staged_write_set()
|
|
3309
|
+
};
|
|
3310
|
+
let live_state = StrictStaticLiveStateReader {
|
|
3311
|
+
rows: vec![committed_file_descriptor_row(
|
|
3312
|
+
"file-a",
|
|
3313
|
+
crate::GLOBAL_VERSION_ID,
|
|
3314
|
+
)],
|
|
3315
|
+
};
|
|
3316
|
+
|
|
3317
|
+
let error =
|
|
3318
|
+
validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
|
|
3319
|
+
&staged_writes,
|
|
3320
|
+
&visible_schemas,
|
|
3321
|
+
&live_state,
|
|
3322
|
+
))
|
|
3323
|
+
.await
|
|
3324
|
+
.expect_err("global file descriptor should not satisfy a version-local row");
|
|
3325
|
+
|
|
3326
|
+
assert_eq!(error.code, LixError::CODE_FILE_NOT_FOUND);
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3329
|
+
#[tokio::test]
|
|
3330
|
+
async fn validation_rejects_primary_key_duplicate_with_different_identity() {
|
|
3331
|
+
let visible_schemas = vec![unique_schema()];
|
|
3332
|
+
let mut conflicting = unique_row("post-1", "hello-world", "first");
|
|
3333
|
+
conflicting.entity_id = crate::entity_identity::EntityIdentity::single("post-2");
|
|
3334
|
+
let staged_writes = StagedWriteSet {
|
|
3335
|
+
state_rows: vec![unique_row("post-1", "hello-world", "first"), conflicting],
|
|
3336
|
+
adopted_rows: Vec::new(),
|
|
3337
|
+
..empty_staged_write_set()
|
|
3338
|
+
};
|
|
3339
|
+
|
|
3340
|
+
let error = validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
|
|
3341
|
+
.await
|
|
3342
|
+
.expect_err("same primary key under different identity should fail");
|
|
3343
|
+
|
|
3344
|
+
assert_eq!(error.code, LixError::CODE_UNIQUE);
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3347
|
+
#[tokio::test]
|
|
3348
|
+
async fn validation_rejects_pending_unique_value_duplicate() {
|
|
3349
|
+
let visible_schemas = vec![unique_schema()];
|
|
3350
|
+
let staged_writes = StagedWriteSet {
|
|
3351
|
+
state_rows: vec![
|
|
3352
|
+
unique_row("post-1", "hello-world", "first"),
|
|
3353
|
+
unique_row("post-2", "hello-world", "second"),
|
|
3354
|
+
],
|
|
3355
|
+
..empty_staged_write_set()
|
|
3356
|
+
};
|
|
3357
|
+
|
|
3358
|
+
let error = validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
|
|
3359
|
+
.await
|
|
3360
|
+
.expect_err("duplicate pending unique value should fail");
|
|
3361
|
+
|
|
3362
|
+
assert_eq!(error.code, LixError::CODE_UNIQUE);
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3365
|
+
#[tokio::test]
|
|
3366
|
+
async fn validation_rejects_pending_unique_duplicate_with_null_component() {
|
|
3367
|
+
let visible_schemas = vec![nullable_unique_schema()];
|
|
3368
|
+
let staged_writes = StagedWriteSet {
|
|
3369
|
+
state_rows: vec![
|
|
3370
|
+
nullable_unique_row("row-1", None, "root-name"),
|
|
3371
|
+
nullable_unique_row("row-2", None, "root-name"),
|
|
3372
|
+
],
|
|
3373
|
+
..empty_staged_write_set()
|
|
3374
|
+
};
|
|
3375
|
+
|
|
3376
|
+
let error = validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
|
|
3377
|
+
.await
|
|
3378
|
+
.expect_err("duplicate nullable unique value should fail");
|
|
3379
|
+
|
|
3380
|
+
assert_eq!(error.code, LixError::CODE_UNIQUE);
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3383
|
+
#[tokio::test]
|
|
3384
|
+
async fn validation_rejects_pending_unique_same_value_in_same_version() {
|
|
3385
|
+
let visible_schemas = vec![unique_schema()];
|
|
3386
|
+
let mut duplicate = unique_row("post-2", "hello-world", "second");
|
|
3387
|
+
duplicate.version_id = "version-a".to_string();
|
|
3388
|
+
let staged_writes = StagedWriteSet {
|
|
3389
|
+
state_rows: vec![unique_row("post-1", "hello-world", "first"), duplicate],
|
|
3390
|
+
adopted_rows: Vec::new(),
|
|
3391
|
+
..empty_staged_write_set()
|
|
3392
|
+
};
|
|
3393
|
+
|
|
3394
|
+
let error = validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
|
|
3395
|
+
.await
|
|
3396
|
+
.expect_err("same unique value in the same version should fail");
|
|
3397
|
+
|
|
3398
|
+
assert_eq!(error.code, LixError::CODE_UNIQUE);
|
|
3399
|
+
}
|
|
3400
|
+
|
|
3401
|
+
#[tokio::test]
|
|
3402
|
+
async fn validation_allows_pending_unique_same_value_in_different_versions() {
|
|
3403
|
+
let visible_schemas = vec![unique_schema()];
|
|
3404
|
+
let mut version_b = unique_row("post-2", "hello-world", "second");
|
|
3405
|
+
version_b.version_id = "version-b".to_string();
|
|
3406
|
+
let staged_writes = StagedWriteSet {
|
|
3407
|
+
state_rows: vec![unique_row("post-1", "hello-world", "first"), version_b],
|
|
3408
|
+
adopted_rows: Vec::new(),
|
|
3409
|
+
..empty_staged_write_set()
|
|
3410
|
+
};
|
|
3411
|
+
|
|
3412
|
+
validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
|
|
3413
|
+
.await
|
|
3414
|
+
.expect("unique values should be scoped to the exact version_id");
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
#[tokio::test]
|
|
3418
|
+
async fn validation_allows_pending_unique_overwrite_of_same_identity() {
|
|
3419
|
+
let visible_schemas = vec![unique_schema()];
|
|
3420
|
+
let staged_writes = StagedWriteSet {
|
|
3421
|
+
state_rows: vec![
|
|
3422
|
+
unique_row("post-1", "hello-world", "first"),
|
|
3423
|
+
unique_row("post-1", "hello-world", "updated"),
|
|
3424
|
+
],
|
|
3425
|
+
..empty_staged_write_set()
|
|
3426
|
+
};
|
|
3427
|
+
|
|
3428
|
+
validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
|
|
3429
|
+
.await
|
|
3430
|
+
.expect("same identity should be treated as replacement, not duplicate");
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3433
|
+
#[tokio::test]
|
|
3434
|
+
async fn validation_skips_pending_unique_indexes_for_tombstones() {
|
|
3435
|
+
let visible_schemas = vec![unique_schema()];
|
|
3436
|
+
let mut tombstone = unique_row("post-1", "hello-world", "deleted");
|
|
3437
|
+
tombstone.snapshot_content = None;
|
|
3438
|
+
let staged_writes = StagedWriteSet {
|
|
3439
|
+
state_rows: vec![tombstone, unique_row("post-2", "hello-world", "second")],
|
|
3440
|
+
adopted_rows: Vec::new(),
|
|
3441
|
+
..empty_staged_write_set()
|
|
3442
|
+
};
|
|
3443
|
+
|
|
3444
|
+
validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
|
|
3445
|
+
.await
|
|
3446
|
+
.expect("tombstones should not claim pending unique values");
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
#[tokio::test]
|
|
3450
|
+
async fn validation_scopes_pending_unique_values_by_file_and_version() {
|
|
3451
|
+
let visible_schemas = vec![unique_schema()];
|
|
3452
|
+
let mut different_file = unique_row("post-2", "hello-world", "second");
|
|
3453
|
+
different_file.file_id = Some("file-b".to_string());
|
|
3454
|
+
let mut different_version = unique_row("post-3", "hello-world", "third");
|
|
3455
|
+
different_version.version_id = "version-b".to_string();
|
|
3456
|
+
let staged_writes = StagedWriteSet {
|
|
3457
|
+
state_rows: vec![
|
|
3458
|
+
unique_row("post-1", "hello-world", "first"),
|
|
3459
|
+
different_file,
|
|
3460
|
+
different_version,
|
|
3461
|
+
],
|
|
3462
|
+
..empty_staged_write_set()
|
|
3463
|
+
};
|
|
3464
|
+
|
|
3465
|
+
validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
|
|
3466
|
+
.await
|
|
3467
|
+
.expect("unique values are scoped by file and version");
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3470
|
+
#[tokio::test]
|
|
3471
|
+
async fn validation_rejects_committed_visible_unique_value_duplicate() {
|
|
3472
|
+
let visible_schemas = vec![unique_schema()];
|
|
3473
|
+
let staged_writes = StagedWriteSet {
|
|
3474
|
+
state_rows: vec![unique_row("post-2", "hello-world", "second")],
|
|
3475
|
+
adopted_rows: Vec::new(),
|
|
3476
|
+
..empty_staged_write_set()
|
|
3477
|
+
};
|
|
3478
|
+
let live_state = StaticLiveStateReader {
|
|
3479
|
+
rows: vec![committed_unique_row("post-1", "hello-world", "first")],
|
|
3480
|
+
};
|
|
3481
|
+
|
|
3482
|
+
let error =
|
|
3483
|
+
validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
|
|
3484
|
+
&staged_writes,
|
|
3485
|
+
&visible_schemas,
|
|
3486
|
+
&live_state,
|
|
3487
|
+
))
|
|
3488
|
+
.await
|
|
3489
|
+
.expect_err("committed visible unique value should conflict");
|
|
3490
|
+
|
|
3491
|
+
assert_eq!(error.code, LixError::CODE_UNIQUE);
|
|
3492
|
+
}
|
|
3493
|
+
|
|
3494
|
+
#[tokio::test]
|
|
3495
|
+
async fn validation_rejects_committed_unique_duplicate_with_null_component() {
|
|
3496
|
+
let visible_schemas = vec![nullable_unique_schema()];
|
|
3497
|
+
let staged_writes = StagedWriteSet {
|
|
3498
|
+
state_rows: vec![nullable_unique_row("row-2", None, "root-name")],
|
|
3499
|
+
adopted_rows: Vec::new(),
|
|
3500
|
+
..empty_staged_write_set()
|
|
3501
|
+
};
|
|
3502
|
+
let live_state = StaticLiveStateReader {
|
|
3503
|
+
rows: vec![committed_nullable_unique_row("row-1", None, "root-name")],
|
|
3504
|
+
};
|
|
3505
|
+
|
|
3506
|
+
let error =
|
|
3507
|
+
validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
|
|
3508
|
+
&staged_writes,
|
|
3509
|
+
&visible_schemas,
|
|
3510
|
+
&live_state,
|
|
3511
|
+
))
|
|
3512
|
+
.await
|
|
3513
|
+
.expect_err("committed duplicate nullable unique value should conflict");
|
|
3514
|
+
|
|
3515
|
+
assert_eq!(error.code, LixError::CODE_UNIQUE);
|
|
3516
|
+
}
|
|
3517
|
+
|
|
3518
|
+
#[tokio::test]
|
|
3519
|
+
async fn validation_rejects_committed_unique_same_value_in_same_version() {
|
|
3520
|
+
let visible_schemas = vec![unique_schema()];
|
|
3521
|
+
let staged_writes = StagedWriteSet {
|
|
3522
|
+
state_rows: vec![unique_row("post-2", "hello-world", "second")],
|
|
3523
|
+
adopted_rows: Vec::new(),
|
|
3524
|
+
..empty_staged_write_set()
|
|
3525
|
+
};
|
|
3526
|
+
let live_state = StaticLiveStateReader {
|
|
3527
|
+
rows: vec![committed_unique_row("post-1", "hello-world", "first")],
|
|
3528
|
+
};
|
|
3529
|
+
|
|
3530
|
+
let error =
|
|
3531
|
+
validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
|
|
3532
|
+
&staged_writes,
|
|
3533
|
+
&visible_schemas,
|
|
3534
|
+
&live_state,
|
|
3535
|
+
))
|
|
3536
|
+
.await
|
|
3537
|
+
.expect_err("same unique value in the same version should conflict");
|
|
3538
|
+
|
|
3539
|
+
assert_eq!(error.code, LixError::CODE_UNIQUE);
|
|
3540
|
+
}
|
|
3541
|
+
|
|
3542
|
+
#[tokio::test]
|
|
3543
|
+
async fn validation_allows_committed_unique_same_value_in_different_versions() {
|
|
3544
|
+
let visible_schemas = vec![unique_schema()];
|
|
3545
|
+
let mut version_b = unique_row("post-2", "hello-world", "second");
|
|
3546
|
+
version_b.version_id = "version-b".to_string();
|
|
3547
|
+
let staged_writes = StagedWriteSet {
|
|
3548
|
+
state_rows: vec![version_b],
|
|
3549
|
+
adopted_rows: Vec::new(),
|
|
3550
|
+
..empty_staged_write_set()
|
|
3551
|
+
};
|
|
3552
|
+
let live_state = StaticLiveStateReader {
|
|
3553
|
+
rows: vec![committed_unique_row("post-1", "hello-world", "first")],
|
|
3554
|
+
};
|
|
3555
|
+
|
|
3556
|
+
validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
|
|
3557
|
+
&staged_writes,
|
|
3558
|
+
&visible_schemas,
|
|
3559
|
+
&live_state,
|
|
3560
|
+
))
|
|
3561
|
+
.await
|
|
3562
|
+
.expect("committed unique values should be scoped to the exact version_id");
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
#[tokio::test]
|
|
3566
|
+
async fn validation_ignores_projected_live_state_rows_for_unique_constraints() {
|
|
3567
|
+
let visible_schemas = vec![unique_schema()];
|
|
3568
|
+
let staged_writes = StagedWriteSet {
|
|
3569
|
+
state_rows: vec![unique_row("post-2", "hello-world", "second")],
|
|
3570
|
+
adopted_rows: Vec::new(),
|
|
3571
|
+
..empty_staged_write_set()
|
|
3572
|
+
};
|
|
3573
|
+
let mut projected_overlay_row = committed_unique_row("post-1", "hello-world", "first");
|
|
3574
|
+
projected_overlay_row.version_id = "version-a".to_string();
|
|
3575
|
+
projected_overlay_row.global = true;
|
|
3576
|
+
let live_state = StaticLiveStateReader {
|
|
3577
|
+
rows: vec![projected_overlay_row],
|
|
3578
|
+
};
|
|
3579
|
+
|
|
3580
|
+
validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
|
|
3581
|
+
&staged_writes,
|
|
3582
|
+
&visible_schemas,
|
|
3583
|
+
&live_state,
|
|
3584
|
+
))
|
|
3585
|
+
.await
|
|
3586
|
+
.expect("validation should ignore live-state overlay projections");
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3589
|
+
#[tokio::test]
|
|
3590
|
+
async fn validation_allows_committed_visible_unique_update_of_same_identity() {
|
|
3591
|
+
let visible_schemas = vec![unique_schema()];
|
|
3592
|
+
let staged_writes = StagedWriteSet {
|
|
3593
|
+
state_rows: vec![unique_row("post-1", "hello-world", "updated")],
|
|
3594
|
+
adopted_rows: Vec::new(),
|
|
3595
|
+
..empty_staged_write_set()
|
|
3596
|
+
};
|
|
3597
|
+
let live_state = StaticLiveStateReader {
|
|
3598
|
+
rows: vec![committed_unique_row("post-1", "hello-world", "first")],
|
|
3599
|
+
};
|
|
3600
|
+
|
|
3601
|
+
validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
|
|
3602
|
+
&staged_writes,
|
|
3603
|
+
&visible_schemas,
|
|
3604
|
+
&live_state,
|
|
3605
|
+
))
|
|
3606
|
+
.await
|
|
3607
|
+
.expect("same identity should update committed unique owner");
|
|
3608
|
+
}
|
|
3609
|
+
|
|
3610
|
+
#[tokio::test]
|
|
3611
|
+
async fn validation_ignores_committed_unique_owner_tombstoned_by_transaction() {
|
|
3612
|
+
let visible_schemas = vec![unique_schema()];
|
|
3613
|
+
let mut tombstone = unique_row("post-1", "hello-world", "deleted");
|
|
3614
|
+
tombstone.snapshot_content = None;
|
|
3615
|
+
let staged_writes = StagedWriteSet {
|
|
3616
|
+
state_rows: vec![tombstone, unique_row("post-2", "hello-world", "second")],
|
|
3617
|
+
adopted_rows: Vec::new(),
|
|
3618
|
+
..empty_staged_write_set()
|
|
3619
|
+
};
|
|
3620
|
+
let live_state = StaticLiveStateReader {
|
|
3621
|
+
rows: vec![committed_unique_row("post-1", "hello-world", "first")],
|
|
3622
|
+
};
|
|
3623
|
+
|
|
3624
|
+
validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
|
|
3625
|
+
&staged_writes,
|
|
3626
|
+
&visible_schemas,
|
|
3627
|
+
&live_state,
|
|
3628
|
+
))
|
|
3629
|
+
.await
|
|
3630
|
+
.expect("tombstoned committed owner should not conflict");
|
|
3631
|
+
}
|
|
3632
|
+
|
|
3633
|
+
#[tokio::test]
|
|
3634
|
+
async fn validation_allows_committed_unique_same_value_in_different_file_or_version() {
|
|
3635
|
+
let visible_schemas = vec![unique_schema()];
|
|
3636
|
+
let mut different_file = unique_row("post-2", "hello-world", "second");
|
|
3637
|
+
different_file.file_id = Some("file-b".to_string());
|
|
3638
|
+
let mut different_version = unique_row("post-3", "hello-world", "third");
|
|
3639
|
+
different_version.version_id = "version-b".to_string();
|
|
3640
|
+
let staged_writes = StagedWriteSet {
|
|
3641
|
+
state_rows: vec![different_file, different_version],
|
|
3642
|
+
adopted_rows: Vec::new(),
|
|
3643
|
+
..empty_staged_write_set()
|
|
3644
|
+
};
|
|
3645
|
+
let live_state = StaticLiveStateReader {
|
|
3646
|
+
rows: vec![committed_unique_row("post-1", "hello-world", "first")],
|
|
3647
|
+
};
|
|
3648
|
+
|
|
3649
|
+
validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
|
|
3650
|
+
&staged_writes,
|
|
3651
|
+
&visible_schemas,
|
|
3652
|
+
&live_state,
|
|
3653
|
+
))
|
|
3654
|
+
.await
|
|
3655
|
+
.expect("committed uniqueness is scoped by file and version");
|
|
3656
|
+
}
|
|
3657
|
+
|
|
3658
|
+
#[tokio::test]
|
|
3659
|
+
async fn validation_rejects_foreign_key_target_missing_in_same_version() {
|
|
3660
|
+
let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
|
|
3661
|
+
let staged_writes = StagedWriteSet {
|
|
3662
|
+
state_rows: vec![fk_child_row("child-1", "parent-1", "version-a")],
|
|
3663
|
+
adopted_rows: Vec::new(),
|
|
3664
|
+
..empty_staged_write_set()
|
|
3665
|
+
};
|
|
3666
|
+
|
|
3667
|
+
let error = validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
|
|
3668
|
+
.await
|
|
3669
|
+
.expect_err("foreign key must resolve in the same version");
|
|
3670
|
+
|
|
3671
|
+
assert_eq!(error.code, LixError::CODE_FOREIGN_KEY);
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
#[tokio::test]
|
|
3675
|
+
async fn validation_allows_foreign_key_target_in_same_version() {
|
|
3676
|
+
let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
|
|
3677
|
+
let staged_writes = StagedWriteSet {
|
|
3678
|
+
state_rows: vec![
|
|
3679
|
+
fk_parent_row("parent-1", "version-a"),
|
|
3680
|
+
fk_child_row("child-1", "parent-1", "version-a"),
|
|
3681
|
+
],
|
|
3682
|
+
..empty_staged_write_set()
|
|
3683
|
+
};
|
|
3684
|
+
|
|
3685
|
+
validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
|
|
3686
|
+
.await
|
|
3687
|
+
.expect("foreign key should resolve against pending rows in the same version");
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
#[tokio::test]
|
|
3691
|
+
async fn validation_rejects_foreign_key_target_that_exists_only_in_different_version() {
|
|
3692
|
+
let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
|
|
3693
|
+
let staged_writes = StagedWriteSet {
|
|
3694
|
+
state_rows: vec![
|
|
3695
|
+
fk_parent_row("parent-1", "version-b"),
|
|
3696
|
+
fk_child_row("child-1", "parent-1", "version-a"),
|
|
3697
|
+
],
|
|
3698
|
+
..empty_staged_write_set()
|
|
3699
|
+
};
|
|
3700
|
+
|
|
3701
|
+
let error = validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
|
|
3702
|
+
.await
|
|
3703
|
+
.expect_err("foreign key target in another version should not satisfy this version");
|
|
3704
|
+
|
|
3705
|
+
assert_eq!(error.code, LixError::CODE_FOREIGN_KEY);
|
|
3706
|
+
}
|
|
3707
|
+
|
|
3708
|
+
#[tokio::test]
|
|
3709
|
+
async fn validation_allows_foreign_key_target_committed_in_same_version() {
|
|
3710
|
+
let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
|
|
3711
|
+
let staged_writes = StagedWriteSet {
|
|
3712
|
+
state_rows: vec![fk_child_row("child-1", "parent-1", "version-a")],
|
|
3713
|
+
adopted_rows: Vec::new(),
|
|
3714
|
+
..empty_staged_write_set()
|
|
3715
|
+
};
|
|
3716
|
+
let live_state = StaticLiveStateReader {
|
|
3717
|
+
rows: vec![LiveStateRow::from(fk_parent_row("parent-1", "version-a"))],
|
|
3718
|
+
};
|
|
3719
|
+
|
|
3720
|
+
validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
|
|
3721
|
+
&staged_writes,
|
|
3722
|
+
&visible_schemas,
|
|
3723
|
+
&live_state,
|
|
3724
|
+
))
|
|
3725
|
+
.await
|
|
3726
|
+
.expect("foreign key should resolve against committed rows in the same version");
|
|
3727
|
+
}
|
|
3728
|
+
|
|
3729
|
+
#[tokio::test]
|
|
3730
|
+
async fn validation_rejects_foreign_key_target_committed_only_in_different_version() {
|
|
3731
|
+
let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
|
|
3732
|
+
let staged_writes = StagedWriteSet {
|
|
3733
|
+
state_rows: vec![fk_child_row("child-1", "parent-1", "version-a")],
|
|
3734
|
+
adopted_rows: Vec::new(),
|
|
3735
|
+
..empty_staged_write_set()
|
|
3736
|
+
};
|
|
3737
|
+
let live_state = StaticLiveStateReader {
|
|
3738
|
+
rows: vec![LiveStateRow::from(fk_parent_row("parent-1", "version-b"))],
|
|
3739
|
+
};
|
|
3740
|
+
|
|
3741
|
+
let error =
|
|
3742
|
+
validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
|
|
3743
|
+
&staged_writes,
|
|
3744
|
+
&visible_schemas,
|
|
3745
|
+
&live_state,
|
|
3746
|
+
))
|
|
3747
|
+
.await
|
|
3748
|
+
.expect_err(
|
|
3749
|
+
"foreign key target in another committed version should not satisfy this version",
|
|
3750
|
+
);
|
|
3751
|
+
|
|
3752
|
+
assert_eq!(error.code, LixError::CODE_FOREIGN_KEY);
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3755
|
+
#[tokio::test]
|
|
3756
|
+
async fn validation_rejects_foreign_key_target_tombstoned_by_transaction() {
|
|
3757
|
+
let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
|
|
3758
|
+
let mut parent_delete = fk_parent_row("parent-1", "version-a");
|
|
3759
|
+
parent_delete.snapshot_content = None;
|
|
3760
|
+
let staged_writes = StagedWriteSet {
|
|
3761
|
+
state_rows: vec![
|
|
3762
|
+
parent_delete,
|
|
3763
|
+
fk_child_row("child-1", "parent-1", "version-a"),
|
|
3764
|
+
],
|
|
3765
|
+
..empty_staged_write_set()
|
|
3766
|
+
};
|
|
3767
|
+
let live_state = StaticLiveStateReader {
|
|
3768
|
+
rows: vec![LiveStateRow::from(fk_parent_row("parent-1", "version-a"))],
|
|
3769
|
+
};
|
|
3770
|
+
|
|
3771
|
+
let error =
|
|
3772
|
+
validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
|
|
3773
|
+
&staged_writes,
|
|
3774
|
+
&visible_schemas,
|
|
3775
|
+
&live_state,
|
|
3776
|
+
))
|
|
3777
|
+
.await
|
|
3778
|
+
.expect_err("same-transaction tombstone should hide the committed FK target");
|
|
3779
|
+
|
|
3780
|
+
assert_eq!(error.code, LixError::CODE_FOREIGN_KEY);
|
|
3781
|
+
}
|
|
3782
|
+
|
|
3783
|
+
#[tokio::test]
|
|
3784
|
+
async fn validation_rejects_pending_reference_to_deleted_identity() {
|
|
3785
|
+
let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
|
|
3786
|
+
let mut parent_delete = fk_parent_row("parent-1", "version-a");
|
|
3787
|
+
parent_delete.snapshot_content = None;
|
|
3788
|
+
let staged_writes = StagedWriteSet {
|
|
3789
|
+
state_rows: vec![
|
|
3790
|
+
parent_delete,
|
|
3791
|
+
fk_child_row("child-1", "parent-1", "version-a"),
|
|
3792
|
+
],
|
|
3793
|
+
..empty_staged_write_set()
|
|
3794
|
+
};
|
|
3795
|
+
let live_state = StaticLiveStateReader {
|
|
3796
|
+
rows: vec![LiveStateRow::from(fk_parent_row("parent-1", "version-a"))],
|
|
3797
|
+
};
|
|
3798
|
+
|
|
3799
|
+
let error =
|
|
3800
|
+
validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
|
|
3801
|
+
&staged_writes,
|
|
3802
|
+
&visible_schemas,
|
|
3803
|
+
&live_state,
|
|
3804
|
+
))
|
|
3805
|
+
.await
|
|
3806
|
+
.expect_err("pending child reference should block parent delete");
|
|
3807
|
+
|
|
3808
|
+
assert_eq!(error.code, LixError::CODE_FOREIGN_KEY);
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3811
|
+
#[tokio::test]
|
|
3812
|
+
async fn validation_allows_delete_with_pending_reference_in_different_version() {
|
|
3813
|
+
let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
|
|
3814
|
+
let mut parent_delete = fk_parent_row("parent-1", "version-a");
|
|
3815
|
+
parent_delete.snapshot_content = None;
|
|
3816
|
+
let staged_writes = StagedWriteSet {
|
|
3817
|
+
state_rows: vec![
|
|
3818
|
+
parent_delete,
|
|
3819
|
+
fk_parent_row("parent-1", "version-b"),
|
|
3820
|
+
fk_child_row("child-1", "parent-1", "version-b"),
|
|
3821
|
+
],
|
|
3822
|
+
..empty_staged_write_set()
|
|
3823
|
+
};
|
|
3824
|
+
|
|
3825
|
+
validate_staged_writes(validation_input(&staged_writes, &visible_schemas))
|
|
3826
|
+
.await
|
|
3827
|
+
.expect("pending references in another version should not block this delete");
|
|
3828
|
+
}
|
|
3829
|
+
|
|
3830
|
+
#[tokio::test]
|
|
3831
|
+
async fn validation_allows_state_surface_fk_target_committed_by_exact_identity() {
|
|
3832
|
+
let visible_schemas = vec![fk_parent_schema(), state_surface_ref_schema()];
|
|
3833
|
+
let staged_writes = StagedWriteSet {
|
|
3834
|
+
state_rows: vec![state_surface_ref_row(
|
|
3835
|
+
"ref-1",
|
|
3836
|
+
"target-1",
|
|
3837
|
+
"fk_parent_schema",
|
|
3838
|
+
"file-a",
|
|
3839
|
+
)],
|
|
3840
|
+
..empty_staged_write_set()
|
|
3841
|
+
};
|
|
3842
|
+
let live_state = StaticLiveStateReader {
|
|
3843
|
+
rows: vec![LiveStateRow::from(fk_parent_row("target-1", "version-a"))],
|
|
3844
|
+
};
|
|
3845
|
+
|
|
3846
|
+
validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
|
|
3847
|
+
&staged_writes,
|
|
3848
|
+
&visible_schemas,
|
|
3849
|
+
&live_state,
|
|
3850
|
+
))
|
|
3851
|
+
.await
|
|
3852
|
+
.expect("lix_state FK should resolve against exact committed identity");
|
|
3853
|
+
}
|
|
3854
|
+
|
|
3855
|
+
#[tokio::test]
|
|
3856
|
+
async fn validation_rejects_delete_when_same_version_reference_exists() {
|
|
3857
|
+
let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
|
|
3858
|
+
let mut parent_delete = fk_parent_row("parent-1", "version-a");
|
|
3859
|
+
parent_delete.snapshot_content = None;
|
|
3860
|
+
let live_state = StaticLiveStateReader {
|
|
3861
|
+
rows: vec![
|
|
3862
|
+
LiveStateRow::from(fk_parent_row("parent-1", "version-a")),
|
|
3863
|
+
LiveStateRow::from(fk_child_row("child-1", "parent-1", "version-a")),
|
|
3864
|
+
],
|
|
3865
|
+
};
|
|
3866
|
+
let staged_writes = StagedWriteSet {
|
|
3867
|
+
state_rows: vec![parent_delete],
|
|
3868
|
+
adopted_rows: Vec::new(),
|
|
3869
|
+
..empty_staged_write_set()
|
|
3870
|
+
};
|
|
3871
|
+
|
|
3872
|
+
let error =
|
|
3873
|
+
validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
|
|
3874
|
+
&staged_writes,
|
|
3875
|
+
&visible_schemas,
|
|
3876
|
+
&live_state,
|
|
3877
|
+
))
|
|
3878
|
+
.await
|
|
3879
|
+
.expect_err("delete should be restricted by same-version references");
|
|
3880
|
+
|
|
3881
|
+
assert_eq!(error.code, LixError::CODE_FOREIGN_KEY);
|
|
3882
|
+
}
|
|
3883
|
+
|
|
3884
|
+
#[tokio::test]
|
|
3885
|
+
async fn validation_allows_delete_when_only_different_version_reference_exists() {
|
|
3886
|
+
let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
|
|
3887
|
+
let mut parent_delete = fk_parent_row("parent-1", "version-a");
|
|
3888
|
+
parent_delete.snapshot_content = None;
|
|
3889
|
+
let live_state = StaticLiveStateReader {
|
|
3890
|
+
rows: vec![
|
|
3891
|
+
LiveStateRow::from(fk_parent_row("parent-1", "version-a")),
|
|
3892
|
+
LiveStateRow::from(fk_child_row("child-1", "parent-1", "version-b")),
|
|
3893
|
+
],
|
|
3894
|
+
};
|
|
3895
|
+
let staged_writes = StagedWriteSet {
|
|
3896
|
+
state_rows: vec![parent_delete],
|
|
3897
|
+
adopted_rows: Vec::new(),
|
|
3898
|
+
..empty_staged_write_set()
|
|
3899
|
+
};
|
|
3900
|
+
|
|
3901
|
+
validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
|
|
3902
|
+
&staged_writes,
|
|
3903
|
+
&visible_schemas,
|
|
3904
|
+
&live_state,
|
|
3905
|
+
))
|
|
3906
|
+
.await
|
|
3907
|
+
.expect("references in another version should not restrict this version");
|
|
3908
|
+
}
|
|
3909
|
+
|
|
3910
|
+
#[tokio::test]
|
|
3911
|
+
async fn validation_allows_delete_when_committed_reference_is_also_deleted() {
|
|
3912
|
+
let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
|
|
3913
|
+
let mut parent_delete = fk_parent_row("parent-1", "version-a");
|
|
3914
|
+
parent_delete.snapshot_content = None;
|
|
3915
|
+
let mut child_delete = fk_child_row("child-1", "parent-1", "version-a");
|
|
3916
|
+
child_delete.snapshot_content = None;
|
|
3917
|
+
let live_state = StaticLiveStateReader {
|
|
3918
|
+
rows: vec![
|
|
3919
|
+
LiveStateRow::from(fk_parent_row("parent-1", "version-a")),
|
|
3920
|
+
LiveStateRow::from(fk_child_row("child-1", "parent-1", "version-a")),
|
|
3921
|
+
],
|
|
3922
|
+
};
|
|
3923
|
+
let staged_writes = StagedWriteSet {
|
|
3924
|
+
state_rows: vec![parent_delete, child_delete],
|
|
3925
|
+
adopted_rows: Vec::new(),
|
|
3926
|
+
..empty_staged_write_set()
|
|
3927
|
+
};
|
|
3928
|
+
|
|
3929
|
+
validate_staged_writes(TransactionValidationInput::from_visible_schemas_for_tests(
|
|
3930
|
+
&staged_writes,
|
|
3931
|
+
&visible_schemas,
|
|
3932
|
+
&live_state,
|
|
3933
|
+
))
|
|
3934
|
+
.await
|
|
3935
|
+
.expect("committed references deleted in the same transaction should not restrict delete");
|
|
3936
|
+
}
|
|
3937
|
+
|
|
3938
|
+
#[test]
|
|
3939
|
+
fn compiled_schema_catalog_compiles_each_schema_once() {
|
|
3940
|
+
let visible_schemas = vec![key_value_schema()];
|
|
3941
|
+
let staged_writes = empty_staged_write_set();
|
|
3942
|
+
let input = validation_input(&staged_writes, &visible_schemas);
|
|
3943
|
+
let catalog = catalog_from_transaction_input(&input).expect("schema catalog should build");
|
|
3944
|
+
let mut compiled = CompiledSchemaCatalog::new(&catalog);
|
|
3945
|
+
|
|
3946
|
+
compiled
|
|
3947
|
+
.compiled_schema("lix_key_value", "1")
|
|
3948
|
+
.expect("schema should compile");
|
|
3949
|
+
compiled
|
|
3950
|
+
.compiled_schema("lix_key_value", "1")
|
|
3951
|
+
.expect("schema should be cached");
|
|
3952
|
+
|
|
3953
|
+
assert_eq!(compiled.compiled_count(), 1);
|
|
3954
|
+
}
|
|
3955
|
+
|
|
3956
|
+
#[test]
|
|
3957
|
+
fn pending_indexes_record_primary_key_fk_targets_by_exact_scope() {
|
|
3958
|
+
let mut indexes = PendingConstraintIndexes::default();
|
|
3959
|
+
let row = fk_parent_row("parent-1", "version-a");
|
|
3960
|
+
let snapshot = serde_json::from_str::<JsonValue>(
|
|
3961
|
+
row.snapshot_content
|
|
3962
|
+
.as_deref()
|
|
3963
|
+
.expect("fixture should have snapshot"),
|
|
3964
|
+
)
|
|
3965
|
+
.expect("fixture JSON should parse");
|
|
3966
|
+
|
|
3967
|
+
indexes
|
|
3968
|
+
.remember_row(&row, &fk_parent_schema(), &snapshot)
|
|
3969
|
+
.expect("parent row should index");
|
|
3970
|
+
|
|
3971
|
+
assert!(indexes
|
|
3972
|
+
.has_fk_target(
|
|
3973
|
+
"fk_parent_schema",
|
|
3974
|
+
"1",
|
|
3975
|
+
"version-a",
|
|
3976
|
+
Some("file-a"),
|
|
3977
|
+
&["/id"],
|
|
3978
|
+
UniqueConstraintValue::string_values(["parent-1"]),
|
|
3979
|
+
)
|
|
3980
|
+
.expect("lookup should build"));
|
|
3981
|
+
assert!(!indexes
|
|
3982
|
+
.has_fk_target(
|
|
3983
|
+
"fk_parent_schema",
|
|
3984
|
+
"1",
|
|
3985
|
+
"version-b",
|
|
3986
|
+
Some("file-a"),
|
|
3987
|
+
&["/id"],
|
|
3988
|
+
UniqueConstraintValue::string_values(["parent-1"]),
|
|
3989
|
+
)
|
|
3990
|
+
.expect("lookup should build"));
|
|
3991
|
+
}
|
|
3992
|
+
|
|
3993
|
+
#[test]
|
|
3994
|
+
fn pending_indexes_record_unique_fk_targets_by_exact_scope() {
|
|
3995
|
+
let mut indexes = PendingConstraintIndexes::default();
|
|
3996
|
+
let row = unique_row("post-1", "hello-world", "first");
|
|
3997
|
+
let snapshot = serde_json::from_str::<JsonValue>(
|
|
3998
|
+
row.snapshot_content
|
|
3999
|
+
.as_deref()
|
|
4000
|
+
.expect("fixture should have snapshot"),
|
|
4001
|
+
)
|
|
4002
|
+
.expect("fixture JSON should parse");
|
|
4003
|
+
|
|
4004
|
+
indexes
|
|
4005
|
+
.remember_row(&row, &unique_schema(), &snapshot)
|
|
4006
|
+
.expect("unique row should index");
|
|
4007
|
+
|
|
4008
|
+
assert!(indexes
|
|
4009
|
+
.has_fk_target(
|
|
4010
|
+
"unique_schema",
|
|
4011
|
+
"1",
|
|
4012
|
+
"version-a",
|
|
4013
|
+
Some("file-a"),
|
|
4014
|
+
&["/slug"],
|
|
4015
|
+
UniqueConstraintValue::string_values(["hello-world"]),
|
|
4016
|
+
)
|
|
4017
|
+
.expect("lookup should build"));
|
|
4018
|
+
}
|
|
4019
|
+
|
|
4020
|
+
#[test]
|
|
4021
|
+
fn pending_indexes_record_normal_fk_references_by_exact_scope() {
|
|
4022
|
+
let mut indexes = PendingConstraintIndexes::default();
|
|
4023
|
+
let row = fk_child_row("child-1", "parent-1", "version-a");
|
|
4024
|
+
let snapshot = serde_json::from_str::<JsonValue>(
|
|
4025
|
+
row.snapshot_content
|
|
4026
|
+
.as_deref()
|
|
4027
|
+
.expect("fixture should have snapshot"),
|
|
4028
|
+
)
|
|
4029
|
+
.expect("fixture JSON should parse");
|
|
4030
|
+
let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
|
|
4031
|
+
let staged_writes = empty_staged_write_set();
|
|
4032
|
+
let input = validation_input(&staged_writes, &visible_schemas);
|
|
4033
|
+
let catalog = catalog_from_transaction_input(&input).expect("catalog should build");
|
|
4034
|
+
|
|
4035
|
+
indexes
|
|
4036
|
+
.remember_foreign_key_references(&catalog, &row, &fk_child_schema(), &snapshot)
|
|
4037
|
+
.expect("child row should index FK reference");
|
|
4038
|
+
|
|
4039
|
+
assert!(indexes
|
|
4040
|
+
.has_fk_reference_to_key(
|
|
4041
|
+
"fk_parent_schema",
|
|
4042
|
+
"1",
|
|
4043
|
+
"version-a",
|
|
4044
|
+
Some("file-a"),
|
|
4045
|
+
&["/id"],
|
|
4046
|
+
UniqueConstraintValue::string_values(["parent-1"]),
|
|
4047
|
+
)
|
|
4048
|
+
.expect("lookup should build"));
|
|
4049
|
+
assert!(!indexes
|
|
4050
|
+
.has_fk_reference_to_key(
|
|
4051
|
+
"fk_parent_schema",
|
|
4052
|
+
"1",
|
|
4053
|
+
"version-b",
|
|
4054
|
+
Some("file-a"),
|
|
4055
|
+
&["/id"],
|
|
4056
|
+
UniqueConstraintValue::string_values(["parent-1"]),
|
|
4057
|
+
)
|
|
4058
|
+
.expect("lookup should build"));
|
|
4059
|
+
}
|
|
4060
|
+
|
|
4061
|
+
#[test]
|
|
4062
|
+
fn pending_indexes_record_state_surface_fk_references_by_exact_identity() {
|
|
4063
|
+
let mut indexes = PendingConstraintIndexes::default();
|
|
4064
|
+
let row = state_surface_ref_row("ref-1", "target-1", "fk_parent_schema", "file-a");
|
|
4065
|
+
let snapshot = serde_json::from_str::<JsonValue>(
|
|
4066
|
+
row.snapshot_content
|
|
4067
|
+
.as_deref()
|
|
4068
|
+
.expect("fixture should have snapshot"),
|
|
4069
|
+
)
|
|
4070
|
+
.expect("fixture JSON should parse");
|
|
4071
|
+
let visible_schemas = vec![state_surface_ref_schema()];
|
|
4072
|
+
let staged_writes = empty_staged_write_set();
|
|
4073
|
+
let input = validation_input(&staged_writes, &visible_schemas);
|
|
4074
|
+
let catalog = catalog_from_transaction_input(&input).expect("catalog should build");
|
|
4075
|
+
|
|
4076
|
+
indexes
|
|
4077
|
+
.remember_foreign_key_references(&catalog, &row, &state_surface_ref_schema(), &snapshot)
|
|
4078
|
+
.expect("state-surface row should index FK reference");
|
|
4079
|
+
|
|
4080
|
+
assert!(indexes.has_fk_reference_to_identity(LiveStateRowIdentity {
|
|
4081
|
+
version_id: "version-a".to_string(),
|
|
4082
|
+
schema_key: "fk_parent_schema".to_string(),
|
|
4083
|
+
entity_id: EntityIdentity::single("target-1"),
|
|
4084
|
+
file_id: Some("file-a".to_string()),
|
|
4085
|
+
}));
|
|
4086
|
+
}
|
|
4087
|
+
|
|
4088
|
+
#[test]
|
|
4089
|
+
fn pending_delete_restrictions_ignore_tombstoned_referencing_rows() {
|
|
4090
|
+
let mut indexes = PendingConstraintIndexes::default();
|
|
4091
|
+
let mut parent_delete = fk_parent_row("parent-1", "version-a");
|
|
4092
|
+
parent_delete.snapshot_content = None;
|
|
4093
|
+
indexes.remember_tombstone(&parent_delete);
|
|
4094
|
+
|
|
4095
|
+
let child = fk_child_row("child-1", "parent-1", "version-a");
|
|
4096
|
+
let child_snapshot = serde_json::from_str::<JsonValue>(
|
|
4097
|
+
child
|
|
4098
|
+
.snapshot_content
|
|
4099
|
+
.as_deref()
|
|
4100
|
+
.expect("fixture should have snapshot"),
|
|
4101
|
+
)
|
|
4102
|
+
.expect("fixture JSON should parse");
|
|
4103
|
+
let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
|
|
4104
|
+
let staged_writes = empty_staged_write_set();
|
|
4105
|
+
let input = validation_input(&staged_writes, &visible_schemas);
|
|
4106
|
+
let catalog = catalog_from_transaction_input(&input).expect("catalog should build");
|
|
4107
|
+
indexes
|
|
4108
|
+
.remember_foreign_key_references(&catalog, &child, &fk_child_schema(), &child_snapshot)
|
|
4109
|
+
.expect("child row should index FK reference");
|
|
4110
|
+
|
|
4111
|
+
let mut child_delete = fk_child_row("child-1", "parent-1", "version-a");
|
|
4112
|
+
child_delete.snapshot_content = None;
|
|
4113
|
+
indexes.remember_tombstone(&child_delete);
|
|
4114
|
+
|
|
4115
|
+
validate_pending_delete_restrictions(&catalog, &indexes)
|
|
4116
|
+
.expect("a row deleted in the same transaction should not block target delete");
|
|
4117
|
+
}
|
|
4118
|
+
|
|
4119
|
+
#[test]
|
|
4120
|
+
fn pending_fk_validation_collects_unresolved_normal_fk_check() {
|
|
4121
|
+
let indexes = PendingConstraintIndexes::default();
|
|
4122
|
+
let row = fk_child_row("child-1", "parent-1", "version-a");
|
|
4123
|
+
let snapshot = serde_json::from_str::<JsonValue>(
|
|
4124
|
+
row.snapshot_content
|
|
4125
|
+
.as_deref()
|
|
4126
|
+
.expect("fixture should have snapshot"),
|
|
4127
|
+
)
|
|
4128
|
+
.expect("fixture JSON should parse");
|
|
4129
|
+
let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
|
|
4130
|
+
let staged_writes = empty_staged_write_set();
|
|
4131
|
+
let input = validation_input(&staged_writes, &visible_schemas);
|
|
4132
|
+
let catalog = catalog_from_transaction_input(&input).expect("catalog should build");
|
|
4133
|
+
|
|
4134
|
+
let unresolved = validate_pending_foreign_keys(
|
|
4135
|
+
&catalog,
|
|
4136
|
+
&indexes,
|
|
4137
|
+
&[(&row, &fk_child_schema(), snapshot)],
|
|
4138
|
+
)
|
|
4139
|
+
.expect("FK validation should collect unresolved checks");
|
|
4140
|
+
|
|
4141
|
+
assert_eq!(unresolved.len(), 1);
|
|
4142
|
+
assert_eq!(
|
|
4143
|
+
unresolved[0].source_identity,
|
|
4144
|
+
LiveStateRowIdentity {
|
|
4145
|
+
version_id: "version-a".to_string(),
|
|
4146
|
+
schema_key: "fk_child_schema".to_string(),
|
|
4147
|
+
entity_id: EntityIdentity::single("child-1"),
|
|
4148
|
+
file_id: Some("file-a".to_string()),
|
|
4149
|
+
}
|
|
4150
|
+
);
|
|
4151
|
+
assert_eq!(unresolved[0].source_schema_key, "fk_child_schema");
|
|
4152
|
+
assert_eq!(
|
|
4153
|
+
unresolved[0].source_pointer_group,
|
|
4154
|
+
vec![vec!["parent_id".to_string()]]
|
|
4155
|
+
);
|
|
4156
|
+
let UnresolvedForeignKeyTarget::Key(target) = &unresolved[0].target else {
|
|
4157
|
+
panic!("normal FK should produce key target");
|
|
4158
|
+
};
|
|
4159
|
+
assert_eq!(target.schema_key, "fk_parent_schema");
|
|
4160
|
+
assert_eq!(target.schema_version, "1");
|
|
4161
|
+
assert_eq!(target.version_id, "version-a");
|
|
4162
|
+
assert_eq!(target.file_id.as_deref(), Some("file-a"));
|
|
4163
|
+
assert_eq!(target.pointer_group, vec![vec!["id".to_string()]]);
|
|
4164
|
+
assert_eq!(
|
|
4165
|
+
target.value,
|
|
4166
|
+
UniqueConstraintValue::string_values(["parent-1"])
|
|
4167
|
+
);
|
|
4168
|
+
}
|
|
4169
|
+
|
|
4170
|
+
#[test]
|
|
4171
|
+
fn pending_fk_validation_resolves_normal_fk_against_pending_target() {
|
|
4172
|
+
let mut indexes = PendingConstraintIndexes::default();
|
|
4173
|
+
let parent = fk_parent_row("parent-1", "version-a");
|
|
4174
|
+
let parent_snapshot = serde_json::from_str::<JsonValue>(
|
|
4175
|
+
parent
|
|
4176
|
+
.snapshot_content
|
|
4177
|
+
.as_deref()
|
|
4178
|
+
.expect("fixture should have snapshot"),
|
|
4179
|
+
)
|
|
4180
|
+
.expect("fixture JSON should parse");
|
|
4181
|
+
indexes
|
|
4182
|
+
.remember_row(&parent, &fk_parent_schema(), &parent_snapshot)
|
|
4183
|
+
.expect("parent should index as pending FK target");
|
|
4184
|
+
|
|
4185
|
+
let child = fk_child_row("child-1", "parent-1", "version-a");
|
|
4186
|
+
let child_snapshot = serde_json::from_str::<JsonValue>(
|
|
4187
|
+
child
|
|
4188
|
+
.snapshot_content
|
|
4189
|
+
.as_deref()
|
|
4190
|
+
.expect("fixture should have snapshot"),
|
|
4191
|
+
)
|
|
4192
|
+
.expect("fixture JSON should parse");
|
|
4193
|
+
let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
|
|
4194
|
+
let staged_writes = empty_staged_write_set();
|
|
4195
|
+
let input = validation_input(&staged_writes, &visible_schemas);
|
|
4196
|
+
let catalog = catalog_from_transaction_input(&input).expect("catalog should build");
|
|
4197
|
+
|
|
4198
|
+
let unresolved = validate_pending_foreign_keys(
|
|
4199
|
+
&catalog,
|
|
4200
|
+
&indexes,
|
|
4201
|
+
&[(&child, &fk_child_schema(), child_snapshot)],
|
|
4202
|
+
)
|
|
4203
|
+
.expect("FK validation should inspect pending targets");
|
|
4204
|
+
|
|
4205
|
+
assert!(
|
|
4206
|
+
unresolved.is_empty(),
|
|
4207
|
+
"same-version pending parent should satisfy the child FK"
|
|
4208
|
+
);
|
|
4209
|
+
}
|
|
4210
|
+
|
|
4211
|
+
#[test]
|
|
4212
|
+
fn pending_fk_validation_keeps_normal_fk_unresolved_across_versions() {
|
|
4213
|
+
let mut indexes = PendingConstraintIndexes::default();
|
|
4214
|
+
let parent = fk_parent_row("parent-1", "version-b");
|
|
4215
|
+
let parent_snapshot = serde_json::from_str::<JsonValue>(
|
|
4216
|
+
parent
|
|
4217
|
+
.snapshot_content
|
|
4218
|
+
.as_deref()
|
|
4219
|
+
.expect("fixture should have snapshot"),
|
|
4220
|
+
)
|
|
4221
|
+
.expect("fixture JSON should parse");
|
|
4222
|
+
indexes
|
|
4223
|
+
.remember_row(&parent, &fk_parent_schema(), &parent_snapshot)
|
|
4224
|
+
.expect("parent should index as pending FK target");
|
|
4225
|
+
|
|
4226
|
+
let child = fk_child_row("child-1", "parent-1", "version-a");
|
|
4227
|
+
let child_snapshot = serde_json::from_str::<JsonValue>(
|
|
4228
|
+
child
|
|
4229
|
+
.snapshot_content
|
|
4230
|
+
.as_deref()
|
|
4231
|
+
.expect("fixture should have snapshot"),
|
|
4232
|
+
)
|
|
4233
|
+
.expect("fixture JSON should parse");
|
|
4234
|
+
let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
|
|
4235
|
+
let staged_writes = empty_staged_write_set();
|
|
4236
|
+
let input = validation_input(&staged_writes, &visible_schemas);
|
|
4237
|
+
let catalog = catalog_from_transaction_input(&input).expect("catalog should build");
|
|
4238
|
+
|
|
4239
|
+
let unresolved = validate_pending_foreign_keys(
|
|
4240
|
+
&catalog,
|
|
4241
|
+
&indexes,
|
|
4242
|
+
&[(&child, &fk_child_schema(), child_snapshot)],
|
|
4243
|
+
)
|
|
4244
|
+
.expect("FK validation should inspect pending targets");
|
|
4245
|
+
|
|
4246
|
+
assert_eq!(unresolved.len(), 1);
|
|
4247
|
+
let UnresolvedForeignKeyTarget::Key(target) = &unresolved[0].target else {
|
|
4248
|
+
panic!("normal FK should produce key target");
|
|
4249
|
+
};
|
|
4250
|
+
assert_eq!(
|
|
4251
|
+
target.version_id, "version-a",
|
|
4252
|
+
"FK checks are exact-version scoped, not overlay scoped"
|
|
4253
|
+
);
|
|
4254
|
+
}
|
|
4255
|
+
|
|
4256
|
+
#[test]
|
|
4257
|
+
fn pending_fk_validation_collects_unresolved_state_surface_check() {
|
|
4258
|
+
let indexes = PendingConstraintIndexes::default();
|
|
4259
|
+
let row = state_surface_ref_row("ref-1", "target-1", "fk_parent_schema", "file-a");
|
|
4260
|
+
let snapshot = serde_json::from_str::<JsonValue>(
|
|
4261
|
+
row.snapshot_content
|
|
4262
|
+
.as_deref()
|
|
4263
|
+
.expect("fixture should have snapshot"),
|
|
4264
|
+
)
|
|
4265
|
+
.expect("fixture JSON should parse");
|
|
4266
|
+
let visible_schemas = vec![state_surface_ref_schema()];
|
|
4267
|
+
let staged_writes = empty_staged_write_set();
|
|
4268
|
+
let input = validation_input(&staged_writes, &visible_schemas);
|
|
4269
|
+
let catalog = catalog_from_transaction_input(&input).expect("catalog should build");
|
|
4270
|
+
|
|
4271
|
+
let unresolved = validate_pending_foreign_keys(
|
|
4272
|
+
&catalog,
|
|
4273
|
+
&indexes,
|
|
4274
|
+
&[(&row, &state_surface_ref_schema(), snapshot)],
|
|
4275
|
+
)
|
|
4276
|
+
.expect("FK validation should collect unresolved checks");
|
|
4277
|
+
|
|
4278
|
+
assert_eq!(unresolved.len(), 1);
|
|
4279
|
+
assert_eq!(
|
|
4280
|
+
unresolved[0].source_identity,
|
|
4281
|
+
LiveStateRowIdentity {
|
|
4282
|
+
version_id: "version-a".to_string(),
|
|
4283
|
+
schema_key: "state_surface_ref_schema".to_string(),
|
|
4284
|
+
entity_id: EntityIdentity::single("ref-1"),
|
|
4285
|
+
file_id: Some("file-a".to_string()),
|
|
4286
|
+
}
|
|
4287
|
+
);
|
|
4288
|
+
assert_eq!(unresolved[0].source_schema_key, "state_surface_ref_schema");
|
|
4289
|
+
assert_eq!(
|
|
4290
|
+
unresolved[0].source_pointer_group,
|
|
4291
|
+
vec![
|
|
4292
|
+
vec!["target_entity_id".to_string()],
|
|
4293
|
+
vec!["target_schema_key".to_string()],
|
|
4294
|
+
vec!["target_file_id".to_string()],
|
|
4295
|
+
]
|
|
4296
|
+
);
|
|
4297
|
+
let UnresolvedForeignKeyTarget::StateSurfaceIdentity(target) = &unresolved[0].target else {
|
|
4298
|
+
panic!("lix_state FK should produce state-surface identity target");
|
|
4299
|
+
};
|
|
4300
|
+
assert_eq!(target.version_id, "version-a");
|
|
4301
|
+
assert_eq!(target.schema_key, "fk_parent_schema");
|
|
4302
|
+
assert_eq!(target.entity_id, EntityIdentity::single("target-1"));
|
|
4303
|
+
assert_eq!(target.file_id.as_deref(), Some("file-a"));
|
|
4304
|
+
}
|
|
4305
|
+
|
|
4306
|
+
#[tokio::test]
|
|
4307
|
+
async fn committed_fk_lookup_resolves_normal_fk_in_exact_scope() {
|
|
4308
|
+
let indexes = PendingConstraintIndexes::default();
|
|
4309
|
+
let child = fk_child_row("child-1", "parent-1", "version-a");
|
|
4310
|
+
let child_snapshot = serde_json::from_str::<JsonValue>(
|
|
4311
|
+
child
|
|
4312
|
+
.snapshot_content
|
|
4313
|
+
.as_deref()
|
|
4314
|
+
.expect("fixture should have snapshot"),
|
|
4315
|
+
)
|
|
4316
|
+
.expect("fixture JSON should parse");
|
|
4317
|
+
let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
|
|
4318
|
+
let staged_writes = empty_staged_write_set();
|
|
4319
|
+
let input = validation_input(&staged_writes, &visible_schemas);
|
|
4320
|
+
let catalog = catalog_from_transaction_input(&input).expect("catalog should build");
|
|
4321
|
+
let unresolved = validate_pending_foreign_keys(
|
|
4322
|
+
&catalog,
|
|
4323
|
+
&indexes,
|
|
4324
|
+
&[(&child, &fk_child_schema(), child_snapshot)],
|
|
4325
|
+
)
|
|
4326
|
+
.expect("pending FK validation should collect unresolved check");
|
|
4327
|
+
let live_state = StaticLiveStateReader {
|
|
4328
|
+
rows: vec![LiveStateRow::from(fk_parent_row("parent-1", "version-a"))],
|
|
4329
|
+
};
|
|
4330
|
+
|
|
4331
|
+
let still_unresolved = validate_committed_foreign_keys(
|
|
4332
|
+
&TransactionValidationInput::from_visible_schemas_for_tests(
|
|
4333
|
+
&staged_writes,
|
|
4334
|
+
&visible_schemas,
|
|
4335
|
+
&live_state,
|
|
4336
|
+
),
|
|
4337
|
+
&indexes,
|
|
4338
|
+
&unresolved,
|
|
4339
|
+
)
|
|
4340
|
+
.await
|
|
4341
|
+
.expect("committed FK lookup should scan live state");
|
|
4342
|
+
|
|
4343
|
+
assert!(
|
|
4344
|
+
still_unresolved.is_empty(),
|
|
4345
|
+
"same-version committed parent should satisfy unresolved FK"
|
|
4346
|
+
);
|
|
4347
|
+
}
|
|
4348
|
+
|
|
4349
|
+
#[tokio::test]
|
|
4350
|
+
async fn committed_fk_lookup_keeps_normal_fk_unresolved_across_versions() {
|
|
4351
|
+
let indexes = PendingConstraintIndexes::default();
|
|
4352
|
+
let child = fk_child_row("child-1", "parent-1", "version-a");
|
|
4353
|
+
let child_snapshot = serde_json::from_str::<JsonValue>(
|
|
4354
|
+
child
|
|
4355
|
+
.snapshot_content
|
|
4356
|
+
.as_deref()
|
|
4357
|
+
.expect("fixture should have snapshot"),
|
|
4358
|
+
)
|
|
4359
|
+
.expect("fixture JSON should parse");
|
|
4360
|
+
let visible_schemas = vec![fk_parent_schema(), fk_child_schema()];
|
|
4361
|
+
let staged_writes = empty_staged_write_set();
|
|
4362
|
+
let input = validation_input(&staged_writes, &visible_schemas);
|
|
4363
|
+
let catalog = catalog_from_transaction_input(&input).expect("catalog should build");
|
|
4364
|
+
let unresolved = validate_pending_foreign_keys(
|
|
4365
|
+
&catalog,
|
|
4366
|
+
&indexes,
|
|
4367
|
+
&[(&child, &fk_child_schema(), child_snapshot)],
|
|
4368
|
+
)
|
|
4369
|
+
.expect("pending FK validation should collect unresolved check");
|
|
4370
|
+
let live_state = StaticLiveStateReader {
|
|
4371
|
+
rows: vec![LiveStateRow::from(fk_parent_row("parent-1", "version-b"))],
|
|
4372
|
+
};
|
|
4373
|
+
|
|
4374
|
+
let still_unresolved = validate_committed_foreign_keys(
|
|
4375
|
+
&TransactionValidationInput::from_visible_schemas_for_tests(
|
|
4376
|
+
&staged_writes,
|
|
4377
|
+
&visible_schemas,
|
|
4378
|
+
&live_state,
|
|
4379
|
+
),
|
|
4380
|
+
&indexes,
|
|
4381
|
+
&unresolved,
|
|
4382
|
+
)
|
|
4383
|
+
.await
|
|
4384
|
+
.expect("committed FK lookup should scan live state");
|
|
4385
|
+
|
|
4386
|
+
assert_eq!(
|
|
4387
|
+
still_unresolved.len(),
|
|
4388
|
+
1,
|
|
4389
|
+
"committed FK lookup is exact-version scoped"
|
|
4390
|
+
);
|
|
4391
|
+
}
|
|
4392
|
+
|
|
4393
|
+
#[tokio::test]
|
|
4394
|
+
async fn committed_fk_lookup_resolves_state_surface_fk_by_exact_identity() {
|
|
4395
|
+
let indexes = PendingConstraintIndexes::default();
|
|
4396
|
+
let row = state_surface_ref_row("ref-1", "target-1", "fk_parent_schema", "file-a");
|
|
4397
|
+
let snapshot = serde_json::from_str::<JsonValue>(
|
|
4398
|
+
row.snapshot_content
|
|
4399
|
+
.as_deref()
|
|
4400
|
+
.expect("fixture should have snapshot"),
|
|
4401
|
+
)
|
|
4402
|
+
.expect("fixture JSON should parse");
|
|
4403
|
+
let visible_schemas = vec![state_surface_ref_schema()];
|
|
4404
|
+
let staged_writes = empty_staged_write_set();
|
|
4405
|
+
let input = validation_input(&staged_writes, &visible_schemas);
|
|
4406
|
+
let catalog = catalog_from_transaction_input(&input).expect("catalog should build");
|
|
4407
|
+
let unresolved = validate_pending_foreign_keys(
|
|
4408
|
+
&catalog,
|
|
4409
|
+
&indexes,
|
|
4410
|
+
&[(&row, &state_surface_ref_schema(), snapshot)],
|
|
4411
|
+
)
|
|
4412
|
+
.expect("pending FK validation should collect unresolved check");
|
|
4413
|
+
let live_state = StaticLiveStateReader {
|
|
4414
|
+
rows: vec![LiveStateRow::from(fk_parent_row("target-1", "version-a"))],
|
|
4415
|
+
};
|
|
4416
|
+
|
|
4417
|
+
let still_unresolved = validate_committed_foreign_keys(
|
|
4418
|
+
&TransactionValidationInput::from_visible_schemas_for_tests(
|
|
4419
|
+
&staged_writes,
|
|
4420
|
+
&visible_schemas,
|
|
4421
|
+
&live_state,
|
|
4422
|
+
),
|
|
4423
|
+
&indexes,
|
|
4424
|
+
&unresolved,
|
|
4425
|
+
)
|
|
4426
|
+
.await
|
|
4427
|
+
.expect("committed FK lookup should load exact live-state row");
|
|
4428
|
+
|
|
4429
|
+
assert!(
|
|
4430
|
+
still_unresolved.is_empty(),
|
|
4431
|
+
"committed state-surface target should satisfy unresolved FK"
|
|
4432
|
+
);
|
|
4433
|
+
}
|
|
4434
|
+
|
|
4435
|
+
fn empty_staged_write_set() -> StagedWriteSet {
|
|
4436
|
+
StagedWriteSet {
|
|
4437
|
+
state_rows: Vec::new(),
|
|
4438
|
+
adopted_rows: Vec::new(),
|
|
4439
|
+
insert_identities: BTreeMap::new(),
|
|
4440
|
+
commit_members_by_version: BTreeMap::new(),
|
|
4441
|
+
extra_commit_parents_by_version: BTreeMap::new(),
|
|
4442
|
+
file_data_writes: Vec::new(),
|
|
4443
|
+
}
|
|
4444
|
+
}
|
|
4445
|
+
|
|
4446
|
+
fn live_state_row_matches_scan(row: &LiveStateRow, request: &LiveStateScanRequest) -> bool {
|
|
4447
|
+
(request.filter.schema_keys.is_empty()
|
|
4448
|
+
|| request.filter.schema_keys.contains(&row.schema_key))
|
|
4449
|
+
&& (request.filter.version_ids.is_empty()
|
|
4450
|
+
|| request.filter.version_ids.contains(&row.version_id))
|
|
4451
|
+
&& (request.filter.file_ids.is_empty()
|
|
4452
|
+
|| request
|
|
4453
|
+
.filter
|
|
4454
|
+
.file_ids
|
|
4455
|
+
.iter()
|
|
4456
|
+
.any(|filter| filter.matches(row.file_id.as_ref())))
|
|
4457
|
+
}
|
|
4458
|
+
|
|
4459
|
+
fn live_state_row_matches_load(row: &LiveStateRow, request: &LiveStateRowRequest) -> bool {
|
|
4460
|
+
row.schema_key == request.schema_key
|
|
4461
|
+
&& row.version_id == request.version_id
|
|
4462
|
+
&& row.entity_id == request.entity_id
|
|
4463
|
+
&& request.file_id.matches(row.file_id.as_ref())
|
|
4464
|
+
}
|
|
4465
|
+
|
|
4466
|
+
fn test_file_descriptor_rows() -> Vec<LiveStateRow> {
|
|
4467
|
+
vec![
|
|
4468
|
+
committed_file_descriptor_row("file-a", "version-a"),
|
|
4469
|
+
committed_file_descriptor_row("file-a", "version-b"),
|
|
4470
|
+
committed_file_descriptor_row("file-b", "version-a"),
|
|
4471
|
+
committed_file_descriptor_row("file-b", "version-b"),
|
|
4472
|
+
]
|
|
4473
|
+
}
|
|
4474
|
+
|
|
4475
|
+
fn pending_registered_schema_row(schema_key: &str, schema_version: &str) -> StagedStateRow {
|
|
4476
|
+
pending_registered_schema_from_definition(json!({
|
|
4477
|
+
"x-lix-key": schema_key,
|
|
4478
|
+
"x-lix-version": schema_version,
|
|
4479
|
+
"type": "object",
|
|
4480
|
+
"properties": {
|
|
4481
|
+
"id": { "type": "string" }
|
|
4482
|
+
},
|
|
4483
|
+
"required": ["id"],
|
|
4484
|
+
"additionalProperties": false,
|
|
4485
|
+
}))
|
|
4486
|
+
}
|
|
4487
|
+
|
|
4488
|
+
fn pending_registered_schema_from_definition(schema: JsonValue) -> StagedStateRow {
|
|
4489
|
+
let key = schema_key_from_definition(&schema).expect("test schema should have a key");
|
|
4490
|
+
StagedStateRow {
|
|
4491
|
+
entity_id: registered_schema_entity_id(&key.schema_key, &key.schema_version),
|
|
4492
|
+
schema_key: REGISTERED_SCHEMA_KEY.to_string(),
|
|
4493
|
+
file_id: None,
|
|
4494
|
+
snapshot_content: Some(json!({ "value": schema }).to_string()),
|
|
4495
|
+
metadata: None,
|
|
4496
|
+
origin: None,
|
|
4497
|
+
schema_version: "1".to_string(),
|
|
4498
|
+
created_at: "2026-04-29T00:00:00.000Z".to_string(),
|
|
4499
|
+
updated_at: "2026-04-29T00:00:00.000Z".to_string(),
|
|
4500
|
+
global: true,
|
|
4501
|
+
change_id: Some("change-registered-schema".to_string()),
|
|
4502
|
+
commit_id: Some("commit-registered-schema".to_string()),
|
|
4503
|
+
untracked: false,
|
|
4504
|
+
version_id: crate::GLOBAL_VERSION_ID.to_string(),
|
|
4505
|
+
}
|
|
4506
|
+
}
|
|
4507
|
+
|
|
4508
|
+
fn registered_schema_entity_id(
|
|
4509
|
+
schema_key: &str,
|
|
4510
|
+
schema_version: &str,
|
|
4511
|
+
) -> crate::entity_identity::EntityIdentity {
|
|
4512
|
+
crate::entity_identity::EntityIdentity::from_primary_key_paths(
|
|
4513
|
+
&serde_json::json!({
|
|
4514
|
+
"value": {
|
|
4515
|
+
"x-lix-key": schema_key,
|
|
4516
|
+
"x-lix-version": schema_version,
|
|
4517
|
+
}
|
|
4518
|
+
}),
|
|
4519
|
+
&[
|
|
4520
|
+
vec!["value".to_string(), "x-lix-key".to_string()],
|
|
4521
|
+
vec!["value".to_string(), "x-lix-version".to_string()],
|
|
4522
|
+
],
|
|
4523
|
+
)
|
|
4524
|
+
.expect("registered schema identity should derive")
|
|
4525
|
+
}
|
|
4526
|
+
|
|
4527
|
+
fn key_value_schema() -> JsonValue {
|
|
4528
|
+
seed_schema_definition("lix_key_value")
|
|
4529
|
+
.expect("lix_key_value builtin schema should exist")
|
|
4530
|
+
.clone()
|
|
4531
|
+
}
|
|
4532
|
+
|
|
4533
|
+
fn registered_schema() -> JsonValue {
|
|
4534
|
+
seed_schema_definition(REGISTERED_SCHEMA_KEY)
|
|
4535
|
+
.expect("lix_registered_schema builtin schema should exist")
|
|
4536
|
+
.clone()
|
|
4537
|
+
}
|
|
4538
|
+
|
|
4539
|
+
fn file_descriptor_schema() -> JsonValue {
|
|
4540
|
+
seed_schema_definition(FILE_DESCRIPTOR_SCHEMA_KEY)
|
|
4541
|
+
.expect("lix_file_descriptor builtin schema should exist")
|
|
4542
|
+
.clone()
|
|
4543
|
+
}
|
|
4544
|
+
|
|
4545
|
+
fn directory_descriptor_schema() -> JsonValue {
|
|
4546
|
+
seed_schema_definition(DIRECTORY_DESCRIPTOR_SCHEMA_KEY)
|
|
4547
|
+
.expect("lix_directory_descriptor builtin schema should exist")
|
|
4548
|
+
.clone()
|
|
4549
|
+
}
|
|
4550
|
+
|
|
4551
|
+
fn unique_schema() -> JsonValue {
|
|
4552
|
+
json!({
|
|
4553
|
+
"x-lix-key": "unique_schema",
|
|
4554
|
+
"x-lix-version": "1",
|
|
4555
|
+
"x-lix-primary-key": ["/id"],
|
|
4556
|
+
"x-lix-unique": [["/slug"]],
|
|
4557
|
+
"type": "object",
|
|
4558
|
+
"properties": {
|
|
4559
|
+
"id": { "type": "string" },
|
|
4560
|
+
"slug": { "type": "string" },
|
|
4561
|
+
"title": { "type": "string" }
|
|
4562
|
+
},
|
|
4563
|
+
"required": ["id", "slug", "title"],
|
|
4564
|
+
"additionalProperties": false
|
|
4565
|
+
})
|
|
4566
|
+
}
|
|
4567
|
+
|
|
4568
|
+
fn nullable_unique_schema() -> JsonValue {
|
|
4569
|
+
json!({
|
|
4570
|
+
"x-lix-key": "nullable_unique_schema",
|
|
4571
|
+
"x-lix-version": "1",
|
|
4572
|
+
"x-lix-primary-key": ["/id"],
|
|
4573
|
+
"x-lix-unique": [["/scope", "/name"]],
|
|
4574
|
+
"type": "object",
|
|
4575
|
+
"properties": {
|
|
4576
|
+
"id": { "type": "string" },
|
|
4577
|
+
"scope": { "type": ["string", "null"] },
|
|
4578
|
+
"name": { "type": "string" }
|
|
4579
|
+
},
|
|
4580
|
+
"required": ["id", "scope", "name"],
|
|
4581
|
+
"additionalProperties": false
|
|
4582
|
+
})
|
|
4583
|
+
}
|
|
4584
|
+
|
|
4585
|
+
fn fk_parent_schema() -> JsonValue {
|
|
4586
|
+
json!({
|
|
4587
|
+
"x-lix-key": "fk_parent_schema",
|
|
4588
|
+
"x-lix-version": "1",
|
|
4589
|
+
"x-lix-primary-key": ["/id"],
|
|
4590
|
+
"type": "object",
|
|
4591
|
+
"properties": {
|
|
4592
|
+
"id": { "type": "string" }
|
|
4593
|
+
},
|
|
4594
|
+
"required": ["id"],
|
|
4595
|
+
"additionalProperties": false
|
|
4596
|
+
})
|
|
4597
|
+
}
|
|
4598
|
+
|
|
4599
|
+
fn fk_child_schema() -> JsonValue {
|
|
4600
|
+
json!({
|
|
4601
|
+
"x-lix-key": "fk_child_schema",
|
|
4602
|
+
"x-lix-version": "1",
|
|
4603
|
+
"x-lix-primary-key": ["/id"],
|
|
4604
|
+
"x-lix-foreign-keys": [{
|
|
4605
|
+
"properties": ["/parent_id"],
|
|
4606
|
+
"references": {
|
|
4607
|
+
"schemaKey": "fk_parent_schema",
|
|
4608
|
+
"properties": ["/id"]
|
|
4609
|
+
}
|
|
4610
|
+
}],
|
|
4611
|
+
"type": "object",
|
|
4612
|
+
"properties": {
|
|
4613
|
+
"id": { "type": "string" },
|
|
4614
|
+
"parent_id": { "type": "string" }
|
|
4615
|
+
},
|
|
4616
|
+
"required": ["id", "parent_id"],
|
|
4617
|
+
"additionalProperties": false
|
|
4618
|
+
})
|
|
4619
|
+
}
|
|
4620
|
+
|
|
4621
|
+
fn state_surface_ref_schema() -> JsonValue {
|
|
4622
|
+
json!({
|
|
4623
|
+
"x-lix-key": "state_surface_ref_schema",
|
|
4624
|
+
"x-lix-version": "1",
|
|
4625
|
+
"x-lix-primary-key": ["/id"],
|
|
4626
|
+
"x-lix-foreign-keys": [{
|
|
4627
|
+
"properties": ["/target_entity_id", "/target_schema_key", "/target_file_id"],
|
|
4628
|
+
"references": {
|
|
4629
|
+
"schemaKey": "lix_state",
|
|
4630
|
+
"properties": ["/entity_id", "/schema_key", "/file_id"]
|
|
4631
|
+
}
|
|
4632
|
+
}],
|
|
4633
|
+
"type": "object",
|
|
4634
|
+
"properties": {
|
|
4635
|
+
"id": { "type": "string" },
|
|
4636
|
+
"target_entity_id": { "type": "string" },
|
|
4637
|
+
"target_schema_key": { "type": "string" },
|
|
4638
|
+
"target_file_id": { "type": ["string", "null"] }
|
|
4639
|
+
},
|
|
4640
|
+
"required": ["id", "target_entity_id", "target_schema_key", "target_file_id"],
|
|
4641
|
+
"additionalProperties": false
|
|
4642
|
+
})
|
|
4643
|
+
}
|
|
4644
|
+
|
|
4645
|
+
fn unique_row(entity_id: &str, slug: &str, title: &str) -> StagedStateRow {
|
|
4646
|
+
let mut row = staged_row(
|
|
4647
|
+
"unique_schema",
|
|
4648
|
+
"1",
|
|
4649
|
+
Some(
|
|
4650
|
+
json!({
|
|
4651
|
+
"id": entity_id,
|
|
4652
|
+
"slug": slug,
|
|
4653
|
+
"title": title,
|
|
4654
|
+
})
|
|
4655
|
+
.to_string(),
|
|
4656
|
+
),
|
|
4657
|
+
);
|
|
4658
|
+
row.entity_id = crate::entity_identity::EntityIdentity::single(entity_id);
|
|
4659
|
+
row.file_id = Some("file-a".to_string());
|
|
4660
|
+
row.version_id = "version-a".to_string();
|
|
4661
|
+
row.global = false;
|
|
4662
|
+
row
|
|
4663
|
+
}
|
|
4664
|
+
|
|
4665
|
+
fn nullable_unique_row(entity_id: &str, scope: Option<&str>, name: &str) -> StagedStateRow {
|
|
4666
|
+
let mut row = staged_row(
|
|
4667
|
+
"nullable_unique_schema",
|
|
4668
|
+
"1",
|
|
4669
|
+
Some(
|
|
4670
|
+
json!({
|
|
4671
|
+
"id": entity_id,
|
|
4672
|
+
"scope": scope,
|
|
4673
|
+
"name": name,
|
|
4674
|
+
})
|
|
4675
|
+
.to_string(),
|
|
4676
|
+
),
|
|
4677
|
+
);
|
|
4678
|
+
row.entity_id = crate::entity_identity::EntityIdentity::single(entity_id);
|
|
4679
|
+
row.file_id = Some("file-a".to_string());
|
|
4680
|
+
row.version_id = "version-a".to_string();
|
|
4681
|
+
row.global = false;
|
|
4682
|
+
row
|
|
4683
|
+
}
|
|
4684
|
+
|
|
4685
|
+
fn fk_parent_row(entity_id: &str, version_id: &str) -> StagedStateRow {
|
|
4686
|
+
let mut row = staged_row(
|
|
4687
|
+
"fk_parent_schema",
|
|
4688
|
+
"1",
|
|
4689
|
+
Some(json!({ "id": entity_id }).to_string()),
|
|
4690
|
+
);
|
|
4691
|
+
row.entity_id = crate::entity_identity::EntityIdentity::single(entity_id);
|
|
4692
|
+
row.file_id = Some("file-a".to_string());
|
|
4693
|
+
row.version_id = version_id.to_string();
|
|
4694
|
+
row.global = false;
|
|
4695
|
+
row
|
|
4696
|
+
}
|
|
4697
|
+
|
|
4698
|
+
fn fk_child_row(entity_id: &str, parent_id: &str, version_id: &str) -> StagedStateRow {
|
|
4699
|
+
let mut row = staged_row(
|
|
4700
|
+
"fk_child_schema",
|
|
4701
|
+
"1",
|
|
4702
|
+
Some(json!({ "id": entity_id, "parent_id": parent_id }).to_string()),
|
|
4703
|
+
);
|
|
4704
|
+
row.entity_id = crate::entity_identity::EntityIdentity::single(entity_id);
|
|
4705
|
+
row.file_id = Some("file-a".to_string());
|
|
4706
|
+
row.version_id = version_id.to_string();
|
|
4707
|
+
row.global = false;
|
|
4708
|
+
row
|
|
4709
|
+
}
|
|
4710
|
+
|
|
4711
|
+
fn state_surface_ref_row(
|
|
4712
|
+
entity_id: &str,
|
|
4713
|
+
target_entity_id: &str,
|
|
4714
|
+
target_schema_key: &str,
|
|
4715
|
+
target_file_id: &str,
|
|
4716
|
+
) -> StagedStateRow {
|
|
4717
|
+
let mut row = staged_row(
|
|
4718
|
+
"state_surface_ref_schema",
|
|
4719
|
+
"1",
|
|
4720
|
+
Some(
|
|
4721
|
+
json!({
|
|
4722
|
+
"id": entity_id,
|
|
4723
|
+
"target_entity_id": target_entity_id,
|
|
4724
|
+
"target_schema_key": target_schema_key,
|
|
4725
|
+
"target_file_id": target_file_id,
|
|
4726
|
+
})
|
|
4727
|
+
.to_string(),
|
|
4728
|
+
),
|
|
4729
|
+
);
|
|
4730
|
+
row.entity_id = crate::entity_identity::EntityIdentity::single(entity_id);
|
|
4731
|
+
row.file_id = Some("file-a".to_string());
|
|
4732
|
+
row.version_id = "version-a".to_string();
|
|
4733
|
+
row.global = false;
|
|
4734
|
+
row
|
|
4735
|
+
}
|
|
4736
|
+
|
|
4737
|
+
fn staged_file_descriptor_row(file_id: &str, version_id: &str) -> StagedStateRow {
|
|
4738
|
+
let mut row = staged_row(
|
|
4739
|
+
FILE_DESCRIPTOR_SCHEMA_KEY,
|
|
4740
|
+
"1",
|
|
4741
|
+
Some(
|
|
4742
|
+
json!({
|
|
4743
|
+
"id": file_id,
|
|
4744
|
+
"directory_id": null,
|
|
4745
|
+
"name": file_id,
|
|
4746
|
+
"hidden": false,
|
|
4747
|
+
})
|
|
4748
|
+
.to_string(),
|
|
4749
|
+
),
|
|
4750
|
+
);
|
|
4751
|
+
row.entity_id = crate::entity_identity::EntityIdentity::single(file_id);
|
|
4752
|
+
row.file_id = None;
|
|
4753
|
+
row.version_id = version_id.to_string();
|
|
4754
|
+
row.global = version_id == crate::GLOBAL_VERSION_ID;
|
|
4755
|
+
row
|
|
4756
|
+
}
|
|
4757
|
+
|
|
4758
|
+
fn committed_file_descriptor_row(file_id: &str, version_id: &str) -> LiveStateRow {
|
|
4759
|
+
LiveStateRow::from(staged_file_descriptor_row(file_id, version_id))
|
|
4760
|
+
}
|
|
4761
|
+
|
|
4762
|
+
fn committed_unique_row(entity_id: &str, slug: &str, title: &str) -> LiveStateRow {
|
|
4763
|
+
let row = unique_row(entity_id, slug, title);
|
|
4764
|
+
LiveStateRow {
|
|
4765
|
+
entity_id: row.entity_id,
|
|
4766
|
+
schema_key: row.schema_key,
|
|
4767
|
+
file_id: row.file_id,
|
|
4768
|
+
snapshot_content: row.snapshot_content,
|
|
4769
|
+
metadata: row.metadata,
|
|
4770
|
+
schema_version: row.schema_version,
|
|
4771
|
+
created_at: row.created_at,
|
|
4772
|
+
updated_at: row.updated_at,
|
|
4773
|
+
global: row.global,
|
|
4774
|
+
change_id: row.change_id,
|
|
4775
|
+
commit_id: row.commit_id,
|
|
4776
|
+
untracked: row.untracked,
|
|
4777
|
+
version_id: row.version_id,
|
|
4778
|
+
}
|
|
4779
|
+
}
|
|
4780
|
+
|
|
4781
|
+
fn committed_nullable_unique_row(
|
|
4782
|
+
entity_id: &str,
|
|
4783
|
+
scope: Option<&str>,
|
|
4784
|
+
name: &str,
|
|
4785
|
+
) -> LiveStateRow {
|
|
4786
|
+
LiveStateRow::from(nullable_unique_row(entity_id, scope, name))
|
|
4787
|
+
}
|
|
4788
|
+
|
|
4789
|
+
fn staged_row(
|
|
4790
|
+
schema_key: &str,
|
|
4791
|
+
schema_version: &str,
|
|
4792
|
+
snapshot_content: Option<String>,
|
|
4793
|
+
) -> StagedStateRow {
|
|
4794
|
+
StagedStateRow {
|
|
4795
|
+
entity_id: crate::entity_identity::EntityIdentity::single("entity-1"),
|
|
4796
|
+
schema_key: schema_key.to_string(),
|
|
4797
|
+
file_id: None,
|
|
4798
|
+
snapshot_content,
|
|
4799
|
+
metadata: None,
|
|
4800
|
+
origin: None,
|
|
4801
|
+
schema_version: schema_version.to_string(),
|
|
4802
|
+
created_at: "2026-04-29T00:00:00.000Z".to_string(),
|
|
4803
|
+
updated_at: "2026-04-29T00:00:00.000Z".to_string(),
|
|
4804
|
+
global: true,
|
|
4805
|
+
change_id: Some("change-1".to_string()),
|
|
4806
|
+
commit_id: Some("commit-1".to_string()),
|
|
4807
|
+
untracked: false,
|
|
4808
|
+
version_id: crate::GLOBAL_VERSION_ID.to_string(),
|
|
4809
|
+
}
|
|
4810
|
+
}
|
|
4811
|
+
}
|