ducpy 3.0.4__tar.gz → 3.1.0__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 (29) hide show
  1. {ducpy-3.0.4 → ducpy-3.1.0}/Cargo.lock +49 -42
  2. {ducpy-3.0.4 → ducpy-3.1.0}/PKG-INFO +1 -1
  3. {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducpy/crate/Cargo.toml +1 -1
  4. {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/Cargo.toml +1 -0
  5. {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/build.rs +56 -0
  6. {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/api/version_control.rs +150 -50
  7. {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/db/bootstrap.rs +46 -1
  8. {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/parse.rs +232 -45
  9. {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/serialize.rs +30 -6
  10. {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/types.rs +34 -21
  11. {ducpy-3.0.4 → ducpy-3.1.0}/Cargo.toml +0 -0
  12. {ducpy-3.0.4 → ducpy-3.1.0}/LICENSE +0 -0
  13. {ducpy-3.0.4 → ducpy-3.1.0}/README.md +0 -0
  14. {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducpy/crate/src/lib.rs +0 -0
  15. {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/.gitignore +0 -0
  16. {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/LICENSE +0 -0
  17. {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/README.md +0 -0
  18. {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/package.json +0 -0
  19. {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/release.config.cjs +0 -0
  20. {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/api/document.rs +0 -0
  21. {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/api/meta.rs +0 -0
  22. {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/api/mod.rs +0 -0
  23. {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/db/mod.rs +0 -0
  24. {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/db/native.rs +0 -0
  25. {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/db/wasm.rs +0 -0
  26. {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/lib.rs +0 -0
  27. {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/tests/.gitignore +0 -0
  28. {ducpy-3.0.4 → ducpy-3.1.0}/pyproject.toml +0 -0
  29. {ducpy-3.0.4 → ducpy-3.1.0}/src/ducpy_native/__init__.py +0 -0
@@ -288,9 +288,9 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
288
288
 
289
289
  [[package]]
290
290
  name = "crypto-common"
291
- version = "0.1.6"
291
+ version = "0.1.7"
292
292
  source = "registry+https://github.com/rust-lang/crates.io-index"
293
- checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
293
+ checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
294
294
  dependencies = [
295
295
  "generic-array",
296
296
  "typenum",
@@ -318,9 +318,9 @@ dependencies = [
318
318
 
319
319
  [[package]]
320
320
  name = "deranged"
321
- version = "0.5.4"
321
+ version = "0.5.8"
322
322
  source = "registry+https://github.com/rust-lang/crates.io-index"
323
- checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071"
323
+ checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
324
324
  dependencies = [
325
325
  "powerfmt",
326
326
  ]
@@ -364,6 +364,7 @@ dependencies = [
364
364
  "base64 0.21.7",
365
365
  "env_logger",
366
366
  "flate2",
367
+ "fossil-delta",
367
368
  "lazy_static",
368
369
  "log",
369
370
  "percent-encoding",
@@ -410,7 +411,7 @@ dependencies = [
410
411
 
411
412
  [[package]]
412
413
  name = "ducpy-native"
413
- version = "3.0.4"
414
+ version = "3.1.0"
414
415
  dependencies = [
415
416
  "duc",
416
417
  "pyo3",
@@ -570,11 +571,17 @@ dependencies = [
570
571
  "ttf-parser 0.25.1",
571
572
  ]
572
573
 
574
+ [[package]]
575
+ name = "fossil-delta"
576
+ version = "0.2.0"
577
+ source = "registry+https://github.com/rust-lang/crates.io-index"
578
+ checksum = "4e42f72dc65ff480af54c73c63d4b425936aac4167a01b7c9e1ed15aacace22c"
579
+
573
580
  [[package]]
574
581
  name = "generic-array"
575
- version = "0.14.9"
582
+ version = "0.14.7"
576
583
  source = "registry+https://github.com/rust-lang/crates.io-index"
577
- checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
584
+ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
578
585
  dependencies = [
579
586
  "typenum",
580
587
  "version_check",
@@ -706,9 +713,9 @@ checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
706
713
 
707
714
  [[package]]
708
715
  name = "hipdf"
709
- version = "1.3.3"
716
+ version = "1.3.5"
710
717
  source = "registry+https://github.com/rust-lang/crates.io-index"
711
- checksum = "5d8e0025c137fd7f666ec73ba8a1c8c7f4940edb83fdb77013e5e12703204c80"
718
+ checksum = "e3b986659f7ff291144addff668450799e4c2cd69686601cd48dfe23d7afc316"
712
719
  dependencies = [
713
720
  "jpeg-decoder",
714
721
  "lopdf",
@@ -873,24 +880,24 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
873
880
 
874
881
  [[package]]
875
882
  name = "jiff"
876
- version = "0.2.15"
883
+ version = "0.2.23"
877
884
  source = "registry+https://github.com/rust-lang/crates.io-index"
878
- checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49"
885
+ checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
879
886
  dependencies = [
880
887
  "jiff-static",
881
888
  "jiff-tzdb-platform",
882
889
  "log",
883
890
  "portable-atomic",
884
891
  "portable-atomic-util",
885
- "serde",
886
- "windows-sys 0.59.0",
892
+ "serde_core",
893
+ "windows-sys 0.61.2",
887
894
  ]
888
895
 
889
896
  [[package]]
890
897
  name = "jiff-static"
891
- version = "0.2.15"
898
+ version = "0.2.23"
892
899
  source = "registry+https://github.com/rust-lang/crates.io-index"
893
- checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4"
900
+ checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
894
901
  dependencies = [
895
902
  "proc-macro2",
896
903
  "quote",
@@ -899,9 +906,9 @@ dependencies = [
899
906
 
900
907
  [[package]]
901
908
  name = "jiff-tzdb"
902
- version = "0.1.4"
909
+ version = "0.1.6"
903
910
  source = "registry+https://github.com/rust-lang/crates.io-index"
904
- checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524"
911
+ checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076"
905
912
 
906
913
  [[package]]
907
914
  name = "jiff-tzdb-platform"
@@ -1159,9 +1166,9 @@ dependencies = [
1159
1166
 
1160
1167
  [[package]]
1161
1168
  name = "num-conv"
1162
- version = "0.1.0"
1169
+ version = "0.2.0"
1163
1170
  source = "registry+https://github.com/rust-lang/crates.io-index"
1164
- checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
1171
+ checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
1165
1172
 
1166
1173
  [[package]]
1167
1174
  name = "num-traits"
@@ -1315,9 +1322,9 @@ checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
1315
1322
 
1316
1323
  [[package]]
1317
1324
  name = "portable-atomic-util"
1318
- version = "0.2.4"
1325
+ version = "0.2.5"
1319
1326
  source = "registry+https://github.com/rust-lang/crates.io-index"
1320
- checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
1327
+ checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5"
1321
1328
  dependencies = [
1322
1329
  "portable-atomic",
1323
1330
  ]
@@ -1471,18 +1478,18 @@ dependencies = [
1471
1478
 
1472
1479
  [[package]]
1473
1480
  name = "rand_core"
1474
- version = "0.9.3"
1481
+ version = "0.9.5"
1475
1482
  source = "registry+https://github.com/rust-lang/crates.io-index"
1476
- checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
1483
+ checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
1477
1484
  dependencies = [
1478
1485
  "getrandom",
1479
1486
  ]
1480
1487
 
1481
1488
  [[package]]
1482
1489
  name = "rangemap"
1483
- version = "1.6.0"
1490
+ version = "1.7.1"
1484
1491
  source = "registry+https://github.com/rust-lang/crates.io-index"
1485
- checksum = "f93e7e49bb0bf967717f7bd674458b3d6b0c5f48ec7e3038166026a69fc22223"
1492
+ checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
1486
1493
 
1487
1494
  [[package]]
1488
1495
  name = "rayon"
@@ -1971,30 +1978,30 @@ dependencies = [
1971
1978
 
1972
1979
  [[package]]
1973
1980
  name = "time"
1974
- version = "0.3.44"
1981
+ version = "0.3.47"
1975
1982
  source = "registry+https://github.com/rust-lang/crates.io-index"
1976
- checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
1983
+ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
1977
1984
  dependencies = [
1978
1985
  "deranged",
1979
1986
  "itoa",
1980
1987
  "num-conv",
1981
1988
  "powerfmt",
1982
- "serde",
1989
+ "serde_core",
1983
1990
  "time-core",
1984
1991
  "time-macros",
1985
1992
  ]
1986
1993
 
1987
1994
  [[package]]
1988
1995
  name = "time-core"
1989
- version = "0.1.6"
1996
+ version = "0.1.8"
1990
1997
  source = "registry+https://github.com/rust-lang/crates.io-index"
1991
- checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
1998
+ checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
1992
1999
 
1993
2000
  [[package]]
1994
2001
  name = "time-macros"
1995
- version = "0.2.24"
2002
+ version = "0.2.27"
1996
2003
  source = "registry+https://github.com/rust-lang/crates.io-index"
1997
- checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
2004
+ checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
1998
2005
  dependencies = [
1999
2006
  "num-conv",
2000
2007
  "time-core",
@@ -2097,9 +2104,9 @@ checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06"
2097
2104
 
2098
2105
  [[package]]
2099
2106
  name = "unicode-normalization"
2100
- version = "0.1.24"
2107
+ version = "0.1.25"
2101
2108
  source = "registry+https://github.com/rust-lang/crates.io-index"
2102
- checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956"
2109
+ checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
2103
2110
  dependencies = [
2104
2111
  "tinyvec",
2105
2112
  ]
@@ -2175,9 +2182,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
2175
2182
 
2176
2183
  [[package]]
2177
2184
  name = "wasip2"
2178
- version = "1.0.1+wasi-0.2.4"
2185
+ version = "1.0.2+wasi-0.2.9"
2179
2186
  source = "registry+https://github.com/rust-lang/crates.io-index"
2180
- checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
2187
+ checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
2181
2188
  dependencies = [
2182
2189
  "wit-bindgen",
2183
2190
  ]
@@ -2422,9 +2429,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
2422
2429
 
2423
2430
  [[package]]
2424
2431
  name = "wit-bindgen"
2425
- version = "0.46.0"
2432
+ version = "0.51.0"
2426
2433
  source = "registry+https://github.com/rust-lang/crates.io-index"
2427
- checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
2434
+ checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
2428
2435
 
2429
2436
  [[package]]
2430
2437
  name = "write-fonts"
@@ -2470,18 +2477,18 @@ dependencies = [
2470
2477
 
2471
2478
  [[package]]
2472
2479
  name = "zerocopy"
2473
- version = "0.8.27"
2480
+ version = "0.8.41"
2474
2481
  source = "registry+https://github.com/rust-lang/crates.io-index"
2475
- checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c"
2482
+ checksum = "96e13bc581734df6250836c59a5f44f3c57db9f9acb9dc8e3eaabdaf6170254d"
2476
2483
  dependencies = [
2477
2484
  "zerocopy-derive",
2478
2485
  ]
2479
2486
 
2480
2487
  [[package]]
2481
2488
  name = "zerocopy-derive"
2482
- version = "0.8.27"
2489
+ version = "0.8.41"
2483
2490
  source = "registry+https://github.com/rust-lang/crates.io-index"
2484
- checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
2491
+ checksum = "3545ea9e86d12ab9bba9fcd99b54c1556fd3199007def5a03c375623d05fac1c"
2485
2492
  dependencies = [
2486
2493
  "proc-macro2",
2487
2494
  "quote",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ducpy
3
- Version: 3.0.4
3
+ Version: 3.1.0
4
4
  Classifier: License :: OSI Approved :: MIT License
5
5
  Requires-Dist: nanoid>=2.0.0
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ducpy-native"
3
- version = "3.0.4"
3
+ version = "3.1.0"
4
4
  edition = "2021"
5
5
  description = "Python native extension for the duc file format (used by ducpy)"
6
6
  publish = false
@@ -13,6 +13,7 @@ base64 = "0.21.5"
13
13
  percent-encoding = "2.3.1"
14
14
  log = "0.4.22"
15
15
  flate2 = "1.0"
16
+ fossil-delta = "0.2"
16
17
  serde = { version = "1.0", features = ["derive"] }
17
18
  serde_json = "1.0"
18
19
  serde_repr = "0.1"
@@ -43,7 +43,63 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
43
43
  }
44
44
  println!("cargo:rerun-if-changed={}", src.display());
45
45
  }
46
+ // Scan schema/migrations/*.sql, parse (from, to) from filenames like
47
+ // "3000000_to_3000001.sql", and generate migrations_registry.rs with a
48
+ // sorted static array of (from_version, to_version, sql) tuples.
49
+ // Adding a new migration requires only a new SQL file — no Rust changes.
50
+ let migrations_dir = schema_dir.join("migrations");
51
+ let mut migrations: Vec<(i64, i64, String)> = Vec::new();
46
52
 
53
+ if migrations_dir.is_dir() {
54
+ for entry in fs::read_dir(&migrations_dir)? {
55
+ let entry = entry?;
56
+ let path = entry.path();
57
+ if path.extension().and_then(|e| e.to_str()) != Some("sql") {
58
+ continue;
59
+ }
60
+ let stem = match path.file_stem().and_then(|s| s.to_str()) {
61
+ Some(s) => s.to_owned(),
62
+ None => continue,
63
+ };
64
+ // Expect exactly "<from>_to_<to>".
65
+ if let Some((from_str, to_str)) = stem.split_once("_to_") {
66
+ if let (Ok(from), Ok(to)) = (from_str.parse::<i64>(), to_str.parse::<i64>()) {
67
+ let sql = fs::read_to_string(&path)?;
68
+ migrations.push((from, to, sql));
69
+ println!("cargo:rerun-if-changed={}", path.display());
70
+ } else {
71
+ eprintln!("cargo:warning=Skipping migration file with non-integer versions: {stem}.sql");
72
+ }
73
+ } else {
74
+ eprintln!("cargo:warning=Skipping migration file with unexpected name: {stem}.sql");
75
+ }
76
+ }
77
+ println!("cargo:rerun-if-changed={}", migrations_dir.display());
78
+ }
79
+
80
+ // Sort ascending by from_version so chained migrations apply in order.
81
+ migrations.sort_by_key(|(from, _, _)| *from);
82
+
83
+ // Emit migrations_registry.rs: a static array included by bootstrap.rs.
84
+ let mut reg = String::from(
85
+ "// Auto-generated by build.rs — do not edit by hand.\n\
86
+ // Each entry: (from_version, to_version, sql).\n\
87
+ pub(crate) static MIGRATIONS: &[(i64, i64, &str)] = &[\n",
88
+ );
89
+ for (from, to, sql) in &migrations {
90
+ reg.push_str(&format!(" ({from}i64, {to}i64, \""));
91
+ for c in sql.chars() {
92
+ match c {
93
+ '"' => reg.push_str("\\\""),
94
+ '\\' => reg.push_str("\\\\"),
95
+ '\r' => {} // normalise CRLF → LF
96
+ c => reg.push(c),
97
+ }
98
+ }
99
+ reg.push_str("\"),\n");
100
+ }
101
+ reg.push_str("];\n");
102
+ fs::write(out_dir.join("migrations_registry.rs"), reg)?;
47
103
  let sql_path = schema_dir.join("duc.sql");
48
104
  let (user_version, semver_version) = match fs::read_to_string(&sql_path) {
49
105
  Ok(sql) => {
@@ -14,10 +14,12 @@ use flate2::read::ZlibDecoder;
14
14
  use flate2::write::ZlibEncoder;
15
15
  use flate2::Compression;
16
16
  use rusqlite::OptionalExtension;
17
- use std::io::{Read, Write};
17
+ use std::io::Read;
18
+ use std::io::Write;
18
19
  use std::os::raw::c_char;
19
20
 
20
21
  use crate::db::{DbError, DbResult, DucConnection};
22
+ use crate::parse::{decompress_duc_bytes, is_sqlite_header};
21
23
  use crate::types::{
22
24
  Checkpoint, Delta, SchemaMigration, VersionBase, VersionChain, VersionGraph,
23
25
  VersionGraphMetadata,
@@ -132,10 +134,11 @@ impl<'a> VersionControl<'a> {
132
134
 
133
135
  /// Restore the document state at *exactly* `version_number`.
134
136
  ///
135
- /// Algorithm (matches the SQL schema comments):
137
+ /// Algorithm:
136
138
  /// 1. If a checkpoint exists at that version → return its `data` directly.
137
- /// 2. Otherwise find the nearest checkpoint with `version_number <= target`
138
- /// in the same schema version, then replay deltas sequentially.
139
+ /// 2. Otherwise find the delta at that version, load its base checkpoint,
140
+ /// and decode the changeset (handles both legacy full-snapshot and
141
+ /// modern XOR diff formats transparently).
139
142
  pub fn restore_version(&self, version_number: i64) -> DbResult<RestoredVersion> {
140
143
  self.conn.with(|c| {
141
144
  // 1) Try direct checkpoint hit
@@ -172,7 +175,7 @@ impl<'a> VersionControl<'a> {
172
175
  )
173
176
  .map_err(DbError::from)?;
174
177
 
175
- // 3) Load the base checkpoint
178
+ // 3) Load the base checkpoint data
176
179
  let base_data: Vec<u8> = c
177
180
  .query_row(
178
181
  "SELECT data FROM checkpoints WHERE id = ?1",
@@ -181,48 +184,17 @@ impl<'a> VersionControl<'a> {
181
184
  )
182
185
  .map_err(DbError::from)?;
183
186
 
184
- let base_cp_version: i64 = c
187
+ // 4) Load the target delta's changeset
188
+ let target_changeset: Vec<u8> = c
185
189
  .query_row(
186
- "SELECT version_number FROM checkpoints WHERE id = ?1",
187
- [&target_base_cp_id],
190
+ "SELECT changeset FROM deltas WHERE version_number = ?1",
191
+ [version_number],
188
192
  |row| row.get(0),
189
193
  )
190
194
  .map_err(DbError::from)?;
191
195
 
192
- // 4) Collect and replay deltas from base_checkpoint up to target version
193
- let mut stmt = c
194
- .prepare(
195
- "SELECT changeset FROM deltas
196
- WHERE base_checkpoint_id = ?1
197
- AND schema_version = ?2
198
- AND version_number > ?3
199
- AND version_number <= ?4
200
- ORDER BY delta_sequence ASC",
201
- )
202
- .map_err(DbError::from)?;
203
-
204
- let delta_payloads: Vec<Vec<u8>> = stmt
205
- .query_map(
206
- rusqlite::params![target_base_cp_id, target_sv, base_cp_version, version_number],
207
- |row| row.get::<_, Vec<u8>>(0),
208
- )
209
- .map_err(DbError::from)?
210
- .collect::<Result<Vec<_>, _>>()
211
- .map_err(DbError::from)?;
212
-
213
- // 5) Apply deltas sequentially. Each delta payload is zlib-compressed.
214
- // The application-level delta format is opaque — the caller is
215
- // responsible for interpreting the bytes. What we do here is
216
- // decompress each payload and return the *final* state.
217
- //
218
- // For snapshot-based deltas (our current model), each delta
219
- // payload IS the full state at that version (compressed).
220
- // So we just need the *last* payload.
221
- let final_data = if let Some(last_changeset) = delta_payloads.last() {
222
- decompress_zlib(last_changeset)?
223
- } else {
224
- base_data
225
- };
196
+ // 5) Decode: auto-detects v3 (bsdiff) / v2 (XOR) / v1 (legacy snapshot)
197
+ let final_data = apply_delta_changeset(&base_data, &target_changeset)?;
226
198
 
227
199
  Ok(RestoredVersion {
228
200
  version_number,
@@ -411,6 +383,10 @@ impl<'a> VersionControl<'a> {
411
383
  /// Insert a new delta into the database and update the version graph
412
384
  /// singleton row.
413
385
  ///
386
+ /// `delta.payload` must be the **full document state** at this version
387
+ /// (uncompressed). The method automatically computes a fossil delta
388
+ /// against the base checkpoint, producing a compact changeset.
389
+ ///
414
390
  /// If the delta's `schema_version` is higher than the stored
415
391
  /// `current_schema_version`, the migration bookkeeping is performed
416
392
  /// automatically.
@@ -429,8 +405,18 @@ impl<'a> VersionControl<'a> {
429
405
  )
430
406
  .map_err(DbError::from)?;
431
407
 
432
- // Compress the payload with zlib before storing
433
- let compressed = compress_zlib(&delta.payload)?;
408
+ // Load the base checkpoint data for delta computation
409
+ let base_data: Vec<u8> = c
410
+ .query_row(
411
+ "SELECT data FROM checkpoints WHERE id = ?1",
412
+ [&delta.base_checkpoint_id],
413
+ |row| row.get(0),
414
+ )
415
+ .map_err(DbError::from)?;
416
+
417
+ // Compute checkpoint-relative fossil delta changeset
418
+ let changeset = create_bsdiff_changeset(&base_data, &delta.payload)?;
419
+ let stored_size = changeset.len() as i64;
434
420
 
435
421
  c.execute(
436
422
  "INSERT OR REPLACE INTO deltas
@@ -450,8 +436,8 @@ impl<'a> VersionControl<'a> {
450
436
  delta.base.description,
451
437
  delta.base.is_manual_save as i32,
452
438
  delta.base.user_id,
453
- compressed,
454
- delta.size_bytes,
439
+ changeset,
440
+ stored_size,
455
441
  ],
456
442
  )
457
443
  .map_err(DbError::from)?;
@@ -983,15 +969,129 @@ fn decompress_zlib(compressed: &[u8]) -> DbResult<Vec<u8>> {
983
969
  Ok(decompressed)
984
970
  }
985
971
 
986
- /// Compress a blob with zlib.
987
- fn compress_zlib(data: &[u8]) -> DbResult<Vec<u8>> {
972
+ /// Compress a blob with zlib (legacy v1 snapshot-compatible payload).
973
+ fn compress_zlib(raw: &[u8]) -> DbResult<Vec<u8>> {
988
974
  let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
989
975
  encoder
990
- .write_all(data)
976
+ .write_all(raw)
991
977
  .map_err(|e| DbError::Bootstrap(format!("zlib compression failed: {e}")))?;
992
978
  encoder
993
979
  .finish()
994
- .map_err(|e| DbError::Bootstrap(format!("zlib compression finish failed: {e}")))
980
+ .map_err(|e| DbError::Bootstrap(format!("zlib finalize failed: {e}")))
981
+ }
982
+
983
+ /// Ensure the input is raw (uncompressed) SQLite bytes.
984
+ ///
985
+ /// `.duc` files produced by `serializeDuc` are deflate-compressed. This
986
+ /// helper transparently inflates them so that fossil delta operates on the
987
+ /// raw SQLite pages — producing compact patches. If the input is already
988
+ /// raw SQLite, it is returned as-is (zero-copy via `Cow`).
989
+ fn ensure_raw_sqlite(buf: &[u8]) -> DbResult<std::borrow::Cow<'_, [u8]>> {
990
+ if is_sqlite_header(buf) {
991
+ Ok(std::borrow::Cow::Borrowed(buf))
992
+ } else {
993
+ let raw = decompress_duc_bytes(buf)
994
+ .map_err(|e| DbError::Bootstrap(format!("failed to decompress .duc blob: {e}")))?;
995
+ if !is_sqlite_header(&raw) {
996
+ return Err(DbError::Bootstrap(
997
+ "decompressed blob is not a valid SQLite database".into(),
998
+ ));
999
+ }
1000
+ Ok(std::borrow::Cow::Owned(raw))
1001
+ }
1002
+ }
1003
+
1004
+ // ────────────────────────────────────────────────────────────────────────────
1005
+ // Fossil delta encoding — checkpoint-relative structural diffing
1006
+ // ────────────────────────────────────────────────────────────────────────────
1007
+ //
1008
+ // Format (v5 — fossil delta):
1009
+ // [0x44 'D'][0x46 'F'] – magic bytes ("DF" = Delta Fossil)
1010
+ // [0x05] – format version
1011
+ // [4 bytes LE u32] – raw SQLite length of new state
1012
+ // [remaining bytes] – zlib-compressed fossil delta
1013
+ //
1014
+ // The fossil delta algorithm uses rolling checksums and emits granular
1015
+ // COPY (reference old bytes) + INSERT (literal new bytes) commands.
1016
+ // This is far more compact than page-level diffs because a 4KB page
1017
+ // with 20 changed bytes only stores those 20 bytes, not the full page.
1018
+ //
1019
+ // Fallback: if the delta is larger than a zlib-compressed full snapshot,
1020
+ // the snapshot is stored directly (no magic header → detected as raw zlib).
1021
+
1022
+ /// Magic header identifying a fossil delta changeset.
1023
+ const DELTA_MAGIC_FOSSIL: [u8; 2] = [0x44, 0x46]; // "DF"
1024
+ const DELTA_FORMAT_V5: u8 = 5;
1025
+
1026
+ /// Header: magic(2) + version(1) + new_len(4) = 7 bytes.
1027
+ const FOSSIL_HEADER_SIZE: usize = 2 + 1 + 4;
1028
+
1029
+ /// Returns `true` if the blob starts with the fossil v5 magic header.
1030
+ fn is_fossil_format(changeset: &[u8]) -> bool {
1031
+ changeset.len() >= FOSSIL_HEADER_SIZE
1032
+ && changeset[0] == DELTA_MAGIC_FOSSIL[0]
1033
+ && changeset[1] == DELTA_MAGIC_FOSSIL[1]
1034
+ && changeset[2] == DELTA_FORMAT_V5
1035
+ }
1036
+
1037
+ /// Compute a checkpoint-relative changeset using fossil delta.
1038
+ ///
1039
+ /// Both inputs are transparently decompressed to raw SQLite bytes,
1040
+ /// then a fossil delta is computed and zlib-compressed.
1041
+ /// Falls back to a full zlib snapshot if the delta isn't smaller.
1042
+ pub fn create_bsdiff_changeset(base: &[u8], current: &[u8]) -> DbResult<Vec<u8>> {
1043
+ let raw_base = ensure_raw_sqlite(base)?;
1044
+ let raw_current = ensure_raw_sqlite(current)?;
1045
+
1046
+ // fossil_delta::delta(target, source) — target is what we want to reconstruct,
1047
+ // source is what we already have. apply(source, delta) → target.
1048
+ let raw_delta = fossil_delta::delta(&raw_current, &raw_base);
1049
+ let compressed_delta = compress_zlib(&raw_delta)?;
1050
+
1051
+ let new_len = raw_current.len() as u32;
1052
+ let mut encoded = Vec::with_capacity(FOSSIL_HEADER_SIZE + compressed_delta.len());
1053
+ encoded.extend_from_slice(&DELTA_MAGIC_FOSSIL);
1054
+ encoded.push(DELTA_FORMAT_V5);
1055
+ encoded.extend_from_slice(&new_len.to_le_bytes());
1056
+ encoded.extend_from_slice(&compressed_delta);
1057
+
1058
+ // Fallback: full zlib snapshot if delta isn't beneficial
1059
+ let snapshot = compress_zlib(&raw_current)?;
1060
+
1061
+ if encoded.len() < snapshot.len() {
1062
+ Ok(encoded)
1063
+ } else {
1064
+ Ok(snapshot)
1065
+ }
1066
+ }
1067
+
1068
+ /// Apply a fossil delta changeset to reconstruct the document state.
1069
+ ///
1070
+ /// `base` is transparently decompressed if compressed.
1071
+ /// Returns raw (uncompressed) SQLite bytes.
1072
+ fn apply_fossil_changeset(base: &[u8], changeset: &[u8]) -> DbResult<Vec<u8>> {
1073
+ let raw_base = ensure_raw_sqlite(base)?;
1074
+
1075
+ let compressed_delta = &changeset[FOSSIL_HEADER_SIZE..];
1076
+ let raw_delta = decompress_zlib(compressed_delta)?;
1077
+
1078
+ fossil_delta::apply(&raw_base, &raw_delta)
1079
+ .map_err(|e| DbError::Bootstrap(format!("fossil delta apply failed: {e:?}")))
1080
+ }
1081
+
1082
+ /// Decode a stored changeset.
1083
+ ///
1084
+ /// Detects fossil delta (v5) by magic header. Anything else is treated as
1085
+ /// a zlib-compressed full snapshot (fallback).
1086
+ ///
1087
+ /// Returns raw (uncompressed) SQLite bytes.
1088
+ pub fn apply_delta_changeset(base_data: &[u8], changeset: &[u8]) -> DbResult<Vec<u8>> {
1089
+ if is_fossil_format(changeset) {
1090
+ apply_fossil_changeset(base_data, changeset)
1091
+ } else {
1092
+ // Snapshot fallback: zlib-compressed full state
1093
+ decompress_zlib(changeset)
1094
+ }
995
1095
  }
996
1096
 
997
1097
  /// Generate a nanoid-style ID (22-char URL-safe random string).
@@ -18,6 +18,24 @@ const SEARCH_SCHEMA: &str = include_str!(concat!(env!("OUT_DIR"), "/search.sql")
18
18
  /// Expected `application_id` written by `duc.sql`.
19
19
  const APP_ID: i64 = 1_146_569_567; // "DUC_" in ASCII
20
20
 
21
+ /// Schema version read from `duc.sql` at compile time by `build.rs`.
22
+ /// Never hardcode this — it is always in sync with the SQL file.
23
+ const CURRENT_VERSION: i64 = {
24
+ // build.rs emits DUC_SCHEMA_USER_VERSION as a decimal integer string.
25
+ let bytes = env!("DUC_SCHEMA_USER_VERSION").as_bytes();
26
+ let mut val: i64 = 0;
27
+ let mut i = 0;
28
+ while i < bytes.len() {
29
+ val = val * 10 + (bytes[i] - b'0') as i64;
30
+ i += 1;
31
+ }
32
+ val
33
+ };
34
+
35
+ // Migration registry auto-generated by build.rs from `schema/migrations/*.sql`.
36
+ // Each entry is (from_version, to_version, sql). Sorted ascending by from_version.
37
+ include!(concat!(env!("OUT_DIR"), "/migrations_registry.rs"));
38
+
21
39
  /// Per-connection PRAGMAs that must be re-applied on every open (they are
22
40
  /// either ephemeral or take effect only after the first statement on the
23
41
  /// connection).
@@ -57,8 +75,35 @@ pub(crate) fn bootstrap(conn: &Connection) -> Result<(), DbError> {
57
75
  "unexpected application_id after bootstrap: {app_id} (expected {APP_ID})"
58
76
  )));
59
77
  }
78
+ } else if user_version == CURRENT_VERSION {
79
+ // Current schema — just ensure per-connection pragmas are active.
80
+ conn.execute_batch(CONN_PRAGMAS)
81
+ .map_err(|e| DbError::Bootstrap(format!("pragma apply failed: {e}")))?;
60
82
  } else {
61
- // Existing database just ensure per-connection pragmas are active.
83
+ // Walk the migration chain until we reach CURRENT_VERSION.
84
+ // build.rs generates MIGRATIONS sorted by from_version, so chaining
85
+ // (e.g. 3000000→3000001→3000002) works without any code changes here.
86
+ let mut current = user_version;
87
+ loop {
88
+ match MIGRATIONS.iter().find(|(from, _, _)| *from == current) {
89
+ Some((from, to, sql)) => {
90
+ conn.execute_batch(sql).map_err(|e| {
91
+ DbError::Bootstrap(format!("migration {from}\u{2192}{to} failed: {e}"))
92
+ })?;
93
+ // Re-read the version the migration SQL set via PRAGMA user_version.
94
+ current =
95
+ conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
96
+ if current == CURRENT_VERSION {
97
+ break;
98
+ }
99
+ }
100
+ None => {
101
+ return Err(DbError::Bootstrap(format!(
102
+ "unsupported schema version {user_version}; expected 0 or {CURRENT_VERSION}"
103
+ )));
104
+ }
105
+ }
106
+ }
62
107
  conn.execute_batch(CONN_PRAGMAS)
63
108
  .map_err(|e| DbError::Bootstrap(format!("pragma apply failed: {e}")))?;
64
109
  }
@@ -121,48 +121,131 @@ pub fn parse_lazy(buf: &[u8]) -> ParseResult<ExportedDataState> {
121
121
  }
122
122
 
123
123
  /// 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<DucExternalFileEntry>> {
124
+ pub fn get_external_file(buf: &[u8], file_id: &str) -> ParseResult<Option<DucExternalFile>> {
125
125
  let conn = load_db_bytes(buf)?;
126
- let mut stmt = conn.prepare(
127
- "SELECT id, mime_type, data, created, last_retrieved, version FROM external_files WHERE id = ?1"
128
- )?;
129
- let result = stmt.query_row(params![file_id], |row| {
130
- let id: String = row.get(0)?;
131
- Ok(DucExternalFileEntry {
132
- key: id.clone(),
133
- value: DucExternalFileData {
126
+ let has_revisions_table: bool = conn.prepare(
127
+ "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='external_file_revisions'"
128
+ )?.query_row([], |row| row.get::<_, i32>(0)).unwrap_or(0) > 0;
129
+
130
+ if has_revisions_table {
131
+ // New schema: load file header + revisions.
132
+ let file = conn.prepare(
133
+ "SELECT id, active_revision_id, updated, version FROM external_files WHERE id = ?1"
134
+ )?.query_row(params![file_id], |row| {
135
+ Ok(DucExternalFile {
136
+ id: row.get(0)?,
137
+ active_revision_id: row.get(1)?,
138
+ updated: row.get(2)?,
139
+ revisions: HashMap::new(),
140
+ version: row.get(3)?,
141
+ })
142
+ });
143
+ let mut file = match file {
144
+ Ok(f) => f,
145
+ Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
146
+ Err(e) => return Err(e.into()),
147
+ };
148
+
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 {
134
157
  id,
135
- mime_type: row.get(1)?,
136
- data: row.get(2)?,
137
- created: row.get(3)?,
138
- last_retrieved: row.get(4)?,
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);
171
+ }
172
+ Ok(Some(file))
173
+ } else {
174
+ // Legacy schema: wrap flat row into a single-revision DucExternalFile.
175
+ let result = conn.prepare(
176
+ "SELECT id, mime_type, data, created, last_retrieved, version FROM external_files WHERE id = ?1"
177
+ )?.query_row(params![file_id], |row| {
178
+ let id: String = row.get(0)?;
179
+ 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)?,
139
184
  version: row.get(5)?,
140
- },
141
- })
142
- });
143
- match result {
144
- Ok(entry) => Ok(Some(entry)),
145
- Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
146
- Err(e) => Err(e.into()),
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
199
+ },
200
+ })
201
+ });
202
+ match result {
203
+ Ok(file) => Ok(Some(file)),
204
+ Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
205
+ Err(e) => Err(e.into()),
206
+ }
147
207
  }
148
208
  }
149
209
 
150
- /// List metadata for all external files (without the heavy data blobs).
151
- pub fn list_external_files(buf: &[u8]) -> ParseResult<Vec<ExternalFileMetadata>> {
210
+ /// List metadata summary for all external files (no data blobs loaded).
211
+ pub fn list_external_files(buf: &[u8]) -> ParseResult<Vec<ExternalFileMeta>> {
152
212
  let conn = load_db_bytes(buf)?;
153
- let mut stmt = conn.prepare(
154
- "SELECT id, mime_type, created, last_retrieved, version FROM external_files"
155
- )?;
156
- let files: Vec<ExternalFileMetadata> = stmt.query_map([], |row| {
157
- Ok(ExternalFileMetadata {
158
- id: row.get(0)?,
159
- mime_type: row.get(1)?,
160
- created: row.get(2)?,
161
- last_retrieved: row.get(3)?,
162
- version: row.get(4)?,
163
- })
164
- })?.collect::<Result<Vec<_>, _>>()?;
165
- Ok(files)
213
+ let has_revisions_table: bool = conn.prepare(
214
+ "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='external_file_revisions'"
215
+ )?.query_row([], |row| row.get::<_, i32>(0)).unwrap_or(0) > 0;
216
+
217
+ if has_revisions_table {
218
+ let mut stmt = conn.prepare(
219
+ "SELECT f.id, r.mime_type, r.created, r.last_retrieved, f.version
220
+ FROM external_files f
221
+ JOIN external_file_revisions r ON r.id = f.active_revision_id"
222
+ )?;
223
+ let files: Vec<ExternalFileMeta> = stmt.query_map([], |row| {
224
+ Ok(ExternalFileMeta {
225
+ id: row.get(0)?,
226
+ mime_type: row.get(1)?,
227
+ created: row.get(2)?,
228
+ last_retrieved: row.get(3)?,
229
+ version: row.get(4)?,
230
+ })
231
+ })?.collect::<Result<Vec<_>, _>>()?;
232
+ Ok(files)
233
+ } else {
234
+ // Legacy schema flat table.
235
+ let mut stmt = conn.prepare(
236
+ "SELECT id, mime_type, created, last_retrieved, version FROM external_files"
237
+ )?;
238
+ let files: Vec<ExternalFileMeta> = stmt.query_map([], |row| {
239
+ Ok(ExternalFileMeta {
240
+ id: row.get(0)?,
241
+ mime_type: row.get(1)?,
242
+ created: row.get(2)?,
243
+ last_retrieved: row.get(3)?,
244
+ version: row.get(4)?,
245
+ })
246
+ })?.collect::<Result<Vec<_>, _>>()?;
247
+ Ok(files)
248
+ }
166
249
  }
167
250
 
168
251
  // ─── Database import ─────────────────────────────────────────────────────────
@@ -223,12 +306,12 @@ fn load_db_bytes(buf: &[u8]) -> ParseResult<Connection> {
223
306
  }
224
307
 
225
308
  #[inline]
226
- fn is_sqlite_header(buf: &[u8]) -> bool {
309
+ pub fn is_sqlite_header(buf: &[u8]) -> bool {
227
310
  buf.len() >= SQLITE_HEADER_MAGIC.len() && &buf[..SQLITE_HEADER_MAGIC.len()] == SQLITE_HEADER_MAGIC
228
311
  }
229
312
 
230
313
  /// Inflate a compressed deflate payload.
231
- fn decompress_duc_bytes(compressed: &[u8]) -> ParseResult<Vec<u8>> {
314
+ pub fn decompress_duc_bytes(compressed: &[u8]) -> ParseResult<Vec<u8>> {
232
315
  use flate2::read::DeflateDecoder;
233
316
  use std::io::Read;
234
317
 
@@ -1685,27 +1768,131 @@ fn read_hatch_pattern_lines(conn: &Connection, owner_type: &str, owner_id: i64)
1685
1768
 
1686
1769
  // ─── external_files ──────────────────────────────────────────────────────────
1687
1770
 
1688
- fn read_external_files(conn: &Connection) -> ParseResult<Option<HashMap<String, DucExternalFileData>>> {
1771
+ fn read_external_files(conn: &Connection) -> ParseResult<Option<HashMap<String, DucExternalFile>>> {
1772
+ // Check if the new schema (with external_file_revisions) is present.
1773
+ let has_revisions_table: bool = conn.prepare(
1774
+ "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='external_file_revisions'"
1775
+ )?.query_row([], |row| row.get::<_, i32>(0)).unwrap_or(0) > 0;
1776
+
1777
+ if has_revisions_table {
1778
+ read_external_files_v2(conn)
1779
+ } else {
1780
+ read_external_files_v1_legacy(conn)
1781
+ }
1782
+ }
1783
+
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.
1787
+ let mut rev_stmt = conn.prepare(
1788
+ "SELECT id, file_id, size_bytes, checksum, source_name, mime_type, message,
1789
+ created, last_retrieved, data
1790
+ FROM external_file_revisions"
1791
+ )?;
1792
+ let mut revisions_by_file: HashMap<String, HashMap<String, ExternalFileRevision>> = HashMap::new();
1793
+ let rev_rows = rev_stmt.query_map([], |row| {
1794
+ Ok(ExternalFileRevision {
1795
+ id: row.get(0)?,
1796
+ size_bytes: row.get(2)?,
1797
+ checksum: row.get(3)?,
1798
+ source_name: row.get(4)?,
1799
+ mime_type: row.get(5)?,
1800
+ message: row.get(6)?,
1801
+ created: row.get(7)?,
1802
+ last_retrieved: row.get(8)?,
1803
+ data: row.get(9)?,
1804
+ })
1805
+ })?;
1806
+
1807
+ // We need file_id as well; re-query with it included.
1808
+ let mut rev_stmt2 = conn.prepare(
1809
+ "SELECT id, file_id, size_bytes, checksum, source_name, mime_type, message,
1810
+ created, last_retrieved, data
1811
+ FROM external_file_revisions"
1812
+ )?;
1813
+ // Suppress unused warning from first query
1814
+ drop(rev_rows);
1815
+
1816
+ let rev_rows2 = rev_stmt2.query_map([], |row| {
1817
+ let rev_id: String = row.get(0)?;
1818
+ let file_id: String = row.get(1)?;
1819
+ Ok((file_id, rev_id.clone(), ExternalFileRevision {
1820
+ id: rev_id,
1821
+ size_bytes: row.get(2)?,
1822
+ checksum: row.get(3)?,
1823
+ source_name: row.get(4)?,
1824
+ mime_type: row.get(5)?,
1825
+ message: row.get(6)?,
1826
+ created: row.get(7)?,
1827
+ last_retrieved: row.get(8)?,
1828
+ data: row.get(9)?,
1829
+ }))
1830
+ })?;
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);
1834
+ }
1835
+
1836
+ // Load the file headers.
1837
+ let mut file_stmt = conn.prepare(
1838
+ "SELECT id, active_revision_id, updated, version FROM external_files"
1839
+ )?;
1840
+ let mut map: HashMap<String, DucExternalFile> = HashMap::new();
1841
+ let file_rows = file_stmt.query_map([], |row| {
1842
+ let id: String = row.get(0)?;
1843
+ Ok((id.clone(), DucExternalFile {
1844
+ id,
1845
+ active_revision_id: row.get(1)?,
1846
+ updated: row.get(2)?,
1847
+ revisions: HashMap::new(),
1848
+ version: row.get(3)?,
1849
+ }))
1850
+ })?;
1851
+ for row in file_rows {
1852
+ let (k, mut v) = row?;
1853
+ v.revisions = revisions_by_file.remove(&k).unwrap_or_default();
1854
+ map.insert(k, v);
1855
+ }
1856
+
1857
+ if map.is_empty() { Ok(None) } else { Ok(Some(map)) }
1858
+ }
1859
+
1860
+ /// Fallback for old schema files that only have the flat `external_files` table.
1861
+ /// Wraps each row in a single-revision DucExternalFile.
1862
+ fn read_external_files_v1_legacy(conn: &Connection) -> ParseResult<Option<HashMap<String, DucExternalFile>>> {
1689
1863
  let mut stmt = conn.prepare(
1690
1864
  "SELECT id, mime_type, data, created, last_retrieved, version FROM external_files"
1691
1865
  )?;
1692
1866
  let mut map = HashMap::new();
1693
1867
  let rows = stmt.query_map([], |row| {
1694
1868
  let id: String = row.get(0)?;
1695
- Ok((id.clone(), DucExternalFileData {
1696
- id,
1697
- mime_type: row.get(1)?,
1698
- data: row.get(2)?,
1699
- created: row.get(3)?,
1700
- last_retrieved: row.get(4)?,
1869
+ let rev_id = format!("{}_rev1", id);
1870
+ Ok((id.clone(), DucExternalFile {
1871
+ id: id.clone(),
1872
+ active_revision_id: rev_id.clone(),
1873
+ updated: row.get(3)?,
1701
1874
  version: row.get(5)?,
1875
+ revisions: {
1876
+ 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
+ });
1888
+ revs
1889
+ },
1702
1890
  }))
1703
1891
  })?;
1704
1892
  for row in rows {
1705
1893
  let (k, v) = row?;
1706
1894
  map.insert(k, v);
1707
1895
  }
1708
-
1709
1896
  if map.is_empty() { Ok(None) } else { Ok(Some(map)) }
1710
1897
  }
1711
1898
 
@@ -1123,15 +1123,39 @@ fn write_hatch_pattern_lines(
1123
1123
 
1124
1124
  fn write_external_files(
1125
1125
  tx: &Transaction,
1126
- files: &Option<std::collections::HashMap<String, DucExternalFileData>>,
1126
+ files: &Option<std::collections::HashMap<String, DucExternalFile>>,
1127
1127
  ) -> SerializeResult<()> {
1128
1128
  let Some(files) = files else { return Ok(()) };
1129
- let mut stmt = tx.prepare_cached(
1130
- "INSERT OR REPLACE INTO external_files (id, mime_type, data, created, last_retrieved, version)
1131
- VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
1129
+ let mut file_stmt = tx.prepare_cached(
1130
+ "INSERT OR REPLACE INTO external_files (id, active_revision_id, updated, version)
1131
+ VALUES (?1, ?2, ?3, ?4)"
1132
+ )?;
1133
+ let mut rev_stmt = tx.prepare_cached(
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)"
1132
1137
  )?;
1133
- for (_key, data) in files {
1134
- stmt.execute(params![data.id, data.mime_type, data.data, data.created, data.last_retrieved, data.version])?;
1138
+ for (_key, file) in files {
1139
+ file_stmt.execute(params![
1140
+ file.id,
1141
+ file.active_revision_id,
1142
+ file.updated,
1143
+ file.version,
1144
+ ])?;
1145
+ for (_rev_key, rev) in &file.revisions {
1146
+ rev_stmt.execute(params![
1147
+ rev.id,
1148
+ file.id,
1149
+ rev.size_bytes,
1150
+ rev.checksum,
1151
+ rev.source_name,
1152
+ rev.mime_type,
1153
+ rev.message,
1154
+ rev.created,
1155
+ rev.last_retrieved,
1156
+ rev.data,
1157
+ ])?;
1158
+ }
1135
1159
  }
1136
1160
  Ok(())
1137
1161
  }
@@ -1,3 +1,5 @@
1
+ use std::collections::HashMap;
2
+
1
3
  /**
2
4
  * Aligns text vertically within its bounding box.
3
5
  */
@@ -1461,40 +1463,51 @@ pub struct VersionGraph {
1461
1463
 
1462
1464
  // =============== EXTERNAL FILES ===============
1463
1465
 
1464
- /// Lightweight metadata for an external file (no data blob).
1466
+ /// Snapshot of an external file at a given point in time.
1465
1467
  #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1466
1468
  #[serde(rename_all = "camelCase")]
1467
- pub struct ExternalFileMetadata {
1469
+ pub struct ExternalFileRevision {
1470
+ // Revision number as string, functions as an incremental serial id
1468
1471
  pub id: String,
1472
+ pub size_bytes: i64,
1473
+ /// Content hash for integrity checks and optional deduplication.
1474
+ pub checksum: Option<String>,
1475
+ /// Original upload filename shown to the user.
1476
+ pub source_name: Option<String>,
1469
1477
  pub mime_type: String,
1478
+ /// Optional note describing what changed in this revision.
1479
+ pub message: Option<String>,
1480
+ /// Epoch ms when this revision was created.
1470
1481
  pub created: i64,
1482
+ /// Epoch ms when this revision was last loaded onto the scene.
1471
1483
  pub last_retrieved: Option<i64>,
1472
- pub version: Option<i32>,
1473
- }
1474
1484
 
1485
+ /// The actual file content bytes
1486
+ #[serde(with = "serde_bytes")]
1487
+ pub data: Vec<u8>,
1488
+ }
1475
1489
  #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1476
1490
  #[serde(rename_all = "camelCase")]
1477
- pub struct DucExternalFileData {
1478
- pub mime_type: String,
1491
+ pub struct DucExternalFile {
1479
1492
  pub id: String,
1480
- /** The actual file content bytes. */
1481
- #[serde(with = "serde_bytes")]
1482
- pub data: Vec<u8>,
1483
- /** Epoch timestamp in milliseconds when the file was created. */
1484
- pub created: i64,
1485
- /**
1486
- * Epoch timestamp in milliseconds when the file was last retrieved from storage to be loaded onto the scene.
1487
- * Used to determine whether to delete unused files from storage.
1488
- */
1489
- pub last_retrieved: Option<i64>,
1493
+ pub active_revision_id: String,
1494
+ /// Epoch ms when the logical file was last mutated (revision added or active changed).
1495
+ pub updated: i64,
1496
+ // All revisions of this file, keyed by their `id`.
1497
+ pub revisions: HashMap<String, ExternalFileRevision>,
1490
1498
  pub version: Option<i32>,
1491
1499
  }
1492
1500
 
1501
+ /// Lightweight summary of an external file used for lazy/metadata-only access
1502
+ /// (active revision's metadata + file-level version, no data blob).
1493
1503
  #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1494
1504
  #[serde(rename_all = "camelCase")]
1495
- pub struct DucExternalFileEntry {
1496
- pub key: String,
1497
- pub value: DucExternalFileData,
1505
+ pub struct ExternalFileMeta {
1506
+ pub id: String,
1507
+ pub mime_type: String,
1508
+ pub created: i64,
1509
+ pub last_retrieved: Option<i64>,
1510
+ pub version: Option<i32>,
1498
1511
  }
1499
1512
 
1500
1513
  // =============== ROOT TYPE ===============
@@ -1509,7 +1522,7 @@ pub struct ExportedDataState {
1509
1522
  pub source: String,
1510
1523
  #[serde(rename = "type")]
1511
1524
  pub data_type: String,
1512
- pub dictionary: Option<std::collections::HashMap<String, String>>,
1525
+ pub dictionary: Option<HashMap<String, String>>,
1513
1526
  #[serde(with = "serde_bytes", default, skip_serializing_if = "Option::is_none")]
1514
1527
  pub thumbnail: Option<Vec<u8>>,
1515
1528
  pub elements: Vec<ElementWrapper>,
@@ -1528,5 +1541,5 @@ pub struct ExportedDataState {
1528
1541
  /** In case it is needed to embed the version control into the file format */
1529
1542
  pub version_graph: Option<VersionGraph>,
1530
1543
  #[serde(rename = "files")]
1531
- pub external_files: Option<std::collections::HashMap<String, DucExternalFileData>>,
1544
+ pub external_files: Option<HashMap<String, DucExternalFile>>,
1532
1545
  }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes