@lix-js/sdk 0.6.0-preview.1 → 0.6.0-preview.3

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