Skip to content

Commit c7e31a1

Browse files
committed
feat(vxcore): add folder-bundle import (paths, id oracle, journaled attach)
Adds the vxcore half of "Import Folder -> Shared folder from VNote": attach a folder bundle produced by the share feature to an existing bundled notebook while preserving ids, timestamps, tags and attachments VERBATIM. This is deliberately NOT vxcore_folder_import, which regenerates ids and timestamps and therefore destroys the very identity (tag associations, history) the import exists to keep. New public API: vxcore_folder_get_import_paths Destination resolution. Mirrors vxcore_folder_get_share_paths, including the full root-to-destination index reachability walk, with one deliberate difference: the notebook ROOT is a legal destination. vxcore_notebook_collect_node_ids The authoritative id oracle. Walks the on-disk vx.json tree and NEVER consults SQLite: bundled notebooks index lazily, so a store miss proves nothing. Folder and file ids are returned in ONE namespace, root included, because a cross-kind collision is just as fatal as a same-kind one and `uuid UNIQUE` is per-table. vxcore_folder_attach_imported The journaled commit: publish content, publish metadata, insert-only store transaction, ATOMIC parent vx.json replace (the commit point), then one folder.created event. vxcore_notebook_recover_imports Replays or rolls back an interrupted import. Called on notebook open BEFORE anything rebuilds the metadata store, so a rolled-back import cannot leave rows a later rebuild would resurrect. Supporting changes: - MetadataStore/SqliteMetadataStore/FileDb gain InsertFolder/InsertFile, which use a plain INSERT rather than the existing INSERT OR REPLACE. The upsert would silently REPLACE a colliding row and cascade-delete its associations while reporting success; import must fail closed instead. - AttachImportedFolder re-verifies id uniqueness against the on-disk walk itself, since the caller releases its lock between check and call and the store cannot detect a cross-kind collision at all. - SaveFolderConfigAtomic writes a temp file, flushes it to stable storage and renames it over the live file. The existing in-place rewrite would destroy the destination parent's index if interrupted at the commit point. Note the deliberate deviation from the existing import path: attach emits a single folder.created for the imported root. Descendant creations are NOT replayed; consumers reload the subtree from the parent. Covered by tests/test_folder_import_bundle.cpp (13 cases), which constructs crash states by hand at each journal phase -- the only way to test a crash without crashing.
1 parent 9b359e7 commit c7e31a1

12 files changed

Lines changed: 2159 additions & 3 deletions

include/vxcore/vxcore.h

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,153 @@ VXCORE_API VxCoreError vxcore_folder_get_share_paths(VxCoreContextHandle context
300300
char **out_content_root,
301301
char **out_metadata_root);
302302

303+
// Resolve the storage roots needed to ATTACH an imported folder bundle as a new
304+
// child of an existing bundled folder. The mirror image of
305+
// vxcore_folder_get_share_paths, with two deliberate differences:
306+
// - the notebook ROOT is a valid destination (empty or "." folder_path),
307+
// - the outputs describe the DESTINATION CONTAINER, not a selected folder.
308+
//
309+
// Like the share variant this is a SYNCHRONOUS, PATH-ONLY query performing no
310+
// copy and no mutation, and it proves full index reachability by walking every
311+
// path component from the notebook root.
312+
//
313+
// dest_folder_path: path relative to the notebook root, e.g. "Projects".
314+
// Empty or "." denotes the notebook root.
315+
//
316+
// Outputs (all UTF-8, allocated by vxcore, freed with vxcore_string_free()):
317+
// out_notebook_root absolute notebook root folder
318+
// out_content_root absolute physical directory that will CONTAIN the
319+
// imported folder
320+
// out_metadata_root absolute metadata directory that will CONTAIN the
321+
// imported folder's metadata directory
322+
// (i.e. <root>/vx_notebook/contents/<dest_folder_path>)
323+
//
324+
// All three outputs are initialized to NULL before any validation and are
325+
// assigned only after every check succeeds.
326+
//
327+
// Errors:
328+
// VXCORE_ERR_INVALID_PARAM null argument, or an absolute / "." / ".."
329+
// containing / escaping dest_folder_path
330+
// VXCORE_ERR_UNSUPPORTED raw (non-bundled) notebook
331+
// VXCORE_ERR_READ_ONLY read-only notebook
332+
// VXCORE_ERR_NOT_FOUND notebook not found, or a path component is not
333+
// indexed by its parent, or its vx.json is absent
334+
// VXCORE_ERR_JSON_PARSE a vx.json along the path is malformed
335+
// VXCORE_ERR_INVALID_STATE a vx.json's "name" does not match its path
336+
// component (corrupt metadata)
337+
// VXCORE_ERR_NODE_NOT_EXISTS the physical directory is missing on disk
338+
VXCORE_API VxCoreError vxcore_folder_get_import_paths(VxCoreContextHandle context,
339+
const char *notebook_id,
340+
const char *dest_folder_path,
341+
char **out_notebook_root,
342+
char **out_content_root,
343+
char **out_metadata_root);
344+
345+
// Collect EVERY node id reachable in a bundled notebook by walking the
346+
// on-disk metadata tree (<root>/vx_notebook/contents/**/vx.json), starting at
347+
// the root folder's own vx.json.
348+
//
349+
// This is the AUTHORITATIVE id oracle. It deliberately NEVER consults SQLite:
350+
// bundled notebooks populate the metadata store lazily, so the store is an
351+
// incomplete index and cannot prove the absence of an id. Bundle import uses
352+
// this to reject an id collision before writing anything.
353+
//
354+
// Folder ids and file ids are returned in ONE namespace, because a collision
355+
// across kinds is just as fatal as one within a kind. The ROOT folder's id is
356+
// included.
357+
//
358+
// out_ids_json: JSON array of id strings, allocated by vxcore, freed with
359+
// vxcore_string_free().
360+
//
361+
// Errors:
362+
// VXCORE_ERR_INVALID_PARAM null argument
363+
// VXCORE_ERR_UNSUPPORTED raw (non-bundled) notebook
364+
// VXCORE_ERR_NOT_FOUND notebook not found, or the root vx.json is absent
365+
// VXCORE_ERR_JSON_PARSE a vx.json in the tree is malformed
366+
VXCORE_API VxCoreError vxcore_notebook_collect_node_ids(VxCoreContextHandle context,
367+
const char *notebook_id,
368+
char **out_ids_json);
369+
370+
// Attach a STAGED imported folder bundle to its destination parent.
371+
//
372+
// @staging_dir is a directory prepared by the caller (VNote's
373+
// FolderBundleImporter) containing exactly two children:
374+
// content/ the physical folder tree to publish as <dest>/<name>
375+
// metadata/ the parallel metadata tree (vx.json files) to publish as
376+
// <root>/vx_notebook/contents/<dest_folder_path>/<name>
377+
// The metadata tree's top-level vx.json MUST already carry its final "name"
378+
// (equal to @name) and every id in the subtree MUST be final.
379+
//
380+
// Preconditions the caller establishes while holding its notebook I/O lock:
381+
// - no id in the staged subtree collides with an existing notebook id
382+
// (verify with vxcore_notebook_collect_node_ids). This call RE-VERIFIES it
383+
// itself — the caller's check happens before the lock is re-acquired, and
384+
// the store cannot detect a cross-kind collision because `uuid UNIQUE` is
385+
// per-table,
386+
// - @name is not already listed by the destination parent nor present on
387+
// disk under either destination.
388+
//
389+
// This call performs the whole COMMIT, journaled for crash recovery under
390+
// <root>/vx_notebook/vx_import/<uuid>/journal.json:
391+
// 1. write the journal,
392+
// 2. rename staged content into place,
393+
// 3. rename staged metadata into place,
394+
// 4. INSERT-ONLY metadata-store transaction (never INSERT OR REPLACE, so a
395+
// surviving collision fails instead of silently replacing a row and
396+
// cascade-deleting its associations), including attachments,
397+
// 5. ATOMICALLY replace the destination parent's vx.json with @name added to
398+
// its "folders" array — THIS IS THE COMMIT POINT,
399+
// 6. invalidate caches, emit ONE `folder.created` for the attached folder,
400+
// delete the journal.
401+
// A failure before step 5 rolls everything back and writes nothing. A crash at
402+
// any point is repaired by vxcore_notebook_recover_imports() on next open.
403+
//
404+
// Descendant creation events are NOT replayed; exactly one `folder.created`
405+
// fires, for the imported root.
406+
//
407+
// out_folder_id: id of the attached top-level folder, read back from its
408+
// vx.json. Allocated by vxcore, freed with vxcore_string_free().
409+
//
410+
// Errors:
411+
// VXCORE_ERR_INVALID_PARAM null argument or an unsafe @name
412+
// VXCORE_ERR_UNSUPPORTED raw (non-bundled) notebook
413+
// VXCORE_ERR_READ_ONLY read-only notebook
414+
// VXCORE_ERR_NOT_FOUND notebook, destination, or staged subtree absent
415+
// VXCORE_ERR_ALREADY_EXISTS @name already listed by the parent or present on
416+
// disk, or an id in the subtree already exists
417+
// VXCORE_ERR_JSON_PARSE a vx.json in the staged subtree is malformed
418+
// VXCORE_ERR_DATABASE the store transaction failed
419+
// VXCORE_ERR_IO a rename or the parent vx.json replace failed
420+
VXCORE_API VxCoreError vxcore_folder_attach_imported(VxCoreContextHandle context,
421+
const char *notebook_id,
422+
const char *dest_folder_path, const char *name,
423+
const char *staging_dir,
424+
char **out_folder_id);
425+
426+
// Replay or roll back any incomplete folder-import journals left by a crash.
427+
//
428+
// Called automatically when a bundled notebook is opened, BEFORE the metadata
429+
// store is rebuilt from configs, so a rolled-back import never leaves rows the
430+
// rebuild would resurrect. Safe to call again at any time; a notebook with no
431+
// journals is a no-op returning VXCORE_OK.
432+
//
433+
// Per journal phase:
434+
// init/content/metadata/db roll BACK: delete the published directories,
435+
// delete the recorded ids from the store, restore
436+
// the parent vx.json bytes, delete the journal
437+
// committed roll FORWARD: ensure the store rows exist,
438+
// invalidate caches, delete the journal
439+
//
440+
// out_recovered_count: optional; receives the number of journals processed.
441+
//
442+
// Errors:
443+
// VXCORE_ERR_INVALID_PARAM null argument
444+
// VXCORE_ERR_UNSUPPORTED raw (non-bundled) notebook
445+
// VXCORE_ERR_NOT_FOUND notebook not found
446+
VXCORE_API VxCoreError vxcore_notebook_recover_imports(VxCoreContextHandle context,
447+
const char *notebook_id,
448+
int *out_recovered_count);
449+
303450
// ============ File Operations ============
304451
VXCORE_API VxCoreError vxcore_file_create(VxCoreContextHandle context, const char *notebook_id,
305452
const char *folder_path, const char *file_name,

0 commit comments

Comments
 (0)