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.
- {ducpy-3.0.4 → ducpy-3.1.0}/Cargo.lock +49 -42
- {ducpy-3.0.4 → ducpy-3.1.0}/PKG-INFO +1 -1
- {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducpy/crate/Cargo.toml +1 -1
- {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/Cargo.toml +1 -0
- {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/build.rs +56 -0
- {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/api/version_control.rs +150 -50
- {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/db/bootstrap.rs +46 -1
- {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/parse.rs +232 -45
- {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/serialize.rs +30 -6
- {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/types.rs +34 -21
- {ducpy-3.0.4 → ducpy-3.1.0}/Cargo.toml +0 -0
- {ducpy-3.0.4 → ducpy-3.1.0}/LICENSE +0 -0
- {ducpy-3.0.4 → ducpy-3.1.0}/README.md +0 -0
- {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducpy/crate/src/lib.rs +0 -0
- {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/.gitignore +0 -0
- {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/LICENSE +0 -0
- {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/README.md +0 -0
- {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/package.json +0 -0
- {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/release.config.cjs +0 -0
- {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/api/document.rs +0 -0
- {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/api/meta.rs +0 -0
- {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/api/mod.rs +0 -0
- {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/db/mod.rs +0 -0
- {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/db/native.rs +0 -0
- {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/db/wasm.rs +0 -0
- {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/src/lib.rs +0 -0
- {ducpy-3.0.4 → ducpy-3.1.0}/packages/ducrs/tests/.gitignore +0 -0
- {ducpy-3.0.4 → ducpy-3.1.0}/pyproject.toml +0 -0
- {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.
|
|
291
|
+
version = "0.1.7"
|
|
292
292
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
293
|
-
checksum = "
|
|
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.
|
|
321
|
+
version = "0.5.8"
|
|
322
322
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
323
|
-
checksum = "
|
|
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
|
|
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.
|
|
582
|
+
version = "0.14.7"
|
|
576
583
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
577
|
-
checksum = "
|
|
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.
|
|
716
|
+
version = "1.3.5"
|
|
710
717
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
711
|
-
checksum = "
|
|
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.
|
|
883
|
+
version = "0.2.23"
|
|
877
884
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
878
|
-
checksum = "
|
|
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
|
-
"
|
|
886
|
-
"windows-sys 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.
|
|
898
|
+
version = "0.2.23"
|
|
892
899
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
893
|
-
checksum = "
|
|
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.
|
|
909
|
+
version = "0.1.6"
|
|
903
910
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
904
|
-
checksum = "
|
|
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.
|
|
1169
|
+
version = "0.2.0"
|
|
1163
1170
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1164
|
-
checksum = "
|
|
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.
|
|
1325
|
+
version = "0.2.5"
|
|
1319
1326
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1320
|
-
checksum = "
|
|
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.
|
|
1481
|
+
version = "0.9.5"
|
|
1475
1482
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1476
|
-
checksum = "
|
|
1483
|
+
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
|
1477
1484
|
dependencies = [
|
|
1478
1485
|
"getrandom",
|
|
1479
1486
|
]
|
|
1480
1487
|
|
|
1481
1488
|
[[package]]
|
|
1482
1489
|
name = "rangemap"
|
|
1483
|
-
version = "1.
|
|
1490
|
+
version = "1.7.1"
|
|
1484
1491
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1485
|
-
checksum = "
|
|
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.
|
|
1981
|
+
version = "0.3.47"
|
|
1975
1982
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1976
|
-
checksum = "
|
|
1983
|
+
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
|
1977
1984
|
dependencies = [
|
|
1978
1985
|
"deranged",
|
|
1979
1986
|
"itoa",
|
|
1980
1987
|
"num-conv",
|
|
1981
1988
|
"powerfmt",
|
|
1982
|
-
"
|
|
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.
|
|
1996
|
+
version = "0.1.8"
|
|
1990
1997
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1991
|
-
checksum = "
|
|
1998
|
+
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
|
1992
1999
|
|
|
1993
2000
|
[[package]]
|
|
1994
2001
|
name = "time-macros"
|
|
1995
|
-
version = "0.2.
|
|
2002
|
+
version = "0.2.27"
|
|
1996
2003
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1997
|
-
checksum = "
|
|
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.
|
|
2107
|
+
version = "0.1.25"
|
|
2101
2108
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
2102
|
-
checksum = "
|
|
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.
|
|
2185
|
+
version = "1.0.2+wasi-0.2.9"
|
|
2179
2186
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
2180
|
-
checksum = "
|
|
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.
|
|
2432
|
+
version = "0.51.0"
|
|
2426
2433
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
2427
|
-
checksum = "
|
|
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.
|
|
2480
|
+
version = "0.8.41"
|
|
2474
2481
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
2475
|
-
checksum = "
|
|
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.
|
|
2489
|
+
version = "0.8.41"
|
|
2483
2490
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
2484
|
-
checksum = "
|
|
2491
|
+
checksum = "3545ea9e86d12ab9bba9fcd99b54c1556fd3199007def5a03c375623d05fac1c"
|
|
2485
2492
|
dependencies = [
|
|
2486
2493
|
"proc-macro2",
|
|
2487
2494
|
"quote",
|
|
@@ -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::
|
|
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
|
|
137
|
+
/// Algorithm:
|
|
136
138
|
/// 1. If a checkpoint exists at that version → return its `data` directly.
|
|
137
|
-
/// 2. Otherwise find the
|
|
138
|
-
///
|
|
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
|
-
|
|
187
|
+
// 4) Load the target delta's changeset
|
|
188
|
+
let target_changeset: Vec<u8> = c
|
|
185
189
|
.query_row(
|
|
186
|
-
"SELECT
|
|
187
|
-
[
|
|
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
|
-
//
|
|
193
|
-
let
|
|
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
|
-
//
|
|
433
|
-
let
|
|
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
|
-
|
|
454
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
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<
|
|
124
|
+
pub fn get_external_file(buf: &[u8], file_id: &str) -> ParseResult<Option<DucExternalFile>> {
|
|
125
125
|
let conn = load_db_bytes(buf)?;
|
|
126
|
-
let
|
|
127
|
-
"SELECT
|
|
128
|
-
)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 (
|
|
151
|
-
pub fn list_external_files(buf: &[u8]) -> ParseResult<Vec<
|
|
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
|
|
154
|
-
"SELECT
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
mime_type
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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,
|
|
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
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
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,
|
|
1126
|
+
files: &Option<std::collections::HashMap<String, DucExternalFile>>,
|
|
1127
1127
|
) -> SerializeResult<()> {
|
|
1128
1128
|
let Some(files) = files else { return Ok(()) };
|
|
1129
|
-
let mut
|
|
1130
|
-
"INSERT OR REPLACE INTO external_files (id,
|
|
1131
|
-
VALUES (?1, ?2, ?3, ?4
|
|
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,
|
|
1134
|
-
|
|
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
|
-
///
|
|
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
|
|
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
|
|
1478
|
-
pub mime_type: String,
|
|
1491
|
+
pub struct DucExternalFile {
|
|
1479
1492
|
pub id: String,
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
pub
|
|
1483
|
-
|
|
1484
|
-
pub
|
|
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
|
|
1496
|
-
pub
|
|
1497
|
-
pub
|
|
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<
|
|
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<
|
|
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
|
|
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
|