@noy-db/to-file 0.2.0-pre.12 → 0.2.0-pre.14
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.
- package/dist/index.cjs +10 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -145,6 +145,16 @@ function jsonFile(options) {
|
|
|
145
145
|
}
|
|
146
146
|
return {
|
|
147
147
|
name: "file",
|
|
148
|
+
capabilities: {
|
|
149
|
+
casAtomic: false,
|
|
150
|
+
serverWriteTime: true,
|
|
151
|
+
auth: { kind: "filesystem", required: false, flow: "static" }
|
|
152
|
+
},
|
|
153
|
+
async getStoreTime() {
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
const \u03B5 = options.clockUncertaintyMs ?? 0;
|
|
156
|
+
return { earliest: now - \u03B5, latest: now + \u03B5 };
|
|
157
|
+
},
|
|
148
158
|
async get(vault, collection, id) {
|
|
149
159
|
const path = recordPath(vault, collection, id);
|
|
150
160
|
try {
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/export-blobs-to-directory.ts"],"sourcesContent":["/**\n * **@noy-db/to-file** — JSON file store for NOYDB (USB / local disk).\n *\n * Maps the NOYDB hierarchy directly to the filesystem:\n *\n * ```\n * {dir}/\n * {vault}/\n * {collection}/\n * {id}.json ← EncryptedEnvelope, optionally pretty-printed\n * _keyring/\n * {userId}.json ← wrapped DEKs for this user\n * _sync/\n * meta.json ← sync metadata\n * ```\n *\n * ## When to use\n *\n * - **USB stick workflow** — the data directory lives on a removable drive.\n * Plug in, unlock, work offline, eject. No cloud dependency.\n * - **Local development** — simple, inspectable files; no Docker or cloud\n * credentials required.\n * - **Single-user desktop apps** — Electron, Tauri, or any Node.js app that\n * writes to a local directory.\n *\n * ## Capabilities\n *\n * | Capability | Value |\n * |---|---|\n * | `casAtomic` | `false` — no atomic compare-and-swap at the FS layer |\n * | `listVaults` | ✓ — enumerates subdirectories |\n * | `listPage` | ✓ — cursor-based pagination over sorted filenames |\n * | `ping` | ✓ — `stat(dir)` |\n *\n * ## Bundle helpers\n *\n * {@link saveBundle} and {@link loadBundle} are thin wrappers around the\n * core `writeNoydbBundle` / `readNoydbBundle` primitives that pipe bytes\n * to/from `node:fs`.\n *\n * @packageDocumentation\n */\n\nimport { readFile, writeFile, mkdir, readdir, unlink, stat } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport type {\n NoydbStore,\n EncryptedEnvelope,\n VaultSnapshot,\n Vault,\n WriteNoydbBundleOptions,\n NoydbBundleReadResult,\n} from '@noy-db/hub'\nimport {\n ConflictError,\n writeNoydbBundle,\n readNoydbBundle,\n} from '@noy-db/hub'\n\n/**\n * Options for `jsonFile()`.\n *\n * Files are laid out as `{dir}/{vault}/{collection}/{id}.json`.\n * Internal collections (`_keyring`, `_sync`) follow the same pattern\n * under their vault directory.\n */\nexport interface JsonFileOptions {\n /** Base directory for NOYDB data. */\n dir: string\n /** Pretty-print JSON files. Default: true. */\n pretty?: boolean\n}\n\n/**\n * Create a JSON file adapter.\n * Maps the NOYDB hierarchy to the filesystem:\n *\n * ```\n * {dir}/{vault}/{collection}/{id}.json\n * {dir}/{vault}/_keyring/{userId}.json\n * ```\n */\nexport function jsonFile(options: JsonFileOptions): NoydbStore {\n const { dir, pretty = true } = options\n\n function recordPath(vault: string, collection: string, id: string): string {\n return join(dir, vault, collection, `${id}.json`)\n }\n\n function collectionDir(vault: string, collection: string): string {\n return join(dir, vault, collection)\n }\n\n async function ensureDir(path: string): Promise<void> {\n await mkdir(path, { recursive: true })\n }\n\n async function fileExists(path: string): Promise<boolean> {\n try {\n await stat(path)\n return true\n } catch {\n return false\n }\n }\n\n function serialize(envelope: EncryptedEnvelope): string {\n return pretty ? JSON.stringify(envelope, null, 2) : JSON.stringify(envelope)\n }\n\n return {\n name: 'file',\n\n async get(vault, collection, id) {\n const path = recordPath(vault, collection, id)\n try {\n const content = await readFile(path, 'utf-8')\n return JSON.parse(content) as EncryptedEnvelope\n } catch {\n return null\n }\n },\n\n async put(vault, collection, id, envelope, expectedVersion) {\n const path = recordPath(vault, collection, id)\n\n if (expectedVersion !== undefined && await fileExists(path)) {\n const existing = JSON.parse(await readFile(path, 'utf-8')) as EncryptedEnvelope\n if (existing._v !== expectedVersion) {\n throw new ConflictError(existing._v, `Version conflict: expected ${expectedVersion}, found ${existing._v}`)\n }\n }\n\n await ensureDir(collectionDir(vault, collection))\n await writeFile(path, serialize(envelope), 'utf-8')\n },\n\n async delete(vault, collection, id) {\n const path = recordPath(vault, collection, id)\n try {\n await unlink(path)\n } catch {\n // File doesn't exist — that's fine\n }\n },\n\n async list(vault, collection) {\n const dirPath = collectionDir(vault, collection)\n try {\n const entries = await readdir(dirPath)\n return entries\n .filter(f => f.endsWith('.json'))\n .map(f => f.slice(0, -5)) // remove .json extension\n } catch {\n return []\n }\n },\n\n async loadAll(vault) {\n const compDir = join(dir, vault)\n const snapshot: VaultSnapshot = {}\n\n try {\n const collections = await readdir(compDir)\n for (const collName of collections) {\n if (collName.startsWith('_')) continue // skip _keyring, _sync\n const collPath = join(compDir, collName)\n const collStat = await stat(collPath)\n if (!collStat.isDirectory()) continue\n\n const records: Record<string, EncryptedEnvelope> = {}\n const files = await readdir(collPath)\n for (const file of files) {\n if (!file.endsWith('.json')) continue\n const id = file.slice(0, -5)\n const content = await readFile(join(collPath, file), 'utf-8')\n records[id] = JSON.parse(content) as EncryptedEnvelope\n }\n snapshot[collName] = records\n }\n } catch {\n // Directory doesn't exist — return empty snapshot\n }\n\n return snapshot\n },\n\n async saveAll(vault, data) {\n for (const [collName, records] of Object.entries(data)) {\n const collDir = collectionDir(vault, collName)\n await ensureDir(collDir)\n for (const [id, envelope] of Object.entries(records)) {\n await writeFile(join(collDir, `${id}.json`), serialize(envelope), 'utf-8')\n }\n }\n },\n\n async ping() {\n try {\n await stat(dir)\n return true\n } catch {\n return false\n }\n },\n\n /**\n * Enumerate every top-level vault subdirectory under the\n * configured base directory. Used by\n * `Noydb.listAccessibleVaults()`.\n *\n * The implementation is `readdir(dir)` filtered to entries that\n * are themselves directories — files at the top level (READMEs,\n * .DS_Store, etc.) are skipped, and missing base directory\n * returns an empty array rather than throwing. Result order is\n * filesystem-defined; consumers that want stable order should\n * sort themselves.\n */\n async listVaults() {\n let entries: string[]\n try {\n entries = await readdir(dir)\n } catch {\n return []\n }\n const compartments: string[] = []\n for (const entry of entries) {\n try {\n const entryStat = await stat(join(dir, entry))\n if (entryStat.isDirectory()) compartments.push(entry)\n } catch {\n // Entry vanished between readdir and stat — skip silently.\n }\n }\n return compartments\n },\n\n /**\n * Paginate over a collection. Cursor is a numeric offset (as a string)\n * into the sorted filename list. Files are sorted alphabetically so\n * pages are stable across runs and across processes that share the\n * same data directory.\n *\n * The default `limit` is 100. Each item carries its decoded envelope\n * so callers don't need an extra `get()` round-trip per id.\n */\n async listPage(vault, collection, cursor, limit = 100) {\n const dirPath = collectionDir(vault, collection)\n let files: string[]\n try {\n files = await readdir(dirPath)\n } catch {\n return { items: [], nextCursor: null }\n }\n\n const ids = files\n .filter(f => f.endsWith('.json'))\n .map(f => f.slice(0, -5))\n .sort()\n\n const start = cursor ? parseInt(cursor, 10) : 0\n const end = Math.min(start + limit, ids.length)\n\n const items: Array<{ id: string; envelope: EncryptedEnvelope }> = []\n for (let i = start; i < end; i++) {\n const id = ids[i]!\n try {\n const content = await readFile(join(dirPath, `${id}.json`), 'utf-8')\n items.push({ id, envelope: JSON.parse(content) as EncryptedEnvelope })\n } catch {\n // File disappeared between readdir and readFile — skip silently.\n }\n }\n\n return {\n items,\n nextCursor: end < ids.length ? String(end) : null,\n }\n },\n }\n}\n\n// ─── .noydb bundle helpers ─────────────────────────────────\n\n/**\n * Write a `.noydb` container for a vault to a local file.\n *\n * Thin wrapper around `writeNoydbBundle` from `@noy-db/core` —\n * the core primitive returns a `Uint8Array`, this helper just\n * pipes it to `node:fs.writeFile` after ensuring the parent\n * directory exists. Use the same options as the core primitive.\n *\n * **Path convention** is up to the caller — `.noydb` is the\n * recommended extension. Consumers using cloud-sync folders\n * should name files by the bundle handle (available via\n * `vault.getBundleHandle()`) rather than the vault\n * name to avoid leaking metadata at the filesystem layer:\n *\n * ```ts\n * const handle = await company.getBundleHandle()\n * await saveBundle(`./bundles/${handle}.noydb`, company)\n * ```\n *\n * The full container is written atomically by `node:fs.writeFile`\n * (the platform's atomic-write semantics apply — POSIX `write()`\n * is atomic up to PIPE_BUF, larger files race with concurrent\n * readers; consumers writing into shared cloud folders should\n * pair this with their cloud sync's conflict resolution).\n */\nexport async function saveBundle(\n path: string,\n vault: Vault,\n opts: WriteNoydbBundleOptions = {},\n): Promise<void> {\n const bytes = await writeNoydbBundle(vault, opts)\n // Ensure the parent directory exists — `writeFile` does NOT\n // create intermediate directories on its own. Recursive mkdir\n // is a no-op when the directory already exists.\n await mkdir(dirname(path), { recursive: true })\n await writeFile(path, bytes)\n}\n\n/**\n * Read and verify a `.noydb` container from a local file.\n *\n * Returns the parsed header plus the unwrapped `dump()` JSON\n * string ready to feed to `vault.load(json, passphrase)`.\n * Throws `BundleIntegrityError` from `@noy-db/core` if the body\n * bytes don't match the integrity hash declared in the header\n * (the bundle was modified between write and read), or any\n * format error from the core reader if the bytes aren't a valid\n * bundle at all.\n *\n * Does NOT take a passphrase — the bundle reader is purely a\n * format layer. Restoring a vault from the returned dump\n * JSON requires a separate `vault.load()` call with the\n * passphrase, mirroring the split between\n * `readNoydbBundle()` and `vault.load()` in core.\n */\nexport async function loadBundle(path: string): Promise<NoydbBundleReadResult> {\n const bytes = await readFile(path)\n // node:fs.readFile returns a Buffer, which is a Uint8Array\n // subclass — `readNoydbBundle` accepts Uint8Array directly,\n // no copy needed.\n return readNoydbBundle(bytes)\n}\n\n// Export-blobs FS materializer — wraps `vault.exportBlobs()` with\n// target-profile filename sanitization, Zip-Slip path containment, and\n// collision policy. Lives in `to-file` (not core) because hub stays\n// portable across browser/Node and shouldn't import `node:fs`.\nexport {\n exportBlobsToDirectory,\n} from './export-blobs-to-directory.js'\nexport type {\n ExportBlobsToDirectoryOptions,\n ExportBlobsToDirectoryResult,\n CollisionStrategy,\n} from './export-blobs-to-directory.js'\n","/**\n * `exportBlobsToDirectory(vault, targetDir, opts)` — bulk blob\n * extraction into a real filesystem directory, with target-profile\n * filename sanitization and Zip-Slip path containment built in\n *.\n *\n * Wraps `vault.exportBlobs()` (the framework-agnostic async iterable\n * in core) with the FS-write concerns that don't belong in core:\n *\n * - sanitize filenames per a target profile (`posix`, `windows`,\n * `macos-smb`, `zip`, `url-path`, `s3-key`, `opaque`),\n * - guard against path-escape after sanitization (`PathEscapeError`),\n * - resolve filename collisions (`suffix` / `overwrite` / `fail` /\n * custom callback),\n * - emit a sidecar `manifest.json` when the profile is `'opaque'`,\n * mapping opaque ids back to the original record-supplied\n * filenames.\n *\n * @module\n */\n\nimport { mkdir, writeFile } from 'node:fs/promises'\nimport { resolve, sep, dirname, extname } from 'node:path'\nimport type { Vault } from '@noy-db/hub'\nimport { PathEscapeError } from '@noy-db/hub'\nimport { sanitizeFilename, type FilenameProfile } from '@noy-db/hub/util'\n\n/** Strategy for resolving two records that sanitize to the same name. */\nexport type CollisionStrategy =\n | 'suffix'\n | 'overwrite'\n | 'fail'\n | ((existing: string, attempt: number) => string)\n\nexport interface ExportBlobsToDirectoryOptions {\n /**\n * Filename profile to sanitize against. Default: `'macos-smb'` —\n * the most restrictive intersection of the rules adopters\n * typically hit. Pick a more specific profile when you know the\n * exact destination.\n */\n readonly filenameProfile?: FilenameProfile\n /**\n * How to handle two blobs whose sanitized filenames collide.\n * Default: `'suffix'`.\n */\n readonly onCollision?: CollisionStrategy\n /**\n * Optional collection allowlist forwarded to `vault.exportBlobs`.\n */\n readonly collections?: readonly string[]\n /**\n * Optional record predicate forwarded to `vault.exportBlobs`.\n */\n readonly where?: (\n record: unknown,\n context: { collection: string; id: string },\n ) => boolean\n /**\n * Optional resume cursor forwarded to `vault.exportBlobs`.\n */\n readonly afterBlobId?: string\n /**\n * External abort signal forwarded to `vault.exportBlobs`.\n */\n readonly signal?: AbortSignal\n}\n\nexport interface ExportBlobsToDirectoryResult {\n /** Total blobs written. */\n readonly written: number\n /** Total bytes written across all blobs. */\n readonly bytes: number\n /** Pairs of `{ blobId, path }` for every blob that landed on disk. */\n readonly entries: ReadonlyArray<{ blobId: string; path: string }>\n /**\n * When `filenameProfile === 'opaque'`, the absolute path of the\n * `manifest.json` sidecar. `null` for every other profile.\n */\n readonly manifestPath: string | null\n}\n\ninterface OpaqueManifestEntry {\n readonly opaqueName: string\n readonly originalName: string\n readonly collection: string\n readonly recordId: string\n readonly slot: string\n readonly blobId: string\n readonly mimeType?: string\n}\n\n/**\n * Materialize every blob in the vault into `targetDir`. Returns a\n * summary suitable for logging / audit.\n *\n * Caller MUST already hold whatever capability the vault demands\n * (`canExportPlaintext['blob']`) — this function delegates to\n * `vault.exportBlobs()`, which performs the capability check itself.\n */\nexport async function exportBlobsToDirectory(\n vault: Vault,\n targetDir: string,\n options: ExportBlobsToDirectoryOptions = {},\n): Promise<ExportBlobsToDirectoryResult> {\n const profile: FilenameProfile = options.filenameProfile ?? 'macos-smb'\n const onCollision: CollisionStrategy = options.onCollision ?? 'suffix'\n\n const absTargetDir = resolve(targetDir)\n await mkdir(absTargetDir, { recursive: true })\n const containmentPrefix = absTargetDir + sep\n\n // Track filenames already used in this run so collision resolution\n // is deterministic and cheap (no extra stat() per attempt).\n const used = new Set<string>()\n const entries: { blobId: string; path: string }[] = []\n const opaqueEntries: OpaqueManifestEntry[] = []\n let totalBytes = 0\n\n const handle = vault.exportBlobs({\n ...(options.collections && { collections: options.collections }),\n ...(options.where && { where: options.where }),\n ...(options.afterBlobId && { afterBlobId: options.afterBlobId }),\n ...(options.signal && { signal: options.signal }),\n })\n\n for await (const blob of handle) {\n const original = blob.meta.filename\n const sanitizeOpts =\n profile === 'opaque'\n ? { profile, opaqueId: blob.blobId } as const\n : { profile } as const\n const candidate = sanitizeFilename(original, sanitizeOpts)\n const finalName = resolveCollision(candidate, used, onCollision)\n used.add(finalName)\n\n const absPath = resolve(absTargetDir, finalName)\n if (absPath !== absTargetDir && !absPath.startsWith(containmentPrefix)) {\n throw new PathEscapeError({ attempted: finalName, targetDir: absTargetDir })\n }\n\n await mkdir(dirname(absPath), { recursive: true })\n await writeFile(absPath, blob.bytes)\n entries.push({ blobId: blob.blobId, path: absPath })\n totalBytes += blob.bytes.byteLength\n\n if (profile === 'opaque') {\n const entry: OpaqueManifestEntry = {\n opaqueName: finalName,\n originalName: original,\n collection: blob.recordRef.collection,\n recordId: blob.recordRef.id,\n slot: blob.recordRef.slot,\n blobId: blob.blobId,\n ...(blob.meta.mimeType !== undefined && { mimeType: blob.meta.mimeType }),\n }\n opaqueEntries.push(entry)\n }\n }\n\n let manifestPath: string | null = null\n if (profile === 'opaque') {\n manifestPath = resolve(absTargetDir, 'manifest.json')\n const json = JSON.stringify(\n {\n format: 'noydb-opaque-export',\n version: 1,\n entries: opaqueEntries,\n },\n null,\n 2,\n )\n await writeFile(manifestPath, json)\n }\n\n return {\n written: entries.length,\n bytes: totalBytes,\n entries,\n manifestPath,\n }\n}\n\nfunction resolveCollision(\n candidate: string,\n used: Set<string>,\n strategy: CollisionStrategy,\n): string {\n if (!used.has(candidate)) return candidate\n if (strategy === 'overwrite') return candidate\n if (strategy === 'fail') {\n throw new Error(`exportBlobsToDirectory: filename collision on \"${candidate}\"`)\n }\n // `'suffix'` and the function-callback path both build a sequence\n // of attempts and pick the first non-colliding one.\n for (let attempt = 1; attempt < 10_000; attempt++) {\n const next =\n typeof strategy === 'function'\n ? strategy(candidate, attempt)\n : addSuffix(candidate, attempt)\n if (!used.has(next)) return next\n }\n throw new Error(`exportBlobsToDirectory: collision suffix exhausted for \"${candidate}\"`)\n}\n\nfunction addSuffix(name: string, attempt: number): string {\n const ext = extname(name)\n if (ext.length > 0 && ext.length < name.length) {\n const stem = name.slice(0, name.length - ext.length)\n return `${stem}-${attempt}${ext}`\n }\n return `${name}-${attempt}`\n}\n"],"mappings":"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","names":["import_promises","import_node_path","import_hub"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/export-blobs-to-directory.ts"],"sourcesContent":["/**\n * **@noy-db/to-file** — JSON file store for NOYDB (USB / local disk).\n *\n * Maps the NOYDB hierarchy directly to the filesystem:\n *\n * ```\n * {dir}/\n * {vault}/\n * {collection}/\n * {id}.json ← EncryptedEnvelope, optionally pretty-printed\n * _keyring/\n * {userId}.json ← wrapped DEKs for this user\n * _sync/\n * meta.json ← sync metadata\n * ```\n *\n * ## When to use\n *\n * - **USB stick workflow** — the data directory lives on a removable drive.\n * Plug in, unlock, work offline, eject. No cloud dependency.\n * - **Local development** — simple, inspectable files; no Docker or cloud\n * credentials required.\n * - **Single-user desktop apps** — Electron, Tauri, or any Node.js app that\n * writes to a local directory.\n *\n * ## Capabilities\n *\n * | Capability | Value |\n * |---|---|\n * | `casAtomic` | `false` — no atomic compare-and-swap at the FS layer |\n * | `serverWriteTime` | `true` — local filesystem clock; solo-writer only |\n * | `listVaults` | ✓ — enumerates subdirectories |\n * | `listPage` | ✓ — cursor-based pagination over sorted filenames |\n * | `ping` | ✓ — `stat(dir)` |\n *\n * ## Bundle helpers\n *\n * {@link saveBundle} and {@link loadBundle} are thin wrappers around the\n * core `writeNoydbBundle` / `readNoydbBundle` primitives that pipe bytes\n * to/from `node:fs`.\n *\n * @packageDocumentation\n */\n\nimport { readFile, writeFile, mkdir, readdir, unlink, stat } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport type {\n NoydbStore,\n EncryptedEnvelope,\n VaultSnapshot,\n Vault,\n WriteNoydbBundleOptions,\n NoydbBundleReadResult,\n} from '@noy-db/hub'\nimport {\n ConflictError,\n writeNoydbBundle,\n readNoydbBundle,\n} from '@noy-db/hub'\n\n/**\n * Options for `jsonFile()`.\n *\n * Files are laid out as `{dir}/{vault}/{collection}/{id}.json`.\n * Internal collections (`_keyring`, `_sync`) follow the same pattern\n * under their vault directory.\n */\nexport interface JsonFileOptions {\n /** Base directory for NOYDB data. */\n dir: string\n /** Pretty-print JSON files. Default: true. */\n pretty?: boolean\n /** Clock uncertainty bound (ms). Default: 0. */\n clockUncertaintyMs?: number\n}\n\n/**\n * Create a JSON file adapter.\n * Maps the NOYDB hierarchy to the filesystem:\n *\n * ```\n * {dir}/{vault}/{collection}/{id}.json\n * {dir}/{vault}/_keyring/{userId}.json\n * ```\n */\nexport function jsonFile(options: JsonFileOptions): NoydbStore {\n const { dir, pretty = true } = options\n\n function recordPath(vault: string, collection: string, id: string): string {\n return join(dir, vault, collection, `${id}.json`)\n }\n\n function collectionDir(vault: string, collection: string): string {\n return join(dir, vault, collection)\n }\n\n async function ensureDir(path: string): Promise<void> {\n await mkdir(path, { recursive: true })\n }\n\n async function fileExists(path: string): Promise<boolean> {\n try {\n await stat(path)\n return true\n } catch {\n return false\n }\n }\n\n function serialize(envelope: EncryptedEnvelope): string {\n return pretty ? JSON.stringify(envelope, null, 2) : JSON.stringify(envelope)\n }\n\n return {\n name: 'file',\n capabilities: {\n casAtomic: false,\n serverWriteTime: true,\n auth: { kind: 'filesystem', required: false, flow: 'static' },\n },\n\n async getStoreTime() {\n const now = Date.now()\n const ε = options.clockUncertaintyMs ?? 0\n return { earliest: now - ε, latest: now + ε }\n },\n\n async get(vault, collection, id) {\n const path = recordPath(vault, collection, id)\n try {\n const content = await readFile(path, 'utf-8')\n return JSON.parse(content) as EncryptedEnvelope\n } catch {\n return null\n }\n },\n\n async put(vault, collection, id, envelope, expectedVersion) {\n const path = recordPath(vault, collection, id)\n\n if (expectedVersion !== undefined && await fileExists(path)) {\n const existing = JSON.parse(await readFile(path, 'utf-8')) as EncryptedEnvelope\n if (existing._v !== expectedVersion) {\n throw new ConflictError(existing._v, `Version conflict: expected ${expectedVersion}, found ${existing._v}`)\n }\n }\n\n await ensureDir(collectionDir(vault, collection))\n await writeFile(path, serialize(envelope), 'utf-8')\n },\n\n async delete(vault, collection, id) {\n const path = recordPath(vault, collection, id)\n try {\n await unlink(path)\n } catch {\n // File doesn't exist — that's fine\n }\n },\n\n async list(vault, collection) {\n const dirPath = collectionDir(vault, collection)\n try {\n const entries = await readdir(dirPath)\n return entries\n .filter(f => f.endsWith('.json'))\n .map(f => f.slice(0, -5)) // remove .json extension\n } catch {\n return []\n }\n },\n\n async loadAll(vault) {\n const compDir = join(dir, vault)\n const snapshot: VaultSnapshot = {}\n\n try {\n const collections = await readdir(compDir)\n for (const collName of collections) {\n if (collName.startsWith('_')) continue // skip _keyring, _sync\n const collPath = join(compDir, collName)\n const collStat = await stat(collPath)\n if (!collStat.isDirectory()) continue\n\n const records: Record<string, EncryptedEnvelope> = {}\n const files = await readdir(collPath)\n for (const file of files) {\n if (!file.endsWith('.json')) continue\n const id = file.slice(0, -5)\n const content = await readFile(join(collPath, file), 'utf-8')\n records[id] = JSON.parse(content) as EncryptedEnvelope\n }\n snapshot[collName] = records\n }\n } catch {\n // Directory doesn't exist — return empty snapshot\n }\n\n return snapshot\n },\n\n async saveAll(vault, data) {\n for (const [collName, records] of Object.entries(data)) {\n const collDir = collectionDir(vault, collName)\n await ensureDir(collDir)\n for (const [id, envelope] of Object.entries(records)) {\n await writeFile(join(collDir, `${id}.json`), serialize(envelope), 'utf-8')\n }\n }\n },\n\n async ping() {\n try {\n await stat(dir)\n return true\n } catch {\n return false\n }\n },\n\n /**\n * Enumerate every top-level vault subdirectory under the\n * configured base directory. Used by\n * `Noydb.listAccessibleVaults()`.\n *\n * The implementation is `readdir(dir)` filtered to entries that\n * are themselves directories — files at the top level (READMEs,\n * .DS_Store, etc.) are skipped, and missing base directory\n * returns an empty array rather than throwing. Result order is\n * filesystem-defined; consumers that want stable order should\n * sort themselves.\n */\n async listVaults() {\n let entries: string[]\n try {\n entries = await readdir(dir)\n } catch {\n return []\n }\n const compartments: string[] = []\n for (const entry of entries) {\n try {\n const entryStat = await stat(join(dir, entry))\n if (entryStat.isDirectory()) compartments.push(entry)\n } catch {\n // Entry vanished between readdir and stat — skip silently.\n }\n }\n return compartments\n },\n\n /**\n * Paginate over a collection. Cursor is a numeric offset (as a string)\n * into the sorted filename list. Files are sorted alphabetically so\n * pages are stable across runs and across processes that share the\n * same data directory.\n *\n * The default `limit` is 100. Each item carries its decoded envelope\n * so callers don't need an extra `get()` round-trip per id.\n */\n async listPage(vault, collection, cursor, limit = 100) {\n const dirPath = collectionDir(vault, collection)\n let files: string[]\n try {\n files = await readdir(dirPath)\n } catch {\n return { items: [], nextCursor: null }\n }\n\n const ids = files\n .filter(f => f.endsWith('.json'))\n .map(f => f.slice(0, -5))\n .sort()\n\n const start = cursor ? parseInt(cursor, 10) : 0\n const end = Math.min(start + limit, ids.length)\n\n const items: Array<{ id: string; envelope: EncryptedEnvelope }> = []\n for (let i = start; i < end; i++) {\n const id = ids[i]!\n try {\n const content = await readFile(join(dirPath, `${id}.json`), 'utf-8')\n items.push({ id, envelope: JSON.parse(content) as EncryptedEnvelope })\n } catch {\n // File disappeared between readdir and readFile — skip silently.\n }\n }\n\n return {\n items,\n nextCursor: end < ids.length ? String(end) : null,\n }\n },\n }\n}\n\n// ─── .noydb bundle helpers ─────────────────────────────────\n\n/**\n * Write a `.noydb` container for a vault to a local file.\n *\n * Thin wrapper around `writeNoydbBundle` from `@noy-db/core` —\n * the core primitive returns a `Uint8Array`, this helper just\n * pipes it to `node:fs.writeFile` after ensuring the parent\n * directory exists. Use the same options as the core primitive.\n *\n * **Path convention** is up to the caller — `.noydb` is the\n * recommended extension. Consumers using cloud-sync folders\n * should name files by the bundle handle (available via\n * `vault.getBundleHandle()`) rather than the vault\n * name to avoid leaking metadata at the filesystem layer:\n *\n * ```ts\n * const handle = await company.getBundleHandle()\n * await saveBundle(`./bundles/${handle}.noydb`, company)\n * ```\n *\n * The full container is written atomically by `node:fs.writeFile`\n * (the platform's atomic-write semantics apply — POSIX `write()`\n * is atomic up to PIPE_BUF, larger files race with concurrent\n * readers; consumers writing into shared cloud folders should\n * pair this with their cloud sync's conflict resolution).\n */\nexport async function saveBundle(\n path: string,\n vault: Vault,\n opts: WriteNoydbBundleOptions = {},\n): Promise<void> {\n const bytes = await writeNoydbBundle(vault, opts)\n // Ensure the parent directory exists — `writeFile` does NOT\n // create intermediate directories on its own. Recursive mkdir\n // is a no-op when the directory already exists.\n await mkdir(dirname(path), { recursive: true })\n await writeFile(path, bytes)\n}\n\n/**\n * Read and verify a `.noydb` container from a local file.\n *\n * Returns the parsed header plus the unwrapped `dump()` JSON\n * string ready to feed to `vault.load(json, passphrase)`.\n * Throws `BundleIntegrityError` from `@noy-db/core` if the body\n * bytes don't match the integrity hash declared in the header\n * (the bundle was modified between write and read), or any\n * format error from the core reader if the bytes aren't a valid\n * bundle at all.\n *\n * Does NOT take a passphrase — the bundle reader is purely a\n * format layer. Restoring a vault from the returned dump\n * JSON requires a separate `vault.load()` call with the\n * passphrase, mirroring the split between\n * `readNoydbBundle()` and `vault.load()` in core.\n */\nexport async function loadBundle(path: string): Promise<NoydbBundleReadResult> {\n const bytes = await readFile(path)\n // node:fs.readFile returns a Buffer, which is a Uint8Array\n // subclass — `readNoydbBundle` accepts Uint8Array directly,\n // no copy needed.\n return readNoydbBundle(bytes)\n}\n\n// Export-blobs FS materializer — wraps `vault.exportBlobs()` with\n// target-profile filename sanitization, Zip-Slip path containment, and\n// collision policy. Lives in `to-file` (not core) because hub stays\n// portable across browser/Node and shouldn't import `node:fs`.\nexport {\n exportBlobsToDirectory,\n} from './export-blobs-to-directory.js'\nexport type {\n ExportBlobsToDirectoryOptions,\n ExportBlobsToDirectoryResult,\n CollisionStrategy,\n} from './export-blobs-to-directory.js'\n","/**\n * `exportBlobsToDirectory(vault, targetDir, opts)` — bulk blob\n * extraction into a real filesystem directory, with target-profile\n * filename sanitization and Zip-Slip path containment built in\n *.\n *\n * Wraps `vault.exportBlobs()` (the framework-agnostic async iterable\n * in core) with the FS-write concerns that don't belong in core:\n *\n * - sanitize filenames per a target profile (`posix`, `windows`,\n * `macos-smb`, `zip`, `url-path`, `s3-key`, `opaque`),\n * - guard against path-escape after sanitization (`PathEscapeError`),\n * - resolve filename collisions (`suffix` / `overwrite` / `fail` /\n * custom callback),\n * - emit a sidecar `manifest.json` when the profile is `'opaque'`,\n * mapping opaque ids back to the original record-supplied\n * filenames.\n *\n * @module\n */\n\nimport { mkdir, writeFile } from 'node:fs/promises'\nimport { resolve, sep, dirname, extname } from 'node:path'\nimport type { Vault } from '@noy-db/hub'\nimport { PathEscapeError } from '@noy-db/hub'\nimport { sanitizeFilename, type FilenameProfile } from '@noy-db/hub/util'\n\n/** Strategy for resolving two records that sanitize to the same name. */\nexport type CollisionStrategy =\n | 'suffix'\n | 'overwrite'\n | 'fail'\n | ((existing: string, attempt: number) => string)\n\nexport interface ExportBlobsToDirectoryOptions {\n /**\n * Filename profile to sanitize against. Default: `'macos-smb'` —\n * the most restrictive intersection of the rules adopters\n * typically hit. Pick a more specific profile when you know the\n * exact destination.\n */\n readonly filenameProfile?: FilenameProfile\n /**\n * How to handle two blobs whose sanitized filenames collide.\n * Default: `'suffix'`.\n */\n readonly onCollision?: CollisionStrategy\n /**\n * Optional collection allowlist forwarded to `vault.exportBlobs`.\n */\n readonly collections?: readonly string[]\n /**\n * Optional record predicate forwarded to `vault.exportBlobs`.\n */\n readonly where?: (\n record: unknown,\n context: { collection: string; id: string },\n ) => boolean\n /**\n * Optional resume cursor forwarded to `vault.exportBlobs`.\n */\n readonly afterBlobId?: string\n /**\n * External abort signal forwarded to `vault.exportBlobs`.\n */\n readonly signal?: AbortSignal\n}\n\nexport interface ExportBlobsToDirectoryResult {\n /** Total blobs written. */\n readonly written: number\n /** Total bytes written across all blobs. */\n readonly bytes: number\n /** Pairs of `{ blobId, path }` for every blob that landed on disk. */\n readonly entries: ReadonlyArray<{ blobId: string; path: string }>\n /**\n * When `filenameProfile === 'opaque'`, the absolute path of the\n * `manifest.json` sidecar. `null` for every other profile.\n */\n readonly manifestPath: string | null\n}\n\ninterface OpaqueManifestEntry {\n readonly opaqueName: string\n readonly originalName: string\n readonly collection: string\n readonly recordId: string\n readonly slot: string\n readonly blobId: string\n readonly mimeType?: string\n}\n\n/**\n * Materialize every blob in the vault into `targetDir`. Returns a\n * summary suitable for logging / audit.\n *\n * Caller MUST already hold whatever capability the vault demands\n * (`canExportPlaintext['blob']`) — this function delegates to\n * `vault.exportBlobs()`, which performs the capability check itself.\n */\nexport async function exportBlobsToDirectory(\n vault: Vault,\n targetDir: string,\n options: ExportBlobsToDirectoryOptions = {},\n): Promise<ExportBlobsToDirectoryResult> {\n const profile: FilenameProfile = options.filenameProfile ?? 'macos-smb'\n const onCollision: CollisionStrategy = options.onCollision ?? 'suffix'\n\n const absTargetDir = resolve(targetDir)\n await mkdir(absTargetDir, { recursive: true })\n const containmentPrefix = absTargetDir + sep\n\n // Track filenames already used in this run so collision resolution\n // is deterministic and cheap (no extra stat() per attempt).\n const used = new Set<string>()\n const entries: { blobId: string; path: string }[] = []\n const opaqueEntries: OpaqueManifestEntry[] = []\n let totalBytes = 0\n\n const handle = vault.exportBlobs({\n ...(options.collections && { collections: options.collections }),\n ...(options.where && { where: options.where }),\n ...(options.afterBlobId && { afterBlobId: options.afterBlobId }),\n ...(options.signal && { signal: options.signal }),\n })\n\n for await (const blob of handle) {\n const original = blob.meta.filename\n const sanitizeOpts =\n profile === 'opaque'\n ? { profile, opaqueId: blob.blobId } as const\n : { profile } as const\n const candidate = sanitizeFilename(original, sanitizeOpts)\n const finalName = resolveCollision(candidate, used, onCollision)\n used.add(finalName)\n\n const absPath = resolve(absTargetDir, finalName)\n if (absPath !== absTargetDir && !absPath.startsWith(containmentPrefix)) {\n throw new PathEscapeError({ attempted: finalName, targetDir: absTargetDir })\n }\n\n await mkdir(dirname(absPath), { recursive: true })\n await writeFile(absPath, blob.bytes)\n entries.push({ blobId: blob.blobId, path: absPath })\n totalBytes += blob.bytes.byteLength\n\n if (profile === 'opaque') {\n const entry: OpaqueManifestEntry = {\n opaqueName: finalName,\n originalName: original,\n collection: blob.recordRef.collection,\n recordId: blob.recordRef.id,\n slot: blob.recordRef.slot,\n blobId: blob.blobId,\n ...(blob.meta.mimeType !== undefined && { mimeType: blob.meta.mimeType }),\n }\n opaqueEntries.push(entry)\n }\n }\n\n let manifestPath: string | null = null\n if (profile === 'opaque') {\n manifestPath = resolve(absTargetDir, 'manifest.json')\n const json = JSON.stringify(\n {\n format: 'noydb-opaque-export',\n version: 1,\n entries: opaqueEntries,\n },\n null,\n 2,\n )\n await writeFile(manifestPath, json)\n }\n\n return {\n written: entries.length,\n bytes: totalBytes,\n entries,\n manifestPath,\n }\n}\n\nfunction resolveCollision(\n candidate: string,\n used: Set<string>,\n strategy: CollisionStrategy,\n): string {\n if (!used.has(candidate)) return candidate\n if (strategy === 'overwrite') return candidate\n if (strategy === 'fail') {\n throw new Error(`exportBlobsToDirectory: filename collision on \"${candidate}\"`)\n }\n // `'suffix'` and the function-callback path both build a sequence\n // of attempts and pick the first non-colliding one.\n for (let attempt = 1; attempt < 10_000; attempt++) {\n const next =\n typeof strategy === 'function'\n ? strategy(candidate, attempt)\n : addSuffix(candidate, attempt)\n if (!used.has(next)) return next\n }\n throw new Error(`exportBlobsToDirectory: collision suffix exhausted for \"${candidate}\"`)\n}\n\nfunction addSuffix(name: string, attempt: number): string {\n const ext = extname(name)\n if (ext.length > 0 && ext.length < name.length) {\n const stem = name.slice(0, name.length - ext.length)\n return `${stem}-${attempt}${ext}`\n }\n return `${name}-${attempt}`\n}\n"],"mappings":"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","names":["import_promises","import_node_path","import_hub"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -113,6 +113,7 @@ declare function exportBlobsToDirectory(vault: Vault, targetDir: string, options
|
|
|
113
113
|
* | Capability | Value |
|
|
114
114
|
* |---|---|
|
|
115
115
|
* | `casAtomic` | `false` — no atomic compare-and-swap at the FS layer |
|
|
116
|
+
* | `serverWriteTime` | `true` — local filesystem clock; solo-writer only |
|
|
116
117
|
* | `listVaults` | ✓ — enumerates subdirectories |
|
|
117
118
|
* | `listPage` | ✓ — cursor-based pagination over sorted filenames |
|
|
118
119
|
* | `ping` | ✓ — `stat(dir)` |
|
|
@@ -138,6 +139,8 @@ interface JsonFileOptions {
|
|
|
138
139
|
dir: string;
|
|
139
140
|
/** Pretty-print JSON files. Default: true. */
|
|
140
141
|
pretty?: boolean;
|
|
142
|
+
/** Clock uncertainty bound (ms). Default: 0. */
|
|
143
|
+
clockUncertaintyMs?: number;
|
|
141
144
|
}
|
|
142
145
|
/**
|
|
143
146
|
* Create a JSON file adapter.
|
package/dist/index.d.ts
CHANGED
|
@@ -113,6 +113,7 @@ declare function exportBlobsToDirectory(vault: Vault, targetDir: string, options
|
|
|
113
113
|
* | Capability | Value |
|
|
114
114
|
* |---|---|
|
|
115
115
|
* | `casAtomic` | `false` — no atomic compare-and-swap at the FS layer |
|
|
116
|
+
* | `serverWriteTime` | `true` — local filesystem clock; solo-writer only |
|
|
116
117
|
* | `listVaults` | ✓ — enumerates subdirectories |
|
|
117
118
|
* | `listPage` | ✓ — cursor-based pagination over sorted filenames |
|
|
118
119
|
* | `ping` | ✓ — `stat(dir)` |
|
|
@@ -138,6 +139,8 @@ interface JsonFileOptions {
|
|
|
138
139
|
dir: string;
|
|
139
140
|
/** Pretty-print JSON files. Default: true. */
|
|
140
141
|
pretty?: boolean;
|
|
142
|
+
/** Clock uncertainty bound (ms). Default: 0. */
|
|
143
|
+
clockUncertaintyMs?: number;
|
|
141
144
|
}
|
|
142
145
|
/**
|
|
143
146
|
* Create a JSON file adapter.
|
package/dist/index.js
CHANGED
|
@@ -122,6 +122,16 @@ function jsonFile(options) {
|
|
|
122
122
|
}
|
|
123
123
|
return {
|
|
124
124
|
name: "file",
|
|
125
|
+
capabilities: {
|
|
126
|
+
casAtomic: false,
|
|
127
|
+
serverWriteTime: true,
|
|
128
|
+
auth: { kind: "filesystem", required: false, flow: "static" }
|
|
129
|
+
},
|
|
130
|
+
async getStoreTime() {
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
const \u03B5 = options.clockUncertaintyMs ?? 0;
|
|
133
|
+
return { earliest: now - \u03B5, latest: now + \u03B5 };
|
|
134
|
+
},
|
|
125
135
|
async get(vault, collection, id) {
|
|
126
136
|
const path = recordPath(vault, collection, id);
|
|
127
137
|
try {
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/export-blobs-to-directory.ts"],"sourcesContent":["/**\n * **@noy-db/to-file** — JSON file store for NOYDB (USB / local disk).\n *\n * Maps the NOYDB hierarchy directly to the filesystem:\n *\n * ```\n * {dir}/\n * {vault}/\n * {collection}/\n * {id}.json ← EncryptedEnvelope, optionally pretty-printed\n * _keyring/\n * {userId}.json ← wrapped DEKs for this user\n * _sync/\n * meta.json ← sync metadata\n * ```\n *\n * ## When to use\n *\n * - **USB stick workflow** — the data directory lives on a removable drive.\n * Plug in, unlock, work offline, eject. No cloud dependency.\n * - **Local development** — simple, inspectable files; no Docker or cloud\n * credentials required.\n * - **Single-user desktop apps** — Electron, Tauri, or any Node.js app that\n * writes to a local directory.\n *\n * ## Capabilities\n *\n * | Capability | Value |\n * |---|---|\n * | `casAtomic` | `false` — no atomic compare-and-swap at the FS layer |\n * | `listVaults` | ✓ — enumerates subdirectories |\n * | `listPage` | ✓ — cursor-based pagination over sorted filenames |\n * | `ping` | ✓ — `stat(dir)` |\n *\n * ## Bundle helpers\n *\n * {@link saveBundle} and {@link loadBundle} are thin wrappers around the\n * core `writeNoydbBundle` / `readNoydbBundle` primitives that pipe bytes\n * to/from `node:fs`.\n *\n * @packageDocumentation\n */\n\nimport { readFile, writeFile, mkdir, readdir, unlink, stat } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport type {\n NoydbStore,\n EncryptedEnvelope,\n VaultSnapshot,\n Vault,\n WriteNoydbBundleOptions,\n NoydbBundleReadResult,\n} from '@noy-db/hub'\nimport {\n ConflictError,\n writeNoydbBundle,\n readNoydbBundle,\n} from '@noy-db/hub'\n\n/**\n * Options for `jsonFile()`.\n *\n * Files are laid out as `{dir}/{vault}/{collection}/{id}.json`.\n * Internal collections (`_keyring`, `_sync`) follow the same pattern\n * under their vault directory.\n */\nexport interface JsonFileOptions {\n /** Base directory for NOYDB data. */\n dir: string\n /** Pretty-print JSON files. Default: true. */\n pretty?: boolean\n}\n\n/**\n * Create a JSON file adapter.\n * Maps the NOYDB hierarchy to the filesystem:\n *\n * ```\n * {dir}/{vault}/{collection}/{id}.json\n * {dir}/{vault}/_keyring/{userId}.json\n * ```\n */\nexport function jsonFile(options: JsonFileOptions): NoydbStore {\n const { dir, pretty = true } = options\n\n function recordPath(vault: string, collection: string, id: string): string {\n return join(dir, vault, collection, `${id}.json`)\n }\n\n function collectionDir(vault: string, collection: string): string {\n return join(dir, vault, collection)\n }\n\n async function ensureDir(path: string): Promise<void> {\n await mkdir(path, { recursive: true })\n }\n\n async function fileExists(path: string): Promise<boolean> {\n try {\n await stat(path)\n return true\n } catch {\n return false\n }\n }\n\n function serialize(envelope: EncryptedEnvelope): string {\n return pretty ? JSON.stringify(envelope, null, 2) : JSON.stringify(envelope)\n }\n\n return {\n name: 'file',\n\n async get(vault, collection, id) {\n const path = recordPath(vault, collection, id)\n try {\n const content = await readFile(path, 'utf-8')\n return JSON.parse(content) as EncryptedEnvelope\n } catch {\n return null\n }\n },\n\n async put(vault, collection, id, envelope, expectedVersion) {\n const path = recordPath(vault, collection, id)\n\n if (expectedVersion !== undefined && await fileExists(path)) {\n const existing = JSON.parse(await readFile(path, 'utf-8')) as EncryptedEnvelope\n if (existing._v !== expectedVersion) {\n throw new ConflictError(existing._v, `Version conflict: expected ${expectedVersion}, found ${existing._v}`)\n }\n }\n\n await ensureDir(collectionDir(vault, collection))\n await writeFile(path, serialize(envelope), 'utf-8')\n },\n\n async delete(vault, collection, id) {\n const path = recordPath(vault, collection, id)\n try {\n await unlink(path)\n } catch {\n // File doesn't exist — that's fine\n }\n },\n\n async list(vault, collection) {\n const dirPath = collectionDir(vault, collection)\n try {\n const entries = await readdir(dirPath)\n return entries\n .filter(f => f.endsWith('.json'))\n .map(f => f.slice(0, -5)) // remove .json extension\n } catch {\n return []\n }\n },\n\n async loadAll(vault) {\n const compDir = join(dir, vault)\n const snapshot: VaultSnapshot = {}\n\n try {\n const collections = await readdir(compDir)\n for (const collName of collections) {\n if (collName.startsWith('_')) continue // skip _keyring, _sync\n const collPath = join(compDir, collName)\n const collStat = await stat(collPath)\n if (!collStat.isDirectory()) continue\n\n const records: Record<string, EncryptedEnvelope> = {}\n const files = await readdir(collPath)\n for (const file of files) {\n if (!file.endsWith('.json')) continue\n const id = file.slice(0, -5)\n const content = await readFile(join(collPath, file), 'utf-8')\n records[id] = JSON.parse(content) as EncryptedEnvelope\n }\n snapshot[collName] = records\n }\n } catch {\n // Directory doesn't exist — return empty snapshot\n }\n\n return snapshot\n },\n\n async saveAll(vault, data) {\n for (const [collName, records] of Object.entries(data)) {\n const collDir = collectionDir(vault, collName)\n await ensureDir(collDir)\n for (const [id, envelope] of Object.entries(records)) {\n await writeFile(join(collDir, `${id}.json`), serialize(envelope), 'utf-8')\n }\n }\n },\n\n async ping() {\n try {\n await stat(dir)\n return true\n } catch {\n return false\n }\n },\n\n /**\n * Enumerate every top-level vault subdirectory under the\n * configured base directory. Used by\n * `Noydb.listAccessibleVaults()`.\n *\n * The implementation is `readdir(dir)` filtered to entries that\n * are themselves directories — files at the top level (READMEs,\n * .DS_Store, etc.) are skipped, and missing base directory\n * returns an empty array rather than throwing. Result order is\n * filesystem-defined; consumers that want stable order should\n * sort themselves.\n */\n async listVaults() {\n let entries: string[]\n try {\n entries = await readdir(dir)\n } catch {\n return []\n }\n const compartments: string[] = []\n for (const entry of entries) {\n try {\n const entryStat = await stat(join(dir, entry))\n if (entryStat.isDirectory()) compartments.push(entry)\n } catch {\n // Entry vanished between readdir and stat — skip silently.\n }\n }\n return compartments\n },\n\n /**\n * Paginate over a collection. Cursor is a numeric offset (as a string)\n * into the sorted filename list. Files are sorted alphabetically so\n * pages are stable across runs and across processes that share the\n * same data directory.\n *\n * The default `limit` is 100. Each item carries its decoded envelope\n * so callers don't need an extra `get()` round-trip per id.\n */\n async listPage(vault, collection, cursor, limit = 100) {\n const dirPath = collectionDir(vault, collection)\n let files: string[]\n try {\n files = await readdir(dirPath)\n } catch {\n return { items: [], nextCursor: null }\n }\n\n const ids = files\n .filter(f => f.endsWith('.json'))\n .map(f => f.slice(0, -5))\n .sort()\n\n const start = cursor ? parseInt(cursor, 10) : 0\n const end = Math.min(start + limit, ids.length)\n\n const items: Array<{ id: string; envelope: EncryptedEnvelope }> = []\n for (let i = start; i < end; i++) {\n const id = ids[i]!\n try {\n const content = await readFile(join(dirPath, `${id}.json`), 'utf-8')\n items.push({ id, envelope: JSON.parse(content) as EncryptedEnvelope })\n } catch {\n // File disappeared between readdir and readFile — skip silently.\n }\n }\n\n return {\n items,\n nextCursor: end < ids.length ? String(end) : null,\n }\n },\n }\n}\n\n// ─── .noydb bundle helpers ─────────────────────────────────\n\n/**\n * Write a `.noydb` container for a vault to a local file.\n *\n * Thin wrapper around `writeNoydbBundle` from `@noy-db/core` —\n * the core primitive returns a `Uint8Array`, this helper just\n * pipes it to `node:fs.writeFile` after ensuring the parent\n * directory exists. Use the same options as the core primitive.\n *\n * **Path convention** is up to the caller — `.noydb` is the\n * recommended extension. Consumers using cloud-sync folders\n * should name files by the bundle handle (available via\n * `vault.getBundleHandle()`) rather than the vault\n * name to avoid leaking metadata at the filesystem layer:\n *\n * ```ts\n * const handle = await company.getBundleHandle()\n * await saveBundle(`./bundles/${handle}.noydb`, company)\n * ```\n *\n * The full container is written atomically by `node:fs.writeFile`\n * (the platform's atomic-write semantics apply — POSIX `write()`\n * is atomic up to PIPE_BUF, larger files race with concurrent\n * readers; consumers writing into shared cloud folders should\n * pair this with their cloud sync's conflict resolution).\n */\nexport async function saveBundle(\n path: string,\n vault: Vault,\n opts: WriteNoydbBundleOptions = {},\n): Promise<void> {\n const bytes = await writeNoydbBundle(vault, opts)\n // Ensure the parent directory exists — `writeFile` does NOT\n // create intermediate directories on its own. Recursive mkdir\n // is a no-op when the directory already exists.\n await mkdir(dirname(path), { recursive: true })\n await writeFile(path, bytes)\n}\n\n/**\n * Read and verify a `.noydb` container from a local file.\n *\n * Returns the parsed header plus the unwrapped `dump()` JSON\n * string ready to feed to `vault.load(json, passphrase)`.\n * Throws `BundleIntegrityError` from `@noy-db/core` if the body\n * bytes don't match the integrity hash declared in the header\n * (the bundle was modified between write and read), or any\n * format error from the core reader if the bytes aren't a valid\n * bundle at all.\n *\n * Does NOT take a passphrase — the bundle reader is purely a\n * format layer. Restoring a vault from the returned dump\n * JSON requires a separate `vault.load()` call with the\n * passphrase, mirroring the split between\n * `readNoydbBundle()` and `vault.load()` in core.\n */\nexport async function loadBundle(path: string): Promise<NoydbBundleReadResult> {\n const bytes = await readFile(path)\n // node:fs.readFile returns a Buffer, which is a Uint8Array\n // subclass — `readNoydbBundle` accepts Uint8Array directly,\n // no copy needed.\n return readNoydbBundle(bytes)\n}\n\n// Export-blobs FS materializer — wraps `vault.exportBlobs()` with\n// target-profile filename sanitization, Zip-Slip path containment, and\n// collision policy. Lives in `to-file` (not core) because hub stays\n// portable across browser/Node and shouldn't import `node:fs`.\nexport {\n exportBlobsToDirectory,\n} from './export-blobs-to-directory.js'\nexport type {\n ExportBlobsToDirectoryOptions,\n ExportBlobsToDirectoryResult,\n CollisionStrategy,\n} from './export-blobs-to-directory.js'\n","/**\n * `exportBlobsToDirectory(vault, targetDir, opts)` — bulk blob\n * extraction into a real filesystem directory, with target-profile\n * filename sanitization and Zip-Slip path containment built in\n *.\n *\n * Wraps `vault.exportBlobs()` (the framework-agnostic async iterable\n * in core) with the FS-write concerns that don't belong in core:\n *\n * - sanitize filenames per a target profile (`posix`, `windows`,\n * `macos-smb`, `zip`, `url-path`, `s3-key`, `opaque`),\n * - guard against path-escape after sanitization (`PathEscapeError`),\n * - resolve filename collisions (`suffix` / `overwrite` / `fail` /\n * custom callback),\n * - emit a sidecar `manifest.json` when the profile is `'opaque'`,\n * mapping opaque ids back to the original record-supplied\n * filenames.\n *\n * @module\n */\n\nimport { mkdir, writeFile } from 'node:fs/promises'\nimport { resolve, sep, dirname, extname } from 'node:path'\nimport type { Vault } from '@noy-db/hub'\nimport { PathEscapeError } from '@noy-db/hub'\nimport { sanitizeFilename, type FilenameProfile } from '@noy-db/hub/util'\n\n/** Strategy for resolving two records that sanitize to the same name. */\nexport type CollisionStrategy =\n | 'suffix'\n | 'overwrite'\n | 'fail'\n | ((existing: string, attempt: number) => string)\n\nexport interface ExportBlobsToDirectoryOptions {\n /**\n * Filename profile to sanitize against. Default: `'macos-smb'` —\n * the most restrictive intersection of the rules adopters\n * typically hit. Pick a more specific profile when you know the\n * exact destination.\n */\n readonly filenameProfile?: FilenameProfile\n /**\n * How to handle two blobs whose sanitized filenames collide.\n * Default: `'suffix'`.\n */\n readonly onCollision?: CollisionStrategy\n /**\n * Optional collection allowlist forwarded to `vault.exportBlobs`.\n */\n readonly collections?: readonly string[]\n /**\n * Optional record predicate forwarded to `vault.exportBlobs`.\n */\n readonly where?: (\n record: unknown,\n context: { collection: string; id: string },\n ) => boolean\n /**\n * Optional resume cursor forwarded to `vault.exportBlobs`.\n */\n readonly afterBlobId?: string\n /**\n * External abort signal forwarded to `vault.exportBlobs`.\n */\n readonly signal?: AbortSignal\n}\n\nexport interface ExportBlobsToDirectoryResult {\n /** Total blobs written. */\n readonly written: number\n /** Total bytes written across all blobs. */\n readonly bytes: number\n /** Pairs of `{ blobId, path }` for every blob that landed on disk. */\n readonly entries: ReadonlyArray<{ blobId: string; path: string }>\n /**\n * When `filenameProfile === 'opaque'`, the absolute path of the\n * `manifest.json` sidecar. `null` for every other profile.\n */\n readonly manifestPath: string | null\n}\n\ninterface OpaqueManifestEntry {\n readonly opaqueName: string\n readonly originalName: string\n readonly collection: string\n readonly recordId: string\n readonly slot: string\n readonly blobId: string\n readonly mimeType?: string\n}\n\n/**\n * Materialize every blob in the vault into `targetDir`. Returns a\n * summary suitable for logging / audit.\n *\n * Caller MUST already hold whatever capability the vault demands\n * (`canExportPlaintext['blob']`) — this function delegates to\n * `vault.exportBlobs()`, which performs the capability check itself.\n */\nexport async function exportBlobsToDirectory(\n vault: Vault,\n targetDir: string,\n options: ExportBlobsToDirectoryOptions = {},\n): Promise<ExportBlobsToDirectoryResult> {\n const profile: FilenameProfile = options.filenameProfile ?? 'macos-smb'\n const onCollision: CollisionStrategy = options.onCollision ?? 'suffix'\n\n const absTargetDir = resolve(targetDir)\n await mkdir(absTargetDir, { recursive: true })\n const containmentPrefix = absTargetDir + sep\n\n // Track filenames already used in this run so collision resolution\n // is deterministic and cheap (no extra stat() per attempt).\n const used = new Set<string>()\n const entries: { blobId: string; path: string }[] = []\n const opaqueEntries: OpaqueManifestEntry[] = []\n let totalBytes = 0\n\n const handle = vault.exportBlobs({\n ...(options.collections && { collections: options.collections }),\n ...(options.where && { where: options.where }),\n ...(options.afterBlobId && { afterBlobId: options.afterBlobId }),\n ...(options.signal && { signal: options.signal }),\n })\n\n for await (const blob of handle) {\n const original = blob.meta.filename\n const sanitizeOpts =\n profile === 'opaque'\n ? { profile, opaqueId: blob.blobId } as const\n : { profile } as const\n const candidate = sanitizeFilename(original, sanitizeOpts)\n const finalName = resolveCollision(candidate, used, onCollision)\n used.add(finalName)\n\n const absPath = resolve(absTargetDir, finalName)\n if (absPath !== absTargetDir && !absPath.startsWith(containmentPrefix)) {\n throw new PathEscapeError({ attempted: finalName, targetDir: absTargetDir })\n }\n\n await mkdir(dirname(absPath), { recursive: true })\n await writeFile(absPath, blob.bytes)\n entries.push({ blobId: blob.blobId, path: absPath })\n totalBytes += blob.bytes.byteLength\n\n if (profile === 'opaque') {\n const entry: OpaqueManifestEntry = {\n opaqueName: finalName,\n originalName: original,\n collection: blob.recordRef.collection,\n recordId: blob.recordRef.id,\n slot: blob.recordRef.slot,\n blobId: blob.blobId,\n ...(blob.meta.mimeType !== undefined && { mimeType: blob.meta.mimeType }),\n }\n opaqueEntries.push(entry)\n }\n }\n\n let manifestPath: string | null = null\n if (profile === 'opaque') {\n manifestPath = resolve(absTargetDir, 'manifest.json')\n const json = JSON.stringify(\n {\n format: 'noydb-opaque-export',\n version: 1,\n entries: opaqueEntries,\n },\n null,\n 2,\n )\n await writeFile(manifestPath, json)\n }\n\n return {\n written: entries.length,\n bytes: totalBytes,\n entries,\n manifestPath,\n }\n}\n\nfunction resolveCollision(\n candidate: string,\n used: Set<string>,\n strategy: CollisionStrategy,\n): string {\n if (!used.has(candidate)) return candidate\n if (strategy === 'overwrite') return candidate\n if (strategy === 'fail') {\n throw new Error(`exportBlobsToDirectory: filename collision on \"${candidate}\"`)\n }\n // `'suffix'` and the function-callback path both build a sequence\n // of attempts and pick the first non-colliding one.\n for (let attempt = 1; attempt < 10_000; attempt++) {\n const next =\n typeof strategy === 'function'\n ? strategy(candidate, attempt)\n : addSuffix(candidate, attempt)\n if (!used.has(next)) return next\n }\n throw new Error(`exportBlobsToDirectory: collision suffix exhausted for \"${candidate}\"`)\n}\n\nfunction addSuffix(name: string, attempt: number): string {\n const ext = extname(name)\n if (ext.length > 0 && ext.length < name.length) {\n const stem = name.slice(0, name.length - ext.length)\n return `${stem}-${attempt}${ext}`\n }\n return `${name}-${attempt}`\n}\n"],"mappings":"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","names":["writeFile","mkdir","dirname","mkdir","writeFile","dirname"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/export-blobs-to-directory.ts"],"sourcesContent":["/**\n * **@noy-db/to-file** — JSON file store for NOYDB (USB / local disk).\n *\n * Maps the NOYDB hierarchy directly to the filesystem:\n *\n * ```\n * {dir}/\n * {vault}/\n * {collection}/\n * {id}.json ← EncryptedEnvelope, optionally pretty-printed\n * _keyring/\n * {userId}.json ← wrapped DEKs for this user\n * _sync/\n * meta.json ← sync metadata\n * ```\n *\n * ## When to use\n *\n * - **USB stick workflow** — the data directory lives on a removable drive.\n * Plug in, unlock, work offline, eject. No cloud dependency.\n * - **Local development** — simple, inspectable files; no Docker or cloud\n * credentials required.\n * - **Single-user desktop apps** — Electron, Tauri, or any Node.js app that\n * writes to a local directory.\n *\n * ## Capabilities\n *\n * | Capability | Value |\n * |---|---|\n * | `casAtomic` | `false` — no atomic compare-and-swap at the FS layer |\n * | `serverWriteTime` | `true` — local filesystem clock; solo-writer only |\n * | `listVaults` | ✓ — enumerates subdirectories |\n * | `listPage` | ✓ — cursor-based pagination over sorted filenames |\n * | `ping` | ✓ — `stat(dir)` |\n *\n * ## Bundle helpers\n *\n * {@link saveBundle} and {@link loadBundle} are thin wrappers around the\n * core `writeNoydbBundle` / `readNoydbBundle` primitives that pipe bytes\n * to/from `node:fs`.\n *\n * @packageDocumentation\n */\n\nimport { readFile, writeFile, mkdir, readdir, unlink, stat } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport type {\n NoydbStore,\n EncryptedEnvelope,\n VaultSnapshot,\n Vault,\n WriteNoydbBundleOptions,\n NoydbBundleReadResult,\n} from '@noy-db/hub'\nimport {\n ConflictError,\n writeNoydbBundle,\n readNoydbBundle,\n} from '@noy-db/hub'\n\n/**\n * Options for `jsonFile()`.\n *\n * Files are laid out as `{dir}/{vault}/{collection}/{id}.json`.\n * Internal collections (`_keyring`, `_sync`) follow the same pattern\n * under their vault directory.\n */\nexport interface JsonFileOptions {\n /** Base directory for NOYDB data. */\n dir: string\n /** Pretty-print JSON files. Default: true. */\n pretty?: boolean\n /** Clock uncertainty bound (ms). Default: 0. */\n clockUncertaintyMs?: number\n}\n\n/**\n * Create a JSON file adapter.\n * Maps the NOYDB hierarchy to the filesystem:\n *\n * ```\n * {dir}/{vault}/{collection}/{id}.json\n * {dir}/{vault}/_keyring/{userId}.json\n * ```\n */\nexport function jsonFile(options: JsonFileOptions): NoydbStore {\n const { dir, pretty = true } = options\n\n function recordPath(vault: string, collection: string, id: string): string {\n return join(dir, vault, collection, `${id}.json`)\n }\n\n function collectionDir(vault: string, collection: string): string {\n return join(dir, vault, collection)\n }\n\n async function ensureDir(path: string): Promise<void> {\n await mkdir(path, { recursive: true })\n }\n\n async function fileExists(path: string): Promise<boolean> {\n try {\n await stat(path)\n return true\n } catch {\n return false\n }\n }\n\n function serialize(envelope: EncryptedEnvelope): string {\n return pretty ? JSON.stringify(envelope, null, 2) : JSON.stringify(envelope)\n }\n\n return {\n name: 'file',\n capabilities: {\n casAtomic: false,\n serverWriteTime: true,\n auth: { kind: 'filesystem', required: false, flow: 'static' },\n },\n\n async getStoreTime() {\n const now = Date.now()\n const ε = options.clockUncertaintyMs ?? 0\n return { earliest: now - ε, latest: now + ε }\n },\n\n async get(vault, collection, id) {\n const path = recordPath(vault, collection, id)\n try {\n const content = await readFile(path, 'utf-8')\n return JSON.parse(content) as EncryptedEnvelope\n } catch {\n return null\n }\n },\n\n async put(vault, collection, id, envelope, expectedVersion) {\n const path = recordPath(vault, collection, id)\n\n if (expectedVersion !== undefined && await fileExists(path)) {\n const existing = JSON.parse(await readFile(path, 'utf-8')) as EncryptedEnvelope\n if (existing._v !== expectedVersion) {\n throw new ConflictError(existing._v, `Version conflict: expected ${expectedVersion}, found ${existing._v}`)\n }\n }\n\n await ensureDir(collectionDir(vault, collection))\n await writeFile(path, serialize(envelope), 'utf-8')\n },\n\n async delete(vault, collection, id) {\n const path = recordPath(vault, collection, id)\n try {\n await unlink(path)\n } catch {\n // File doesn't exist — that's fine\n }\n },\n\n async list(vault, collection) {\n const dirPath = collectionDir(vault, collection)\n try {\n const entries = await readdir(dirPath)\n return entries\n .filter(f => f.endsWith('.json'))\n .map(f => f.slice(0, -5)) // remove .json extension\n } catch {\n return []\n }\n },\n\n async loadAll(vault) {\n const compDir = join(dir, vault)\n const snapshot: VaultSnapshot = {}\n\n try {\n const collections = await readdir(compDir)\n for (const collName of collections) {\n if (collName.startsWith('_')) continue // skip _keyring, _sync\n const collPath = join(compDir, collName)\n const collStat = await stat(collPath)\n if (!collStat.isDirectory()) continue\n\n const records: Record<string, EncryptedEnvelope> = {}\n const files = await readdir(collPath)\n for (const file of files) {\n if (!file.endsWith('.json')) continue\n const id = file.slice(0, -5)\n const content = await readFile(join(collPath, file), 'utf-8')\n records[id] = JSON.parse(content) as EncryptedEnvelope\n }\n snapshot[collName] = records\n }\n } catch {\n // Directory doesn't exist — return empty snapshot\n }\n\n return snapshot\n },\n\n async saveAll(vault, data) {\n for (const [collName, records] of Object.entries(data)) {\n const collDir = collectionDir(vault, collName)\n await ensureDir(collDir)\n for (const [id, envelope] of Object.entries(records)) {\n await writeFile(join(collDir, `${id}.json`), serialize(envelope), 'utf-8')\n }\n }\n },\n\n async ping() {\n try {\n await stat(dir)\n return true\n } catch {\n return false\n }\n },\n\n /**\n * Enumerate every top-level vault subdirectory under the\n * configured base directory. Used by\n * `Noydb.listAccessibleVaults()`.\n *\n * The implementation is `readdir(dir)` filtered to entries that\n * are themselves directories — files at the top level (READMEs,\n * .DS_Store, etc.) are skipped, and missing base directory\n * returns an empty array rather than throwing. Result order is\n * filesystem-defined; consumers that want stable order should\n * sort themselves.\n */\n async listVaults() {\n let entries: string[]\n try {\n entries = await readdir(dir)\n } catch {\n return []\n }\n const compartments: string[] = []\n for (const entry of entries) {\n try {\n const entryStat = await stat(join(dir, entry))\n if (entryStat.isDirectory()) compartments.push(entry)\n } catch {\n // Entry vanished between readdir and stat — skip silently.\n }\n }\n return compartments\n },\n\n /**\n * Paginate over a collection. Cursor is a numeric offset (as a string)\n * into the sorted filename list. Files are sorted alphabetically so\n * pages are stable across runs and across processes that share the\n * same data directory.\n *\n * The default `limit` is 100. Each item carries its decoded envelope\n * so callers don't need an extra `get()` round-trip per id.\n */\n async listPage(vault, collection, cursor, limit = 100) {\n const dirPath = collectionDir(vault, collection)\n let files: string[]\n try {\n files = await readdir(dirPath)\n } catch {\n return { items: [], nextCursor: null }\n }\n\n const ids = files\n .filter(f => f.endsWith('.json'))\n .map(f => f.slice(0, -5))\n .sort()\n\n const start = cursor ? parseInt(cursor, 10) : 0\n const end = Math.min(start + limit, ids.length)\n\n const items: Array<{ id: string; envelope: EncryptedEnvelope }> = []\n for (let i = start; i < end; i++) {\n const id = ids[i]!\n try {\n const content = await readFile(join(dirPath, `${id}.json`), 'utf-8')\n items.push({ id, envelope: JSON.parse(content) as EncryptedEnvelope })\n } catch {\n // File disappeared between readdir and readFile — skip silently.\n }\n }\n\n return {\n items,\n nextCursor: end < ids.length ? String(end) : null,\n }\n },\n }\n}\n\n// ─── .noydb bundle helpers ─────────────────────────────────\n\n/**\n * Write a `.noydb` container for a vault to a local file.\n *\n * Thin wrapper around `writeNoydbBundle` from `@noy-db/core` —\n * the core primitive returns a `Uint8Array`, this helper just\n * pipes it to `node:fs.writeFile` after ensuring the parent\n * directory exists. Use the same options as the core primitive.\n *\n * **Path convention** is up to the caller — `.noydb` is the\n * recommended extension. Consumers using cloud-sync folders\n * should name files by the bundle handle (available via\n * `vault.getBundleHandle()`) rather than the vault\n * name to avoid leaking metadata at the filesystem layer:\n *\n * ```ts\n * const handle = await company.getBundleHandle()\n * await saveBundle(`./bundles/${handle}.noydb`, company)\n * ```\n *\n * The full container is written atomically by `node:fs.writeFile`\n * (the platform's atomic-write semantics apply — POSIX `write()`\n * is atomic up to PIPE_BUF, larger files race with concurrent\n * readers; consumers writing into shared cloud folders should\n * pair this with their cloud sync's conflict resolution).\n */\nexport async function saveBundle(\n path: string,\n vault: Vault,\n opts: WriteNoydbBundleOptions = {},\n): Promise<void> {\n const bytes = await writeNoydbBundle(vault, opts)\n // Ensure the parent directory exists — `writeFile` does NOT\n // create intermediate directories on its own. Recursive mkdir\n // is a no-op when the directory already exists.\n await mkdir(dirname(path), { recursive: true })\n await writeFile(path, bytes)\n}\n\n/**\n * Read and verify a `.noydb` container from a local file.\n *\n * Returns the parsed header plus the unwrapped `dump()` JSON\n * string ready to feed to `vault.load(json, passphrase)`.\n * Throws `BundleIntegrityError` from `@noy-db/core` if the body\n * bytes don't match the integrity hash declared in the header\n * (the bundle was modified between write and read), or any\n * format error from the core reader if the bytes aren't a valid\n * bundle at all.\n *\n * Does NOT take a passphrase — the bundle reader is purely a\n * format layer. Restoring a vault from the returned dump\n * JSON requires a separate `vault.load()` call with the\n * passphrase, mirroring the split between\n * `readNoydbBundle()` and `vault.load()` in core.\n */\nexport async function loadBundle(path: string): Promise<NoydbBundleReadResult> {\n const bytes = await readFile(path)\n // node:fs.readFile returns a Buffer, which is a Uint8Array\n // subclass — `readNoydbBundle` accepts Uint8Array directly,\n // no copy needed.\n return readNoydbBundle(bytes)\n}\n\n// Export-blobs FS materializer — wraps `vault.exportBlobs()` with\n// target-profile filename sanitization, Zip-Slip path containment, and\n// collision policy. Lives in `to-file` (not core) because hub stays\n// portable across browser/Node and shouldn't import `node:fs`.\nexport {\n exportBlobsToDirectory,\n} from './export-blobs-to-directory.js'\nexport type {\n ExportBlobsToDirectoryOptions,\n ExportBlobsToDirectoryResult,\n CollisionStrategy,\n} from './export-blobs-to-directory.js'\n","/**\n * `exportBlobsToDirectory(vault, targetDir, opts)` — bulk blob\n * extraction into a real filesystem directory, with target-profile\n * filename sanitization and Zip-Slip path containment built in\n *.\n *\n * Wraps `vault.exportBlobs()` (the framework-agnostic async iterable\n * in core) with the FS-write concerns that don't belong in core:\n *\n * - sanitize filenames per a target profile (`posix`, `windows`,\n * `macos-smb`, `zip`, `url-path`, `s3-key`, `opaque`),\n * - guard against path-escape after sanitization (`PathEscapeError`),\n * - resolve filename collisions (`suffix` / `overwrite` / `fail` /\n * custom callback),\n * - emit a sidecar `manifest.json` when the profile is `'opaque'`,\n * mapping opaque ids back to the original record-supplied\n * filenames.\n *\n * @module\n */\n\nimport { mkdir, writeFile } from 'node:fs/promises'\nimport { resolve, sep, dirname, extname } from 'node:path'\nimport type { Vault } from '@noy-db/hub'\nimport { PathEscapeError } from '@noy-db/hub'\nimport { sanitizeFilename, type FilenameProfile } from '@noy-db/hub/util'\n\n/** Strategy for resolving two records that sanitize to the same name. */\nexport type CollisionStrategy =\n | 'suffix'\n | 'overwrite'\n | 'fail'\n | ((existing: string, attempt: number) => string)\n\nexport interface ExportBlobsToDirectoryOptions {\n /**\n * Filename profile to sanitize against. Default: `'macos-smb'` —\n * the most restrictive intersection of the rules adopters\n * typically hit. Pick a more specific profile when you know the\n * exact destination.\n */\n readonly filenameProfile?: FilenameProfile\n /**\n * How to handle two blobs whose sanitized filenames collide.\n * Default: `'suffix'`.\n */\n readonly onCollision?: CollisionStrategy\n /**\n * Optional collection allowlist forwarded to `vault.exportBlobs`.\n */\n readonly collections?: readonly string[]\n /**\n * Optional record predicate forwarded to `vault.exportBlobs`.\n */\n readonly where?: (\n record: unknown,\n context: { collection: string; id: string },\n ) => boolean\n /**\n * Optional resume cursor forwarded to `vault.exportBlobs`.\n */\n readonly afterBlobId?: string\n /**\n * External abort signal forwarded to `vault.exportBlobs`.\n */\n readonly signal?: AbortSignal\n}\n\nexport interface ExportBlobsToDirectoryResult {\n /** Total blobs written. */\n readonly written: number\n /** Total bytes written across all blobs. */\n readonly bytes: number\n /** Pairs of `{ blobId, path }` for every blob that landed on disk. */\n readonly entries: ReadonlyArray<{ blobId: string; path: string }>\n /**\n * When `filenameProfile === 'opaque'`, the absolute path of the\n * `manifest.json` sidecar. `null` for every other profile.\n */\n readonly manifestPath: string | null\n}\n\ninterface OpaqueManifestEntry {\n readonly opaqueName: string\n readonly originalName: string\n readonly collection: string\n readonly recordId: string\n readonly slot: string\n readonly blobId: string\n readonly mimeType?: string\n}\n\n/**\n * Materialize every blob in the vault into `targetDir`. Returns a\n * summary suitable for logging / audit.\n *\n * Caller MUST already hold whatever capability the vault demands\n * (`canExportPlaintext['blob']`) — this function delegates to\n * `vault.exportBlobs()`, which performs the capability check itself.\n */\nexport async function exportBlobsToDirectory(\n vault: Vault,\n targetDir: string,\n options: ExportBlobsToDirectoryOptions = {},\n): Promise<ExportBlobsToDirectoryResult> {\n const profile: FilenameProfile = options.filenameProfile ?? 'macos-smb'\n const onCollision: CollisionStrategy = options.onCollision ?? 'suffix'\n\n const absTargetDir = resolve(targetDir)\n await mkdir(absTargetDir, { recursive: true })\n const containmentPrefix = absTargetDir + sep\n\n // Track filenames already used in this run so collision resolution\n // is deterministic and cheap (no extra stat() per attempt).\n const used = new Set<string>()\n const entries: { blobId: string; path: string }[] = []\n const opaqueEntries: OpaqueManifestEntry[] = []\n let totalBytes = 0\n\n const handle = vault.exportBlobs({\n ...(options.collections && { collections: options.collections }),\n ...(options.where && { where: options.where }),\n ...(options.afterBlobId && { afterBlobId: options.afterBlobId }),\n ...(options.signal && { signal: options.signal }),\n })\n\n for await (const blob of handle) {\n const original = blob.meta.filename\n const sanitizeOpts =\n profile === 'opaque'\n ? { profile, opaqueId: blob.blobId } as const\n : { profile } as const\n const candidate = sanitizeFilename(original, sanitizeOpts)\n const finalName = resolveCollision(candidate, used, onCollision)\n used.add(finalName)\n\n const absPath = resolve(absTargetDir, finalName)\n if (absPath !== absTargetDir && !absPath.startsWith(containmentPrefix)) {\n throw new PathEscapeError({ attempted: finalName, targetDir: absTargetDir })\n }\n\n await mkdir(dirname(absPath), { recursive: true })\n await writeFile(absPath, blob.bytes)\n entries.push({ blobId: blob.blobId, path: absPath })\n totalBytes += blob.bytes.byteLength\n\n if (profile === 'opaque') {\n const entry: OpaqueManifestEntry = {\n opaqueName: finalName,\n originalName: original,\n collection: blob.recordRef.collection,\n recordId: blob.recordRef.id,\n slot: blob.recordRef.slot,\n blobId: blob.blobId,\n ...(blob.meta.mimeType !== undefined && { mimeType: blob.meta.mimeType }),\n }\n opaqueEntries.push(entry)\n }\n }\n\n let manifestPath: string | null = null\n if (profile === 'opaque') {\n manifestPath = resolve(absTargetDir, 'manifest.json')\n const json = JSON.stringify(\n {\n format: 'noydb-opaque-export',\n version: 1,\n entries: opaqueEntries,\n },\n null,\n 2,\n )\n await writeFile(manifestPath, json)\n }\n\n return {\n written: entries.length,\n bytes: totalBytes,\n entries,\n manifestPath,\n }\n}\n\nfunction resolveCollision(\n candidate: string,\n used: Set<string>,\n strategy: CollisionStrategy,\n): string {\n if (!used.has(candidate)) return candidate\n if (strategy === 'overwrite') return candidate\n if (strategy === 'fail') {\n throw new Error(`exportBlobsToDirectory: filename collision on \"${candidate}\"`)\n }\n // `'suffix'` and the function-callback path both build a sequence\n // of attempts and pick the first non-colliding one.\n for (let attempt = 1; attempt < 10_000; attempt++) {\n const next =\n typeof strategy === 'function'\n ? strategy(candidate, attempt)\n : addSuffix(candidate, attempt)\n if (!used.has(next)) return next\n }\n throw new Error(`exportBlobsToDirectory: collision suffix exhausted for \"${candidate}\"`)\n}\n\nfunction addSuffix(name: string, attempt: number): string {\n const ext = extname(name)\n if (ext.length > 0 && ext.length < name.length) {\n const stem = name.slice(0, name.length - ext.length)\n return `${stem}-${attempt}${ext}`\n }\n return `${name}-${attempt}`\n}\n"],"mappings":"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","names":["writeFile","mkdir","dirname","mkdir","writeFile","dirname"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noy-db/to-file",
|
|
3
|
-
"version": "0.2.0-pre.
|
|
3
|
+
"version": "0.2.0-pre.14",
|
|
4
4
|
"description": "JSON file adapter for noy-db — encrypted document store on local disk, USB sticks, or network drives",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "vLannaAi <vicio@lanna.ai>",
|
|
@@ -39,11 +39,11 @@
|
|
|
39
39
|
"node": ">=18.0.0"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"@noy-db/hub": "0.2.0-pre.
|
|
42
|
+
"@noy-db/hub": "0.2.0-pre.14"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/node": "^22.0.0",
|
|
46
|
-
"@noy-db/hub": "0.2.0-pre.
|
|
46
|
+
"@noy-db/hub": "0.2.0-pre.14",
|
|
47
47
|
"@noy-db/test-adapter-conformance": "0.0.0"
|
|
48
48
|
},
|
|
49
49
|
"keywords": [
|