ducpy 3.1.1__tar.gz → 3.2.1__tar.gz

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 (30) hide show
  1. {ducpy-3.1.1 → ducpy-3.2.1}/Cargo.lock +1 -1
  2. {ducpy-3.1.1 → ducpy-3.2.1}/PKG-INFO +2 -2
  3. {ducpy-3.1.1 → ducpy-3.2.1}/README.md +1 -1
  4. {ducpy-3.1.1 → ducpy-3.2.1}/packages/ducpy/crate/Cargo.toml +1 -1
  5. {ducpy-3.1.1 → ducpy-3.2.1}/packages/ducrs/README.md +1 -1
  6. {ducpy-3.1.1 → ducpy-3.2.1}/packages/ducrs/src/api/version_control.rs +4 -94
  7. {ducpy-3.1.1 → ducpy-3.2.1}/packages/ducrs/src/lib.rs +4 -3
  8. {ducpy-3.1.1 → ducpy-3.2.1}/packages/ducrs/src/parse.rs +214 -98
  9. {ducpy-3.1.1 → ducpy-3.2.1}/packages/ducrs/src/serialize.rs +17 -10
  10. {ducpy-3.1.1 → ducpy-3.2.1}/packages/ducrs/src/types.rs +20 -24
  11. {ducpy-3.1.1 → ducpy-3.2.1}/Cargo.toml +0 -0
  12. {ducpy-3.1.1 → ducpy-3.2.1}/LICENSE +0 -0
  13. {ducpy-3.1.1 → ducpy-3.2.1}/packages/ducpy/crate/src/lib.rs +0 -0
  14. {ducpy-3.1.1 → ducpy-3.2.1}/packages/ducrs/.gitignore +0 -0
  15. {ducpy-3.1.1 → ducpy-3.2.1}/packages/ducrs/Cargo.toml +0 -0
  16. {ducpy-3.1.1 → ducpy-3.2.1}/packages/ducrs/LICENSE +0 -0
  17. {ducpy-3.1.1 → ducpy-3.2.1}/packages/ducrs/build.rs +0 -0
  18. {ducpy-3.1.1 → ducpy-3.2.1}/packages/ducrs/package.json +0 -0
  19. {ducpy-3.1.1 → ducpy-3.2.1}/packages/ducrs/release.config.cjs +0 -0
  20. {ducpy-3.1.1 → ducpy-3.2.1}/packages/ducrs/src/api/document.rs +0 -0
  21. {ducpy-3.1.1 → ducpy-3.2.1}/packages/ducrs/src/api/meta.rs +0 -0
  22. {ducpy-3.1.1 → ducpy-3.2.1}/packages/ducrs/src/api/mod.rs +0 -0
  23. {ducpy-3.1.1 → ducpy-3.2.1}/packages/ducrs/src/db/bootstrap.rs +0 -0
  24. {ducpy-3.1.1 → ducpy-3.2.1}/packages/ducrs/src/db/mod.rs +0 -0
  25. {ducpy-3.1.1 → ducpy-3.2.1}/packages/ducrs/src/db/native.rs +0 -0
  26. {ducpy-3.1.1 → ducpy-3.2.1}/packages/ducrs/src/db/wasm.rs +0 -0
  27. {ducpy-3.1.1 → ducpy-3.2.1}/packages/ducrs/src/serde_utils.rs +0 -0
  28. {ducpy-3.1.1 → ducpy-3.2.1}/packages/ducrs/tests/.gitignore +0 -0
  29. {ducpy-3.1.1 → ducpy-3.2.1}/pyproject.toml +0 -0
  30. {ducpy-3.1.1 → ducpy-3.2.1}/src/ducpy_native/__init__.py +0 -0
@@ -411,7 +411,7 @@ dependencies = [
411
411
 
412
412
  [[package]]
413
413
  name = "ducpy-native"
414
- version = "3.1.1"
414
+ version = "3.2.1"
415
415
  dependencies = [
416
416
  "duc",
417
417
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ducpy
3
- Version: 3.1.1
3
+ Version: 3.2.1
4
4
  Classifier: License :: OSI Approved :: MIT License
5
5
  Requires-Dist: nanoid>=2.0.0
6
6
  License-File: LICENSE
@@ -19,7 +19,7 @@ Project-URL: Source, https://github.com/ducflair/duc/tree/main/packages/ducpy
19
19
 
20
20
  <p align="center">
21
21
  <br/>
22
- <a href="https://duc.ducflair.com" target="_blank"><img width="256px" src="https://raw.githubusercontent.com/ducflair/assets/refs/heads/main/src/duc/duc-extended.png" /></a>
22
+ <a href="https://duc.ducflair.com" target="_blank"><img width="256px" src="https://cdn.jsdelivr.net/gh/ducflair/assets@main/src/duc/duc-extended.png" /></a>
23
23
  <p align="center">2D CAD File Format</p>
24
24
  <p align="center" style="align: center;">
25
25
  <a href="https://pypi.org/project/ducpy/"><img src="https://shields.io/badge/Pip-blue?logo=Pypi&logoColor=white&style=round-square" alt="Pip" /></a>
@@ -2,7 +2,7 @@
2
2
 
3
3
  <p align="center">
4
4
  <br/>
5
- <a href="https://duc.ducflair.com" target="_blank"><img width="256px" src="https://raw.githubusercontent.com/ducflair/assets/refs/heads/main/src/duc/duc-extended.png" /></a>
5
+ <a href="https://duc.ducflair.com" target="_blank"><img width="256px" src="https://cdn.jsdelivr.net/gh/ducflair/assets@main/src/duc/duc-extended.png" /></a>
6
6
  <p align="center">2D CAD File Format</p>
7
7
  <p align="center" style="align: center;">
8
8
  <a href="https://pypi.org/project/ducpy/"><img src="https://shields.io/badge/Pip-blue?logo=Pypi&logoColor=white&style=round-square" alt="Pip" /></a>
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ducpy-native"
3
- version = "3.1.1"
3
+ version = "3.2.1"
4
4
  edition = "2021"
5
5
  description = "Python native extension for the duc file format (used by ducpy)"
6
6
  publish = false
@@ -2,7 +2,7 @@
2
2
 
3
3
  <p align="center">
4
4
  <br/>
5
- <a href="https://duc.ducflair.com" target="_blank"><img width="256px" src="https://raw.githubusercontent.com/ducflair/assets/refs/heads/main/src/duc/duc-extended.png" /></a>
5
+ <a href="https://duc.ducflair.com" target="_blank"><img width="256px" src="https://cdn.jsdelivr.net/gh/ducflair/assets@main/src/duc/duc-extended.png" /></a>
6
6
  <p align="center">2D CAD File Format</p>
7
7
  <p align="center" style="align: center;">
8
8
  <a href="https://crates.io/crates/duc/"><img src="https://shields.io/badge/Crates-FFC933?logo=Rust&logoColor=646464&style=round-square" alt="Crates" /></a>
@@ -4,7 +4,6 @@
4
4
  //! - Restoring document state at any version (checkpoint or delta replay)
5
5
  //! - Creating new checkpoints and deltas
6
6
  //! - Listing version history
7
- //! - Pruning old versions
8
7
  //!
9
8
  //! All operations work directly against the embedded SQLite schema
10
9
  //! (`version_control.sql`) and produce/consume the canonical Rust types
@@ -313,7 +312,7 @@ impl<'a> VersionControl<'a> {
313
312
  self.conn.with(|c| {
314
313
  c.query_row(
315
314
  "SELECT current_version, current_schema_version, chain_count,
316
- last_pruned, total_size
315
+ total_size
317
316
  FROM version_graph WHERE id = 1",
318
317
  [],
319
318
  |row| {
@@ -321,8 +320,7 @@ impl<'a> VersionControl<'a> {
321
320
  current_version: row.get(0)?,
322
321
  current_schema_version: row.get(1)?,
323
322
  chain_count: row.get(2)?,
324
- last_pruned: row.get::<_, Option<i64>>(3)?.unwrap_or(0),
325
- total_size: row.get::<_, Option<i64>>(4)?.unwrap_or(0),
323
+ total_size: row.get::<_, Option<i64>>(3)?.unwrap_or(0),
326
324
  })
327
325
  },
328
326
  )
@@ -465,93 +463,6 @@ impl<'a> VersionControl<'a> {
465
463
  })
466
464
  }
467
465
 
468
- // ────────────────────────────────────────────────────────────────────────
469
- // WRITE — Pruning
470
- // ────────────────────────────────────────────────────────────────────────
471
-
472
- /// Prune old deltas that precede the given `keep_after_version`.
473
- /// Checkpoints are never pruned (they are self-contained recovery points).
474
- /// Returns the number of deltas removed.
475
- pub fn prune_deltas_before(&self, keep_after_version: i64) -> DbResult<usize> {
476
- self.conn.with(|c| {
477
- let removed = c
478
- .execute(
479
- "DELETE FROM deltas WHERE version_number < ?1",
480
- [keep_after_version],
481
- )
482
- .map_err(DbError::from)?;
483
-
484
- // Update last_pruned timestamp
485
- let now_ms = std::time::SystemTime::now()
486
- .duration_since(std::time::UNIX_EPOCH)
487
- .unwrap_or_default()
488
- .as_millis() as i64;
489
-
490
- c.execute(
491
- "UPDATE version_graph SET last_pruned = ?1 WHERE id = 1",
492
- [now_ms],
493
- )
494
- .map_err(DbError::from)?;
495
-
496
- // Recalculate total_size
497
- self.recalculate_total_size(c)?;
498
-
499
- Ok(removed)
500
- })
501
- }
502
-
503
- /// Remove ALL versions (checkpoints + deltas) that are strictly older than
504
- /// `keep_after_version`, except the most recent checkpoint before that
505
- /// boundary (needed as a recovery base).
506
- pub fn prune_before(&self, keep_after_version: i64) -> DbResult<usize> {
507
- self.conn.with(|c| {
508
- // Find the latest checkpoint at or before the boundary — keep it
509
- let safe_cp_version: Option<i64> = c
510
- .query_row(
511
- "SELECT MAX(version_number) FROM checkpoints
512
- WHERE version_number <= ?1",
513
- [keep_after_version],
514
- |row| row.get(0),
515
- )
516
- .optional()
517
- .map_err(DbError::from)?
518
- .flatten();
519
-
520
- let safe_version = safe_cp_version.unwrap_or(0);
521
-
522
- // Delete deltas before the safe checkpoint
523
- let del_deltas = c
524
- .execute(
525
- "DELETE FROM deltas WHERE version_number < ?1",
526
- [safe_version],
527
- )
528
- .map_err(DbError::from)?;
529
-
530
- // Delete checkpoints before the safe checkpoint (keep the safe one)
531
- let del_cps = c
532
- .execute(
533
- "DELETE FROM checkpoints WHERE version_number < ?1",
534
- [safe_version],
535
- )
536
- .map_err(DbError::from)?;
537
-
538
- let now_ms = std::time::SystemTime::now()
539
- .duration_since(std::time::UNIX_EPOCH)
540
- .unwrap_or_default()
541
- .as_millis() as i64;
542
-
543
- c.execute(
544
- "UPDATE version_graph SET last_pruned = ?1 WHERE id = 1",
545
- [now_ms],
546
- )
547
- .map_err(DbError::from)?;
548
-
549
- self.recalculate_total_size(c)?;
550
-
551
- Ok(del_deltas + del_cps)
552
- })
553
- }
554
-
555
466
  // ────────────────────────────────────────────────────────────────────────
556
467
  // WRITE — Revert
557
468
  // ────────────────────────────────────────────────────────────────────────
@@ -808,7 +719,7 @@ pub(crate) fn read_version_graph_inner(
808
719
  let mut vg_stmt = conn
809
720
  .prepare(
810
721
  "SELECT current_version, current_schema_version, user_checkpoint_version_id,
811
- latest_version_id, chain_count, last_pruned, total_size
722
+ latest_version_id, chain_count, total_size
812
723
  FROM version_graph WHERE id = 1",
813
724
  )
814
725
  .map_err(DbError::from)?;
@@ -819,8 +730,7 @@ pub(crate) fn read_version_graph_inner(
819
730
  current_version: row.get(0)?,
820
731
  current_schema_version: row.get(1)?,
821
732
  chain_count: row.get(4)?,
822
- last_pruned: row.get::<_, Option<i64>>(5)?.unwrap_or(0),
823
- total_size: row.get::<_, Option<i64>>(6)?.unwrap_or(0),
733
+ total_size: row.get::<_, Option<i64>>(5)?.unwrap_or(0),
824
734
  },
825
735
  row.get::<_, Option<String>>(2)?.unwrap_or_default(),
826
736
  row.get::<_, Option<String>>(3)?.unwrap_or_default(),
@@ -114,6 +114,7 @@ mod tests {
114
114
  .unwrap_or_else(|e| panic!("parse_lazy {} failed: {e}", path.display()));
115
115
 
116
116
  assert!(lazy.external_files.is_none(), "{}: lazy parse must omit external_files", path.display());
117
+ assert!(lazy.external_files_data.is_none(), "{}: lazy parse must omit external_files_data", path.display());
117
118
  assert_eq!(full.elements.len(), lazy.elements.len(), "{}: element count diverged", path.display());
118
119
  assert_eq!(full.layers.len(), lazy.layers.len(), "{}: layer count diverged", path.display());
119
120
  }
@@ -194,9 +195,9 @@ mod tests {
194
195
  let file = parse::get_external_file(&buf, &meta.id)
195
196
  .unwrap_or_else(|e| panic!("get_external_file {} id={} failed: {e}", path.display(), meta.id));
196
197
  assert!(file.is_some(), "{}: listed file '{}' not found via get", path.display(), meta.id);
197
- let file = file.unwrap();
198
- assert_eq!(file.id, meta.id);
199
- assert!(!file.revisions.is_empty(), "{}: file '{}' has no revisions", path.display(), meta.id);
198
+ let loaded = file.unwrap();
199
+ assert_eq!(loaded.file.id, meta.id);
200
+ assert!(!loaded.file.revisions.is_empty(), "{}: file '{}' has no revisions", path.display(), meta.id);
200
201
  }
201
202
  }
202
203
  }
@@ -57,7 +57,7 @@ pub fn parse(buf: &[u8]) -> ParseResult<ExportedDataState> {
57
57
  let regions = read_regions(&conn)?;
58
58
  let (blocks, block_instances, block_collections) = read_blocks(&conn)?;
59
59
  let elements = read_elements(&conn)?;
60
- let external_files = read_external_files(&conn)?;
60
+ let (external_files, external_files_data) = read_external_files(&conn)?;
61
61
  let version_graph = read_version_graph(&conn)?;
62
62
 
63
63
  Ok(ExportedDataState {
@@ -78,6 +78,7 @@ pub fn parse(buf: &[u8]) -> ParseResult<ExportedDataState> {
78
78
  duc_global_state,
79
79
  version_graph,
80
80
  external_files,
81
+ external_files_data,
81
82
  })
82
83
  }
83
84
 
@@ -117,16 +118,22 @@ pub fn parse_lazy(buf: &[u8]) -> ParseResult<ExportedDataState> {
117
118
  duc_global_state,
118
119
  version_graph,
119
120
  external_files: None,
121
+ external_files_data: None,
120
122
  })
121
123
  }
122
124
 
123
125
  /// Fetch a single external file from a `.duc` buffer by file ID.
124
- pub fn get_external_file(buf: &[u8], file_id: &str) -> ParseResult<Option<DucExternalFile>> {
126
+ /// Returns metadata + data blobs as an `ExternalFileLoaded`.
127
+ pub fn get_external_file(buf: &[u8], file_id: &str) -> ParseResult<Option<ExternalFileLoaded>> {
125
128
  let conn = load_db_bytes(buf)?;
126
129
  let has_revisions_table: bool = conn.prepare(
127
130
  "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='external_file_revisions'"
128
131
  )?.query_row([], |row| row.get::<_, i32>(0)).unwrap_or(0) > 0;
129
132
 
133
+ let has_data_table: bool = conn.prepare(
134
+ "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='external_file_revision_data'"
135
+ )?.query_row([], |row| row.get::<_, i32>(0)).unwrap_or(0) > 0;
136
+
130
137
  if has_revisions_table {
131
138
  // New schema: load file header + revisions.
132
139
  let file = conn.prepare(
@@ -146,30 +153,77 @@ pub fn get_external_file(buf: &[u8], file_id: &str) -> ParseResult<Option<DucExt
146
153
  Err(e) => return Err(e.into()),
147
154
  };
148
155
 
149
- let mut rev_stmt = conn.prepare(
150
- "SELECT id, size_bytes, checksum, source_name, mime_type, message,
151
- created, last_retrieved, data
152
- FROM external_file_revisions WHERE file_id = ?1"
153
- )?;
154
- let rev_rows = rev_stmt.query_map(params![file_id], |row| {
155
- let id: String = row.get(0)?;
156
- Ok((id.clone(), ExternalFileRevision {
157
- id,
158
- size_bytes: row.get(1)?,
159
- checksum: row.get(2)?,
160
- source_name: row.get(3)?,
161
- mime_type: row.get(4)?,
162
- message: row.get(5)?,
163
- created: row.get(6)?,
164
- last_retrieved: row.get(7)?,
165
- data: row.get(8)?,
166
- }))
167
- })?;
168
- for row in rev_rows {
169
- let (rev_id, rev) = row?;
170
- file.revisions.insert(rev_id, rev);
156
+ let mut data_map: HashMap<String, serde_bytes::ByteBuf> = HashMap::new();
157
+
158
+ if has_data_table {
159
+ // Split schema: metadata in external_file_revisions, data in external_file_revision_data
160
+ let mut meta_stmt = conn.prepare(
161
+ "SELECT id, size_bytes, checksum, source_name, mime_type, message,
162
+ created, last_retrieved
163
+ FROM external_file_revisions WHERE file_id = ?1"
164
+ )?;
165
+ let meta_rows = meta_stmt.query_map(params![file_id], |row| {
166
+ let id: String = row.get(0)?;
167
+ Ok((id.clone(), ExternalFileRevisionMeta {
168
+ id,
169
+ size_bytes: row.get(1)?,
170
+ checksum: row.get(2)?,
171
+ source_name: row.get(3)?,
172
+ mime_type: row.get(4)?,
173
+ message: row.get(5)?,
174
+ created: row.get(6)?,
175
+ last_retrieved: row.get(7)?,
176
+ }))
177
+ })?;
178
+ for row in meta_rows {
179
+ let (rev_id, meta) = row?;
180
+ file.revisions.insert(rev_id, meta);
181
+ }
182
+
183
+ let mut data_stmt = conn.prepare(
184
+ "SELECT d.revision_id, d.data
185
+ FROM external_file_revision_data d
186
+ JOIN external_file_revisions r ON r.id = d.revision_id
187
+ WHERE r.file_id = ?1"
188
+ )?;
189
+ let data_rows = data_stmt.query_map(params![file_id], |row| {
190
+ let rev_id: String = row.get(0)?;
191
+ let blob: Vec<u8> = row.get(1)?;
192
+ Ok((rev_id, blob))
193
+ })?;
194
+ for row in data_rows {
195
+ let (rev_id, blob) = row?;
196
+ data_map.insert(rev_id, serde_bytes::ByteBuf::from(blob));
197
+ }
198
+ } else {
199
+ // Pre-split schema: data still in external_file_revisions
200
+ let mut rev_stmt = conn.prepare(
201
+ "SELECT id, size_bytes, checksum, source_name, mime_type, message,
202
+ created, last_retrieved, data
203
+ FROM external_file_revisions WHERE file_id = ?1"
204
+ )?;
205
+ let rev_rows = rev_stmt.query_map(params![file_id], |row| {
206
+ let id: String = row.get(0)?;
207
+ let data: Vec<u8> = row.get(8)?;
208
+ Ok((id.clone(), ExternalFileRevisionMeta {
209
+ id,
210
+ size_bytes: row.get(1)?,
211
+ checksum: row.get(2)?,
212
+ source_name: row.get(3)?,
213
+ mime_type: row.get(4)?,
214
+ message: row.get(5)?,
215
+ created: row.get(6)?,
216
+ last_retrieved: row.get(7)?,
217
+ }, data))
218
+ })?;
219
+ for row in rev_rows {
220
+ let (rev_id, meta, blob) = row?;
221
+ file.revisions.insert(rev_id.clone(), meta);
222
+ data_map.insert(rev_id, serde_bytes::ByteBuf::from(blob));
223
+ }
171
224
  }
172
- Ok(Some(file))
225
+
226
+ Ok(Some(ExternalFileLoaded { file, data: data_map }))
173
227
  } else {
174
228
  // Legacy schema: wrap flat row into a single-revision DucExternalFile.
175
229
  let result = conn.prepare(
@@ -177,30 +231,34 @@ pub fn get_external_file(buf: &[u8], file_id: &str) -> ParseResult<Option<DucExt
177
231
  )?.query_row(params![file_id], |row| {
178
232
  let id: String = row.get(0)?;
179
233
  let rev_id = format!("{}_rev1", id);
180
- Ok(DucExternalFile {
181
- id: id.clone(),
182
- active_revision_id: rev_id.clone(),
183
- updated: row.get(3)?,
184
- version: row.get(5)?,
185
- revisions: {
186
- let mut revs = HashMap::new();
187
- revs.insert(rev_id.clone(), ExternalFileRevision {
188
- id: rev_id,
189
- size_bytes: 0,
190
- checksum: None,
191
- source_name: None,
192
- mime_type: row.get(1)?,
193
- message: None,
194
- created: row.get(3)?,
195
- last_retrieved: row.get(4)?,
196
- data: row.get(2)?,
197
- });
198
- revs
234
+ let blob: Vec<u8> = row.get(2)?;
235
+ let meta = ExternalFileRevisionMeta {
236
+ id: rev_id.clone(),
237
+ size_bytes: blob.len() as i64,
238
+ checksum: None,
239
+ source_name: None,
240
+ mime_type: row.get(1)?,
241
+ message: None,
242
+ created: row.get(3)?,
243
+ last_retrieved: row.get(4)?,
244
+ };
245
+ let mut revisions = HashMap::new();
246
+ revisions.insert(rev_id.clone(), meta);
247
+ let mut data_map = HashMap::new();
248
+ data_map.insert(rev_id.clone(), serde_bytes::ByteBuf::from(blob));
249
+ Ok(ExternalFileLoaded {
250
+ file: DucExternalFile {
251
+ id: id.clone(),
252
+ active_revision_id: rev_id,
253
+ updated: row.get(3)?,
254
+ version: row.get(5)?,
255
+ revisions,
199
256
  },
257
+ data: data_map,
200
258
  })
201
259
  });
202
260
  match result {
203
- Ok(file) => Ok(Some(file)),
261
+ Ok(loaded) => Ok(Some(loaded)),
204
262
  Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
205
263
  Err(e) => Err(e.into()),
206
264
  }
@@ -579,14 +637,6 @@ fn int_to_image_status(v: i32) -> IMAGE_STATUS {
579
637
  _ => IMAGE_STATUS::PENDING,
580
638
  }
581
639
  }
582
- fn int_to_pruning_level(v: i32) -> PRUNING_LEVEL {
583
- match v {
584
- 10 => PRUNING_LEVEL::CONSERVATIVE,
585
- 20 => PRUNING_LEVEL::BALANCED,
586
- 30 => PRUNING_LEVEL::AGGRESSIVE,
587
- _ => PRUNING_LEVEL::BALANCED,
588
- }
589
- }
590
640
  fn int_to_boolean_operation(v: i32) -> BOOLEAN_OPERATION {
591
641
  match v {
592
642
  10 => BOOLEAN_OPERATION::UNION,
@@ -636,7 +686,7 @@ fn read_document(conn: &Connection) -> ParseResult<(Option<String>, String, Stri
636
686
 
637
687
  fn read_global_state(conn: &Connection) -> ParseResult<Option<DucGlobalState>> {
638
688
  let mut stmt = conn.prepare(
639
- "SELECT name, view_background_color, main_scope, scope_exponent_threshold, pruning_level
689
+ "SELECT name, view_background_color, main_scope, scope_exponent_threshold
640
690
  FROM duc_global_state WHERE id = 1"
641
691
  )?;
642
692
  let result = stmt.query_row([], |row| {
@@ -645,7 +695,6 @@ fn read_global_state(conn: &Connection) -> ParseResult<Option<DucGlobalState>> {
645
695
  view_background_color: row.get(1)?,
646
696
  main_scope: row.get(2)?,
647
697
  scope_exponent_threshold: row.get(3)?,
648
- pruning_level: int_to_pruning_level(row.get(4)?),
649
698
  })
650
699
  });
651
700
 
@@ -1768,31 +1817,46 @@ fn read_hatch_pattern_lines(conn: &Connection, owner_type: &str, owner_id: i64)
1768
1817
 
1769
1818
  // ─── external_files ──────────────────────────────────────────────────────────
1770
1819
 
1771
- fn read_external_files(conn: &Connection) -> ParseResult<Option<HashMap<String, DucExternalFile>>> {
1820
+ type ExternalFilesResult = (
1821
+ Option<HashMap<String, DucExternalFile>>,
1822
+ Option<HashMap<String, serde_bytes::ByteBuf>>,
1823
+ );
1824
+
1825
+ fn read_external_files(conn: &Connection) -> ParseResult<ExternalFilesResult> {
1772
1826
  // Check if the new schema (with external_file_revisions) is present.
1773
1827
  let has_revisions_table: bool = conn.prepare(
1774
1828
  "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='external_file_revisions'"
1775
1829
  )?.query_row([], |row| row.get::<_, i32>(0)).unwrap_or(0) > 0;
1776
1830
 
1777
1831
  if has_revisions_table {
1778
- read_external_files_v2(conn)
1832
+ let has_data_table: bool = conn.prepare(
1833
+ "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='external_file_revision_data'"
1834
+ )?.query_row([], |row| row.get::<_, i32>(0)).unwrap_or(0) > 0;
1835
+
1836
+ if has_data_table {
1837
+ read_external_files_v3(conn)
1838
+ } else {
1839
+ read_external_files_v2(conn)
1840
+ }
1779
1841
  } else {
1780
1842
  read_external_files_v1_legacy(conn)
1781
1843
  }
1782
1844
  }
1783
1845
 
1784
- /// Read from the current schema (external_files + external_file_revisions).
1785
- fn read_external_files_v2(conn: &Connection) -> ParseResult<Option<HashMap<String, DucExternalFile>>> {
1786
- // Load all revisions grouped by file_id.
1846
+ /// Read from the split schema (external_file_revisions metadata + external_file_revision_data).
1847
+ fn read_external_files_v3(conn: &Connection) -> ParseResult<ExternalFilesResult> {
1848
+ // Load revision metadata
1787
1849
  let mut rev_stmt = conn.prepare(
1788
1850
  "SELECT id, file_id, size_bytes, checksum, source_name, mime_type, message,
1789
- created, last_retrieved, data
1851
+ created, last_retrieved
1790
1852
  FROM external_file_revisions"
1791
1853
  )?;
1792
- let mut revisions_by_file: HashMap<String, HashMap<String, ExternalFileRevision>> = HashMap::new();
1854
+ let mut revisions_by_file: HashMap<String, HashMap<String, ExternalFileRevisionMeta>> = HashMap::new();
1793
1855
  let rev_rows = rev_stmt.query_map([], |row| {
1794
- Ok(ExternalFileRevision {
1795
- id: row.get(0)?,
1856
+ let rev_id: String = row.get(0)?;
1857
+ let file_id: String = row.get(1)?;
1858
+ Ok((file_id, rev_id.clone(), ExternalFileRevisionMeta {
1859
+ id: rev_id,
1796
1860
  size_bytes: row.get(2)?,
1797
1861
  checksum: row.get(3)?,
1798
1862
  source_name: row.get(4)?,
@@ -1800,23 +1864,69 @@ fn read_external_files_v2(conn: &Connection) -> ParseResult<Option<HashMap<Strin
1800
1864
  message: row.get(6)?,
1801
1865
  created: row.get(7)?,
1802
1866
  last_retrieved: row.get(8)?,
1803
- data: row.get(9)?,
1804
- })
1867
+ }))
1868
+ })?;
1869
+ for row in rev_rows {
1870
+ let (file_id, rev_id, meta) = row?;
1871
+ revisions_by_file.entry(file_id).or_default().insert(rev_id, meta);
1872
+ }
1873
+
1874
+ // Load data blobs
1875
+ let mut data_stmt = conn.prepare(
1876
+ "SELECT revision_id, data FROM external_file_revision_data"
1877
+ )?;
1878
+ let mut data_map: HashMap<String, serde_bytes::ByteBuf> = HashMap::new();
1879
+ let data_rows = data_stmt.query_map([], |row| {
1880
+ let rev_id: String = row.get(0)?;
1881
+ let blob: Vec<u8> = row.get(1)?;
1882
+ Ok((rev_id, blob))
1805
1883
  })?;
1884
+ for row in data_rows {
1885
+ let (rev_id, blob) = row?;
1886
+ data_map.insert(rev_id, serde_bytes::ByteBuf::from(blob));
1887
+ }
1888
+
1889
+ // Load file headers
1890
+ let mut file_stmt = conn.prepare(
1891
+ "SELECT id, active_revision_id, updated, version FROM external_files"
1892
+ )?;
1893
+ let mut map: HashMap<String, DucExternalFile> = HashMap::new();
1894
+ let file_rows = file_stmt.query_map([], |row| {
1895
+ let id: String = row.get(0)?;
1896
+ Ok((id.clone(), DucExternalFile {
1897
+ id,
1898
+ active_revision_id: row.get(1)?,
1899
+ updated: row.get(2)?,
1900
+ revisions: HashMap::new(),
1901
+ version: row.get(3)?,
1902
+ }))
1903
+ })?;
1904
+ for row in file_rows {
1905
+ let (k, mut v) = row?;
1906
+ v.revisions = revisions_by_file.remove(&k).unwrap_or_default();
1907
+ map.insert(k, v);
1908
+ }
1806
1909
 
1807
- // We need file_id as well; re-query with it included.
1808
- let mut rev_stmt2 = conn.prepare(
1910
+ let files = if map.is_empty() { None } else { Some(map) };
1911
+ let data = if data_map.is_empty() { None } else { Some(data_map) };
1912
+ Ok((files, data))
1913
+ }
1914
+
1915
+ /// Read from the pre-split schema (external_files + external_file_revisions with data column).
1916
+ fn read_external_files_v2(conn: &Connection) -> ParseResult<ExternalFilesResult> {
1917
+ let mut rev_stmt = conn.prepare(
1809
1918
  "SELECT id, file_id, size_bytes, checksum, source_name, mime_type, message,
1810
1919
  created, last_retrieved, data
1811
1920
  FROM external_file_revisions"
1812
1921
  )?;
1813
- // Suppress unused warning from first query
1814
- drop(rev_rows);
1922
+ let mut revisions_by_file: HashMap<String, HashMap<String, ExternalFileRevisionMeta>> = HashMap::new();
1923
+ let mut data_map: HashMap<String, serde_bytes::ByteBuf> = HashMap::new();
1815
1924
 
1816
- let rev_rows2 = rev_stmt2.query_map([], |row| {
1925
+ let rev_rows = rev_stmt.query_map([], |row| {
1817
1926
  let rev_id: String = row.get(0)?;
1818
1927
  let file_id: String = row.get(1)?;
1819
- Ok((file_id, rev_id.clone(), ExternalFileRevision {
1928
+ let blob: Vec<u8> = row.get(9)?;
1929
+ Ok((file_id, rev_id.clone(), ExternalFileRevisionMeta {
1820
1930
  id: rev_id,
1821
1931
  size_bytes: row.get(2)?,
1822
1932
  checksum: row.get(3)?,
@@ -1825,12 +1935,12 @@ fn read_external_files_v2(conn: &Connection) -> ParseResult<Option<HashMap<Strin
1825
1935
  message: row.get(6)?,
1826
1936
  created: row.get(7)?,
1827
1937
  last_retrieved: row.get(8)?,
1828
- data: row.get(9)?,
1829
- }))
1938
+ }, blob))
1830
1939
  })?;
1831
- for row in rev_rows2 {
1832
- let (file_id, rev_id, rev) = row?;
1833
- revisions_by_file.entry(file_id).or_default().insert(rev_id, rev);
1940
+ for row in rev_rows {
1941
+ let (file_id, rev_id, meta, blob) = row?;
1942
+ revisions_by_file.entry(file_id).or_default().insert(rev_id.clone(), meta);
1943
+ data_map.insert(rev_id, serde_bytes::ByteBuf::from(blob));
1834
1944
  }
1835
1945
 
1836
1946
  // Load the file headers.
@@ -1854,46 +1964,53 @@ fn read_external_files_v2(conn: &Connection) -> ParseResult<Option<HashMap<Strin
1854
1964
  map.insert(k, v);
1855
1965
  }
1856
1966
 
1857
- if map.is_empty() { Ok(None) } else { Ok(Some(map)) }
1967
+ let files = if map.is_empty() { None } else { Some(map) };
1968
+ let data = if data_map.is_empty() { None } else { Some(data_map) };
1969
+ Ok((files, data))
1858
1970
  }
1859
1971
 
1860
1972
  /// Fallback for old schema files that only have the flat `external_files` table.
1861
1973
  /// Wraps each row in a single-revision DucExternalFile.
1862
- fn read_external_files_v1_legacy(conn: &Connection) -> ParseResult<Option<HashMap<String, DucExternalFile>>> {
1974
+ fn read_external_files_v1_legacy(conn: &Connection) -> ParseResult<ExternalFilesResult> {
1863
1975
  let mut stmt = conn.prepare(
1864
1976
  "SELECT id, mime_type, data, created, last_retrieved, version FROM external_files"
1865
1977
  )?;
1866
1978
  let mut map = HashMap::new();
1979
+ let mut data_map: HashMap<String, serde_bytes::ByteBuf> = HashMap::new();
1867
1980
  let rows = stmt.query_map([], |row| {
1868
1981
  let id: String = row.get(0)?;
1869
1982
  let rev_id = format!("{}_rev1", id);
1870
- Ok((id.clone(), DucExternalFile {
1871
- id: id.clone(),
1983
+ let blob: Vec<u8> = row.get(2)?;
1984
+ let meta = ExternalFileRevisionMeta {
1985
+ id: rev_id.clone(),
1986
+ size_bytes: blob.len() as i64,
1987
+ checksum: None,
1988
+ source_name: None,
1989
+ mime_type: row.get(1)?,
1990
+ message: None,
1991
+ created: row.get(3)?,
1992
+ last_retrieved: row.get(4)?,
1993
+ };
1994
+ Ok((id.clone(), rev_id.clone(), DucExternalFile {
1995
+ id,
1872
1996
  active_revision_id: rev_id.clone(),
1873
1997
  updated: row.get(3)?,
1874
1998
  version: row.get(5)?,
1875
1999
  revisions: {
1876
2000
  let mut revs = HashMap::new();
1877
- revs.insert(rev_id.clone(), ExternalFileRevision {
1878
- id: rev_id,
1879
- size_bytes: 0,
1880
- checksum: None,
1881
- source_name: None,
1882
- mime_type: row.get(1)?,
1883
- message: None,
1884
- created: row.get(3)?,
1885
- last_retrieved: row.get(4)?,
1886
- data: row.get(2)?,
1887
- });
2001
+ revs.insert(rev_id.clone(), meta);
1888
2002
  revs
1889
2003
  },
1890
- }))
2004
+ }, blob))
1891
2005
  })?;
1892
2006
  for row in rows {
1893
- let (k, v) = row?;
2007
+ let (k, rev_id, v, blob) = row?;
2008
+ data_map.insert(rev_id, serde_bytes::ByteBuf::from(blob));
1894
2009
  map.insert(k, v);
1895
2010
  }
1896
- if map.is_empty() { Ok(None) } else { Ok(Some(map)) }
2011
+ let files = if map.is_empty() { None } else { Some(map) };
2012
+ let data = if data_map.is_empty() { None } else { Some(data_map) };
2013
+ Ok((files, data))
1897
2014
  }
1898
2015
 
1899
2016
  // ─── version_graph ───────────────────────────────────────────────────────────
@@ -1910,7 +2027,7 @@ fn read_version_graph(conn: &Connection) -> ParseResult<Option<VersionGraph>> {
1910
2027
 
1911
2028
  let mut vg_stmt = conn.prepare(
1912
2029
  "SELECT current_version, current_schema_version, user_checkpoint_version_id,
1913
- latest_version_id, chain_count, last_pruned, total_size
2030
+ latest_version_id, chain_count, total_size
1914
2031
  FROM version_graph WHERE id = 1"
1915
2032
  )?;
1916
2033
  let (metadata, user_cp_id, latest_id) = match vg_stmt.query_row([], |row| {
@@ -1919,8 +2036,7 @@ fn read_version_graph(conn: &Connection) -> ParseResult<Option<VersionGraph>> {
1919
2036
  current_version: row.get(0)?,
1920
2037
  current_schema_version: row.get(1)?,
1921
2038
  chain_count: row.get(4)?,
1922
- last_pruned: row.get::<_, Option<i64>>(5)?.unwrap_or(0),
1923
- total_size: row.get::<_, Option<i64>>(6)?.unwrap_or(0),
2039
+ total_size: row.get::<_, Option<i64>>(5)?.unwrap_or(0),
1924
2040
  },
1925
2041
  row.get::<_, Option<String>>(2)?.unwrap_or_default(),
1926
2042
  row.get::<_, Option<String>>(3)?.unwrap_or_default(),
@@ -68,7 +68,7 @@ pub fn serialize(state: &ExportedDataState) -> SerializeResult<Vec<u8>> {
68
68
  write_stack_and_containers(&tx, state)?;
69
69
  write_blocks(&tx, state)?;
70
70
  write_elements(&tx, state)?;
71
- write_external_files(&tx, &state.external_files)?;
71
+ write_external_files(&tx, &state.external_files, &state.external_files_data)?;
72
72
  write_version_graph(&tx, &state.version_graph)?;
73
73
  tx.commit()?;
74
74
 
@@ -149,14 +149,13 @@ fn write_global_state(tx: &Transaction, gs: &Option<DucGlobalState>) -> Serializ
149
149
  let Some(gs) = gs else { return Ok(()) };
150
150
  tx.execute(
151
151
  "INSERT OR REPLACE INTO duc_global_state
152
- (id, name, view_background_color, main_scope, scope_exponent_threshold, pruning_level)
153
- VALUES (1, ?1, ?2, ?3, ?4, ?5)",
152
+ (id, name, view_background_color, main_scope, scope_exponent_threshold)
153
+ VALUES (1, ?1, ?2, ?3, ?4)",
154
154
  params![
155
155
  gs.name,
156
156
  gs.view_background_color,
157
157
  gs.main_scope,
158
158
  gs.scope_exponent_threshold,
159
- gs.pruning_level as i32,
160
159
  ],
161
160
  )?;
162
161
  Ok(())
@@ -1124,6 +1123,7 @@ fn write_hatch_pattern_lines(
1124
1123
  fn write_external_files(
1125
1124
  tx: &Transaction,
1126
1125
  files: &Option<std::collections::HashMap<String, DucExternalFile>>,
1126
+ files_data: &Option<std::collections::HashMap<String, serde_bytes::ByteBuf>>,
1127
1127
  ) -> SerializeResult<()> {
1128
1128
  let Some(files) = files else { return Ok(()) };
1129
1129
  let mut file_stmt = tx.prepare_cached(
@@ -1132,8 +1132,12 @@ fn write_external_files(
1132
1132
  )?;
1133
1133
  let mut rev_stmt = tx.prepare_cached(
1134
1134
  "INSERT OR REPLACE INTO external_file_revisions
1135
- (id, file_id, size_bytes, checksum, source_name, mime_type, message, created, last_retrieved, data)
1136
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)"
1135
+ (id, file_id, size_bytes, checksum, source_name, mime_type, message, created, last_retrieved)
1136
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"
1137
+ )?;
1138
+ let mut data_stmt = tx.prepare_cached(
1139
+ "INSERT OR REPLACE INTO external_file_revision_data (revision_id, data)
1140
+ VALUES (?1, ?2)"
1137
1141
  )?;
1138
1142
  for (_key, file) in files {
1139
1143
  file_stmt.execute(params![
@@ -1153,10 +1157,15 @@ fn write_external_files(
1153
1157
  rev.message,
1154
1158
  rev.created,
1155
1159
  rev.last_retrieved,
1156
- rev.data,
1157
1160
  ])?;
1158
1161
  }
1159
1162
  }
1163
+ // Write data blobs from the separate filesData map
1164
+ if let Some(data_map) = files_data {
1165
+ for (rev_id, blob) in data_map {
1166
+ data_stmt.execute(params![rev_id, blob.as_ref()])?;
1167
+ }
1168
+ }
1160
1169
  Ok(())
1161
1170
  }
1162
1171
 
@@ -1172,8 +1181,7 @@ fn write_version_graph(tx: &Transaction, vg: &Option<VersionGraph>) -> Serialize
1172
1181
  user_checkpoint_version_id = ?3,
1173
1182
  latest_version_id = ?4,
1174
1183
  chain_count = ?5,
1175
- last_pruned = ?6,
1176
- total_size = ?7
1184
+ total_size = ?6
1177
1185
  WHERE id = 1",
1178
1186
  params![
1179
1187
  vg.metadata.current_version,
@@ -1181,7 +1189,6 @@ fn write_version_graph(tx: &Transaction, vg: &Option<VersionGraph>) -> Serialize
1181
1189
  vg.user_checkpoint_version_id,
1182
1190
  vg.latest_version_id,
1183
1191
  vg.metadata.chain_count,
1184
- vg.metadata.last_pruned,
1185
1192
  vg.metadata.total_size,
1186
1193
  ],
1187
1194
  )?;
@@ -249,18 +249,6 @@ pub enum IMAGE_STATUS {
249
249
  ERROR = 12,
250
250
  }
251
251
 
252
- #[allow(non_camel_case_types)]
253
- #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde_repr::Serialize_repr, serde_repr::Deserialize_repr)]
254
- #[repr(i32)]
255
- pub enum PRUNING_LEVEL {
256
- /** Conservative pruning, retains more history. */
257
- CONSERVATIVE = 10,
258
- /** Balanced pruning, optimizes between history and size. */
259
- BALANCED = 20,
260
- /** Aggressive pruning, retains less history for smaller size. */
261
- AGGRESSIVE = 30,
262
- }
263
-
264
252
  /**
265
253
  * Defines the types of boolean operations that can be performed.
266
254
  */
@@ -1326,8 +1314,6 @@ pub struct DucGlobalState {
1326
1314
  * This value defines a +/- tolerance range around the exponent of the current scope.
1327
1315
  */
1328
1316
  pub scope_exponent_threshold: i8,
1329
- /** The level of pruning to the versions from the version graph. */
1330
- pub pruning_level: PRUNING_LEVEL,
1331
1317
  }
1332
1318
 
1333
1319
  #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
@@ -1464,7 +1450,6 @@ pub struct VersionGraphMetadata {
1464
1450
  pub current_schema_version: i32,
1465
1451
  #[serde(deserialize_with = "crate::serde_utils::trunc_i32")]
1466
1452
  pub chain_count: i32,
1467
- pub last_pruned: i64,
1468
1453
  pub total_size: i64,
1469
1454
  }
1470
1455
 
@@ -1485,11 +1470,11 @@ pub struct VersionGraph {
1485
1470
 
1486
1471
  // =============== EXTERNAL FILES ===============
1487
1472
 
1488
- /// Snapshot of an external file at a given point in time.
1473
+ /// Revision metadata without the heavy data blob.
1474
+ /// Used inside `DucExternalFile.revisions` so the file record stays lightweight.
1489
1475
  #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1490
1476
  #[serde(rename_all = "camelCase")]
1491
- pub struct ExternalFileRevision {
1492
- // Revision number as string, functions as an incremental serial id
1477
+ pub struct ExternalFileRevisionMeta {
1493
1478
  pub id: String,
1494
1479
  pub size_bytes: i64,
1495
1480
  /// Content hash for integrity checks and optional deduplication.
@@ -1503,11 +1488,8 @@ pub struct ExternalFileRevision {
1503
1488
  pub created: i64,
1504
1489
  /// Epoch ms when this revision was last loaded onto the scene.
1505
1490
  pub last_retrieved: Option<i64>,
1506
-
1507
- /// The actual file content bytes
1508
- #[serde(with = "serde_bytes")]
1509
- pub data: Vec<u8>,
1510
1491
  }
1492
+
1511
1493
  #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1512
1494
  #[serde(rename_all = "camelCase")]
1513
1495
  pub struct DucExternalFile {
@@ -1515,11 +1497,21 @@ pub struct DucExternalFile {
1515
1497
  pub active_revision_id: String,
1516
1498
  /// Epoch ms when the logical file was last mutated (revision added or active changed).
1517
1499
  pub updated: i64,
1518
- // All revisions of this file, keyed by their `id`.
1519
- pub revisions: HashMap<String, ExternalFileRevision>,
1500
+ /// All revisions of this file (metadata only, no data blobs).
1501
+ pub revisions: HashMap<String, ExternalFileRevisionMeta>,
1520
1502
  pub version: Option<i32>,
1521
1503
  }
1522
1504
 
1505
+ /// Result of on-demand file loading — includes both metadata and data blobs.
1506
+ #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1507
+ #[serde(rename_all = "camelCase")]
1508
+ pub struct ExternalFileLoaded {
1509
+ #[serde(flatten)]
1510
+ pub file: DucExternalFile,
1511
+ /// Revision data blobs keyed by revision id.
1512
+ pub data: HashMap<String, serde_bytes::ByteBuf>,
1513
+ }
1514
+
1523
1515
  /// Lightweight summary of an external file used for lazy/metadata-only access
1524
1516
  /// (active revision's metadata + file-level version, no data blob).
1525
1517
  #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
@@ -1564,4 +1556,8 @@ pub struct ExportedDataState {
1564
1556
  pub version_graph: Option<VersionGraph>,
1565
1557
  #[serde(rename = "files")]
1566
1558
  pub external_files: Option<HashMap<String, DucExternalFile>>,
1559
+ /// Binary data blobs for external file revisions, keyed by revision id.
1560
+ /// Separated from `external_files` so metadata can travel without heavy blobs.
1561
+ #[serde(rename = "filesData", default, skip_serializing_if = "Option::is_none")]
1562
+ pub external_files_data: Option<HashMap<String, serde_bytes::ByteBuf>>,
1567
1563
  }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes