ducpy 3.0.3__tar.gz → 3.0.5__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.3 → ducpy-3.0.5}/Cargo.lock +8 -1
  2. {ducpy-3.0.3 → ducpy-3.0.5}/PKG-INFO +1 -1
  3. {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducpy/crate/Cargo.toml +1 -1
  4. {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/Cargo.toml +1 -0
  5. {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/api/version_control.rs +150 -50
  6. {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/parse.rs +2 -2
  7. {ducpy-3.0.3 → ducpy-3.0.5}/Cargo.toml +0 -0
  8. {ducpy-3.0.3 → ducpy-3.0.5}/LICENSE +0 -0
  9. {ducpy-3.0.3 → ducpy-3.0.5}/README.md +0 -0
  10. {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducpy/crate/src/lib.rs +0 -0
  11. {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/.gitignore +0 -0
  12. {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/LICENSE +0 -0
  13. {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/README.md +0 -0
  14. {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/build.rs +0 -0
  15. {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/package.json +0 -0
  16. {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/release.config.cjs +0 -0
  17. {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/api/document.rs +0 -0
  18. {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/api/meta.rs +0 -0
  19. {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/api/mod.rs +0 -0
  20. {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/db/bootstrap.rs +0 -0
  21. {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/db/mod.rs +0 -0
  22. {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/db/native.rs +0 -0
  23. {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/db/wasm.rs +0 -0
  24. {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/lib.rs +0 -0
  25. {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/serialize.rs +0 -0
  26. {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/types.rs +0 -0
  27. {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/tests/.gitignore +0 -0
  28. {ducpy-3.0.3 → ducpy-3.0.5}/pyproject.toml +0 -0
  29. {ducpy-3.0.3 → ducpy-3.0.5}/src/ducpy_native/__init__.py +0 -0
@@ -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.3"
414
+ version = "3.0.5"
414
415
  dependencies = [
415
416
  "duc",
416
417
  "pyo3",
@@ -570,6 +571,12 @@ 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
582
  version = "0.14.9"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ducpy
3
- Version: 3.0.3
3
+ Version: 3.0.5
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.3"
3
+ version = "3.0.5"
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"
@@ -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).
@@ -223,12 +223,12 @@ fn load_db_bytes(buf: &[u8]) -> ParseResult<Connection> {
223
223
  }
224
224
 
225
225
  #[inline]
226
- fn is_sqlite_header(buf: &[u8]) -> bool {
226
+ pub fn is_sqlite_header(buf: &[u8]) -> bool {
227
227
  buf.len() >= SQLITE_HEADER_MAGIC.len() && &buf[..SQLITE_HEADER_MAGIC.len()] == SQLITE_HEADER_MAGIC
228
228
  }
229
229
 
230
230
  /// Inflate a compressed deflate payload.
231
- fn decompress_duc_bytes(compressed: &[u8]) -> ParseResult<Vec<u8>> {
231
+ pub fn decompress_duc_bytes(compressed: &[u8]) -> ParseResult<Vec<u8>> {
232
232
  use flate2::read::DeflateDecoder;
233
233
  use std::io::Read;
234
234
 
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