@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.
Files changed (191) hide show
  1. package/SKILL.md +305 -320
  2. package/dist/engine-wasm/wasm/lix_engine.d.ts +5 -0
  3. package/dist/engine-wasm/wasm/lix_engine.js +9 -13
  4. package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
  5. package/dist/engine-wasm/wasm/lix_engine.wasm.d.ts +1 -0
  6. package/dist/open-lix.d.ts +103 -14
  7. package/dist/open-lix.js +3 -0
  8. package/dist/sqlite/index.js +99 -22
  9. package/dist-engine-src/README.md +18 -0
  10. package/dist-engine-src/src/backend/kv.rs +358 -0
  11. package/dist-engine-src/src/backend/mod.rs +12 -0
  12. package/dist-engine-src/src/backend/testing.rs +658 -0
  13. package/dist-engine-src/src/backend/types.rs +96 -0
  14. package/dist-engine-src/src/binary_cas/chunking.rs +31 -0
  15. package/dist-engine-src/src/binary_cas/codec.rs +346 -0
  16. package/dist-engine-src/src/binary_cas/context.rs +139 -0
  17. package/dist-engine-src/src/binary_cas/kv.rs +1063 -0
  18. package/dist-engine-src/src/binary_cas/mod.rs +11 -0
  19. package/dist-engine-src/src/binary_cas/types.rs +127 -0
  20. package/dist-engine-src/src/cel/context.rs +86 -0
  21. package/dist-engine-src/src/cel/error.rs +19 -0
  22. package/dist-engine-src/src/cel/mod.rs +8 -0
  23. package/dist-engine-src/src/cel/provider.rs +9 -0
  24. package/dist-engine-src/src/cel/runtime.rs +167 -0
  25. package/dist-engine-src/src/cel/value.rs +50 -0
  26. package/dist-engine-src/src/changelog/codec.rs +321 -0
  27. package/dist-engine-src/src/changelog/context.rs +92 -0
  28. package/dist-engine-src/src/changelog/materialization.rs +121 -0
  29. package/dist-engine-src/src/changelog/mod.rs +13 -0
  30. package/dist-engine-src/src/changelog/reader.rs +20 -0
  31. package/dist-engine-src/src/changelog/storage.rs +220 -0
  32. package/dist-engine-src/src/changelog/types.rs +38 -0
  33. package/dist-engine-src/src/commit_graph/context.rs +1588 -0
  34. package/dist-engine-src/src/commit_graph/mod.rs +12 -0
  35. package/dist-engine-src/src/commit_graph/types.rs +145 -0
  36. package/dist-engine-src/src/commit_graph/walker.rs +780 -0
  37. package/dist-engine-src/src/common/error.rs +313 -0
  38. package/dist-engine-src/src/common/fingerprint.rs +3 -0
  39. package/dist-engine-src/src/common/fs_path.rs +1336 -0
  40. package/dist-engine-src/src/common/identity.rs +135 -0
  41. package/dist-engine-src/src/common/metadata.rs +35 -0
  42. package/dist-engine-src/src/common/mod.rs +23 -0
  43. package/dist-engine-src/src/common/types.rs +105 -0
  44. package/dist-engine-src/src/common/wire.rs +222 -0
  45. package/dist-engine-src/src/engine.rs +239 -0
  46. package/dist-engine-src/src/entity_identity.rs +285 -0
  47. package/dist-engine-src/src/functions/context.rs +327 -0
  48. package/dist-engine-src/src/functions/deterministic.rs +113 -0
  49. package/dist-engine-src/src/functions/mod.rs +18 -0
  50. package/dist-engine-src/src/functions/provider.rs +130 -0
  51. package/dist-engine-src/src/functions/state.rs +363 -0
  52. package/dist-engine-src/src/functions/types.rs +37 -0
  53. package/dist-engine-src/src/init.rs +505 -0
  54. package/dist-engine-src/src/json_store/compression.rs +77 -0
  55. package/dist-engine-src/src/json_store/context.rs +129 -0
  56. package/dist-engine-src/src/json_store/encoded.rs +15 -0
  57. package/dist-engine-src/src/json_store/mod.rs +9 -0
  58. package/dist-engine-src/src/json_store/store.rs +236 -0
  59. package/dist-engine-src/src/json_store/types.rs +52 -0
  60. package/dist-engine-src/src/lib.rs +61 -0
  61. package/dist-engine-src/src/live_state/context.rs +2241 -0
  62. package/dist-engine-src/src/live_state/mod.rs +15 -0
  63. package/dist-engine-src/src/live_state/overlay.rs +75 -0
  64. package/dist-engine-src/src/live_state/reader.rs +23 -0
  65. package/dist-engine-src/src/live_state/types.rs +239 -0
  66. package/dist-engine-src/src/live_state/visibility.rs +218 -0
  67. package/dist-engine-src/src/plugin/archive.rs +441 -0
  68. package/dist-engine-src/src/plugin/component.rs +183 -0
  69. package/dist-engine-src/src/plugin/install.rs +637 -0
  70. package/dist-engine-src/src/plugin/manifest.rs +516 -0
  71. package/dist-engine-src/src/plugin/materializer.rs +477 -0
  72. package/dist-engine-src/src/plugin/mod.rs +33 -0
  73. package/dist-engine-src/src/plugin/plugin_manifest.json +119 -0
  74. package/dist-engine-src/src/plugin/storage.rs +74 -0
  75. package/dist-engine-src/src/schema/annotations/defaults.rs +280 -0
  76. package/dist-engine-src/src/schema/annotations/mod.rs +1 -0
  77. package/dist-engine-src/src/schema/builtin/lix_account.json +22 -0
  78. package/dist-engine-src/src/schema/builtin/lix_active_account.json +30 -0
  79. package/dist-engine-src/src/schema/builtin/lix_binary_blob_ref.json +30 -0
  80. package/dist-engine-src/src/schema/builtin/lix_change.json +62 -0
  81. package/dist-engine-src/src/schema/builtin/lix_change_author.json +46 -0
  82. package/dist-engine-src/src/schema/builtin/lix_change_set.json +18 -0
  83. package/dist-engine-src/src/schema/builtin/lix_change_set_element.json +75 -0
  84. package/dist-engine-src/src/schema/builtin/lix_commit.json +62 -0
  85. package/dist-engine-src/src/schema/builtin/lix_commit_edge.json +46 -0
  86. package/dist-engine-src/src/schema/builtin/lix_directory_descriptor.json +53 -0
  87. package/dist-engine-src/src/schema/builtin/lix_entity_label.json +63 -0
  88. package/dist-engine-src/src/schema/builtin/lix_file_descriptor.json +53 -0
  89. package/dist-engine-src/src/schema/builtin/lix_key_value.json +41 -0
  90. package/dist-engine-src/src/schema/builtin/lix_label.json +22 -0
  91. package/dist-engine-src/src/schema/builtin/lix_registered_schema.json +31 -0
  92. package/dist-engine-src/src/schema/builtin/lix_version_descriptor.json +35 -0
  93. package/dist-engine-src/src/schema/builtin/lix_version_ref.json +49 -0
  94. package/dist-engine-src/src/schema/builtin/mod.rs +271 -0
  95. package/dist-engine-src/src/schema/definition.json +157 -0
  96. package/dist-engine-src/src/schema/definition.rs +636 -0
  97. package/dist-engine-src/src/schema/key.rs +206 -0
  98. package/dist-engine-src/src/schema/mod.rs +20 -0
  99. package/dist-engine-src/src/schema/seed.rs +14 -0
  100. package/dist-engine-src/src/schema/tests.rs +739 -0
  101. package/dist-engine-src/src/schema_registry.rs +294 -0
  102. package/dist-engine-src/src/session/context.rs +366 -0
  103. package/dist-engine-src/src/session/create_version.rs +80 -0
  104. package/dist-engine-src/src/session/execute.rs +447 -0
  105. package/dist-engine-src/src/session/merge/analysis.rs +102 -0
  106. package/dist-engine-src/src/session/merge/apply.rs +23 -0
  107. package/dist-engine-src/src/session/merge/conflicts.rs +62 -0
  108. package/dist-engine-src/src/session/merge/mod.rs +11 -0
  109. package/dist-engine-src/src/session/merge/stats.rs +65 -0
  110. package/dist-engine-src/src/session/merge/version.rs +437 -0
  111. package/dist-engine-src/src/session/mod.rs +25 -0
  112. package/dist-engine-src/src/session/switch_version.rs +121 -0
  113. package/dist-engine-src/src/sql2/change_provider.rs +337 -0
  114. package/dist-engine-src/src/sql2/classify.rs +147 -0
  115. package/dist-engine-src/src/sql2/commit_derived_provider.rs +591 -0
  116. package/dist-engine-src/src/sql2/context.rs +307 -0
  117. package/dist-engine-src/src/sql2/directory_history_provider.rs +623 -0
  118. package/dist-engine-src/src/sql2/directory_provider.rs +2405 -0
  119. package/dist-engine-src/src/sql2/dml.rs +148 -0
  120. package/dist-engine-src/src/sql2/entity_history_provider.rs +444 -0
  121. package/dist-engine-src/src/sql2/entity_provider.rs +2700 -0
  122. package/dist-engine-src/src/sql2/error.rs +196 -0
  123. package/dist-engine-src/src/sql2/execute.rs +3379 -0
  124. package/dist-engine-src/src/sql2/file_history_provider.rs +902 -0
  125. package/dist-engine-src/src/sql2/file_provider.rs +3254 -0
  126. package/dist-engine-src/src/sql2/filesystem_planner.rs +1526 -0
  127. package/dist-engine-src/src/sql2/filesystem_predicates.rs +159 -0
  128. package/dist-engine-src/src/sql2/filesystem_visibility.rs +369 -0
  129. package/dist-engine-src/src/sql2/history_projection.rs +80 -0
  130. package/dist-engine-src/src/sql2/history_provider.rs +418 -0
  131. package/dist-engine-src/src/sql2/history_route.rs +643 -0
  132. package/dist-engine-src/src/sql2/lix_state_provider.rs +2430 -0
  133. package/dist-engine-src/src/sql2/mod.rs +43 -0
  134. package/dist-engine-src/src/sql2/read_only.rs +65 -0
  135. package/dist-engine-src/src/sql2/record_batch.rs +17 -0
  136. package/dist-engine-src/src/sql2/result_metadata.rs +29 -0
  137. package/dist-engine-src/src/sql2/runtime.rs +60 -0
  138. package/dist-engine-src/src/sql2/session.rs +135 -0
  139. package/dist-engine-src/src/sql2/udfs/common.rs +295 -0
  140. package/dist-engine-src/src/sql2/udfs/lix_active_version_commit_id.rs +53 -0
  141. package/dist-engine-src/src/sql2/udfs/lix_empty_blob.rs +47 -0
  142. package/dist-engine-src/src/sql2/udfs/lix_json.rs +100 -0
  143. package/dist-engine-src/src/sql2/udfs/lix_json_get.rs +99 -0
  144. package/dist-engine-src/src/sql2/udfs/lix_json_get_text.rs +99 -0
  145. package/dist-engine-src/src/sql2/udfs/lix_text_decode.rs +82 -0
  146. package/dist-engine-src/src/sql2/udfs/lix_text_encode.rs +85 -0
  147. package/dist-engine-src/src/sql2/udfs/lix_uuid_v7.rs +76 -0
  148. package/dist-engine-src/src/sql2/udfs/mod.rs +82 -0
  149. package/dist-engine-src/src/sql2/version_provider.rs +1187 -0
  150. package/dist-engine-src/src/sql2/version_scope.rs +394 -0
  151. package/dist-engine-src/src/sql2/write_normalization.rs +345 -0
  152. package/dist-engine-src/src/storage/context.rs +356 -0
  153. package/dist-engine-src/src/storage/mod.rs +14 -0
  154. package/dist-engine-src/src/storage/read_scope.rs +88 -0
  155. package/dist-engine-src/src/storage/types.rs +501 -0
  156. package/dist-engine-src/src/storage_bench.rs +3406 -0
  157. package/dist-engine-src/src/test_support.rs +81 -0
  158. package/dist-engine-src/src/tracked_state/by_file_index.rs +102 -0
  159. package/dist-engine-src/src/tracked_state/codec.rs +747 -0
  160. package/dist-engine-src/src/tracked_state/context.rs +983 -0
  161. package/dist-engine-src/src/tracked_state/diff.rs +494 -0
  162. package/dist-engine-src/src/tracked_state/materialization.rs +141 -0
  163. package/dist-engine-src/src/tracked_state/merge.rs +474 -0
  164. package/dist-engine-src/src/tracked_state/mod.rs +31 -0
  165. package/dist-engine-src/src/tracked_state/rebuild.rs +771 -0
  166. package/dist-engine-src/src/tracked_state/storage.rs +243 -0
  167. package/dist-engine-src/src/tracked_state/tree.rs +2744 -0
  168. package/dist-engine-src/src/tracked_state/tree_types.rs +176 -0
  169. package/dist-engine-src/src/tracked_state/types.rs +61 -0
  170. package/dist-engine-src/src/transaction/commit.rs +1224 -0
  171. package/dist-engine-src/src/transaction/context.rs +1307 -0
  172. package/dist-engine-src/src/transaction/live_state_overlay.rs +34 -0
  173. package/dist-engine-src/src/transaction/mod.rs +11 -0
  174. package/dist-engine-src/src/transaction/normalization.rs +1026 -0
  175. package/dist-engine-src/src/transaction/schema_resolver.rs +127 -0
  176. package/dist-engine-src/src/transaction/staging.rs +1436 -0
  177. package/dist-engine-src/src/transaction/types.rs +351 -0
  178. package/dist-engine-src/src/transaction/validation.rs +4811 -0
  179. package/dist-engine-src/src/untracked_state/codec.rs +363 -0
  180. package/dist-engine-src/src/untracked_state/context.rs +82 -0
  181. package/dist-engine-src/src/untracked_state/materialization.rs +157 -0
  182. package/dist-engine-src/src/untracked_state/mod.rs +17 -0
  183. package/dist-engine-src/src/untracked_state/storage.rs +348 -0
  184. package/dist-engine-src/src/untracked_state/types.rs +96 -0
  185. package/dist-engine-src/src/version/context.rs +52 -0
  186. package/dist-engine-src/src/version/mod.rs +12 -0
  187. package/dist-engine-src/src/version/refs.rs +421 -0
  188. package/dist-engine-src/src/version/stage_rows.rs +71 -0
  189. package/dist-engine-src/src/version/types.rs +21 -0
  190. package/dist-engine-src/src/wasm/mod.rs +60 -0
  191. package/package.json +68 -64
@@ -0,0 +1,1526 @@
1
+ #![allow(dead_code)]
2
+
3
+ use std::collections::{BTreeMap, BTreeSet};
4
+
5
+ use serde::Deserialize;
6
+ use serde_json::{json, Map as JsonMap, Value as JsonValue};
7
+
8
+ use crate::common::{
9
+ directory_ancestor_paths, directory_name_from_path, normalize_directory_path,
10
+ parent_directory_path, stable_content_fingerprint_hex, ParsedFilePath,
11
+ };
12
+ use crate::entity_identity::EntityIdentity;
13
+ use crate::live_state::LiveStateRow;
14
+ use crate::{LixError, RowMetadata};
15
+
16
+ use super::filesystem_visibility::VisibleFilesystem;
17
+ use crate::transaction::types::{StageFileData, StageRow};
18
+
19
+ pub(crate) const FILE_DESCRIPTOR_SCHEMA_KEY: &str = "lix_file_descriptor";
20
+ pub(crate) const FILE_DESCRIPTOR_SCHEMA_VERSION: &str = "1";
21
+ pub(crate) const DIRECTORY_DESCRIPTOR_SCHEMA_KEY: &str = "lix_directory_descriptor";
22
+ pub(crate) const DIRECTORY_DESCRIPTOR_SCHEMA_VERSION: &str = "1";
23
+ pub(crate) const BLOB_REF_SCHEMA_KEY: &str = "lix_binary_blob_ref";
24
+ pub(crate) const BLOB_REF_SCHEMA_VERSION: &str = "1";
25
+
26
+ /// Planned filesystem write output after SQL surface columns have been lowered
27
+ /// into state rows and optional file payload writes.
28
+ ///
29
+ /// Providers should emit this shape; transaction/commit code should not need
30
+ /// to know whether a row came from `lix_file`, `lix_directory`, or a future
31
+ /// filesystem write surface.
32
+ #[derive(Debug, Clone, PartialEq, Eq, Default)]
33
+ pub(crate) struct FilesystemWritePlan {
34
+ pub(crate) rows: Vec<StageRow>,
35
+ pub(crate) file_data: Vec<StageFileData>,
36
+ pub(crate) count: u64,
37
+ }
38
+
39
+ /// Planned filesystem delete output after SQL predicates have selected rows
40
+ /// and the surface delete has been lowered into tombstone state rows.
41
+ #[derive(Debug, Clone, PartialEq, Eq, Default)]
42
+ pub(crate) struct FilesystemDeletePlan {
43
+ pub(crate) rows: Vec<StageRow>,
44
+ pub(crate) count: u64,
45
+ }
46
+
47
+ /// Common state-row lane fields shared by filesystem descriptor/blob rows.
48
+ #[derive(Debug, Clone, PartialEq, Eq)]
49
+ pub(crate) struct FilesystemRowContext {
50
+ pub(crate) version_id: String,
51
+ pub(crate) global: bool,
52
+ pub(crate) untracked: bool,
53
+ pub(crate) file_id: Option<String>,
54
+ pub(crate) metadata: Option<RowMetadata>,
55
+ }
56
+
57
+ impl FilesystemRowContext {
58
+ pub(crate) fn active_version(version_id: impl Into<String>) -> Self {
59
+ Self {
60
+ version_id: version_id.into(),
61
+ global: false,
62
+ untracked: false,
63
+ file_id: None,
64
+ metadata: None,
65
+ }
66
+ }
67
+ }
68
+
69
+ #[derive(Debug, Clone, PartialEq, Eq)]
70
+ pub(crate) struct DirectoryDescriptorRowInput {
71
+ pub(crate) id: String,
72
+ pub(crate) parent_id: Option<String>,
73
+ pub(crate) name: String,
74
+ pub(crate) hidden: bool,
75
+ pub(crate) context: FilesystemRowContext,
76
+ }
77
+
78
+ #[derive(Debug, Clone, PartialEq, Eq)]
79
+ pub(crate) struct FileDescriptorRowInput {
80
+ pub(crate) id: String,
81
+ pub(crate) directory_id: Option<String>,
82
+ pub(crate) name: String,
83
+ pub(crate) hidden: bool,
84
+ pub(crate) context: FilesystemRowContext,
85
+ }
86
+
87
+ #[derive(Debug, Clone, PartialEq, Eq)]
88
+ pub(crate) struct DirectoryDescriptorWriteIntent {
89
+ pub(crate) id: Option<String>,
90
+ pub(crate) parent_id: Option<String>,
91
+ pub(crate) name: String,
92
+ pub(crate) hidden: Option<bool>,
93
+ pub(crate) context: FilesystemRowContext,
94
+ }
95
+
96
+ #[derive(Debug, Clone, PartialEq, Eq)]
97
+ pub(crate) struct FileDescriptorWriteIntent {
98
+ pub(crate) id: Option<String>,
99
+ pub(crate) directory_id: Option<String>,
100
+ pub(crate) name: String,
101
+ pub(crate) hidden: Option<bool>,
102
+ pub(crate) context: FilesystemRowContext,
103
+ }
104
+
105
+ #[derive(Debug, Clone, PartialEq, Eq)]
106
+ pub(crate) struct BlobRefRowInput {
107
+ pub(crate) file_id: String,
108
+ pub(crate) data: Vec<u8>,
109
+ pub(crate) context: FilesystemRowContext,
110
+ }
111
+
112
+ #[derive(Debug, Clone, PartialEq, Eq)]
113
+ pub(crate) struct FilePathWriteInput {
114
+ pub(crate) id: Option<String>,
115
+ pub(crate) path: String,
116
+ pub(crate) data: Option<Vec<u8>>,
117
+ pub(crate) hidden: Option<bool>,
118
+ pub(crate) context: FilesystemRowContext,
119
+ }
120
+
121
+ #[derive(Debug, Clone, PartialEq, Eq)]
122
+ pub(crate) struct FileDeleteInput {
123
+ pub(crate) file_id: String,
124
+ pub(crate) has_blob_ref: bool,
125
+ pub(crate) context: FilesystemRowContext,
126
+ }
127
+
128
+ #[derive(Debug, Clone, PartialEq, Eq)]
129
+ pub(crate) struct DirectoryDeleteInput {
130
+ pub(crate) directory_id: String,
131
+ pub(crate) context: FilesystemRowContext,
132
+ }
133
+
134
+ #[derive(Debug, Deserialize)]
135
+ struct DirectoryDescriptorSnapshot {
136
+ id: String,
137
+ parent_id: Option<String>,
138
+ name: String,
139
+ }
140
+
141
+ #[derive(Debug, Deserialize)]
142
+ struct FileDescriptorSnapshot {
143
+ id: String,
144
+ directory_id: Option<String>,
145
+ name: String,
146
+ }
147
+
148
+ #[derive(Debug, Clone, PartialEq, Eq)]
149
+ enum FilesystemNamespaceEntry {
150
+ Directory(String),
151
+ File(String),
152
+ }
153
+
154
+ /// Resolves directory paths while planning filesystem writes.
155
+ ///
156
+ /// The resolver is seeded from the transaction-visible filesystem state and is
157
+ /// then updated as the current statement stages implicit directories. That is
158
+ /// what prevents path inserts from restaging committed ancestors or duplicating
159
+ /// an ancestor created earlier in the same SQL batch.
160
+ #[derive(Debug, Clone, Default)]
161
+ pub(crate) struct DirectoryPathResolver {
162
+ directory_ids_by_path: BTreeMap<String, String>,
163
+ entries_by_parent_and_name: BTreeMap<(Option<String>, String), FilesystemNamespaceEntry>,
164
+ }
165
+
166
+ impl DirectoryPathResolver {
167
+ pub(crate) fn from_existing(
168
+ existing_directories: impl IntoIterator<Item = (String, String)>,
169
+ ) -> Result<Self, LixError> {
170
+ Self::from_existing_filesystem(existing_directories, std::iter::empty())
171
+ }
172
+
173
+ pub(crate) fn from_existing_filesystem(
174
+ existing_directories: impl IntoIterator<Item = (String, String)>,
175
+ existing_files: impl IntoIterator<Item = (Option<String>, String, String)>,
176
+ ) -> Result<Self, LixError> {
177
+ let mut directory_ids_by_path = BTreeMap::new();
178
+ for (path, id) in existing_directories {
179
+ directory_ids_by_path.insert(normalize_directory_path(&path)?, id);
180
+ }
181
+
182
+ let mut resolver = Self {
183
+ directory_ids_by_path,
184
+ entries_by_parent_and_name: BTreeMap::new(),
185
+ };
186
+ let mut paths = resolver
187
+ .directory_ids_by_path
188
+ .iter()
189
+ .map(|(path, id)| (path.clone(), id.clone()))
190
+ .collect::<Vec<_>>();
191
+ paths.sort_by_key(|(path, _)| path.len());
192
+ for (path, id) in paths {
193
+ let parent_id = parent_directory_path(&path)
194
+ .and_then(|parent_path| resolver.directory_ids_by_path.get(&parent_path).cloned());
195
+ let name = directory_name_from_path(&path).ok_or_else(|| {
196
+ LixError::new(
197
+ "LIX_ERROR_UNKNOWN",
198
+ format!("directory path '{path}' does not contain a directory name"),
199
+ )
200
+ })?;
201
+ resolver.reserve_directory(parent_id, name, id)?;
202
+ }
203
+ for (directory_id, entry_name, file_id) in existing_files {
204
+ resolver.reserve_file(directory_id, entry_name, file_id)?;
205
+ }
206
+ Ok(resolver)
207
+ }
208
+
209
+ pub(crate) fn directory_id(&self, path: &str) -> Result<Option<&str>, LixError> {
210
+ Ok(self
211
+ .directory_ids_by_path
212
+ .get(&normalize_directory_path(path)?)
213
+ .map(String::as_str))
214
+ }
215
+
216
+ /// Stages only the missing descriptors needed for `directory_path`.
217
+ ///
218
+ /// Existing directories keep their original ids. Missing directories receive
219
+ /// deterministic ids so repeated planning of the same transaction-visible
220
+ /// path resolves to the same descriptor identity.
221
+ pub(crate) fn ensure_directory_path(
222
+ &mut self,
223
+ directory_path: &str,
224
+ context: FilesystemRowContext,
225
+ hidden: bool,
226
+ generate_directory_id: &mut dyn FnMut() -> String,
227
+ ) -> Result<Vec<StageRow>, LixError> {
228
+ self.ensure_directory_path_with_leaf_id(
229
+ directory_path,
230
+ None,
231
+ context,
232
+ hidden,
233
+ generate_directory_id,
234
+ )
235
+ }
236
+
237
+ pub(crate) fn ensure_directory_path_with_leaf_id(
238
+ &mut self,
239
+ directory_path: &str,
240
+ leaf_id: Option<String>,
241
+ context: FilesystemRowContext,
242
+ hidden: bool,
243
+ generate_directory_id: &mut dyn FnMut() -> String,
244
+ ) -> Result<Vec<StageRow>, LixError> {
245
+ self.plan_directory_path(
246
+ directory_path,
247
+ leaf_id,
248
+ context,
249
+ hidden,
250
+ generate_directory_id,
251
+ false,
252
+ )
253
+ }
254
+
255
+ pub(crate) fn create_directory_path_with_leaf_id(
256
+ &mut self,
257
+ directory_path: &str,
258
+ leaf_id: Option<String>,
259
+ context: FilesystemRowContext,
260
+ hidden: bool,
261
+ generate_directory_id: &mut dyn FnMut() -> String,
262
+ ) -> Result<Vec<StageRow>, LixError> {
263
+ self.plan_directory_path(
264
+ directory_path,
265
+ leaf_id,
266
+ context,
267
+ hidden,
268
+ generate_directory_id,
269
+ true,
270
+ )
271
+ }
272
+
273
+ fn plan_directory_path(
274
+ &mut self,
275
+ directory_path: &str,
276
+ leaf_id: Option<String>,
277
+ context: FilesystemRowContext,
278
+ hidden: bool,
279
+ generate_directory_id: &mut dyn FnMut() -> String,
280
+ reject_existing_leaf: bool,
281
+ ) -> Result<Vec<StageRow>, LixError> {
282
+ let directory_path = normalize_directory_path(directory_path)?;
283
+ if directory_path == "/" {
284
+ if reject_existing_leaf {
285
+ return Err(duplicate_directory_path_error(&directory_path));
286
+ }
287
+ return Ok(Vec::new());
288
+ }
289
+
290
+ let mut paths = directory_ancestor_paths(&directory_path);
291
+ paths.push(directory_path.clone());
292
+
293
+ let mut rows = Vec::new();
294
+ for path in paths {
295
+ if self.directory_ids_by_path.contains_key(&path) {
296
+ if reject_existing_leaf && path == directory_path {
297
+ return Err(duplicate_directory_path_error(&directory_path));
298
+ }
299
+ continue;
300
+ }
301
+
302
+ let id = if path == directory_path {
303
+ leaf_id.clone().unwrap_or_else(&mut *generate_directory_id)
304
+ } else {
305
+ generate_directory_id()
306
+ };
307
+ let parent_id = parent_directory_path(&path)
308
+ .and_then(|parent_path| self.directory_ids_by_path.get(&parent_path).cloned());
309
+ let name = directory_name_from_path(&path).ok_or_else(|| {
310
+ LixError::new(
311
+ "LIX_ERROR_UNKNOWN",
312
+ format!("directory path '{path}' does not contain a directory name"),
313
+ )
314
+ })?;
315
+ self.reserve_directory(parent_id.clone(), name.clone(), id.clone())?;
316
+
317
+ rows.push(directory_descriptor_row(DirectoryDescriptorRowInput {
318
+ id: id.clone(),
319
+ parent_id,
320
+ name,
321
+ hidden,
322
+ context: FilesystemRowContext {
323
+ // Directory descriptors are their own filesystem state row,
324
+ // even when they are implicitly planned from a file insert.
325
+ file_id: None,
326
+ ..context.clone()
327
+ },
328
+ }));
329
+ self.directory_ids_by_path.insert(path, id);
330
+ }
331
+
332
+ Ok(rows)
333
+ }
334
+
335
+ pub(crate) fn reserve_directory(
336
+ &mut self,
337
+ parent_id: Option<String>,
338
+ name: String,
339
+ directory_id: String,
340
+ ) -> Result<(), LixError> {
341
+ let key = (parent_id, name);
342
+ match self.entries_by_parent_and_name.get(&key) {
343
+ Some(FilesystemNamespaceEntry::Directory(existing_id))
344
+ if existing_id == &directory_id =>
345
+ {
346
+ Ok(())
347
+ }
348
+ Some(existing) => Err(filesystem_namespace_conflict_error(
349
+ &key.0, &key.1, existing,
350
+ )),
351
+ None => {
352
+ self.entries_by_parent_and_name
353
+ .insert(key, FilesystemNamespaceEntry::Directory(directory_id));
354
+ Ok(())
355
+ }
356
+ }
357
+ }
358
+
359
+ pub(crate) fn reserve_file(
360
+ &mut self,
361
+ directory_id: Option<String>,
362
+ entry_name: String,
363
+ file_id: String,
364
+ ) -> Result<(), LixError> {
365
+ let key = (directory_id, entry_name);
366
+ match self.entries_by_parent_and_name.get(&key) {
367
+ Some(FilesystemNamespaceEntry::File(existing_id)) if existing_id == &file_id => Ok(()),
368
+ Some(existing) => Err(filesystem_namespace_conflict_error(
369
+ &key.0, &key.1, existing,
370
+ )),
371
+ None => {
372
+ self.entries_by_parent_and_name
373
+ .insert(key, FilesystemNamespaceEntry::File(file_id));
374
+ Ok(())
375
+ }
376
+ }
377
+ }
378
+ }
379
+
380
+ fn duplicate_directory_path_error(path: &str) -> LixError {
381
+ LixError::new(
382
+ LixError::CODE_UNIQUE,
383
+ format!("unique constraint violation on lix_directory.path for value {path:?}"),
384
+ )
385
+ }
386
+
387
+ fn filesystem_namespace_conflict_error(
388
+ parent_id: &Option<String>,
389
+ entry_name: &str,
390
+ existing: &FilesystemNamespaceEntry,
391
+ ) -> LixError {
392
+ let parent = parent_id.as_deref().unwrap_or("<root>");
393
+ let existing_kind = match existing {
394
+ FilesystemNamespaceEntry::Directory(_) => "directory",
395
+ FilesystemNamespaceEntry::File(_) => "file",
396
+ };
397
+ LixError::new(
398
+ LixError::CODE_UNIQUE,
399
+ format!(
400
+ "filesystem namespace conflict: parent {parent:?} already contains {existing_kind} entry {entry_name:?}"
401
+ ),
402
+ )
403
+ }
404
+
405
+ pub(crate) fn directory_descriptor_row(input: DirectoryDescriptorRowInput) -> StageRow {
406
+ directory_descriptor_write_row(DirectoryDescriptorWriteIntent {
407
+ id: Some(input.id),
408
+ parent_id: input.parent_id,
409
+ name: input.name,
410
+ hidden: Some(input.hidden),
411
+ context: input.context,
412
+ })
413
+ }
414
+
415
+ pub(crate) fn file_descriptor_row(input: FileDescriptorRowInput) -> StageRow {
416
+ file_descriptor_write_row(FileDescriptorWriteIntent {
417
+ id: Some(input.id),
418
+ directory_id: input.directory_id,
419
+ name: input.name,
420
+ hidden: Some(input.hidden),
421
+ context: input.context,
422
+ })
423
+ }
424
+
425
+ pub(crate) fn directory_descriptor_write_row(input: DirectoryDescriptorWriteIntent) -> StageRow {
426
+ let mut snapshot = JsonMap::new();
427
+ if let Some(id) = input.id.as_ref() {
428
+ snapshot.insert("id".to_string(), JsonValue::String(id.clone()));
429
+ }
430
+ snapshot.insert(
431
+ "parent_id".to_string(),
432
+ input
433
+ .parent_id
434
+ .clone()
435
+ .map(JsonValue::String)
436
+ .unwrap_or(JsonValue::Null),
437
+ );
438
+ snapshot.insert("name".to_string(), JsonValue::String(input.name));
439
+ if let Some(hidden) = input.hidden {
440
+ snapshot.insert("hidden".to_string(), JsonValue::Bool(hidden));
441
+ }
442
+
443
+ partial_state_row(
444
+ input.id,
445
+ DIRECTORY_DESCRIPTOR_SCHEMA_KEY,
446
+ DIRECTORY_DESCRIPTOR_SCHEMA_VERSION.to_string(),
447
+ Some(JsonValue::Object(snapshot).to_string()),
448
+ input.context,
449
+ )
450
+ }
451
+
452
+ pub(crate) fn file_descriptor_write_row(input: FileDescriptorWriteIntent) -> StageRow {
453
+ let mut snapshot = JsonMap::new();
454
+ if let Some(id) = input.id.as_ref() {
455
+ snapshot.insert("id".to_string(), JsonValue::String(id.clone()));
456
+ }
457
+ snapshot.insert(
458
+ "directory_id".to_string(),
459
+ input
460
+ .directory_id
461
+ .clone()
462
+ .map(JsonValue::String)
463
+ .unwrap_or(JsonValue::Null),
464
+ );
465
+ snapshot.insert("name".to_string(), JsonValue::String(input.name));
466
+ if let Some(hidden) = input.hidden {
467
+ snapshot.insert("hidden".to_string(), JsonValue::Bool(hidden));
468
+ }
469
+
470
+ partial_state_row(
471
+ input.id,
472
+ FILE_DESCRIPTOR_SCHEMA_KEY,
473
+ FILE_DESCRIPTOR_SCHEMA_VERSION.to_string(),
474
+ Some(JsonValue::Object(snapshot).to_string()),
475
+ input.context,
476
+ )
477
+ }
478
+
479
+ pub(crate) fn blob_ref_row(input: BlobRefRowInput) -> Result<StageRow, LixError> {
480
+ let size_bytes = u64::try_from(input.data.len()).map_err(|_| {
481
+ LixError::new(
482
+ "LIX_ERROR_UNKNOWN",
483
+ format!(
484
+ "binary blob size exceeds supported range for file '{}' version '{}'",
485
+ input.file_id, input.context.version_id
486
+ ),
487
+ )
488
+ })?;
489
+ let snapshot_content = json!({
490
+ "id": input.file_id.clone(),
491
+ "blob_hash": stable_content_fingerprint_hex(&input.data),
492
+ "size_bytes": size_bytes,
493
+ })
494
+ .to_string();
495
+
496
+ Ok(state_row(
497
+ input.file_id.clone(),
498
+ BLOB_REF_SCHEMA_KEY,
499
+ BLOB_REF_SCHEMA_VERSION.to_string(),
500
+ Some(snapshot_content),
501
+ FilesystemRowContext {
502
+ file_id: Some(input.file_id),
503
+ ..input.context
504
+ },
505
+ ))
506
+ }
507
+
508
+ pub(crate) fn plan_file_path_write(
509
+ resolver: &mut DirectoryPathResolver,
510
+ input: FilePathWriteInput,
511
+ generate_directory_id: &mut dyn FnMut() -> String,
512
+ ) -> Result<FilesystemWritePlan, LixError> {
513
+ let parsed = ParsedFilePath::try_from_path(&input.path)?;
514
+ let mut rows = Vec::new();
515
+ let file_id = input.id.unwrap_or_else(&mut *generate_directory_id);
516
+
517
+ let directory_id = match parsed.directory_path.as_ref() {
518
+ Some(directory_path) => {
519
+ rows.extend(resolver.ensure_directory_path(
520
+ directory_path.as_str(),
521
+ input.context.clone(),
522
+ false,
523
+ generate_directory_id,
524
+ )?);
525
+ resolver
526
+ .directory_id(directory_path.as_str())?
527
+ .map(ToOwned::to_owned)
528
+ }
529
+ None => None,
530
+ };
531
+
532
+ resolver.reserve_file(directory_id.clone(), parsed.name.clone(), file_id.clone())?;
533
+ rows.push(file_descriptor_row(FileDescriptorRowInput {
534
+ id: file_id.clone(),
535
+ directory_id,
536
+ name: parsed.name.clone(),
537
+ hidden: input.hidden.unwrap_or(false),
538
+ context: input.context.clone(),
539
+ }));
540
+
541
+ let mut file_data = Vec::new();
542
+ if let Some(data) = input.data {
543
+ rows.push(blob_ref_row(BlobRefRowInput {
544
+ file_id: file_id.clone(),
545
+ data: data.clone(),
546
+ context: FilesystemRowContext {
547
+ file_id: None,
548
+ metadata: None,
549
+ ..input.context.clone()
550
+ },
551
+ })?);
552
+ file_data.push(StageFileData {
553
+ file_id,
554
+ version_id: input.context.version_id,
555
+ untracked: input.context.untracked,
556
+ data,
557
+ });
558
+ }
559
+
560
+ Ok(FilesystemWritePlan {
561
+ rows,
562
+ file_data,
563
+ count: 1,
564
+ })
565
+ }
566
+
567
+ pub(crate) fn plan_file_path_update(
568
+ resolver: &mut DirectoryPathResolver,
569
+ existing_file_id: String,
570
+ new_path: String,
571
+ existing_hidden: bool,
572
+ _existing_data: Option<Vec<u8>>,
573
+ context: FilesystemRowContext,
574
+ generate_directory_id: &mut dyn FnMut() -> String,
575
+ ) -> Result<FilesystemWritePlan, LixError> {
576
+ let parsed = ParsedFilePath::try_from_path(&new_path)?;
577
+ let mut rows = Vec::new();
578
+
579
+ let directory_id = match parsed.directory_path.as_ref() {
580
+ Some(directory_path) => {
581
+ rows.extend(resolver.ensure_directory_path(
582
+ directory_path.as_str(),
583
+ context.clone(),
584
+ false,
585
+ generate_directory_id,
586
+ )?);
587
+ resolver
588
+ .directory_id(directory_path.as_str())?
589
+ .map(ToOwned::to_owned)
590
+ }
591
+ None => None,
592
+ };
593
+
594
+ resolver.reserve_file(
595
+ directory_id.clone(),
596
+ parsed.name.clone(),
597
+ existing_file_id.clone(),
598
+ )?;
599
+ rows.push(file_descriptor_row(FileDescriptorRowInput {
600
+ id: existing_file_id,
601
+ directory_id,
602
+ name: parsed.name.clone(),
603
+ hidden: existing_hidden,
604
+ context,
605
+ }));
606
+
607
+ // Data/blob-ref state is intentionally left untouched for path-only
608
+ // updates. A provider should plan blob rows only when `data` is assigned.
609
+ Ok(FilesystemWritePlan {
610
+ rows,
611
+ file_data: Vec::new(),
612
+ count: 1,
613
+ })
614
+ }
615
+
616
+ pub(crate) fn plan_file_delete(input: FileDeleteInput) -> FilesystemDeletePlan {
617
+ let mut rows = vec![tombstone_row(
618
+ input.file_id.clone(),
619
+ FILE_DESCRIPTOR_SCHEMA_KEY,
620
+ FILE_DESCRIPTOR_SCHEMA_VERSION.to_string(),
621
+ FilesystemRowContext {
622
+ file_id: None,
623
+ ..input.context.clone()
624
+ },
625
+ )];
626
+
627
+ if input.has_blob_ref {
628
+ rows.push(tombstone_row(
629
+ input.file_id.clone(),
630
+ BLOB_REF_SCHEMA_KEY,
631
+ BLOB_REF_SCHEMA_VERSION.to_string(),
632
+ FilesystemRowContext {
633
+ file_id: Some(input.file_id),
634
+ metadata: None,
635
+ ..input.context
636
+ },
637
+ ));
638
+ }
639
+
640
+ FilesystemDeletePlan { rows, count: 1 }
641
+ }
642
+
643
+ pub(crate) fn plan_directory_delete(input: DirectoryDeleteInput) -> FilesystemDeletePlan {
644
+ FilesystemDeletePlan {
645
+ rows: vec![tombstone_row(
646
+ input.directory_id,
647
+ DIRECTORY_DESCRIPTOR_SCHEMA_KEY,
648
+ DIRECTORY_DESCRIPTOR_SCHEMA_VERSION.to_string(),
649
+ FilesystemRowContext {
650
+ file_id: None,
651
+ ..input.context
652
+ },
653
+ )],
654
+ count: 1,
655
+ }
656
+ }
657
+
658
+ pub(crate) fn plan_recursive_directory_delete(
659
+ root_directory_id: &str,
660
+ visible_filesystem: &VisibleFilesystem,
661
+ context: FilesystemRowContext,
662
+ ) -> FilesystemDeletePlan {
663
+ let mut rows = Vec::new();
664
+ let mut count = 0;
665
+
666
+ collect_recursive_directory_delete(
667
+ root_directory_id,
668
+ visible_filesystem,
669
+ &context,
670
+ &mut rows,
671
+ &mut count,
672
+ );
673
+
674
+ FilesystemDeletePlan { rows, count }
675
+ }
676
+
677
+ pub(crate) fn directory_path_resolvers_from_state_rows(
678
+ rows: Vec<LiveStateRow>,
679
+ ) -> Result<BTreeMap<String, DirectoryPathResolver>, LixError> {
680
+ let mut directory_rows = BTreeMap::<String, BTreeMap<String, DirectoryDescriptorSeed>>::new();
681
+ let mut file_rows = BTreeMap::<String, Vec<(Option<String>, String, String)>>::new();
682
+ for row in rows {
683
+ let Some(snapshot_content) = row.snapshot_content.as_deref() else {
684
+ continue;
685
+ };
686
+ let resolver_key = filesystem_storage_scope_key(
687
+ &row.version_id,
688
+ row.global,
689
+ row.untracked,
690
+ row.file_id.as_deref(),
691
+ );
692
+ match row.schema_key.as_str() {
693
+ DIRECTORY_DESCRIPTOR_SCHEMA_KEY => {
694
+ let snapshot: DirectoryDescriptorSnapshot = serde_json::from_str(snapshot_content)
695
+ .map_err(|error| {
696
+ LixError::new(
697
+ "LIX_ERROR_UNKNOWN",
698
+ format!("invalid lix_directory_descriptor snapshot JSON: {error}"),
699
+ )
700
+ })?;
701
+ directory_rows.entry(resolver_key).or_default().insert(
702
+ snapshot.id.clone(),
703
+ DirectoryDescriptorSeed {
704
+ id: snapshot.id,
705
+ parent_id: snapshot.parent_id,
706
+ name: snapshot.name,
707
+ },
708
+ );
709
+ }
710
+ FILE_DESCRIPTOR_SCHEMA_KEY => {
711
+ let snapshot: FileDescriptorSnapshot = serde_json::from_str(snapshot_content)
712
+ .map_err(|error| {
713
+ LixError::new(
714
+ "LIX_ERROR_UNKNOWN",
715
+ format!("invalid lix_file_descriptor snapshot JSON: {error}"),
716
+ )
717
+ })?;
718
+ file_rows.entry(resolver_key).or_default().push((
719
+ snapshot.directory_id,
720
+ snapshot.name,
721
+ snapshot.id,
722
+ ));
723
+ }
724
+ _ => {}
725
+ }
726
+ }
727
+
728
+ let mut resolvers = BTreeMap::new();
729
+ for (version_id, records) in directory_rows {
730
+ let mut paths = BTreeMap::<String, String>::new();
731
+ for directory_id in records.keys() {
732
+ resolve_directory_seed_path(directory_id, &records, &mut paths, &mut BTreeSet::new())?;
733
+ }
734
+ let seeds = paths
735
+ .into_iter()
736
+ .map(|(directory_id, path)| (path, directory_id))
737
+ .collect::<Vec<_>>();
738
+ let files = file_rows.remove(&version_id).unwrap_or_default();
739
+ resolvers.insert(
740
+ version_id,
741
+ DirectoryPathResolver::from_existing_filesystem(seeds, files)?,
742
+ );
743
+ }
744
+ for (version_id, files) in file_rows {
745
+ resolvers.insert(
746
+ version_id,
747
+ DirectoryPathResolver::from_existing_filesystem(std::iter::empty(), files)?,
748
+ );
749
+ }
750
+ Ok(resolvers)
751
+ }
752
+
753
+ pub(crate) fn filesystem_storage_scope_key(
754
+ version_id: &str,
755
+ global: bool,
756
+ untracked: bool,
757
+ file_id: Option<&str>,
758
+ ) -> String {
759
+ format!(
760
+ "version={version_id}\0global={global}\0untracked={untracked}\0file_id={}",
761
+ file_id.unwrap_or("<null>")
762
+ )
763
+ }
764
+
765
+ #[derive(Debug, Clone)]
766
+ struct DirectoryDescriptorSeed {
767
+ id: String,
768
+ parent_id: Option<String>,
769
+ name: String,
770
+ }
771
+
772
+ fn resolve_directory_seed_path(
773
+ directory_id: &str,
774
+ records: &BTreeMap<String, DirectoryDescriptorSeed>,
775
+ paths: &mut BTreeMap<String, String>,
776
+ visiting: &mut BTreeSet<String>,
777
+ ) -> Result<Option<String>, LixError> {
778
+ if let Some(path) = paths.get(directory_id) {
779
+ return Ok(Some(path.clone()));
780
+ }
781
+ if !visiting.insert(directory_id.to_string()) {
782
+ return Err(directory_parent_cycle_error(directory_id));
783
+ }
784
+ let Some(row) = records.get(directory_id) else {
785
+ visiting.remove(directory_id);
786
+ return Ok(None);
787
+ };
788
+ let path = match row.parent_id.as_deref() {
789
+ Some(parent_id) => {
790
+ let Some(parent_path) =
791
+ resolve_directory_seed_path(parent_id, records, paths, visiting)?
792
+ else {
793
+ visiting.remove(directory_id);
794
+ return Ok(None);
795
+ };
796
+ format!("{parent_path}{}/", row.name)
797
+ }
798
+ None => format!("/{}/", row.name),
799
+ };
800
+ visiting.remove(directory_id);
801
+ paths.insert(row.id.clone(), path.clone());
802
+ Ok(Some(path))
803
+ }
804
+
805
+ fn directory_parent_cycle_error(directory_id: &str) -> LixError {
806
+ LixError::new(
807
+ LixError::CODE_CONSTRAINT_VIOLATION,
808
+ format!(
809
+ "lix_directory_descriptor parent_id cycle detected while resolving directory '{directory_id}'"
810
+ ),
811
+ )
812
+ }
813
+
814
+ fn state_row(
815
+ entity_id: String,
816
+ schema_key: &str,
817
+ schema_version: String,
818
+ snapshot_content: Option<String>,
819
+ context: FilesystemRowContext,
820
+ ) -> StageRow {
821
+ partial_state_row(
822
+ Some(entity_id),
823
+ schema_key,
824
+ schema_version,
825
+ snapshot_content,
826
+ context,
827
+ )
828
+ }
829
+
830
+ fn partial_state_row(
831
+ entity_id: Option<String>,
832
+ schema_key: &str,
833
+ schema_version: String,
834
+ snapshot_content: Option<String>,
835
+ context: FilesystemRowContext,
836
+ ) -> StageRow {
837
+ StageRow {
838
+ entity_id: entity_id.map(|entity_id| {
839
+ EntityIdentity::from_string(&entity_id)
840
+ .expect("filesystem entity id should decode as entity identity")
841
+ }),
842
+ schema_key: schema_key.to_string(),
843
+ file_id: context.file_id,
844
+ snapshot_content,
845
+ metadata: context.metadata,
846
+ origin: None,
847
+ schema_version,
848
+ created_at: None,
849
+ updated_at: None,
850
+ global: context.global,
851
+ change_id: None,
852
+ commit_id: None,
853
+ untracked: context.untracked,
854
+ version_id: context.version_id,
855
+ }
856
+ }
857
+
858
+ fn tombstone_row(
859
+ entity_id: String,
860
+ schema_key: &str,
861
+ schema_version: String,
862
+ context: FilesystemRowContext,
863
+ ) -> StageRow {
864
+ state_row(entity_id, schema_key, schema_version, None, context)
865
+ }
866
+
867
+ fn collect_recursive_directory_delete(
868
+ directory_id: &str,
869
+ visible_filesystem: &VisibleFilesystem,
870
+ context: &FilesystemRowContext,
871
+ rows: &mut Vec<StageRow>,
872
+ count: &mut u64,
873
+ ) {
874
+ if let Some(child_ids) = visible_filesystem
875
+ .directory_children_by_parent_id
876
+ .get(&Some(directory_id.to_string()))
877
+ {
878
+ for child_id in child_ids {
879
+ collect_recursive_directory_delete(child_id, visible_filesystem, context, rows, count);
880
+ }
881
+ }
882
+
883
+ if let Some(files) = visible_filesystem
884
+ .files_by_directory_id
885
+ .get(&Some(directory_id.to_string()))
886
+ {
887
+ for file_id in files.keys() {
888
+ let plan = plan_file_delete(FileDeleteInput {
889
+ file_id: file_id.clone(),
890
+ has_blob_ref: visible_filesystem
891
+ .blob_refs_by_file_id
892
+ .contains_key(file_id),
893
+ context: context.clone(),
894
+ });
895
+ rows.extend(plan.rows);
896
+ *count += plan.count;
897
+ }
898
+ }
899
+
900
+ let plan = plan_directory_delete(DirectoryDeleteInput {
901
+ directory_id: directory_id.to_string(),
902
+ context: context.clone(),
903
+ });
904
+ rows.extend(plan.rows);
905
+ *count += plan.count;
906
+ }
907
+
908
+ #[cfg(test)]
909
+ mod tests {
910
+ use std::collections::{BTreeMap, BTreeSet};
911
+
912
+ use serde_json::Value as JsonValue;
913
+
914
+ use super::{
915
+ blob_ref_row, directory_descriptor_row, file_descriptor_row, plan_file_path_update,
916
+ plan_file_path_write, BlobRefRowInput, DirectoryDeleteInput, DirectoryDescriptorRowInput,
917
+ DirectoryPathResolver, FileDeleteInput, FileDescriptorRowInput, FilePathWriteInput,
918
+ FilesystemRowContext,
919
+ };
920
+ use crate::sql2::filesystem_visibility::{
921
+ VisibleBlobRef, VisibleDirectory, VisibleFile, VisibleFilesystem,
922
+ };
923
+ use crate::{entity_identity::EntityIdentity, live_state::LiveStateRow};
924
+
925
+ fn test_id_generator(ids: &'static [&'static str]) -> impl FnMut() -> String {
926
+ let mut ids = ids.iter();
927
+ move || ids.next().expect("test id should exist").to_string()
928
+ }
929
+
930
+ #[test]
931
+ fn directory_descriptor_row_builds_state_row() {
932
+ let row = directory_descriptor_row(DirectoryDescriptorRowInput {
933
+ id: "dir-docs".to_string(),
934
+ parent_id: None,
935
+ name: "docs".to_string(),
936
+ hidden: false,
937
+ context: FilesystemRowContext::active_version("version-a"),
938
+ });
939
+
940
+ assert_eq!(
941
+ row.entity_id.as_ref(),
942
+ Some(&crate::entity_identity::EntityIdentity::single("dir-docs"))
943
+ );
944
+ assert_eq!(row.schema_key, "lix_directory_descriptor");
945
+ assert_eq!(row.schema_version.as_str(), "1");
946
+ assert_eq!(row.version_id, "version-a");
947
+ let snapshot: JsonValue =
948
+ serde_json::from_str(row.snapshot_content.as_deref().unwrap()).unwrap();
949
+ assert_eq!(snapshot["id"], "dir-docs");
950
+ assert_eq!(snapshot["parent_id"], JsonValue::Null);
951
+ assert_eq!(snapshot["name"], "docs");
952
+ assert_eq!(snapshot["hidden"], false);
953
+ }
954
+
955
+ #[test]
956
+ fn file_descriptor_row_builds_state_row() {
957
+ let row = file_descriptor_row(FileDescriptorRowInput {
958
+ id: "file-readme".to_string(),
959
+ directory_id: Some("dir-docs".to_string()),
960
+ name: "readme.md".to_string(),
961
+ hidden: false,
962
+ context: FilesystemRowContext::active_version("version-a"),
963
+ });
964
+
965
+ assert_eq!(
966
+ row.entity_id.as_ref(),
967
+ Some(&crate::entity_identity::EntityIdentity::single(
968
+ "file-readme"
969
+ ))
970
+ );
971
+ assert_eq!(row.schema_key, "lix_file_descriptor");
972
+ assert_eq!(row.schema_version.as_str(), "1");
973
+ let snapshot: JsonValue =
974
+ serde_json::from_str(row.snapshot_content.as_deref().unwrap()).unwrap();
975
+ assert_eq!(snapshot["directory_id"], "dir-docs");
976
+ assert_eq!(snapshot["name"], "readme.md");
977
+ }
978
+
979
+ #[test]
980
+ fn blob_ref_row_builds_state_row() {
981
+ let row = blob_ref_row(BlobRefRowInput {
982
+ file_id: "file-readme".to_string(),
983
+ data: b"Hello".to_vec(),
984
+ context: FilesystemRowContext::active_version("version-a"),
985
+ })
986
+ .expect("blob ref row should build");
987
+
988
+ assert_eq!(
989
+ row.entity_id.as_ref(),
990
+ Some(&crate::entity_identity::EntityIdentity::single(
991
+ "file-readme"
992
+ ))
993
+ );
994
+ assert_eq!(row.file_id.as_deref(), Some("file-readme"));
995
+ assert_eq!(row.schema_key, "lix_binary_blob_ref");
996
+ assert_eq!(row.schema_version.as_str(), "1");
997
+ let snapshot: JsonValue =
998
+ serde_json::from_str(row.snapshot_content.as_deref().unwrap()).unwrap();
999
+ assert_eq!(snapshot["id"], "file-readme");
1000
+ assert_eq!(snapshot["size_bytes"], 5);
1001
+ assert!(snapshot["blob_hash"]
1002
+ .as_str()
1003
+ .is_some_and(|hash| !hash.is_empty()));
1004
+ }
1005
+
1006
+ #[test]
1007
+ fn directory_path_resolver_reuses_existing_ancestor() {
1008
+ let mut resolver =
1009
+ DirectoryPathResolver::from_existing([("/docs/".to_string(), "dir-docs".to_string())])
1010
+ .expect("existing directories should normalize");
1011
+
1012
+ let rows = resolver
1013
+ .ensure_directory_path(
1014
+ "/docs/nested/",
1015
+ FilesystemRowContext::active_version("version-a"),
1016
+ false,
1017
+ &mut test_id_generator(&["dir-generated-nested"]),
1018
+ )
1019
+ .expect("directory path should plan");
1020
+
1021
+ assert_eq!(rows.len(), 1);
1022
+ assert_eq!(resolver.directory_id("/docs/").unwrap(), Some("dir-docs"));
1023
+ assert_eq!(
1024
+ resolver.directory_id("/docs/nested/").unwrap(),
1025
+ Some("dir-generated-nested")
1026
+ );
1027
+
1028
+ let snapshot: JsonValue =
1029
+ serde_json::from_str(rows[0].snapshot_content.as_deref().unwrap()).unwrap();
1030
+ assert_eq!(snapshot["id"], "dir-generated-nested");
1031
+ assert_eq!(snapshot["parent_id"], "dir-docs");
1032
+ assert_eq!(snapshot["name"], "nested");
1033
+ }
1034
+
1035
+ #[test]
1036
+ fn directory_path_resolver_reuses_ancestor_staged_in_same_batch() {
1037
+ let mut resolver =
1038
+ DirectoryPathResolver::from_existing([]).expect("empty resolver should build");
1039
+
1040
+ let docs_rows = resolver
1041
+ .ensure_directory_path(
1042
+ "/docs/",
1043
+ FilesystemRowContext::active_version("version-a"),
1044
+ false,
1045
+ &mut test_id_generator(&["dir-generated-docs"]),
1046
+ )
1047
+ .expect("top-level directory should plan");
1048
+ assert_eq!(docs_rows.len(), 1);
1049
+
1050
+ let nested_rows = resolver
1051
+ .ensure_directory_path(
1052
+ "/docs/nested/",
1053
+ FilesystemRowContext::active_version("version-a"),
1054
+ false,
1055
+ &mut test_id_generator(&["dir-generated-nested"]),
1056
+ )
1057
+ .expect("nested directory should plan");
1058
+
1059
+ assert_eq!(nested_rows.len(), 1);
1060
+ let snapshot: JsonValue =
1061
+ serde_json::from_str(nested_rows[0].snapshot_content.as_deref().unwrap()).unwrap();
1062
+ assert_eq!(snapshot["id"], "dir-generated-nested");
1063
+ assert_eq!(snapshot["parent_id"], "dir-generated-docs");
1064
+ assert_eq!(snapshot["name"], "nested");
1065
+ }
1066
+
1067
+ #[test]
1068
+ fn directory_path_resolver_uses_explicit_leaf_id() {
1069
+ let mut resolver =
1070
+ DirectoryPathResolver::from_existing([]).expect("empty resolver should build");
1071
+
1072
+ let rows = resolver
1073
+ .ensure_directory_path_with_leaf_id(
1074
+ "/docs/nested/",
1075
+ Some("dir-nested".to_string()),
1076
+ FilesystemRowContext::active_version("version-a"),
1077
+ false,
1078
+ &mut test_id_generator(&["dir-generated-docs"]),
1079
+ )
1080
+ .expect("directory path should plan");
1081
+
1082
+ assert_eq!(rows.len(), 2);
1083
+ assert_eq!(
1084
+ resolver.directory_id("/docs/").unwrap(),
1085
+ Some("dir-generated-docs")
1086
+ );
1087
+ assert_eq!(
1088
+ resolver.directory_id("/docs/nested/").unwrap(),
1089
+ Some("dir-nested")
1090
+ );
1091
+
1092
+ let snapshot: JsonValue =
1093
+ serde_json::from_str(rows[1].snapshot_content.as_deref().unwrap()).unwrap();
1094
+ assert_eq!(snapshot["id"], "dir-nested");
1095
+ assert_eq!(snapshot["parent_id"], "dir-generated-docs");
1096
+ assert_eq!(snapshot["name"], "nested");
1097
+ }
1098
+
1099
+ #[test]
1100
+ fn directory_path_resolver_does_not_restage_same_path() {
1101
+ let mut resolver =
1102
+ DirectoryPathResolver::from_existing([]).expect("empty resolver should build");
1103
+
1104
+ let rows = resolver
1105
+ .ensure_directory_path(
1106
+ "/docs/nested/",
1107
+ FilesystemRowContext::active_version("version-a"),
1108
+ false,
1109
+ &mut test_id_generator(&["dir-generated-docs", "dir-generated-nested"]),
1110
+ )
1111
+ .expect("directory path should plan");
1112
+ assert_eq!(rows.len(), 2);
1113
+
1114
+ let rows = resolver
1115
+ .ensure_directory_path(
1116
+ "/docs/nested/",
1117
+ FilesystemRowContext::active_version("version-a"),
1118
+ false,
1119
+ &mut test_id_generator(&["should-not-be-used"]),
1120
+ )
1121
+ .expect("directory path should plan");
1122
+ assert!(rows.is_empty());
1123
+ }
1124
+
1125
+ #[test]
1126
+ fn file_path_write_stages_missing_directories_file_blob_and_payload() {
1127
+ let mut resolver =
1128
+ DirectoryPathResolver::from_existing([]).expect("empty resolver should build");
1129
+
1130
+ let plan = plan_file_path_write(
1131
+ &mut resolver,
1132
+ FilePathWriteInput {
1133
+ id: Some("file-readme".to_string()),
1134
+ path: "/docs/guides/readme.md".to_string(),
1135
+ data: Some(b"hello".to_vec()),
1136
+ hidden: Some(false),
1137
+ context: FilesystemRowContext::active_version("version-a"),
1138
+ },
1139
+ &mut test_id_generator(&["dir-generated-docs", "dir-generated-guides"]),
1140
+ )
1141
+ .expect("file path write should plan");
1142
+
1143
+ assert_eq!(plan.count, 1);
1144
+ assert_eq!(plan.file_data.len(), 1);
1145
+ assert_eq!(plan.file_data[0].file_id, "file-readme");
1146
+ assert_eq!(plan.file_data[0].version_id, "version-a");
1147
+ assert_eq!(plan.file_data[0].data, b"hello");
1148
+ assert_eq!(plan.rows.len(), 4);
1149
+ assert_eq!(
1150
+ plan.rows
1151
+ .iter()
1152
+ .filter(|row| row.schema_key == "lix_directory_descriptor")
1153
+ .count(),
1154
+ 2
1155
+ );
1156
+ assert!(plan
1157
+ .rows
1158
+ .iter()
1159
+ .any(|row| row.schema_key == "lix_binary_blob_ref"));
1160
+
1161
+ let file_row = plan
1162
+ .rows
1163
+ .iter()
1164
+ .find(|row| row.schema_key == "lix_file_descriptor")
1165
+ .expect("file descriptor row should be planned");
1166
+ let snapshot: JsonValue =
1167
+ serde_json::from_str(file_row.snapshot_content.as_deref().unwrap()).unwrap();
1168
+ assert_eq!(snapshot["id"], "file-readme");
1169
+ assert_eq!(snapshot["directory_id"], "dir-generated-guides");
1170
+ assert_eq!(snapshot["name"], "readme.md");
1171
+ }
1172
+
1173
+ #[test]
1174
+ fn file_path_write_reuses_existing_parent_directory() {
1175
+ let mut resolver = DirectoryPathResolver::from_existing([
1176
+ ("/docs/".to_string(), "dir-docs".to_string()),
1177
+ ("/docs/guides/".to_string(), "dir-guides".to_string()),
1178
+ ])
1179
+ .expect("existing directories should seed");
1180
+
1181
+ let plan = plan_file_path_write(
1182
+ &mut resolver,
1183
+ FilePathWriteInput {
1184
+ id: Some("file-readme".to_string()),
1185
+ path: "/docs/guides/readme.md".to_string(),
1186
+ data: Some(b"hello".to_vec()),
1187
+ hidden: Some(false),
1188
+ context: FilesystemRowContext::active_version("version-a"),
1189
+ },
1190
+ &mut test_id_generator(&["should-not-be-used"]),
1191
+ )
1192
+ .expect("file path write should plan");
1193
+
1194
+ assert_eq!(plan.rows.len(), 2);
1195
+ assert_eq!(
1196
+ plan.rows
1197
+ .iter()
1198
+ .filter(|row| row.schema_key == "lix_directory_descriptor")
1199
+ .count(),
1200
+ 0
1201
+ );
1202
+ let file_row = plan
1203
+ .rows
1204
+ .iter()
1205
+ .find(|row| row.schema_key == "lix_file_descriptor")
1206
+ .expect("file descriptor row should be planned");
1207
+ let snapshot: JsonValue =
1208
+ serde_json::from_str(file_row.snapshot_content.as_deref().unwrap()).unwrap();
1209
+ assert_eq!(snapshot["directory_id"], "dir-guides");
1210
+ }
1211
+
1212
+ #[test]
1213
+ fn file_path_update_reuses_existing_parent_and_preserves_data() {
1214
+ let mut resolver =
1215
+ DirectoryPathResolver::from_existing([("/docs/".to_string(), "dir-docs".to_string())])
1216
+ .expect("existing directories should seed");
1217
+
1218
+ let plan = plan_file_path_update(
1219
+ &mut resolver,
1220
+ "file-readme".to_string(),
1221
+ "/docs/renamed.md".to_string(),
1222
+ false,
1223
+ Some(b"hello".to_vec()),
1224
+ FilesystemRowContext::active_version("version-a"),
1225
+ &mut test_id_generator(&["should-not-be-used"]),
1226
+ )
1227
+ .expect("file path update should plan");
1228
+
1229
+ assert_eq!(plan.count, 1);
1230
+ assert!(plan.file_data.is_empty());
1231
+ assert_eq!(plan.rows.len(), 1);
1232
+ assert!(plan
1233
+ .rows
1234
+ .iter()
1235
+ .all(|row| row.schema_key != "lix_binary_blob_ref"));
1236
+
1237
+ let snapshot: JsonValue =
1238
+ serde_json::from_str(plan.rows[0].snapshot_content.as_deref().unwrap()).unwrap();
1239
+ assert_eq!(snapshot["id"], "file-readme");
1240
+ assert_eq!(snapshot["directory_id"], "dir-docs");
1241
+ assert_eq!(snapshot["name"], "renamed.md");
1242
+ assert_eq!(snapshot["hidden"], false);
1243
+ }
1244
+
1245
+ #[test]
1246
+ fn file_path_update_stages_missing_parent_directories() {
1247
+ let mut resolver =
1248
+ DirectoryPathResolver::from_existing([]).expect("empty resolver should build");
1249
+
1250
+ let plan = plan_file_path_update(
1251
+ &mut resolver,
1252
+ "file-readme".to_string(),
1253
+ "/docs/guides/readme.md".to_string(),
1254
+ true,
1255
+ Some(b"hello".to_vec()),
1256
+ FilesystemRowContext::active_version("version-a"),
1257
+ &mut test_id_generator(&["dir-generated-docs", "dir-generated-guides"]),
1258
+ )
1259
+ .expect("file path update should plan");
1260
+
1261
+ assert_eq!(plan.count, 1);
1262
+ assert!(plan.file_data.is_empty());
1263
+ assert_eq!(plan.rows.len(), 3);
1264
+ assert_eq!(
1265
+ plan.rows
1266
+ .iter()
1267
+ .filter(|row| row.schema_key == "lix_directory_descriptor")
1268
+ .count(),
1269
+ 2
1270
+ );
1271
+ assert!(plan
1272
+ .rows
1273
+ .iter()
1274
+ .all(|row| row.schema_key != "lix_binary_blob_ref"));
1275
+
1276
+ let file_row = plan
1277
+ .rows
1278
+ .iter()
1279
+ .find(|row| row.schema_key == "lix_file_descriptor")
1280
+ .expect("file descriptor row should be planned");
1281
+ let snapshot: JsonValue =
1282
+ serde_json::from_str(file_row.snapshot_content.as_deref().unwrap()).unwrap();
1283
+ assert_eq!(snapshot["directory_id"], "dir-generated-guides");
1284
+ assert_eq!(snapshot["name"], "readme.md");
1285
+ assert_eq!(snapshot["hidden"], true);
1286
+ }
1287
+
1288
+ #[test]
1289
+ fn directory_path_resolvers_from_state_rows_derives_nested_paths() {
1290
+ let resolvers = super::directory_path_resolvers_from_state_rows(vec![
1291
+ live_directory_row(
1292
+ "dir-docs",
1293
+ "version-a",
1294
+ "{\"id\":\"dir-docs\",\"parent_id\":null,\"name\":\"docs\"}",
1295
+ ),
1296
+ live_directory_row(
1297
+ "dir-guides",
1298
+ "version-a",
1299
+ "{\"id\":\"dir-guides\",\"parent_id\":\"dir-docs\",\"name\":\"guides\"}",
1300
+ ),
1301
+ ])
1302
+ .expect("state rows should seed directory resolvers");
1303
+
1304
+ let resolver = resolvers
1305
+ .get(&super::filesystem_storage_scope_key(
1306
+ "version-a",
1307
+ false,
1308
+ false,
1309
+ None,
1310
+ ))
1311
+ .expect("storage-scope resolver should exist");
1312
+ assert_eq!(resolver.directory_id("/docs/").unwrap(), Some("dir-docs"));
1313
+ assert_eq!(
1314
+ resolver.directory_id("/docs/guides/").unwrap(),
1315
+ Some("dir-guides")
1316
+ );
1317
+ }
1318
+
1319
+ #[test]
1320
+ fn file_delete_plans_descriptor_and_blob_ref_tombstones() {
1321
+ let plan = super::plan_file_delete(FileDeleteInput {
1322
+ file_id: "file-readme".to_string(),
1323
+ has_blob_ref: true,
1324
+ context: FilesystemRowContext::active_version("version-a"),
1325
+ });
1326
+
1327
+ assert_eq!(plan.count, 1);
1328
+ assert_eq!(plan.rows.len(), 2);
1329
+ let descriptor = plan
1330
+ .rows
1331
+ .iter()
1332
+ .find(|row| row.schema_key == "lix_file_descriptor")
1333
+ .expect("file descriptor tombstone should be planned");
1334
+ assert_eq!(
1335
+ descriptor.entity_id.as_ref(),
1336
+ Some(&crate::entity_identity::EntityIdentity::single(
1337
+ "file-readme"
1338
+ ))
1339
+ );
1340
+ assert_eq!(descriptor.file_id, None);
1341
+ assert_eq!(descriptor.snapshot_content, None);
1342
+ assert_eq!(descriptor.schema_version.as_str(), "1");
1343
+
1344
+ let blob_ref = plan
1345
+ .rows
1346
+ .iter()
1347
+ .find(|row| row.schema_key == "lix_binary_blob_ref")
1348
+ .expect("blob ref tombstone should be planned");
1349
+ assert_eq!(
1350
+ blob_ref.entity_id.as_ref(),
1351
+ Some(&crate::entity_identity::EntityIdentity::single(
1352
+ "file-readme"
1353
+ ))
1354
+ );
1355
+ assert_eq!(blob_ref.file_id.as_deref(), Some("file-readme"));
1356
+ assert_eq!(blob_ref.snapshot_content, None);
1357
+ assert_eq!(blob_ref.schema_version.as_str(), "1");
1358
+ }
1359
+
1360
+ #[test]
1361
+ fn file_delete_without_blob_ref_plans_only_descriptor_tombstone() {
1362
+ let plan = super::plan_file_delete(FileDeleteInput {
1363
+ file_id: "file-readme".to_string(),
1364
+ has_blob_ref: false,
1365
+ context: FilesystemRowContext::active_version("version-a"),
1366
+ });
1367
+
1368
+ assert_eq!(plan.count, 1);
1369
+ assert_eq!(plan.rows.len(), 1);
1370
+ assert_eq!(plan.rows[0].schema_key, "lix_file_descriptor");
1371
+ assert_eq!(plan.rows[0].snapshot_content, None);
1372
+ }
1373
+
1374
+ #[test]
1375
+ fn directory_delete_plans_descriptor_tombstone() {
1376
+ let plan = super::plan_directory_delete(DirectoryDeleteInput {
1377
+ directory_id: "dir-docs".to_string(),
1378
+ context: FilesystemRowContext::active_version("version-a"),
1379
+ });
1380
+
1381
+ assert_eq!(plan.count, 1);
1382
+ assert_eq!(plan.rows.len(), 1);
1383
+ assert_eq!(
1384
+ plan.rows[0].entity_id.as_ref(),
1385
+ Some(&crate::entity_identity::EntityIdentity::single("dir-docs"))
1386
+ );
1387
+ assert_eq!(plan.rows[0].schema_key, "lix_directory_descriptor");
1388
+ assert_eq!(plan.rows[0].file_id, None);
1389
+ assert_eq!(plan.rows[0].snapshot_content, None);
1390
+ assert_eq!(plan.rows[0].schema_version.as_str(), "1");
1391
+ }
1392
+
1393
+ #[test]
1394
+ fn recursive_directory_delete_plans_files_blobs_and_deepest_directories_first() {
1395
+ let context = FilesystemRowContext::active_version("version-a");
1396
+ let mut directories_by_id = BTreeMap::new();
1397
+ directories_by_id.insert(
1398
+ "dir-docs".to_string(),
1399
+ visible_directory("dir-docs", None, "docs", context.clone()),
1400
+ );
1401
+ directories_by_id.insert(
1402
+ "dir-guides".to_string(),
1403
+ visible_directory("dir-guides", Some("dir-docs"), "guides", context.clone()),
1404
+ );
1405
+
1406
+ let mut directory_children_by_parent_id = BTreeMap::new();
1407
+ directory_children_by_parent_id.insert(
1408
+ Some("dir-docs".to_string()),
1409
+ BTreeSet::from(["dir-guides".to_string()]),
1410
+ );
1411
+
1412
+ let mut files_by_directory_id = BTreeMap::new();
1413
+ files_by_directory_id.insert(
1414
+ Some("dir-guides".to_string()),
1415
+ BTreeMap::from([(
1416
+ "file-readme".to_string(),
1417
+ visible_file("file-readme", Some("dir-guides"), "readme", context.clone()),
1418
+ )]),
1419
+ );
1420
+ files_by_directory_id.insert(
1421
+ Some("dir-docs".to_string()),
1422
+ BTreeMap::from([(
1423
+ "file-index".to_string(),
1424
+ visible_file("file-index", Some("dir-docs"), "index", context.clone()),
1425
+ )]),
1426
+ );
1427
+
1428
+ let visible_filesystem = VisibleFilesystem {
1429
+ directories_by_id,
1430
+ directory_children_by_parent_id,
1431
+ files_by_directory_id,
1432
+ blob_refs_by_file_id: BTreeMap::from([(
1433
+ "file-readme".to_string(),
1434
+ visible_blob_ref("file-readme", context.clone()),
1435
+ )]),
1436
+ };
1437
+
1438
+ let plan = super::plan_recursive_directory_delete("dir-docs", &visible_filesystem, context);
1439
+
1440
+ assert_eq!(plan.count, 4);
1441
+ assert_eq!(
1442
+ plan.rows
1443
+ .iter()
1444
+ .map(|row| {
1445
+ (
1446
+ row.schema_key.as_str(),
1447
+ row.entity_id
1448
+ .as_ref()
1449
+ .expect("planned recursive delete row should carry entity_id")
1450
+ .as_string()
1451
+ .expect("planned recursive delete row should project entity_id"),
1452
+ )
1453
+ })
1454
+ .collect::<Vec<_>>(),
1455
+ vec![
1456
+ ("lix_file_descriptor", "file-readme".to_string()),
1457
+ ("lix_binary_blob_ref", "file-readme".to_string()),
1458
+ ("lix_directory_descriptor", "dir-guides".to_string()),
1459
+ ("lix_file_descriptor", "file-index".to_string()),
1460
+ ("lix_directory_descriptor", "dir-docs".to_string()),
1461
+ ]
1462
+ );
1463
+ assert!(plan.rows.iter().all(|row| row.snapshot_content.is_none()));
1464
+ }
1465
+
1466
+ fn visible_directory(
1467
+ id: &str,
1468
+ parent_id: Option<&str>,
1469
+ name: &str,
1470
+ context: FilesystemRowContext,
1471
+ ) -> VisibleDirectory {
1472
+ VisibleDirectory {
1473
+ id: id.to_string(),
1474
+ parent_id: parent_id.map(ToOwned::to_owned),
1475
+ name: name.to_string(),
1476
+ hidden: false,
1477
+ context,
1478
+ }
1479
+ }
1480
+
1481
+ fn visible_file(
1482
+ id: &str,
1483
+ directory_id: Option<&str>,
1484
+ name: &str,
1485
+ context: FilesystemRowContext,
1486
+ ) -> VisibleFile {
1487
+ VisibleFile {
1488
+ id: id.to_string(),
1489
+ directory_id: directory_id.map(ToOwned::to_owned),
1490
+ name: name.to_string(),
1491
+ hidden: false,
1492
+ context,
1493
+ }
1494
+ }
1495
+
1496
+ fn visible_blob_ref(file_id: &str, context: FilesystemRowContext) -> VisibleBlobRef {
1497
+ VisibleBlobRef {
1498
+ file_id: file_id.to_string(),
1499
+ blob_hash: format!("hash-{file_id}"),
1500
+ size_bytes: Some(1),
1501
+ context,
1502
+ }
1503
+ }
1504
+
1505
+ fn live_directory_row(
1506
+ entity_id: &str,
1507
+ version_id: &str,
1508
+ snapshot_content: &str,
1509
+ ) -> LiveStateRow {
1510
+ LiveStateRow {
1511
+ entity_id: EntityIdentity::from_string(entity_id).expect("entity id should decode"),
1512
+ schema_key: "lix_directory_descriptor".to_string(),
1513
+ file_id: None,
1514
+ snapshot_content: Some(snapshot_content.to_string()),
1515
+ metadata: None,
1516
+ schema_version: "1".to_string(),
1517
+ version_id: version_id.to_string(),
1518
+ change_id: Some(format!("change-{entity_id}")),
1519
+ commit_id: Some(format!("commit-{entity_id}")),
1520
+ global: false,
1521
+ untracked: false,
1522
+ created_at: "2026-04-23T00:00:00Z".to_string(),
1523
+ updated_at: "2026-04-23T01:00:00Z".to_string(),
1524
+ }
1525
+ }
1526
+ }