@noy-db/to-file 0.6.0-pre.8 → 0.6.0

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/README.md CHANGED
@@ -30,6 +30,8 @@ Each compartment is written as a set of JSON files containing only ciphertext en
30
30
  - Network drive sharing with per-user secrets
31
31
  - Backup-friendly storage
32
32
 
33
+ Record writes are staged in a `.tmp` sidecar and renamed into place, so an interrupted write — Wi-Fi dropping mid-write to a mounted share, a USB stick pulled during a flush — never leaves a truncated file under a record's name. Readers see the complete previous file or the complete new one.
34
+
33
35
  ## License
34
36
 
35
37
  MIT © vLannaAi — see the [noy-db repo](https://github.com/vLannaAi/noy-db) for full documentation.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { StoreDescriptor, StoreFactory, StoreLocator, NoydbStore } from '@noy-db/hub/to';
2
- import { Vault, NoydbBundleReadResult, WriteNoydbBundleOptions } from '@noy-db/hub';
2
+ import { Vault, PodReadResult, WritePodOptions } from '@noy-db/hub';
3
3
  import { FilenameProfile } from '@noy-db/hub/util';
4
4
 
5
5
  /**
@@ -119,11 +119,26 @@ declare function exportBlobsToDirectory(vault: Vault, targetDir: string, options
119
119
  * | `listPage` | ✓ — cursor-based pagination over sorted filenames |
120
120
  * | `ping` | ✓ — `stat(dir)` |
121
121
  *
122
- * ## Bundle helpers
122
+ * ## Atomicity
123
123
  *
124
- * {@link saveBundle} and {@link loadBundle} are thin wrappers around the
125
- * core `writeNoydbBundle` / `readNoydbBundle` primitives that pipe bytes
126
- * to/from `node:fs`.
124
+ * The filesystem has `rename` but no atomic CAS `casAtomic` is `false`,
125
+ * and the `expectedVersion` check is read-then-write, so it is advisory
126
+ * under concurrent writers. Per-record writes do go through
127
+ * `{id}.json.{pid}.{n}.tmp` + rename, so a write interrupted partway (a
128
+ * laptop dropping Wi-Fi mid-write to a mounted share, a USB stick pulled
129
+ * during a flush) can never leave a truncated `{id}.json` behind — readers
130
+ * see the complete previous file or the complete new one. Orphaned `.tmp`
131
+ * sidecars from a crashed process are invisible to `list`, `listPage` and
132
+ * `loadAll`, which only accept `.json`.
133
+ *
134
+ * This is atomicity of *visibility*, not durability: surviving a power cut
135
+ * would additionally require fsyncing the file and its directory, which is
136
+ * deliberately not paid per record.
137
+ *
138
+ * ## Pod helpers
139
+ *
140
+ * {@link savePod} and {@link loadPod} are thin wrappers around the hub
141
+ * `writePod` / `readPod` primitives that pipe bytes to/from `node:fs`.
127
142
  *
128
143
  * @packageDocumentation
129
144
  */
@@ -177,46 +192,47 @@ declare function registerFileStore(locator: StoreLocator): void;
177
192
  /**
178
193
  * Write a `.noydb` container for a vault to a local file.
179
194
  *
180
- * Thin wrapper around `writeNoydbBundle` from `@noy-db/core` —
181
- * the core primitive returns a `Uint8Array`, this helper just
182
- * pipes it to `node:fs.writeFile` after ensuring the parent
183
- * directory exists. Use the same options as the core primitive.
195
+ * Thin wrapper around `writePod` from `@noy-db/hub` — the hub
196
+ * primitive returns a `Uint8Array`, this helper just pipes it to
197
+ * disk after ensuring the parent directory exists. Use the same
198
+ * options as the hub primitive.
184
199
  *
185
200
  * **Path convention** is up to the caller — `.noydb` is the
186
201
  * recommended extension. Consumers using cloud-sync folders
187
202
  * should name files by the bundle handle (available via
188
- * `vault.getBundleHandle()`) rather than the vault
203
+ * `vault.getPodHandle()`) rather than the vault
189
204
  * name to avoid leaking metadata at the filesystem layer:
190
205
  *
191
206
  * ```ts
192
- * const handle = await company.getBundleHandle()
193
- * await saveBundle(`./bundles/${handle}.noydb`, company)
207
+ * const handle = await company.getPodHandle()
208
+ * await savePod(`./pods/${handle}.noydb`, company)
194
209
  * ```
195
210
  *
196
- * The full container is written atomically by `node:fs.writeFile`
197
- * (the platform's atomic-write semantics apply POSIX `write()`
198
- * is atomic up to PIPE_BUF, larger files race with concurrent
199
- * readers; consumers writing into shared cloud folders should
200
- * pair this with their cloud sync's conflict resolution).
211
+ * The container is staged in a `.tmp` sidecar and renamed into
212
+ * place (#1040), so a readeror a cloud-sync daemon watching the
213
+ * folder never observes a partially-written pod under its final
214
+ * name. A pod is past `PIPE_BUF` essentially always, so the
215
+ * previous bare `writeFile` genuinely did race with concurrent
216
+ * readers despite the docstring that claimed otherwise.
201
217
  */
202
- declare function saveBundle(path: string, vault: Vault, opts?: WriteNoydbBundleOptions): Promise<void>;
218
+ declare function savePod(path: string, vault: Vault, opts?: WritePodOptions): Promise<void>;
203
219
  /**
204
220
  * Read and verify a `.noydb` container from a local file.
205
221
  *
206
222
  * Returns the parsed header plus the unwrapped `dump()` JSON
207
223
  * string ready to feed to `vault.load(json, secret)`.
208
- * Throws `BundleIntegrityError` from `@noy-db/core` if the body
224
+ * Throws `PodIntegrityError` from `@noy-db/hub` if the body
209
225
  * bytes don't match the integrity hash declared in the header
210
- * (the bundle was modified between write and read), or any
211
- * format error from the core reader if the bytes aren't a valid
212
- * bundle at all.
226
+ * (the pod was modified between write and read), or any
227
+ * format error from the hub reader if the bytes aren't a valid
228
+ * pod at all.
213
229
  *
214
- * Does NOT take a secret — the bundle reader is purely a
230
+ * Does NOT take a secret — the pod reader is purely a
215
231
  * format layer. Restoring a vault from the returned dump
216
232
  * JSON requires a separate `vault.load()` call with the
217
233
  * secret, mirroring the split between
218
- * `readNoydbBundle()` and `vault.load()` in core.
234
+ * `readPod()` and `vault.load()` in hub.
219
235
  */
220
- declare function loadBundle(path: string): Promise<NoydbBundleReadResult>;
236
+ declare function loadPod(path: string): Promise<PodReadResult>;
221
237
 
222
- export { type CollisionStrategy, type ExportBlobsToDirectoryOptions, type ExportBlobsToDirectoryResult, type JsonFileOptions, exportBlobsToDirectory, fileStoreDescriptor, fileStoreFactory, loadBundle, registerFileStore, saveBundle, toFile };
238
+ export { type CollisionStrategy, type ExportBlobsToDirectoryOptions, type ExportBlobsToDirectoryResult, type JsonFileOptions, exportBlobsToDirectory, fileStoreDescriptor, fileStoreFactory, loadPod, registerFileStore, savePod, toFile };
package/dist/index.js CHANGED
@@ -1,11 +1,28 @@
1
1
  // src/index.ts
2
- import { readFile, writeFile as writeFile2, mkdir as mkdir2, readdir, unlink, stat } from "fs/promises";
2
+ import { readFile, mkdir as mkdir2, readdir, unlink as unlink2, stat } from "fs/promises";
3
3
  import { dirname as dirname2, join } from "path";
4
+
5
+ // src/atomic-write.ts
6
+ import { writeFile, unlink, rename } from "fs/promises";
7
+ var tmpCounter = 0;
8
+ async function atomicWrite(path, content) {
9
+ const tmp = `${path}.${process.pid}.${tmpCounter++}.tmp`;
10
+ try {
11
+ await writeFile(tmp, content);
12
+ await rename(tmp, path);
13
+ } catch (err) {
14
+ await unlink(tmp).catch(() => {
15
+ });
16
+ throw err;
17
+ }
18
+ }
19
+
20
+ // src/index.ts
4
21
  import { ConflictError } from "@noy-db/hub/to";
5
- import { writeNoydbBundle, readNoydbBundle } from "@noy-db/hub";
22
+ import { writePod, readPod } from "@noy-db/hub";
6
23
 
7
24
  // src/export-blobs-to-directory.ts
8
- import { mkdir, writeFile } from "fs/promises";
25
+ import { mkdir } from "fs/promises";
9
26
  import { resolve, sep, dirname, extname } from "path";
10
27
  import { PathEscapeError } from "@noy-db/hub";
11
28
  import { sanitizeFilename } from "@noy-db/hub/util";
@@ -36,7 +53,7 @@ async function exportBlobsToDirectory(vault, targetDir, options = {}) {
36
53
  throw new PathEscapeError({ attempted: finalName, targetDir: absTargetDir });
37
54
  }
38
55
  await mkdir(dirname(absPath), { recursive: true });
39
- await writeFile(absPath, blob.bytes);
56
+ await atomicWrite(absPath, blob.bytes);
40
57
  entries.push({ blobId: blob.blobId, path: absPath });
41
58
  totalBytes += blob.bytes.byteLength;
42
59
  if (profile === "opaque") {
@@ -64,7 +81,7 @@ async function exportBlobsToDirectory(vault, targetDir, options = {}) {
64
81
  null,
65
82
  2
66
83
  );
67
- await writeFile(manifestPath, json);
84
+ await atomicWrite(manifestPath, json);
68
85
  }
69
86
  return {
70
87
  written: entries.length,
@@ -147,12 +164,12 @@ function toFile(options) {
147
164
  }
148
165
  }
149
166
  await ensureDir(collectionDir(vault, collection));
150
- await writeFile2(path, serialize(envelope), "utf-8");
167
+ await atomicWrite(path, serialize(envelope));
151
168
  },
152
169
  async delete(vault, collection, id) {
153
170
  const path = recordPath(vault, collection, id);
154
171
  try {
155
- await unlink(path);
172
+ await unlink2(path);
156
173
  } catch {
157
174
  }
158
175
  },
@@ -194,7 +211,7 @@ function toFile(options) {
194
211
  const collDir = collectionDir(vault, collName);
195
212
  await ensureDir(collDir);
196
213
  for (const [id, envelope] of Object.entries(records)) {
197
- await writeFile2(join(collDir, `${id}.json`), serialize(envelope), "utf-8");
214
+ await atomicWrite(join(collDir, `${id}.json`), serialize(envelope));
198
215
  }
199
216
  }
200
217
  },
@@ -290,22 +307,22 @@ var fileStoreFactory = (descriptor, opts) => {
290
307
  function registerFileStore(locator) {
291
308
  locator.register("file", fileStoreFactory);
292
309
  }
293
- async function saveBundle(path, vault, opts = {}) {
294
- const bytes = await writeNoydbBundle(vault, opts);
310
+ async function savePod(path, vault, opts = {}) {
311
+ const bytes = await writePod(vault, opts);
295
312
  await mkdir2(dirname2(path), { recursive: true });
296
- await writeFile2(path, bytes);
313
+ await atomicWrite(path, bytes);
297
314
  }
298
- async function loadBundle(path) {
315
+ async function loadPod(path) {
299
316
  const bytes = await readFile(path);
300
- return readNoydbBundle(bytes);
317
+ return readPod(bytes);
301
318
  }
302
319
  export {
303
320
  exportBlobsToDirectory,
304
321
  fileStoreDescriptor,
305
322
  fileStoreFactory,
306
- loadBundle,
323
+ loadPod,
307
324
  registerFileStore,
308
- saveBundle,
325
+ savePod,
309
326
  toFile
310
327
  };
311
328
  //# sourceMappingURL=index.js.map
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 * | `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 StoreDescriptor,\n StoreFactory,\n StoreLocator,\n} from '@noy-db/hub/to'\nimport { ConflictError } from '@noy-db/hub/to'\nimport type {\n Vault,\n WriteNoydbBundleOptions,\n NoydbBundleReadResult,\n} from '@noy-db/hub'\nimport { writeNoydbBundle, readNoydbBundle } from '@noy-db/hub'\n\n/**\n * Options for `toFile()`.\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 toFile(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// ─── Store-locator descriptor (#945 — `local`-class reference impl) ──\n\n/**\n * Builds the `StoreDescriptor` form of a `toFile()` store: `kind: 'file'`,\n * `class: 'local'`, and a serializable `address` carrying the base\n * directory (same value `JsonFileOptions.dir` would take directly).\n *\n * Credentialless — `to-file` never needs a `StoreCredentialSource`.\n */\nexport function fileStoreDescriptor(dir: string): StoreDescriptor {\n return { kind: 'file', class: 'local', address: { dir } }\n}\n\n/**\n * `StoreFactory` for `to-file`: reconstructs the same store `toFile()`\n * builds, from a `StoreDescriptor` (as produced by {@link fileStoreDescriptor}).\n *\n * `to-file` is a credentialless local store, so `opts.credentials` is\n * unused. `opts.binding` may carry a device-local directory override — a\n * bare string or `{ dir }` — applied in place of `descriptor.address.dir`\n * when present (e.g. a different mount point on this device than what the\n * descriptor was authored with).\n */\nexport const fileStoreFactory: StoreFactory = (descriptor, opts) => {\n const address = descriptor.address as { dir: string }\n const binding = opts.binding as { dir?: string } | string | undefined\n\n let dir: string\n if (typeof binding === 'string') {\n dir = binding\n } else if (binding && binding.dir !== undefined) {\n dir = binding.dir\n } else {\n dir = address.dir\n }\n\n return toFile({ dir })\n}\n\n/** Registers {@link fileStoreFactory} under the `'file'` kind on `locator`. */\nexport function registerFileStore(locator: StoreLocator): void {\n locator.register('file', fileStoreFactory)\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, secret)`.\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 secret — 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 * secret, 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/atomic-write.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 * ## Atomicity\n *\n * The filesystem has `rename` but no atomic CAS — `casAtomic` is `false`,\n * and the `expectedVersion` check is read-then-write, so it is advisory\n * under concurrent writers. Per-record writes do go through\n * `{id}.json.{pid}.{n}.tmp` + rename, so a write interrupted partway (a\n * laptop dropping Wi-Fi mid-write to a mounted share, a USB stick pulled\n * during a flush) can never leave a truncated `{id}.json` behind — readers\n * see the complete previous file or the complete new one. Orphaned `.tmp`\n * sidecars from a crashed process are invisible to `list`, `listPage` and\n * `loadAll`, which only accept `.json`.\n *\n * This is atomicity of *visibility*, not durability: surviving a power cut\n * would additionally require fsyncing the file and its directory, which is\n * deliberately not paid per record.\n *\n * ## Pod helpers\n *\n * {@link savePod} and {@link loadPod} are thin wrappers around the hub\n * `writePod` / `readPod` primitives that pipe bytes to/from `node:fs`.\n *\n * @packageDocumentation\n */\n\nimport { readFile, mkdir, readdir, unlink, stat } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport { atomicWrite } from './atomic-write.js'\nimport type {\n NoydbStore,\n EncryptedEnvelope,\n VaultSnapshot,\n StoreDescriptor,\n StoreFactory,\n StoreLocator,\n} from '@noy-db/hub/to'\nimport { ConflictError } from '@noy-db/hub/to'\nimport type {\n Vault,\n WritePodOptions,\n PodReadResult,\n} from '@noy-db/hub'\nimport { writePod, readPod } from '@noy-db/hub'\n\n/**\n * Options for `toFile()`.\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 toFile(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 atomicWrite(path, serialize(envelope))\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 atomicWrite(join(collDir, `${id}.json`), serialize(envelope))\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// ─── Store-locator descriptor (#945 — `local`-class reference impl) ──\n\n/**\n * Builds the `StoreDescriptor` form of a `toFile()` store: `kind: 'file'`,\n * `class: 'local'`, and a serializable `address` carrying the base\n * directory (same value `JsonFileOptions.dir` would take directly).\n *\n * Credentialless — `to-file` never needs a `StoreCredentialSource`.\n */\nexport function fileStoreDescriptor(dir: string): StoreDescriptor {\n return { kind: 'file', class: 'local', address: { dir } }\n}\n\n/**\n * `StoreFactory` for `to-file`: reconstructs the same store `toFile()`\n * builds, from a `StoreDescriptor` (as produced by {@link fileStoreDescriptor}).\n *\n * `to-file` is a credentialless local store, so `opts.credentials` is\n * unused. `opts.binding` may carry a device-local directory override — a\n * bare string or `{ dir }` — applied in place of `descriptor.address.dir`\n * when present (e.g. a different mount point on this device than what the\n * descriptor was authored with).\n */\nexport const fileStoreFactory: StoreFactory = (descriptor, opts) => {\n const address = descriptor.address as { dir: string }\n const binding = opts.binding as { dir?: string } | string | undefined\n\n let dir: string\n if (typeof binding === 'string') {\n dir = binding\n } else if (binding && binding.dir !== undefined) {\n dir = binding.dir\n } else {\n dir = address.dir\n }\n\n return toFile({ dir })\n}\n\n/** Registers {@link fileStoreFactory} under the `'file'` kind on `locator`. */\nexport function registerFileStore(locator: StoreLocator): void {\n locator.register('file', fileStoreFactory)\n}\n\n// ─── .noydb pod helpers ────────────────────────────────────\n\n/**\n * Write a `.noydb` container for a vault to a local file.\n *\n * Thin wrapper around `writePod` from `@noy-db/hub` — the hub\n * primitive returns a `Uint8Array`, this helper just pipes it to\n * disk after ensuring the parent directory exists. Use the same\n * options as the hub 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.getPodHandle()`) rather than the vault\n * name to avoid leaking metadata at the filesystem layer:\n *\n * ```ts\n * const handle = await company.getPodHandle()\n * await savePod(`./pods/${handle}.noydb`, company)\n * ```\n *\n * The container is staged in a `.tmp` sidecar and renamed into\n * place (#1040), so a reader — or a cloud-sync daemon watching the\n * folder — never observes a partially-written pod under its final\n * name. A pod is past `PIPE_BUF` essentially always, so the\n * previous bare `writeFile` genuinely did race with concurrent\n * readers despite the docstring that claimed otherwise.\n */\nexport async function savePod(\n path: string,\n vault: Vault,\n opts: WritePodOptions = {},\n): Promise<void> {\n const bytes = await writePod(vault, opts)\n // Ensure the parent directory exists — the write 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 atomicWrite(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, secret)`.\n * Throws `PodIntegrityError` from `@noy-db/hub` if the body\n * bytes don't match the integrity hash declared in the header\n * (the pod was modified between write and read), or any\n * format error from the hub reader if the bytes aren't a valid\n * pod at all.\n *\n * Does NOT take a secret — the pod 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 * secret, mirroring the split between\n * `readPod()` and `vault.load()` in hub.\n */\nexport async function loadPod(path: string): Promise<PodReadResult> {\n const bytes = await readFile(path)\n // node:fs.readFile returns a Buffer, which is a Uint8Array\n // subclass — `readPod` accepts Uint8Array directly,\n // no copy needed.\n return readPod(bytes)\n}\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","import { writeFile, unlink, rename } from 'node:fs/promises'\n\n/** Disambiguates concurrent temp files within a single process. */\nlet tmpCounter = 0\n\n/**\n * Write `content` to `path` without ever exposing a partial file under\n * that name: stage the bytes in a sidecar, then `rename` over the target.\n *\n * `rename(2)` is atomic within a directory on POSIX, and `fs.rename`\n * replaces the target atomically on Windows, so a reader sees either the\n * complete old file or the complete new one — never a truncation. This\n * matters most on the network drives `to-file` advertises: a laptop losing\n * Wi-Fi mid-write to a mounted share would otherwise leave a `{id}.json`\n * that no longer parses, which fails `loadAll()` for the whole vault\n * rather than for the one record.\n *\n * The sidecar carries pid + a process-local counter so concurrent writes\n * (same process, or several machines on one share) never collide. Its name\n * does not end in `.json`, which is what keeps an orphan from a crashed\n * process invisible to `list`, `listPage` and `loadAll` — pinned by a test,\n * since those filters are what make the sidecar safe.\n *\n * Atomicity of *visibility* only. Surviving a power cut additionally needs\n * the file and its directory fsynced, which is deliberately not paid per\n * record here.\n */\nexport async function atomicWrite(\n path: string,\n content: string | Uint8Array,\n): Promise<void> {\n const tmp = `${path}.${process.pid}.${tmpCounter++}.tmp`\n try {\n await writeFile(tmp, content)\n await rename(tmp, path)\n } catch (err) {\n // Leave no residue behind on our own failure path.\n await unlink(tmp).catch(() => {})\n throw err\n }\n}\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 } from 'node:fs/promises'\nimport { atomicWrite } from './atomic-write.js'\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 atomicWrite(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 atomicWrite(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":["mkdir","unlink","dirname","mkdir","unlink","dirname"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noy-db/to-file",
3
- "version": "0.6.0-pre.8",
3
+ "version": "0.6.0",
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>",
@@ -32,12 +32,12 @@
32
32
  "node": ">=22.0.0"
33
33
  },
34
34
  "peerDependencies": {
35
- "@noy-db/hub": "0.6.0-pre.8"
35
+ "@noy-db/hub": "0.6.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/node": "^22.0.0",
39
- "@noy-db/test-adapter-conformance": "0.6.0-pre.8",
40
- "@noy-db/hub": "0.6.0-pre.8"
39
+ "@noy-db/hub": "0.6.0",
40
+ "@noy-db/test-adapter-conformance": "0.6.0"
41
41
  },
42
42
  "keywords": [
43
43
  "noy-db",