ducpy 3.0.5__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.5 → ducpy-3.1.0}/Cargo.lock +42 -42
  2. {ducpy-3.0.5 → ducpy-3.1.0}/PKG-INFO +1 -1
  3. {ducpy-3.0.5 → ducpy-3.1.0}/packages/ducpy/crate/Cargo.toml +1 -1
  4. {ducpy-3.0.5 → ducpy-3.1.0}/packages/ducrs/build.rs +56 -0
  5. {ducpy-3.0.5 → ducpy-3.1.0}/packages/ducrs/src/db/bootstrap.rs +46 -1
  6. {ducpy-3.0.5 → ducpy-3.1.0}/packages/ducrs/src/parse.rs +230 -43
  7. {ducpy-3.0.5 → ducpy-3.1.0}/packages/ducrs/src/serialize.rs +30 -6
  8. {ducpy-3.0.5 → ducpy-3.1.0}/packages/ducrs/src/types.rs +34 -21
  9. {ducpy-3.0.5 → ducpy-3.1.0}/Cargo.toml +0 -0
  10. {ducpy-3.0.5 → ducpy-3.1.0}/LICENSE +0 -0
  11. {ducpy-3.0.5 → ducpy-3.1.0}/README.md +0 -0
  12. {ducpy-3.0.5 → ducpy-3.1.0}/packages/ducpy/crate/src/lib.rs +0 -0
  13. {ducpy-3.0.5 → ducpy-3.1.0}/packages/ducrs/.gitignore +0 -0
  14. {ducpy-3.0.5 → ducpy-3.1.0}/packages/ducrs/Cargo.toml +0 -0
  15. {ducpy-3.0.5 → ducpy-3.1.0}/packages/ducrs/LICENSE +0 -0
  16. {ducpy-3.0.5 → ducpy-3.1.0}/packages/ducrs/README.md +0 -0
  17. {ducpy-3.0.5 → ducpy-3.1.0}/packages/ducrs/package.json +0 -0
  18. {ducpy-3.0.5 → ducpy-3.1.0}/packages/ducrs/release.config.cjs +0 -0
  19. {ducpy-3.0.5 → ducpy-3.1.0}/packages/ducrs/src/api/document.rs +0 -0
  20. {ducpy-3.0.5 → ducpy-3.1.0}/packages/ducrs/src/api/meta.rs +0 -0
  21. {ducpy-3.0.5 → ducpy-3.1.0}/packages/ducrs/src/api/mod.rs +0 -0
  22. {ducpy-3.0.5 → ducpy-3.1.0}/packages/ducrs/src/api/version_control.rs +0 -0
  23. {ducpy-3.0.5 → ducpy-3.1.0}/packages/ducrs/src/db/mod.rs +0 -0
  24. {ducpy-3.0.5 → ducpy-3.1.0}/packages/ducrs/src/db/native.rs +0 -0
  25. {ducpy-3.0.5 → ducpy-3.1.0}/packages/ducrs/src/db/wasm.rs +0 -0
  26. {ducpy-3.0.5 → ducpy-3.1.0}/packages/ducrs/src/lib.rs +0 -0
  27. {ducpy-3.0.5 → ducpy-3.1.0}/packages/ducrs/tests/.gitignore +0 -0
  28. {ducpy-3.0.5 → ducpy-3.1.0}/pyproject.toml +0 -0
  29. {ducpy-3.0.5 → 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
  ]
@@ -411,7 +411,7 @@ dependencies = [
411
411
 
412
412
  [[package]]
413
413
  name = "ducpy-native"
414
- version = "3.0.5"
414
+ version = "3.1.0"
415
415
  dependencies = [
416
416
  "duc",
417
417
  "pyo3",
@@ -579,9 +579,9 @@ checksum = "4e42f72dc65ff480af54c73c63d4b425936aac4167a01b7c9e1ed15aacace22c"
579
579
 
580
580
  [[package]]
581
581
  name = "generic-array"
582
- version = "0.14.9"
582
+ version = "0.14.7"
583
583
  source = "registry+https://github.com/rust-lang/crates.io-index"
584
- checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
584
+ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
585
585
  dependencies = [
586
586
  "typenum",
587
587
  "version_check",
@@ -713,9 +713,9 @@ checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
713
713
 
714
714
  [[package]]
715
715
  name = "hipdf"
716
- version = "1.3.3"
716
+ version = "1.3.5"
717
717
  source = "registry+https://github.com/rust-lang/crates.io-index"
718
- checksum = "5d8e0025c137fd7f666ec73ba8a1c8c7f4940edb83fdb77013e5e12703204c80"
718
+ checksum = "e3b986659f7ff291144addff668450799e4c2cd69686601cd48dfe23d7afc316"
719
719
  dependencies = [
720
720
  "jpeg-decoder",
721
721
  "lopdf",
@@ -880,24 +880,24 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
880
880
 
881
881
  [[package]]
882
882
  name = "jiff"
883
- version = "0.2.15"
883
+ version = "0.2.23"
884
884
  source = "registry+https://github.com/rust-lang/crates.io-index"
885
- checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49"
885
+ checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
886
886
  dependencies = [
887
887
  "jiff-static",
888
888
  "jiff-tzdb-platform",
889
889
  "log",
890
890
  "portable-atomic",
891
891
  "portable-atomic-util",
892
- "serde",
893
- "windows-sys 0.59.0",
892
+ "serde_core",
893
+ "windows-sys 0.61.2",
894
894
  ]
895
895
 
896
896
  [[package]]
897
897
  name = "jiff-static"
898
- version = "0.2.15"
898
+ version = "0.2.23"
899
899
  source = "registry+https://github.com/rust-lang/crates.io-index"
900
- checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4"
900
+ checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
901
901
  dependencies = [
902
902
  "proc-macro2",
903
903
  "quote",
@@ -906,9 +906,9 @@ dependencies = [
906
906
 
907
907
  [[package]]
908
908
  name = "jiff-tzdb"
909
- version = "0.1.4"
909
+ version = "0.1.6"
910
910
  source = "registry+https://github.com/rust-lang/crates.io-index"
911
- checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524"
911
+ checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076"
912
912
 
913
913
  [[package]]
914
914
  name = "jiff-tzdb-platform"
@@ -1166,9 +1166,9 @@ dependencies = [
1166
1166
 
1167
1167
  [[package]]
1168
1168
  name = "num-conv"
1169
- version = "0.1.0"
1169
+ version = "0.2.0"
1170
1170
  source = "registry+https://github.com/rust-lang/crates.io-index"
1171
- checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
1171
+ checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
1172
1172
 
1173
1173
  [[package]]
1174
1174
  name = "num-traits"
@@ -1322,9 +1322,9 @@ checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
1322
1322
 
1323
1323
  [[package]]
1324
1324
  name = "portable-atomic-util"
1325
- version = "0.2.4"
1325
+ version = "0.2.5"
1326
1326
  source = "registry+https://github.com/rust-lang/crates.io-index"
1327
- checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
1327
+ checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5"
1328
1328
  dependencies = [
1329
1329
  "portable-atomic",
1330
1330
  ]
@@ -1478,18 +1478,18 @@ dependencies = [
1478
1478
 
1479
1479
  [[package]]
1480
1480
  name = "rand_core"
1481
- version = "0.9.3"
1481
+ version = "0.9.5"
1482
1482
  source = "registry+https://github.com/rust-lang/crates.io-index"
1483
- checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
1483
+ checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
1484
1484
  dependencies = [
1485
1485
  "getrandom",
1486
1486
  ]
1487
1487
 
1488
1488
  [[package]]
1489
1489
  name = "rangemap"
1490
- version = "1.6.0"
1490
+ version = "1.7.1"
1491
1491
  source = "registry+https://github.com/rust-lang/crates.io-index"
1492
- checksum = "f93e7e49bb0bf967717f7bd674458b3d6b0c5f48ec7e3038166026a69fc22223"
1492
+ checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
1493
1493
 
1494
1494
  [[package]]
1495
1495
  name = "rayon"
@@ -1978,30 +1978,30 @@ dependencies = [
1978
1978
 
1979
1979
  [[package]]
1980
1980
  name = "time"
1981
- version = "0.3.44"
1981
+ version = "0.3.47"
1982
1982
  source = "registry+https://github.com/rust-lang/crates.io-index"
1983
- checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
1983
+ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
1984
1984
  dependencies = [
1985
1985
  "deranged",
1986
1986
  "itoa",
1987
1987
  "num-conv",
1988
1988
  "powerfmt",
1989
- "serde",
1989
+ "serde_core",
1990
1990
  "time-core",
1991
1991
  "time-macros",
1992
1992
  ]
1993
1993
 
1994
1994
  [[package]]
1995
1995
  name = "time-core"
1996
- version = "0.1.6"
1996
+ version = "0.1.8"
1997
1997
  source = "registry+https://github.com/rust-lang/crates.io-index"
1998
- checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
1998
+ checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
1999
1999
 
2000
2000
  [[package]]
2001
2001
  name = "time-macros"
2002
- version = "0.2.24"
2002
+ version = "0.2.27"
2003
2003
  source = "registry+https://github.com/rust-lang/crates.io-index"
2004
- checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
2004
+ checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
2005
2005
  dependencies = [
2006
2006
  "num-conv",
2007
2007
  "time-core",
@@ -2104,9 +2104,9 @@ checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06"
2104
2104
 
2105
2105
  [[package]]
2106
2106
  name = "unicode-normalization"
2107
- version = "0.1.24"
2107
+ version = "0.1.25"
2108
2108
  source = "registry+https://github.com/rust-lang/crates.io-index"
2109
- checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956"
2109
+ checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
2110
2110
  dependencies = [
2111
2111
  "tinyvec",
2112
2112
  ]
@@ -2182,9 +2182,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
2182
2182
 
2183
2183
  [[package]]
2184
2184
  name = "wasip2"
2185
- version = "1.0.1+wasi-0.2.4"
2185
+ version = "1.0.2+wasi-0.2.9"
2186
2186
  source = "registry+https://github.com/rust-lang/crates.io-index"
2187
- checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
2187
+ checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
2188
2188
  dependencies = [
2189
2189
  "wit-bindgen",
2190
2190
  ]
@@ -2429,9 +2429,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
2429
2429
 
2430
2430
  [[package]]
2431
2431
  name = "wit-bindgen"
2432
- version = "0.46.0"
2432
+ version = "0.51.0"
2433
2433
  source = "registry+https://github.com/rust-lang/crates.io-index"
2434
- checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
2434
+ checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
2435
2435
 
2436
2436
  [[package]]
2437
2437
  name = "write-fonts"
@@ -2477,18 +2477,18 @@ dependencies = [
2477
2477
 
2478
2478
  [[package]]
2479
2479
  name = "zerocopy"
2480
- version = "0.8.27"
2480
+ version = "0.8.41"
2481
2481
  source = "registry+https://github.com/rust-lang/crates.io-index"
2482
- checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c"
2482
+ checksum = "96e13bc581734df6250836c59a5f44f3c57db9f9acb9dc8e3eaabdaf6170254d"
2483
2483
  dependencies = [
2484
2484
  "zerocopy-derive",
2485
2485
  ]
2486
2486
 
2487
2487
  [[package]]
2488
2488
  name = "zerocopy-derive"
2489
- version = "0.8.27"
2489
+ version = "0.8.41"
2490
2490
  source = "registry+https://github.com/rust-lang/crates.io-index"
2491
- checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
2491
+ checksum = "3545ea9e86d12ab9bba9fcd99b54c1556fd3199007def5a03c375623d05fac1c"
2492
2492
  dependencies = [
2493
2493
  "proc-macro2",
2494
2494
  "quote",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ducpy
3
- Version: 3.0.5
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.5"
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
@@ -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) => {
@@ -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 ─────────────────────────────────────────────────────────
@@ -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
File without changes