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.
- {ducpy-3.0.3 → ducpy-3.0.5}/Cargo.lock +8 -1
- {ducpy-3.0.3 → ducpy-3.0.5}/PKG-INFO +1 -1
- {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducpy/crate/Cargo.toml +1 -1
- {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/Cargo.toml +1 -0
- {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/api/version_control.rs +150 -50
- {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/parse.rs +2 -2
- {ducpy-3.0.3 → ducpy-3.0.5}/Cargo.toml +0 -0
- {ducpy-3.0.3 → ducpy-3.0.5}/LICENSE +0 -0
- {ducpy-3.0.3 → ducpy-3.0.5}/README.md +0 -0
- {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducpy/crate/src/lib.rs +0 -0
- {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/.gitignore +0 -0
- {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/LICENSE +0 -0
- {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/README.md +0 -0
- {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/build.rs +0 -0
- {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/package.json +0 -0
- {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/release.config.cjs +0 -0
- {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/api/document.rs +0 -0
- {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/api/meta.rs +0 -0
- {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/api/mod.rs +0 -0
- {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/db/bootstrap.rs +0 -0
- {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/db/mod.rs +0 -0
- {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/db/native.rs +0 -0
- {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/db/wasm.rs +0 -0
- {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/lib.rs +0 -0
- {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/serialize.rs +0 -0
- {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/src/types.rs +0 -0
- {ducpy-3.0.3 → ducpy-3.0.5}/packages/ducrs/tests/.gitignore +0 -0
- {ducpy-3.0.3 → ducpy-3.0.5}/pyproject.toml +0 -0
- {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.
|
|
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"
|
|
@@ -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).
|
|
@@ -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
|
|
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
|