diff --git a/.agents/skills/duc-schema-changes/SKILL.md b/.agents/skills/duc-schema-changes/SKILL.md index c9d1d439..76b1307f 100644 --- a/.agents/skills/duc-schema-changes/SKILL.md +++ b/.agents/skills/duc-schema-changes/SKILL.md @@ -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 diff --git a/.github/workflows/deploy-api-docs.yml b/.github/workflows/deploy-api-docs.yml index 1b1f2752..dd6f1bc5 100644 --- a/.github/workflows/deploy-api-docs.yml +++ b/.github/workflows/deploy-api-docs.yml @@ -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 diff --git a/packages/ducjs/crate/src/bin/wasi.rs b/packages/ducjs/crate/src/bin/wasi.rs index 564a43ee..e2c4716f 100644 --- a/packages/ducjs/crate/src/bin/wasi.rs +++ b/packages/ducjs/crate/src/bin/wasi.rs @@ -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}"))?; @@ -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}"))?; @@ -97,15 +99,14 @@ fn run(output_path: &str, manifest_path: Option) -> 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 = 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 => { @@ -131,19 +132,24 @@ fn run(output_path: &str, manifest_path: Option) -> 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); @@ -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); @@ -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; @@ -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))?; @@ -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; @@ -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); @@ -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(()) } diff --git a/packages/ducjs/crate/src/lib.rs b/packages/ducjs/crate/src/lib.rs index 21ccf1ca..1944eb5d 100644 --- a/packages/ducjs/crate/src/lib.rs +++ b/packages/ducjs/crate/src/lib.rs @@ -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, JsError> { + pub async fn export_db_chunk( + &self, + offset_bytes: f64, + length: u32, + ) -> Result, JsError> { if !offset_bytes.is_finite() || offset_bytes < 0.0 || offset_bytes.fract() != 0.0 @@ -538,17 +542,7 @@ fn optional_bytes_to_js(data: Option>) -> Result { #[cfg(all(target_family = "wasm", target_os = "unknown"))] #[wasm_bindgen(js_name = "parseDuc")] pub fn parse_duc(buf: &[u8]) -> Result { - 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 { - 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) } @@ -557,33 +551,8 @@ pub fn parse_duc_lazy(buf: &[u8]) -> Result { #[wasm_bindgen(js_name = "serializeDuc")] pub fn serialize_duc(data: JsValue) -> Result, 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 { - 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 { - 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. diff --git a/packages/ducjs/src/index.ts b/packages/ducjs/src/index.ts index b3e8900d..72a88633 100644 --- a/packages/ducjs/src/index.ts +++ b/packages/ducjs/src/index.ts @@ -1,5 +1,4 @@ export * from "./enums"; -export * from "./lazy-files"; export * from "./opfs"; export * from "./opfs-import"; export * from "./restore"; diff --git a/packages/ducjs/src/lazy-files.ts b/packages/ducjs/src/lazy-files.ts deleted file mode 100644 index 1a0f5ae9..00000000 --- a/packages/ducjs/src/lazy-files.ts +++ /dev/null @@ -1,266 +0,0 @@ -import type { DucExternalFile, DucExternalFiles, ExternalFileId, ExternalFileLoaded, ResolvedFileData, ExternalFilesData } from "./types"; -import { wasmGetExternalFile, wasmListExternalFiles } from "./wasm"; - -export type LazyFileMetadata = { - id: string; - mimeType: string; - created: number; - lastRetrieved?: number; - version?: number; -}; - -/** - * Provides lazy access to external files embedded inside a `.duc` buffer. - * - * Instead of loading all file blobs into memory at parse time, this store - * keeps a reference to the raw `.duc` buffer and fetches individual files - * on demand via WASM calls. - */ -export class LazyExternalFileStore { - private buffer: Uint8Array | null; - private metadataCache: Map | null = null; - private runtimeFiles: Map = new Map(); - private released = false; - - constructor(buffer: Uint8Array) { - this.buffer = buffer.byteLength > 0 ? buffer : null; - } - - get isReleased(): boolean { - return this.released; - } - - get size(): number { - return this.getMetadataMap().size + this.runtimeFiles.size; - } - - /** Check if a file with this ID exists. */ - has(fileId: string): boolean { - return this.runtimeFiles.has(fileId) || this.getMetadataMap().has(fileId); - } - - /** Get metadata (without data blob) for a specific file. */ - getMetadata(fileId: string): LazyFileMetadata | undefined { - const rt = this.runtimeFiles.get(fileId); - if (rt) { - const active = rt.revisions[rt.activeRevisionId]; - if (!active) return undefined; - return { - id: rt.id, - mimeType: active.mimeType, - created: active.created, - lastRetrieved: active.lastRetrieved, - version: rt.version, - }; - } - return this.getMetadataMap().get(fileId); - } - - /** Get metadata for all files. */ - getAllMetadata(): LazyFileMetadata[] { - const result: LazyFileMetadata[] = []; - const persisted = this.getMetadataMap(); - for (const meta of persisted.values()) { - result.push(meta); - } - for (const [id, file] of this.runtimeFiles) { - if (!persisted.has(id)) { - const active = file.revisions[file.activeRevisionId]; - if (active) { - result.push({ - id: file.id, - mimeType: active.mimeType, - created: active.created, - lastRetrieved: active.lastRetrieved, - version: file.version, - }); - } - } - } - return result; - } - - /** Fetch the full file (including data blobs for all revisions) for a specific file. */ - getFile(fileId: string): ExternalFileLoaded | null { - const rt = this.runtimeFiles.get(fileId); - if (rt) return rt; - - if (!this.buffer) return null; - const result = wasmGetExternalFile(this.buffer, fileId); - if (!result) return null; - - return result as ExternalFileLoaded; - } - - /** Get the active revision data for a specific file. */ - getFileData(fileId: string): ResolvedFileData | null { - const loaded = this.getFile(fileId); - if (!loaded) return null; - const meta = loaded.revisions[loaded.activeRevisionId]; - if (!meta) return null; - const dataBlob = loaded.data[loaded.activeRevisionId]; - if (!dataBlob) return null; - return { data: dataBlob, mimeType: meta.mimeType }; - } - - /** Fetch active revision data and return a copy of the data buffer (safe for transfer). */ - getFileDataCopy(fileId: string): ResolvedFileData | null { - const data = this.getFileData(fileId); - if (!data) return null; - return { - ...data, - data: new Uint8Array(data.data), - }; - } - - /** Add a file at runtime (not persisted in .duc until next serialize). */ - addRuntimeFile(fileId: string, file: DucExternalFile, data: Record): void { - this.runtimeFiles.set(fileId, { ...file, data }); - } - - /** Remove a runtime file. */ - removeRuntimeFile(fileId: string): boolean { - return this.runtimeFiles.delete(fileId); - } - - /** Unload a runtime file's data from memory while keeping metadata. */ - unloadRuntimeFileData(fileId: string): boolean { - const file = this.runtimeFiles.get(fileId); - if (file) { - file.data = {}; - return true; - } - return false; - } - - /** Export all files metadata as a DucExternalFiles record. */ - toExternalFiles(): DucExternalFiles { - const result: DucExternalFiles = {}; - - if (this.buffer) { - for (const [id] of this.getMetadataMap()) { - const loaded = this.getFile(id); - if (loaded) { - const { data: _, ...file } = loaded; - result[id as ExternalFileId] = file; - } - } - } - - for (const [id, loaded] of this.runtimeFiles) { - const { data: _, ...file } = loaded; - result[id as ExternalFileId] = file; - } - - return result; - } - - /** Export all revision data blobs as an ExternalFilesData record. */ - toExternalFilesData(): ExternalFilesData { - const result: ExternalFilesData = {}; - - if (this.buffer) { - for (const [id] of this.getMetadataMap()) { - const loaded = this.getFile(id); - if (loaded) { - for (const [revId, blob] of Object.entries(loaded.data)) { - result[revId] = blob; - } - } - } - } - - for (const [, loaded] of this.runtimeFiles) { - for (const [revId, blob] of Object.entries(loaded.data)) { - result[revId] = blob; - } - } - - return result; - } - - /** Merge files from another source. Adds missing files and merges new revisions into existing ones. */ - mergeFiles(files: DucExternalFiles, filesData?: ExternalFilesData): void { - for (const [id, file] of Object.entries(files)) { - const existing = this.runtimeFiles.get(id) ?? this.getFile(id); - - if (!existing) { - const dataMap: Record = {}; - if (filesData) { - for (const revId of Object.keys(file.revisions)) { - if (filesData[revId]) { - dataMap[revId] = filesData[revId]; - } - } - } - this.runtimeFiles.set(id, { ...file, data: dataMap }); - continue; - } - - let merged = false; - const mergedRevisions = { ...existing.revisions }; - const mergedData = { ...existing.data }; - for (const [revId, rev] of Object.entries(file.revisions)) { - if (!mergedRevisions[revId]) { - mergedRevisions[revId] = rev; - if (filesData?.[revId]) { - mergedData[revId] = filesData[revId]; - } - merged = true; - continue; - } - - if (filesData?.[revId] && mergedData[revId] !== filesData[revId]) { - mergedData[revId] = filesData[revId]; - merged = true; - } - - if ( - rev.sizeBytes !== mergedRevisions[revId].sizeBytes || - rev.lastRetrieved !== mergedRevisions[revId].lastRetrieved || - rev.created !== mergedRevisions[revId].created || - rev.mimeType !== mergedRevisions[revId].mimeType || - rev.sourceName !== mergedRevisions[revId].sourceName || - rev.message !== mergedRevisions[revId].message - ) { - mergedRevisions[revId] = rev; - merged = true; - } - } - - const activeRevisionChanged = file.activeRevisionId !== existing.activeRevisionId; - if (merged || activeRevisionChanged || file.updated > existing.updated) { - this.runtimeFiles.set(id, { - ...existing, - activeRevisionId: file.activeRevisionId, - updated: Math.max(file.updated, existing.updated), - version: Math.max(file.version ?? 0, existing.version ?? 0), - revisions: mergedRevisions, - data: mergedData, - }); - } - } - } - - /** Release the underlying buffer to free memory. */ - release(): void { - this.buffer = null; - this.metadataCache = null; - this.released = true; - } - - private getMetadataMap(): Map { - if (!this.metadataCache) { - this.metadataCache = new Map(); - if (this.buffer) { - const metas = wasmListExternalFiles(this.buffer) as LazyFileMetadata[]; - if (metas) { - for (const meta of metas) { - this.metadataCache.set(meta.id, meta); - } - } - } - } - return this.metadataCache; - } -} diff --git a/packages/ducjs/src/opfs.ts b/packages/ducjs/src/opfs.ts index 4ac4082f..7d2a351e 100644 --- a/packages/ducjs/src/opfs.ts +++ b/packages/ducjs/src/opfs.ts @@ -158,6 +158,26 @@ export class BrowserDucDocument { return this.document.listExternalFiles(); } + readCheckpointDataChunk( + checkpointId: string, + chunkIndex: number, + ): Uint8Array | undefined { + return this.document.readCheckpointDataChunk( + checkpointId, + chunkIndex, + ) as Uint8Array | undefined; + } + + readDeltaChangesetChunk( + deltaId: string, + chunkIndex: number, + ): Uint8Array | undefined { + return this.document.readDeltaChangesetChunk( + deltaId, + chunkIndex, + ) as Uint8Array | undefined; + } + clearExternalFileRevisionChunks(revisionId: string): void { this.document.clearExternalFileRevisionChunks(revisionId); } diff --git a/packages/ducjs/src/parse.ts b/packages/ducjs/src/parse.ts index 547ec016..5baa8899 100644 --- a/packages/ducjs/src/parse.ts +++ b/packages/ducjs/src/parse.ts @@ -1,18 +1,10 @@ import type { ElementsConfig, RestoreConfig, RestoredDataState } from "./restore"; import { restoreParsedData } from "./restore-parsed"; -import type { DucExternalFiles, ExportedDataState, ExternalFilesData } from "./types"; -import { ensureWasm, wasmParseDuc, wasmParseDucLazy } from "./wasm"; +import type { ExportedDataState } from "./types"; +import { ensureWasm, wasmParseDuc } from "./wasm"; export type { RestoredDataState }; -export type LazyRestoredDataState = RestoredDataState & { - lazyFileStore: LazyExternalFileStore; -}; - -// Re-export from lazy-files for backwards compatibility -import { LazyExternalFileStore } from "./lazy-files"; -export { LazyExternalFileStore }; - /** * Parse a `.duc` file (Blob/File) into a RestoredDataState. * @@ -51,47 +43,3 @@ export async function parseDuc( return restoreParsedData(raw, elementsConfig, restoreConfig); } - -/** - * Parse a `.duc` file lazily — returns everything EXCEPT external file data blobs. - * Use `LazyExternalFileStore` for on-demand file access. - */ -export async function parseDucLazy( - buffer: Uint8Array, - elementsConfig?: ElementsConfig, - restoreConfig?: RestoreConfig, -): Promise { - await ensureWasm(); - - if (buffer.byteLength === 0) { - throw new Error(`[parseDucLazy] buffer too small (${buffer.byteLength} bytes) — not a valid .duc file`); - } - - const header = new TextDecoder().decode(buffer.slice(0, 15)); - - let raw: ExportedDataState; - try { - raw = wasmParseDucLazy(buffer) as ExportedDataState; - } catch (error) { - const prefixHex = Array.from(buffer.slice(0, 16)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(" "); - throw new Error( - `[parseDucLazy] wasm parse failed (size=${buffer.byteLength}, header="${header}", prefix=${prefixHex}): ${error instanceof Error ? error.message : String(error)}`, - ); - } - - const lazyFileStore = new LazyExternalFileStore(buffer); - const files: DucExternalFiles = {}; - const filesData: ExternalFilesData = {}; - - const restored = restoreParsedData( - raw, - elementsConfig, - restoreConfig, - { files, filesData }, - ) as LazyRestoredDataState; - restored.lazyFileStore = lazyFileStore; - - return restored; -} diff --git a/packages/ducjs/src/wasm.ts b/packages/ducjs/src/wasm.ts index c9929e37..c1dcae5a 100644 --- a/packages/ducjs/src/wasm.ts +++ b/packages/ducjs/src/wasm.ts @@ -5,10 +5,7 @@ import init, { createDeltaChangeset as _createDeltaChangeset, getCurrentSchemaVersion as _getCurrentSchemaVersion, parseDuc as _parseDuc, - parseDucLazy as _parseDucLazy, serializeDuc as _serializeDuc, - getExternalFile as _getExternalFile, - listExternalFiles as _listExternalFiles, restoreVersion as _restoreVersion, restoreCheckpoint as _restoreCheckpoint, listVersions as _listVersions, @@ -101,10 +98,7 @@ export const DucOpfsImporter = _DucOpfsImporter; // Byte-buffer compatibility API export const wasmParseDuc = _parseDuc; -export const wasmParseDucLazy = _parseDucLazy; export const wasmSerializeDuc = _serializeDuc; -export const wasmGetExternalFile = _getExternalFile; -export const wasmListExternalFiles = _listExternalFiles; export const wasmRestoreVersion = _restoreVersion; export const wasmRestoreCheckpoint = _restoreCheckpoint; export const wasmListVersions = _listVersions; diff --git a/packages/ducjs/tests/serialize-parse.test.ts b/packages/ducjs/tests/serialize-parse.test.ts index f403646c..d5626328 100644 --- a/packages/ducjs/tests/serialize-parse.test.ts +++ b/packages/ducjs/tests/serialize-parse.test.ts @@ -8,17 +8,6 @@ import { importDucStreamToOpfs } from "../src/opfs-import"; import { parseDuc } from "../src/parse"; describe("DUC streaming API", () => { - test("treats an empty lazy-file buffer as a runtime-only store", () => { - const store = new ducjs.LazyExternalFileStore(new Uint8Array()); - - expect(store.isReleased).toBe(false); - expect(store.size).toBe(0); - expect(store.toExternalFiles()).toEqual({}); - expect(store.toExternalFilesData()).toEqual({}); - store.release(); - expect(store.isReleased).toBe(true); - }); - test("parses a standalone raw SQLite database exported with WAL header bytes", async () => { const fixture = join( import.meta.dir, @@ -41,6 +30,7 @@ describe("DUC streaming API", () => { expect("parseDuc" in ducjs).toBe(false); expect("parseDucLazy" in ducjs).toBe(false); expect("serializeDuc" in ducjs).toBe(false); + expect("LazyExternalFileStore" in ducjs).toBe(false); const methodNames = [ "readDocumentState", @@ -61,6 +51,29 @@ describe("DUC streaming API", () => { for (const methodName of methodNames) { expect(methodName in ducjs.DucOpfsDocument.prototype).toBe(true); } + + expect("readCheckpointDataChunk" in ducjs.BrowserDucDocument.prototype).toBe(true); + expect("readDeltaChangesetChunk" in ducjs.BrowserDucDocument.prototype).toBe(true); + }); + + test("forwards version artifact chunk reads through BrowserDucDocument", () => { + const checkpointChunk = new Uint8Array([1, 2, 3]); + const deltaChunk = new Uint8Array([4, 5]); + const handle = { + readCheckpointDataChunk: (id: string, index: number) => + id === "checkpoint-1" && index === 0 ? checkpointChunk : undefined, + readDeltaChangesetChunk: (id: string, index: number) => + id === "delta-1" && index === 0 ? deltaChunk : undefined, + }; + const document = new (ducjs.BrowserDucDocument as unknown as new ( + handle: typeof handle, + chunkSize: number, + ) => ducjs.BrowserDucDocument)(handle, 1024); + + expect(document.readCheckpointDataChunk("checkpoint-1", 0)).toEqual(checkpointChunk); + expect(document.readCheckpointDataChunk("missing", 0)).toBeUndefined(); + expect(document.readDeltaChangesetChunk("delta-1", 0)).toEqual(deltaChunk); + expect(document.readDeltaChangesetChunk("missing", 0)).toBeUndefined(); }); test("streams version payload chunks through the document-facing helpers", async () => { diff --git a/packages/ducpy/src/ducpy/serialize.py b/packages/ducpy/src/ducpy/serialize.py index fdb31278..a118a565 100644 --- a/packages/ducpy/src/ducpy/serialize.py +++ b/packages/ducpy/src/ducpy/serialize.py @@ -335,6 +335,15 @@ def _write_typst_external_files(project_dir: Path, files_meta: Optional[Dict[str target.write_bytes(bytes(data)) +def _format_typst_validation_error(exc: Exception, main_path: Path) -> str: + diagnostic = getattr(exc, "diagnostic", None) + detail = diagnostic.strip() if isinstance(diagnostic, str) and diagnostic.strip() else str(exc) + return detail.replace(str(main_path), "").replace( + main_path.name, + "", + ) + + def _run_typst_validation( code: str, label: str, @@ -359,7 +368,7 @@ def _run_typst_validation( except TypeError: typst.compile(str(main_path)) except Exception as exc: - return f"{label}: Typst validation failed\n{exc}" + return f"{label}: Typst validation failed\n{_format_typst_validation_error(exc, main_path)}" return None diff --git a/packages/ducpy/src/tests/src/test_embedded_code_validation.py b/packages/ducpy/src/tests/src/test_embedded_code_validation.py index 6906ed39..2b6093a5 100644 --- a/packages/ducpy/src/tests/src/test_embedded_code_validation.py +++ b/packages/ducpy/src/tests/src/test_embedded_code_validation.py @@ -114,6 +114,7 @@ def test_typst_validation_failure(): assert "Typst validation failed" in str(excinfo.value) assert "unclosed delimiter" in str(excinfo.value).lower() + assert ":" in str(excinfo.value) def test_build123d_validation_success(test_output_dir): diff --git a/packages/ducrs/src/db/bootstrap.rs b/packages/ducrs/src/db/bootstrap.rs index 6ab2e335..fad204df 100644 --- a/packages/ducrs/src/db/bootstrap.rs +++ b/packages/ducrs/src/db/bootstrap.rs @@ -99,7 +99,17 @@ pub(crate) fn bootstrap(conn: &Connection) -> Result<(), DbError> { // have applied parts of later schemas without bumping user_version, so // we add columns/indexes migrations expect when missing and recreate // objects that already exist to keep the migration SQL idempotent. - normalize_legacy_schema(conn, user_version)?; + conn.execute_batch("SAVEPOINT duc_legacy_normalization") + .map_err(|e| DbError::Bootstrap(format!("legacy normalization begin failed: {e}")))?; + if let Err(error) = normalize_legacy_schema(conn, user_version) { + let _ = conn.execute_batch( + "ROLLBACK TO duc_legacy_normalization; + RELEASE duc_legacy_normalization;", + ); + return Err(error); + } + conn.execute_batch("RELEASE duc_legacy_normalization") + .map_err(|e| DbError::Bootstrap(format!("legacy normalization commit failed: {e}")))?; // Walk the migration chain until we reach CURRENT_VERSION. // build.rs generates MIGRATIONS sorted by from_version, so chaining @@ -141,6 +151,10 @@ pub(crate) fn bootstrap(conn: &Connection) -> Result<(), DbError> { /// while still reporting an older `user_version`. Adding the missing legacy /// columns back lets the canonical migration SQL run without modification. fn normalize_legacy_schema(conn: &Connection, user_version: i64) -> Result<(), DbError> { + if user_version <= 3000001 { + normalize_external_revision_storage(conn)?; + } + // 3000003→3000004 migration expects `element_model.svg_path`. if user_version <= 3000004 { let has_column: bool = conn @@ -166,6 +180,151 @@ fn normalize_legacy_schema(conn: &Connection, user_version: i64) -> Result<(), D Ok(()) } +fn normalize_external_revision_storage(conn: &Connection) -> Result<(), DbError> { + let table_exists = |table: &str| -> rusqlite::Result { + conn.query_row( + "SELECT EXISTS( + SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1 + )", + [table], + |row| row.get(0), + ) + }; + let column_exists = |table: &str, column: &str| -> rusqlite::Result { + conn.query_row( + "SELECT EXISTS( + SELECT 1 FROM pragma_table_info(?1) WHERE name = ?2 + )", + [table, column], + |row| row.get(0), + ) + }; + + if !table_exists("external_file_revisions")? { + return Ok(()); + } + + if !column_exists("external_file_revisions", "data")? { + conn.execute( + "ALTER TABLE external_file_revisions ADD COLUMN data BLOB", + [], + ) + .map_err(|error| { + DbError::Bootstrap(format!( + "legacy normalization: add external_file_revisions.data failed: {error}" + )) + })?; + } + + for source_table in [ + "_external_file_revision_data_v3000001", + "external_file_revision_data", + ] { + if table_exists(source_table)? { + let sql = format!( + "UPDATE external_file_revisions + SET data = ( + SELECT source.data FROM {source_table} AS source + WHERE source.revision_id = external_file_revisions.id + ) + WHERE data IS NULL AND EXISTS ( + SELECT 1 FROM {source_table} AS source + WHERE source.revision_id = external_file_revisions.id + )" + ); + conn.execute(&sql, []).map_err(|error| { + DbError::Bootstrap(format!( + "legacy normalization: restore revision data from {source_table} failed: {error}" + )) + })?; + } + } + + if table_exists("external_file_revision_chunks")? { + let revision_ids = { + let mut statement = conn + .prepare( + "SELECT id FROM external_file_revisions + WHERE data IS NULL + ORDER BY id", + ) + .map_err(|error| { + DbError::Bootstrap(format!( + "legacy normalization: list chunked revisions failed: {error}" + )) + })?; + let rows = statement + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|error| { + DbError::Bootstrap(format!( + "legacy normalization: query chunked revisions failed: {error}" + )) + })?; + rows.collect::>>() + .map_err(|error| { + DbError::Bootstrap(format!( + "legacy normalization: read chunked revisions failed: {error}" + )) + })? + }; + + for revision_id in revision_ids { + let data = crate::external_file_chunks::read_revision_chunks(conn, &revision_id) + .map_err(|error| { + DbError::Bootstrap(format!( + "legacy normalization: read chunks for revision {revision_id} failed: {error}" + )) + })?; + if !data.is_empty() { + conn.execute( + "UPDATE external_file_revisions SET data = ?2 WHERE id = ?1", + rusqlite::params![revision_id, data], + ) + .map_err(|error| { + DbError::Bootstrap(format!( + "legacy normalization: restore chunks for revision failed: {error}" + )) + })?; + } + } + } + + conn.execute( + "UPDATE external_file_revisions + SET data = X'' + WHERE data IS NULL AND size_bytes = 0", + [], + )?; + let missing_data: i64 = conn.query_row( + "SELECT count(*) FROM external_file_revisions + WHERE data IS NULL", + [], + |row| row.get(0), + )?; + if missing_data > 0 { + return Err(DbError::Bootstrap(format!( + "legacy normalization: {missing_data} external file revisions have metadata but no recoverable data" + ))); + } + + for table in [ + "external_file_revision_chunks", + "external_file_revision_data", + "_external_file_revision_data_v3000001", + ] { + if table_exists(table)? { + conn.execute_batch(&format!("DROP TABLE {table}")) + .map_err(|error| { + DbError::Bootstrap(format!( + "legacy normalization: drop superseded {table} failed: {error}" + )) + })?; + } + } + + Ok(()) +} + #[cfg(test)] #[path = "bootstrap_tests.rs"] mod tests; diff --git a/packages/ducrs/src/db/bootstrap_tests.rs b/packages/ducrs/src/db/bootstrap_tests.rs index 6d8e6e3b..1b9308a7 100644 --- a/packages/ducrs/src/db/bootstrap_tests.rs +++ b/packages/ducrs/src/db/bootstrap_tests.rs @@ -143,6 +143,192 @@ fn migration_chain_preserves_the_prerelease_schema_step() { assert_eq!(next_version(3_000_009), Some(4_000_000)); } +#[test] +fn migrates_prerelease_3000001_split_revision_storage_without_data_loss() { + let conn = Connection::open_in_memory().expect("open database"); + conn.execute_batch( + r#" + PRAGMA application_id = 1146569567; + PRAGMA user_version = 3000001; + PRAGMA foreign_keys = ON; + + CREATE TABLE external_files ( + id TEXT PRIMARY KEY, + active_revision_id TEXT NOT NULL, + updated INTEGER NOT NULL, + version INTEGER + ) WITHOUT ROWID; + CREATE TABLE external_file_revisions ( + id TEXT PRIMARY KEY, + file_id TEXT NOT NULL REFERENCES external_files(id) ON DELETE CASCADE, + size_bytes INTEGER NOT NULL DEFAULT 0, + checksum TEXT, + source_name TEXT, + mime_type TEXT NOT NULL, + message TEXT, + created INTEGER NOT NULL, + last_retrieved INTEGER + ) WITHOUT ROWID; + CREATE TABLE external_file_revision_data ( + revision_id TEXT PRIMARY KEY REFERENCES external_file_revisions(id) ON DELETE CASCADE, + data BLOB NOT NULL + ) WITHOUT ROWID; + CREATE TABLE _external_file_revision_data_v3000001 ( + revision_id TEXT PRIMARY KEY, + data BLOB NOT NULL + ) WITHOUT ROWID; + CREATE TABLE external_file_revision_chunks ( + revision_id TEXT NOT NULL REFERENCES external_file_revisions(id) ON DELETE CASCADE, + chunk_index INTEGER NOT NULL, + offset_bytes INTEGER NOT NULL, + size_bytes INTEGER NOT NULL, + data BLOB NOT NULL, + PRIMARY KEY (revision_id, chunk_index) + ) WITHOUT ROWID; + + INSERT INTO external_files (id, active_revision_id, updated) + VALUES ('file-1', 'revision-1', 1), + ('file-2', 'revision-2', 1); + INSERT INTO external_file_revisions ( + id, file_id, size_bytes, mime_type, created + ) VALUES ('revision-1', 'file-1', 4, 'application/octet-stream', 1), + ('revision-2', 'file-2', 4, 'application/octet-stream', 1); + INSERT INTO external_file_revision_data (revision_id, data) + VALUES ('revision-1', X'01020304'); + INSERT INTO external_file_revision_chunks ( + revision_id, chunk_index, offset_bytes, size_bytes, data + ) VALUES ('revision-2', 0, 0, 2, X'0506'), + ('revision-2', 1, 2, 2, X'0708'); + "#, + ) + .expect("create split prerelease schema"); + + normalize_external_revision_storage(&conn).expect("normalize split storage"); + normalize_external_revision_storage(&conn).expect("retry normalized split storage"); + let (_, _, migration) = MIGRATIONS + .iter() + .find(|(from, to, _)| *from == 3_000_001 && *to == 3_000_002) + .expect("find revision split migration"); + conn.execute_batch(migration) + .expect("run canonical migration"); + + let data: Vec = conn + .query_row( + "SELECT data FROM external_file_revision_data WHERE revision_id = 'revision-1'", + [], + |row| row.get(0), + ) + .expect("read migrated revision data"); + assert_eq!(data, vec![1, 2, 3, 4]); + let chunked_data: Vec = conn + .query_row( + "SELECT data FROM external_file_revision_data WHERE revision_id = 'revision-2'", + [], + |row| row.get(0), + ) + .expect("read migrated chunked revision data"); + assert_eq!(chunked_data, vec![5, 6, 7, 8]); + assert_eq!( + conn.pragma_query_value::(None, "user_version", |row| row.get(0)) + .expect("read user version"), + 3_000_002 + ); + assert_eq!( + conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| { + row.get::<_, i64>(0) + },) + .expect("check foreign keys"), + 0 + ); +} + +#[test] +fn table_grid_migration_preserves_tables_without_files() { + let conn = Connection::open_in_memory().expect("open database"); + conn.execute_batch( + r#" + PRAGMA foreign_keys = ON; + PRAGMA user_version = 3000005; + + CREATE TABLE elements ( + id TEXT PRIMARY KEY + ) WITHOUT ROWID; + CREATE TABLE document_grid_config ( + element_id TEXT PRIMARY KEY REFERENCES elements(id) ON DELETE CASCADE, + file_id TEXT, + grid_columns INTEGER NOT NULL DEFAULT 1, + grid_gap_x REAL NOT NULL DEFAULT 0.0, + grid_gap_y REAL NOT NULL DEFAULT 0.0, + grid_first_page_alone INTEGER NOT NULL DEFAULT 0, + grid_scale REAL NOT NULL DEFAULT 1.0 + ) WITHOUT ROWID; + CREATE TABLE element_table ( + element_id TEXT PRIMARY KEY REFERENCES elements(id) ON DELETE CASCADE, + file_id TEXT + ) WITHOUT ROWID; + + INSERT INTO elements (id) VALUES ('table-without-file'), ('table-with-config'); + INSERT INTO element_table (element_id, file_id) + VALUES ('table-without-file', NULL), ('table-with-config', 'legacy-file'); + INSERT INTO document_grid_config ( + element_id, file_id, grid_columns, grid_gap_x, grid_gap_y, + grid_first_page_alone, grid_scale + ) VALUES ('table-with-config', 'current-file', 2, 3.0, 4.0, 1, 0.5); + + CREATE TABLE element_table_new ( + element_id TEXT PRIMARY KEY REFERENCES document_grid_config(element_id) ON DELETE CASCADE + ) WITHOUT ROWID; + INSERT INTO element_table_new (element_id) VALUES ('table-with-config'); + "#, + ) + .expect("create legacy table schema"); + + let (_, _, migration) = MIGRATIONS + .iter() + .find(|(from, to, _)| *from == 3_000_005 && *to == 3_000_006) + .expect("find table grid migration"); + conn.execute_batch(migration) + .expect("migrate table grid config"); + + let migrated_without_file: (Option, i64, f64) = conn + .query_row( + "SELECT file_id, grid_columns, grid_scale + FROM document_grid_config + WHERE element_id = 'table-without-file'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .expect("read migrated table config"); + assert_eq!(migrated_without_file, (None, 1, 1.0)); + + let preserved_config: (Option, i64, f64) = conn + .query_row( + "SELECT file_id, grid_columns, grid_scale + FROM document_grid_config + WHERE element_id = 'table-with-config'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .expect("read existing table config"); + assert_eq!(preserved_config, (Some("current-file".to_string()), 2, 0.5)); + + let migrated_table_count: i64 = conn + .query_row("SELECT count(*) FROM element_table", [], |row| row.get(0)) + .expect("count migrated tables"); + assert_eq!(migrated_table_count, 2); + assert_eq!( + conn.pragma_query_value::(None, "user_version", |row| row.get(0)) + .expect("read user version"), + 3_000_006 + ); + let foreign_key_errors: i64 = conn + .query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + }) + .expect("check foreign keys"); + assert_eq!(foreign_key_errors, 0); +} + #[test] fn migrates_legacy_payloads_losslessly_into_bounded_chunks() { let conn = Connection::open_in_memory().expect("open database"); diff --git a/packages/ducrs/src/db/mod.rs b/packages/ducrs/src/db/mod.rs index fafdf0c5..1841b0df 100644 --- a/packages/ducrs/src/db/mod.rs +++ b/packages/ducrs/src/db/mod.rs @@ -112,10 +112,7 @@ pub async fn open_file_opfs(name: &str) -> DbResult { } #[cfg(all(target_family = "wasm", target_os = "unknown", feature = "opfs"))] -pub async fn open_file_opfs_in_namespace( - name: &str, - namespace: &str, -) -> DbResult { +pub async fn open_file_opfs_in_namespace(name: &str, namespace: &str) -> DbResult { wasm::open_file_opfs_in_namespace(name, Some(namespace)).await } diff --git a/packages/ducrs/src/parse.rs b/packages/ducrs/src/parse.rs index 8c8c07af..247aa681 100644 --- a/packages/ducrs/src/parse.rs +++ b/packages/ducrs/src/parse.rs @@ -2600,10 +2600,7 @@ pub fn list_external_files_from_bytes(buf: &[u8]) -> ParseResult ParseResult>> { +pub fn get_external_file_from_bytes(buf: &[u8], file_id: &str) -> ParseResult>> { let conn = open_duc_bytes_connection(buf)?; // Find the active revision for this file ID. @@ -2622,11 +2619,7 @@ pub fn get_external_file_from_bytes( match revision_id { Some(rev_id) => { let mut chunks = Vec::new(); - external_file_chunks::stream_revision_chunks_to_writer( - &conn, - &rev_id, - &mut chunks, - )?; + external_file_chunks::stream_revision_chunks_to_writer(&conn, &rev_id, &mut chunks)?; Ok(Some(chunks)) } None => Ok(None), diff --git a/packages/ducrs/src/serialize.rs b/packages/ducrs/src/serialize.rs index 31bb069b..98d6c55c 100644 --- a/packages/ducrs/src/serialize.rs +++ b/packages/ducrs/src/serialize.rs @@ -1827,7 +1827,11 @@ pub fn serialize_duc_to_bytes(state: &ExportedDataState) -> SerializeResult SerializeResult = conn.serialize(MAIN_DB) + let raw_sqlite: Vec = conn + .serialize(MAIN_DB) .map_err(|e| SerializeError::Io(format!("serialize: {e}")))? .to_vec(); @@ -1870,7 +1875,8 @@ pub fn serialize_duc_to_bytes(state: &ExportedDataState) -> SerializeResult = conn.serialize(MAIN_DB) + let raw_sqlite: Vec = conn + .serialize(MAIN_DB) .map_err(|e| SerializeError::Io(format!("serialize: {e}")))? .to_vec(); diff --git a/schema/migrations/3000001_to_3000002.sql b/schema/migrations/3000001_to_3000002.sql index a3cde8ae..b572679c 100644 --- a/schema/migrations/3000001_to_3000002.sql +++ b/schema/migrations/3000001_to_3000002.sql @@ -4,6 +4,7 @@ -- loading heavy blobs. PRAGMA foreign_keys = OFF; +BEGIN IMMEDIATE; -- 1. Stage existing data blobs before the parent table is renamed/recreated. CREATE TABLE _external_file_revision_data_v3000001 ( @@ -48,5 +49,6 @@ INSERT INTO external_file_revision_data (revision_id, data) DROP TABLE _ext_revisions_old_v3000001; DROP TABLE _external_file_revision_data_v3000001; +COMMIT; PRAGMA foreign_keys = ON; PRAGMA user_version = 3000002; diff --git a/schema/migrations/3000005_to_3000006.sql b/schema/migrations/3000005_to_3000006.sql index 5c93096c..fad42a87 100644 --- a/schema/migrations/3000005_to_3000006.sql +++ b/schema/migrations/3000005_to_3000006.sql @@ -4,8 +4,10 @@ -- Existing element_table rows are preserved; table-specific columns beyond -- file_id can be dropped once all consumers read from document_grid_config. --- 1. Insert missing document_grid_config rows for every table element that --- currently stores its file_id in element_table. Use default grid values. +BEGIN IMMEDIATE; + +-- 1. Insert missing document_grid_config rows for every table element, +-- including tables without an attached file. Use default grid values. INSERT INTO document_grid_config ( element_id, file_id, @@ -24,11 +26,13 @@ SELECT 0, -- grid_first_page_alone 1.0 -- grid_scale FROM element_table -WHERE file_id IS NOT NULL - AND element_id NOT IN (SELECT element_id FROM document_grid_config); +WHERE element_id NOT IN (SELECT element_id FROM document_grid_config); -- 2. Re-create element_table without the file_id column so it references only -- document_grid_config. This also drops the legacy idx_element_table_file index. +-- Clean up the table an older failed, non-transactional attempt may have left. +DROP TABLE IF EXISTS element_table_new; + CREATE TABLE element_table_new ( element_id TEXT PRIMARY KEY REFERENCES document_grid_config(element_id) ON DELETE CASCADE ) WITHOUT ROWID; @@ -40,3 +44,5 @@ DROP TABLE element_table; ALTER TABLE element_table_new RENAME TO element_table; PRAGMA user_version = 3000006; + +COMMIT; diff --git a/scripts/cargo-set-pkg-version.js b/scripts/cargo-set-pkg-version.js index 62cab704..33c45662 100644 --- a/scripts/cargo-set-pkg-version.js +++ b/scripts/cargo-set-pkg-version.js @@ -17,10 +17,16 @@ if (!cargoTomlPath || !version) { process.exit(1); } -const fullPath = path.resolve(process.cwd(), cargoTomlPath); +const repoRoot = path.resolve(__dirname, ".."); +let fullPath = path.resolve(process.cwd(), cargoTomlPath); if (!fs.existsSync(fullPath)) { - console.error(`File not found: ${fullPath}`); - process.exit(1); + const altPath = path.resolve(repoRoot, cargoTomlPath); + if (fs.existsSync(altPath)) { + fullPath = altPath; + } else { + console.error(`File not found: ${fullPath}`); + process.exit(1); + } } let content = fs.readFileSync(fullPath, "utf8"); @@ -34,3 +40,14 @@ if (!/^version\s*=\s*"[^"]+"/m.test(content)) { content = content.replace(/^version\s*=\s*"[^"]+"/m, `version = "${version}"`); fs.writeFileSync(fullPath, content); console.log(`Updated [package] version to "${version}" in ${fullPath}`); + +// Also update any workspace dependent crates that specify a version requirement for 'duc' (e.g. duc2pdf) +const duc2pdfPath = path.join(repoRoot, "packages/ducpdf/src/duc2pdf/Cargo.toml"); +if (fs.existsSync(duc2pdfPath)) { + let duc2pdfContent = fs.readFileSync(duc2pdfPath, "utf8"); + if (/duc\s*=\s*\{\s*version\s*=\s*"[^"]+"/m.test(duc2pdfContent)) { + duc2pdfContent = duc2pdfContent.replace(/(duc\s*=\s*\{\s*version\s*=\s*")[^"]+(")/m, `$1${version}$2`); + fs.writeFileSync(duc2pdfPath, duc2pdfContent); + console.log(`Updated dependent duc version to "${version}" in ${duc2pdfPath}`); + } +}