Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .agents/skills/duc-schema-changes/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,9 @@ If the schema itself changed (new columns, tables, type modifications), create a
2. Create a new file on top of the last one, e.g. `3000015_to_3000016.sql`
3. Make destructive/rebuild migrations transactional and backfill data before dropping source tables
4. Test the migration against a database created with the previous schema; verify payload bytes/lengths, row counts, `PRAGMA foreign_key_check`, and the final `PRAGMA user_version`
5. If existing readers cannot open the new file format or a published API is removed, evaluate a major schema version rather than a patch increment
6. When promoting a prerelease schema to a major version, retain its natural sequential migration, add a final explicit major-version bridge, and scan checked-in fixtures for every represented `user_version`
5. When repairing a migration that may already have failed in the field, clean up its known partial DDL artifacts and test retrying from that partial state
6. If existing readers cannot open the new file format or a published API is removed, evaluate a major schema version rather than a patch increment
7. When promoting a prerelease schema to a major version, retain its natural sequential migration, add a final explicit major-version bridge, and scan checked-in fixtures for every represented `user_version`

```bash
ls -1 duc/schema/migrations/ | sort | tail -5 # see last migrations
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/deploy-api-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ jobs:
fi
echo "Resolved ducrs version: $TAG_VERSION"
if [ -n "$TAG_VERSION" ] && [ "$TAG_VERSION" != "undefined" ]; then
node "../../scripts/cargo-set-pkg-version.js" "packages/ducrs/Cargo.toml" "$TAG_VERSION"
node "../../scripts/cargo-set-pkg-version.js" "Cargo.toml" "$TAG_VERSION"
fi
cargo doc --no-deps
working-directory: packages/ducrs
Expand Down
68 changes: 38 additions & 30 deletions packages/ducjs/crate/src/bin/wasi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,7 @@ fn run_state_json(
.map_err(|e| format!("parse state JSON: {e}"))?;

eprintln!("Opening database: {db_path}");
let mut doc = DucDocument::open(&db_path)
.map_err(|e| format!("open failed: {e}"))?;
let mut doc = DucDocument::open(&db_path).map_err(|e| format!("open failed: {e}"))?;
doc.write_document_state(&state)
.map_err(|e| format!("write state failed: {e}"))?;

Expand All @@ -77,7 +76,10 @@ fn run_state_json(
for entry in &entries {
stream_file_data(&mut doc, entry, &mut total_bytes)?;
}
eprintln!("Streamed {} external revisions, {total_bytes} bytes total.", entries.len());
eprintln!(
"Streamed {} external revisions, {total_bytes} bytes total.",
entries.len()
);
}
doc.checkpoint_wal()
.map_err(|e| format!("checkpoint failed: {e}"))?;
Expand All @@ -97,15 +99,14 @@ fn run(output_path: &str, manifest_path: Option<String>) -> Result<(), String> {
let _ = fs::remove_file(&db_path);

eprintln!("Opening database: {db_path}");
let mut doc = DucDocument::open(&db_path)
.map_err(|e| format!("open failed: {e}"))?;
let mut doc = DucDocument::open(&db_path).map_err(|e| format!("open failed: {e}"))?;

eprintln!("Database opened and bootstrapped.");

let entries: Vec<ManifestEntry> = match manifest_path {
Some(ref path) => {
let file = fs::File::open(path)
.map_err(|e| format!("cannot open manifest {path}: {e}"))?;
let file =
fs::File::open(path).map_err(|e| format!("cannot open manifest {path}: {e}"))?;
read_manifest(file)?
}
None => {
Expand All @@ -131,19 +132,24 @@ fn run(output_path: &str, manifest_path: Option<String>) -> Result<(), String> {
doc.checkpoint_wal()
.map_err(|e| format!("checkpoint failed: {e}"))?;

let db_size = doc.db_size_bytes()
let db_size = doc
.db_size_bytes()
.map_err(|e| format!("db_size_bytes failed: {e}"))?;
eprintln!("Database size: {db_size} bytes ({:.2} GB)", db_size as f64 / (1024.0 * 1024.0 * 1024.0));
eprintln!(
"Database size: {db_size} bytes ({:.2} GB)",
db_size as f64 / (1024.0 * 1024.0 * 1024.0)
);

drop(doc);

eprintln!("Compressing to .duc (gzip)...");
compress_to_gzip(&db_path, output_path)?;

let compressed_size = fs::metadata(output_path)
.map(|m| m.len())
.unwrap_or(0);
eprintln!("Compressed: {compressed_size} bytes ({:.2} GB)", compressed_size as f64 / (1024.0 * 1024.0 * 1024.0));
let compressed_size = fs::metadata(output_path).map(|m| m.len()).unwrap_or(0);
eprintln!(
"Compressed: {compressed_size} bytes ({:.2} GB)",
compressed_size as f64 / (1024.0 * 1024.0 * 1024.0)
);

let _ = fs::remove_file(&db_path);

Expand Down Expand Up @@ -187,9 +193,10 @@ fn stream_file(
total_bytes: &mut u64,
) -> Result<(), String> {
let path = Path::new(&entry.source_path);
let file = fs::File::open(path)
.map_err(|e| format!("cannot open {}: {e}", entry.source_path))?;
let file_size = file.metadata()
let file =
fs::File::open(path).map_err(|e| format!("cannot open {}: {e}", entry.source_path))?;
let file_size = file
.metadata()
.map(|m| m.len() as i64)
.unwrap_or(entry.size_bytes);

Expand Down Expand Up @@ -230,7 +237,8 @@ fn stream_file(
let mut offset: i64 = 0;

loop {
let bytes_read = reader.read(&mut chunk)
let bytes_read = reader
.read(&mut chunk)
.map_err(|e| format!("read chunk {chunk_index}: {e}"))?;
if bytes_read == 0 {
break;
Expand Down Expand Up @@ -266,8 +274,8 @@ fn stream_file_data(
total_bytes: &mut u64,
) -> Result<(), String> {
let path = Path::new(&entry.source_path);
let file = fs::File::open(path)
.map_err(|e| format!("cannot open {}: {e}", entry.source_path))?;
let file =
fs::File::open(path).map_err(|e| format!("cannot open {}: {e}", entry.source_path))?;
doc.clear_external_file_revision_chunks(&entry.revision_id)
.map_err(|e| format!("clear external revision {}: {e}", entry.revision_id))?;

Expand All @@ -276,7 +284,8 @@ fn stream_file_data(
let mut chunk_index: i64 = 0;
let mut offset: i64 = 0;
loop {
let bytes_read = reader.read(&mut chunk)
let bytes_read = reader
.read(&mut chunk)
.map_err(|e| format!("read chunk {chunk_index}: {e}"))?;
if bytes_read == 0 {
break;
Expand Down Expand Up @@ -306,10 +315,8 @@ fn compress_to_gzip(input_path: &str, output_path: &str) -> Result<(), String> {
use flate2::write::GzEncoder;
use flate2::Compression;

let input = fs::File::open(input_path)
.map_err(|e| format!("open input: {e}"))?;
let output = fs::File::create(output_path)
.map_err(|e| format!("create output: {e}"))?;
let input = fs::File::open(input_path).map_err(|e| format!("open input: {e}"))?;
let output = fs::File::create(output_path).map_err(|e| format!("create output: {e}"))?;

let reader = BufReader::new(input);
let writer = BufWriter::new(output);
Expand All @@ -318,15 +325,16 @@ fn compress_to_gzip(input_path: &str, output_path: &str) -> Result<(), String> {
let mut buf = vec![0u8; 1024 * 1024];
let mut reader = reader;
loop {
let n = reader.read(&mut buf)
.map_err(|e| format!("read: {e}"))?;
if n == 0 { break; }
encoder.write_all(&buf[..n])
let n = reader.read(&mut buf).map_err(|e| format!("read: {e}"))?;
if n == 0 {
break;
}
encoder
.write_all(&buf[..n])
.map_err(|e| format!("write: {e}"))?;
}

encoder.finish()
.map_err(|e| format!("gzip finish: {e}"))?;
encoder.finish().map_err(|e| format!("gzip finish: {e}"))?;

Ok(())
}
Expand Down
47 changes: 8 additions & 39 deletions packages/ducjs/crate/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,11 @@ impl DucOpfsDocument {
/// committed data. JavaScript can gzip successive chunks without loading
/// the whole database into WASM memory.
#[wasm_bindgen(js_name = "exportDbChunk")]
pub async fn export_db_chunk(&self, offset_bytes: f64, length: u32) -> Result<Vec<u8>, JsError> {
pub async fn export_db_chunk(
&self,
offset_bytes: f64,
length: u32,
) -> Result<Vec<u8>, JsError> {
if !offset_bytes.is_finite()
|| offset_bytes < 0.0
|| offset_bytes.fract() != 0.0
Expand Down Expand Up @@ -538,17 +542,7 @@ fn optional_bytes_to_js(data: Option<Vec<u8>>) -> Result<JsValue, JsError> {
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
#[wasm_bindgen(js_name = "parseDuc")]
pub fn parse_duc(buf: &[u8]) -> Result<JsValue, JsError> {
let state = duc::parse::parse_duc_bytes(buf)
.map_err(|e| JsError::new(&format!("{e}")))?;
to_js(&state)
}

/// Parse a `.duc` file lazily — returns everything EXCEPT external file data blobs.
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
#[wasm_bindgen(js_name = "parseDucLazy")]
pub fn parse_duc_lazy(buf: &[u8]) -> Result<JsValue, JsError> {
let state = duc::parse::parse_duc_bytes_lazy(buf)
.map_err(|e| JsError::new(&format!("{e}")))?;
let state = duc::parse::parse_duc_bytes(buf).map_err(|e| JsError::new(&format!("{e}")))?;
to_js(&state)
}

Expand All @@ -557,33 +551,8 @@ pub fn parse_duc_lazy(buf: &[u8]) -> Result<JsValue, JsError> {
#[wasm_bindgen(js_name = "serializeDuc")]
pub fn serialize_duc(data: JsValue) -> Result<Vec<u8>, JsError> {
let state: duc::types::ExportedDataState =
serde_wasm_bindgen::from_value(data)
.map_err(|e| JsError::new(&format!("{e}")))?;
duc::serialize::serialize_duc_to_bytes(&state)
.map_err(|e| JsError::new(&format!("{e}")))
}

/// Fetch a single external file from a `.duc` buffer by file ID.
///
/// Returns the file's binary data as a Uint8Array, or `undefined` if not found.
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
#[wasm_bindgen(js_name = "getExternalFile")]
pub fn get_external_file(buf: &[u8], file_id: &str) -> Result<JsValue, JsError> {
let entry = duc::parse::get_external_file_from_bytes(buf, file_id)
.map_err(|e| JsError::new(&format!("{e}")))?;
match entry {
Some(data) => Ok(js_sys::Uint8Array::from(data.as_slice()).into()),
None => Ok(JsValue::UNDEFINED),
}
}

/// List metadata for all external files (without loading the heavy data blobs).
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
#[wasm_bindgen(js_name = "listExternalFiles")]
pub fn list_external_files(buf: &[u8]) -> Result<JsValue, JsError> {
let meta = duc::parse::list_external_files_from_bytes(buf)
.map_err(|e| JsError::new(&format!("{e}")))?;
to_js(&meta)
serde_wasm_bindgen::from_value(data).map_err(|e| JsError::new(&format!("{e}")))?;
duc::serialize::serialize_duc_to_bytes(&state).map_err(|e| JsError::new(&format!("{e}")))
}

/// Restore the document state at `version_number` from a `.duc` file buffer.
Expand Down
1 change: 0 additions & 1 deletion packages/ducjs/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export * from "./enums";
export * from "./lazy-files";
export * from "./opfs";
export * from "./opfs-import";
export * from "./restore";
Expand Down
Loading
Loading