@storyshelf/storage-local 0.6.2 → 0.7.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/dist/index.mjs CHANGED
@@ -1,9 +1,11 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
+ import { storageKey } from "./keys.mjs";
5
+ import { randomUUID } from "node:crypto";
4
6
  import { createReadStream, createWriteStream } from "node:fs";
5
- import { access, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
6
- import { dirname, join, relative, resolve, sep } from "node:path";
7
+ import { access, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
8
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
7
9
  import { pipeline } from "node:stream/promises";
8
10
  //#region src/index.ts
9
11
  /**
@@ -17,7 +19,7 @@ function createLocalStorage(dataDir) {
17
19
  return {
18
20
  metadata: {
19
21
  name: "Local Storage",
20
- version: globalThis.__PKG_VERSION__ ?? "0.0.0",
22
+ version: "0.7.0",
21
23
  description: "Local filesystem storage adapter",
22
24
  kind: "local",
23
25
  category: "storage"
@@ -37,9 +39,9 @@ function createLocalStorage(dataDir) {
37
39
  return await readFile(toAbsolute(root, path));
38
40
  },
39
41
  async write(path, data) {
40
- const target = toAbsolute(root, path);
41
- await mkdir(dirname(target), { recursive: true });
42
- await writeFile(target, data);
42
+ await writeAtomically(toAbsolute(root, path), async (temp) => {
43
+ await writeFile(temp, data);
44
+ });
43
45
  },
44
46
  async delete(path) {
45
47
  await rm(toAbsolute(root, path), { force: true });
@@ -63,6 +65,23 @@ async function pathExists(target) {
63
65
  return false;
64
66
  }
65
67
  }
68
+ /**
69
+ * Write through a temp file in the target's directory, then rename it into place: a write cut
70
+ * short by a crash or kill never leaves a torn file at `target` (content-addressed screenshots
71
+ * trust an existing object, so a torn one would poison every later capture with those bytes).
72
+ * The temp name is a dotfile ending in `.tmp`, so it never looks like a stored screenshot.
73
+ */
74
+ async function writeAtomically(target, fill) {
75
+ await mkdir(dirname(target), { recursive: true });
76
+ const temp = join(dirname(target), `.${basename(target)}.${randomUUID()}.tmp`);
77
+ try {
78
+ await fill(temp);
79
+ await rename(temp, target);
80
+ } catch (error) {
81
+ await rm(temp, { force: true });
82
+ throw error;
83
+ }
84
+ }
66
85
  function toAbsolute(root, path) {
67
86
  const target = resolve(root, path);
68
87
  const rel = relative(root, target);
@@ -74,22 +93,16 @@ async function walk(root, dir) {
74
93
  return (await Promise.all(entries.map(async (entry) => {
75
94
  const full = join(dir, entry.name);
76
95
  if (entry.isDirectory()) return await walk(root, full);
77
- return [relative(root, full)];
96
+ return [storageKey(relative(root, full))];
78
97
  }))).flat();
79
98
  }
80
99
  /** Streaming reads/writes for a local storage root (split for lint limits). */
81
100
  function buildStreamMethods(root) {
82
101
  return {
83
102
  async writeStream(path, stream) {
84
- const target = toAbsolute(root, path);
85
- await mkdir(dirname(target), { recursive: true });
86
- const out = createWriteStream(target);
87
- try {
88
- await pipeline(stream, out);
89
- } catch (error) {
90
- await rm(target, { force: true });
91
- throw error;
92
- }
103
+ await writeAtomically(toAbsolute(root, path), async (temp) => {
104
+ await pipeline(stream, createWriteStream(temp));
105
+ });
93
106
  },
94
107
  async readStream(path) {
95
108
  const target = toAbsolute(root, path);
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["import type { StorageAdapter } from \"@storyshelf/core/adapter/storage\";\nimport { createReadStream, createWriteStream } from \"node:fs\";\nimport { access, mkdir, readdir, readFile, rm, writeFile } from \"node:fs/promises\";\nimport { dirname, join, relative, resolve, sep } from \"node:path\";\nimport { pipeline } from \"node:stream/promises\";\n\ndeclare const __PKG_VERSION__: string | undefined;\n\n/**\n * Create a local filesystem-backed StorageAdapter rooted at the given directory.\n *\n * @param dataDir - Root directory in which all stored files live.\n * @returns A StorageAdapter that reads and writes files under `dataDir`.\n */\nexport function createLocalStorage(dataDir: string): StorageAdapter {\n const root = resolve(dataDir);\n\n return {\n metadata: {\n name: \"Local Storage\",\n version: (globalThis as unknown as { __PKG_VERSION__?: string }).__PKG_VERSION__ ?? \"0.0.0\",\n description: \"Local filesystem storage adapter\",\n kind: \"local\",\n category: \"storage\",\n },\n lifecycle: {\n setup: async () => {\n await mkdir(root, { recursive: true });\n },\n teardown: async () => {\n // No handles held — nothing to destroy.\n await Promise.resolve();\n },\n health: async () => {\n const ok = await pathExists(root);\n return { ok };\n },\n },\n async read(path) {\n return await readFile(toAbsolute(root, path));\n },\n async write(path, data) {\n const target = toAbsolute(root, path);\n await mkdir(dirname(target), { recursive: true });\n await writeFile(target, data);\n },\n async delete(path) {\n await rm(toAbsolute(root, path), { force: true });\n },\n async exists(path) {\n return await pathExists(toAbsolute(root, path));\n },\n async list(prefix) {\n const dir = toAbsolute(root, prefix);\n if (!(await pathExists(dir))) {\n return [];\n }\n return walk(root, dir);\n },\n ...buildStreamMethods(root),\n };\n}\n\nasync function pathExists(target: string): Promise<boolean> {\n try {\n await access(target);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction toAbsolute(root: string, path: string): string {\n const target = resolve(root, path);\n const rel = relative(root, target);\n if (rel === \"..\" || rel.startsWith(`..${sep}`)) {\n throw new Error(`Path escapes storage directory: ${path}`);\n }\n return target;\n}\n\nasync function walk(root: string, dir: string): Promise<string[]> {\n const entries = await readdir(dir, { withFileTypes: true });\n const nested = await Promise.all(\n entries.map(async (entry) => {\n const full = join(dir, entry.name);\n if (entry.isDirectory()) {\n return await walk(root, full);\n }\n return [relative(root, full)];\n }),\n );\n return nested.flat();\n}\n\n/** Streaming reads/writes for a local storage root (split for lint limits). */\nfunction buildStreamMethods(root: string): Pick<StorageAdapter, \"writeStream\" | \"readStream\"> {\n return {\n async writeStream(path, stream) {\n const target = toAbsolute(root, path);\n await mkdir(dirname(target), { recursive: true });\n const out = createWriteStream(target);\n try {\n await pipeline(stream, out);\n } catch (error) {\n await rm(target, { force: true });\n throw error;\n }\n },\n async readStream(path) {\n const target = toAbsolute(root, path);\n if (!(await pathExists(target))) {\n throw new Error(`No object at \"${path}\"`);\n }\n return createReadStream(target);\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;AAcA,SAAgB,mBAAmB,SAAiC;CAClE,MAAM,OAAO,QAAQ,OAAO;CAE5B,OAAO;EACL,UAAU;GACR,MAAM;GACN,SAAU,WAAuD,mBAAmB;GACpF,aAAa;GACb,MAAM;GACN,UAAU;EACZ;EACA,WAAW;GACT,OAAO,YAAY;IACjB,MAAM,MAAM,MAAM,EAAE,WAAW,KAAK,CAAC;GACvC;GACA,UAAU,YAAY;IAEpB,MAAM,QAAQ,QAAQ;GACxB;GACA,QAAQ,YAAY;IAElB,OAAO,EAAE,IAAA,MADQ,WAAW,IAAI,EACpB;GACd;EACF;EACA,MAAM,KAAK,MAAM;GACf,OAAO,MAAM,SAAS,WAAW,MAAM,IAAI,CAAC;EAC9C;EACA,MAAM,MAAM,MAAM,MAAM;GACtB,MAAM,SAAS,WAAW,MAAM,IAAI;GACpC,MAAM,MAAM,QAAQ,MAAM,GAAG,EAAE,WAAW,KAAK,CAAC;GAChD,MAAM,UAAU,QAAQ,IAAI;EAC9B;EACA,MAAM,OAAO,MAAM;GACjB,MAAM,GAAG,WAAW,MAAM,IAAI,GAAG,EAAE,OAAO,KAAK,CAAC;EAClD;EACA,MAAM,OAAO,MAAM;GACjB,OAAO,MAAM,WAAW,WAAW,MAAM,IAAI,CAAC;EAChD;EACA,MAAM,KAAK,QAAQ;GACjB,MAAM,MAAM,WAAW,MAAM,MAAM;GACnC,IAAI,CAAE,MAAM,WAAW,GAAG,GACxB,OAAO,CAAC;GAEV,OAAO,KAAK,MAAM,GAAG;EACvB;EACA,GAAG,mBAAmB,IAAI;CAC5B;AACF;AAEA,eAAe,WAAW,QAAkC;CAC1D,IAAI;EACF,MAAM,OAAO,MAAM;EACnB,OAAO;CACT,QAAQ;EACN,OAAO;CACT;AACF;AAEA,SAAS,WAAW,MAAc,MAAsB;CACtD,MAAM,SAAS,QAAQ,MAAM,IAAI;CACjC,MAAM,MAAM,SAAS,MAAM,MAAM;CACjC,IAAI,QAAQ,QAAQ,IAAI,WAAW,KAAK,KAAK,GAC3C,MAAM,IAAI,MAAM,mCAAmC,MAAM;CAE3D,OAAO;AACT;AAEA,eAAe,KAAK,MAAc,KAAgC;CAChE,MAAM,UAAU,MAAM,QAAQ,KAAK,EAAE,eAAe,KAAK,CAAC;CAU1D,QAAO,MATc,QAAQ,IAC3B,QAAQ,IAAI,OAAO,UAAU;EAC3B,MAAM,OAAO,KAAK,KAAK,MAAM,IAAI;EACjC,IAAI,MAAM,YAAY,GACpB,OAAO,MAAM,KAAK,MAAM,IAAI;EAE9B,OAAO,CAAC,SAAS,MAAM,IAAI,CAAC;CAC9B,CAAC,CACH,EAAA,CACc,KAAK;AACrB;;AAGA,SAAS,mBAAmB,MAAkE;CAC5F,OAAO;EACL,MAAM,YAAY,MAAM,QAAQ;GAC9B,MAAM,SAAS,WAAW,MAAM,IAAI;GACpC,MAAM,MAAM,QAAQ,MAAM,GAAG,EAAE,WAAW,KAAK,CAAC;GAChD,MAAM,MAAM,kBAAkB,MAAM;GACpC,IAAI;IACF,MAAM,SAAS,QAAQ,GAAG;GAC5B,SAAS,OAAO;IACd,MAAM,GAAG,QAAQ,EAAE,OAAO,KAAK,CAAC;IAChC,MAAM;GACR;EACF;EACA,MAAM,WAAW,MAAM;GACrB,MAAM,SAAS,WAAW,MAAM,IAAI;GACpC,IAAI,CAAE,MAAM,WAAW,MAAM,GAC3B,MAAM,IAAI,MAAM,iBAAiB,KAAK,EAAE;GAE1C,OAAO,iBAAiB,MAAM;EAChC;CACF;AACF"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["import type { StorageAdapter } from \"@storyshelf/core/adapter/storage\";\nimport { randomUUID } from \"node:crypto\";\nimport { createReadStream, createWriteStream } from \"node:fs\";\nimport { access, mkdir, readdir, readFile, rename, rm, writeFile } from \"node:fs/promises\";\nimport { basename, dirname, join, relative, resolve, sep } from \"node:path\";\nimport { pipeline } from \"node:stream/promises\";\nimport { storageKey } from \"./keys.ts\";\n\ndeclare const __PKG_VERSION__: string | undefined;\n\n/**\n * Create a local filesystem-backed StorageAdapter rooted at the given directory.\n *\n * @param dataDir - Root directory in which all stored files live.\n * @returns A StorageAdapter that reads and writes files under `dataDir`.\n */\nexport function createLocalStorage(dataDir: string): StorageAdapter {\n const root = resolve(dataDir);\n\n return {\n metadata: {\n name: \"Local Storage\",\n version: typeof __PKG_VERSION__ === \"string\" ? __PKG_VERSION__ : \"0.0.0\",\n description: \"Local filesystem storage adapter\",\n kind: \"local\",\n category: \"storage\",\n },\n lifecycle: {\n setup: async () => {\n await mkdir(root, { recursive: true });\n },\n teardown: async () => {\n // No handles held — nothing to destroy.\n await Promise.resolve();\n },\n health: async () => {\n const ok = await pathExists(root);\n return { ok };\n },\n },\n async read(path) {\n return await readFile(toAbsolute(root, path));\n },\n async write(path, data) {\n await writeAtomically(toAbsolute(root, path), async (temp) => {\n await writeFile(temp, data);\n });\n },\n async delete(path) {\n await rm(toAbsolute(root, path), { force: true });\n },\n async exists(path) {\n return await pathExists(toAbsolute(root, path));\n },\n async list(prefix) {\n const dir = toAbsolute(root, prefix);\n if (!(await pathExists(dir))) {\n return [];\n }\n return walk(root, dir);\n },\n ...buildStreamMethods(root),\n };\n}\n\nasync function pathExists(target: string): Promise<boolean> {\n try {\n await access(target);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Write through a temp file in the target's directory, then rename it into place: a write cut\n * short by a crash or kill never leaves a torn file at `target` (content-addressed screenshots\n * trust an existing object, so a torn one would poison every later capture with those bytes).\n * The temp name is a dotfile ending in `.tmp`, so it never looks like a stored screenshot.\n */\nasync function writeAtomically(\n target: string,\n fill: (temp: string) => Promise<void>,\n): Promise<void> {\n await mkdir(dirname(target), { recursive: true });\n const temp = join(dirname(target), `.${basename(target)}.${randomUUID()}.tmp`);\n try {\n await fill(temp);\n await rename(temp, target);\n } catch (error) {\n await rm(temp, { force: true });\n throw error;\n }\n}\n\nfunction toAbsolute(root: string, path: string): string {\n const target = resolve(root, path);\n const rel = relative(root, target);\n if (rel === \"..\" || rel.startsWith(`..${sep}`)) {\n throw new Error(`Path escapes storage directory: ${path}`);\n }\n return target;\n}\n\nasync function walk(root: string, dir: string): Promise<string[]> {\n const entries = await readdir(dir, { withFileTypes: true });\n const nested = await Promise.all(\n entries.map(async (entry) => {\n const full = join(dir, entry.name);\n if (entry.isDirectory()) {\n return await walk(root, full);\n }\n return [storageKey(relative(root, full))];\n }),\n );\n return nested.flat();\n}\n\n/** Streaming reads/writes for a local storage root (split for lint limits). */\nfunction buildStreamMethods(root: string): Pick<StorageAdapter, \"writeStream\" | \"readStream\"> {\n return {\n async writeStream(path, stream) {\n await writeAtomically(toAbsolute(root, path), async (temp) => {\n await pipeline(stream, createWriteStream(temp));\n });\n },\n async readStream(path) {\n const target = toAbsolute(root, path);\n if (!(await pathExists(target))) {\n throw new Error(`No object at \"${path}\"`);\n }\n return createReadStream(target);\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAgBA,SAAgB,mBAAmB,SAAiC;CAClE,MAAM,OAAO,QAAQ,OAAO;CAE5B,OAAO;EACL,UAAU;GACR,MAAM;GACN,SAAA;GACA,aAAa;GACb,MAAM;GACN,UAAU;EACZ;EACA,WAAW;GACT,OAAO,YAAY;IACjB,MAAM,MAAM,MAAM,EAAE,WAAW,KAAK,CAAC;GACvC;GACA,UAAU,YAAY;IAEpB,MAAM,QAAQ,QAAQ;GACxB;GACA,QAAQ,YAAY;IAElB,OAAO,EAAE,IAAA,MADQ,WAAW,IAAI,EACpB;GACd;EACF;EACA,MAAM,KAAK,MAAM;GACf,OAAO,MAAM,SAAS,WAAW,MAAM,IAAI,CAAC;EAC9C;EACA,MAAM,MAAM,MAAM,MAAM;GACtB,MAAM,gBAAgB,WAAW,MAAM,IAAI,GAAG,OAAO,SAAS;IAC5D,MAAM,UAAU,MAAM,IAAI;GAC5B,CAAC;EACH;EACA,MAAM,OAAO,MAAM;GACjB,MAAM,GAAG,WAAW,MAAM,IAAI,GAAG,EAAE,OAAO,KAAK,CAAC;EAClD;EACA,MAAM,OAAO,MAAM;GACjB,OAAO,MAAM,WAAW,WAAW,MAAM,IAAI,CAAC;EAChD;EACA,MAAM,KAAK,QAAQ;GACjB,MAAM,MAAM,WAAW,MAAM,MAAM;GACnC,IAAI,CAAE,MAAM,WAAW,GAAG,GACxB,OAAO,CAAC;GAEV,OAAO,KAAK,MAAM,GAAG;EACvB;EACA,GAAG,mBAAmB,IAAI;CAC5B;AACF;AAEA,eAAe,WAAW,QAAkC;CAC1D,IAAI;EACF,MAAM,OAAO,MAAM;EACnB,OAAO;CACT,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;AAQA,eAAe,gBACb,QACA,MACe;CACf,MAAM,MAAM,QAAQ,MAAM,GAAG,EAAE,WAAW,KAAK,CAAC;CAChD,MAAM,OAAO,KAAK,QAAQ,MAAM,GAAG,IAAI,SAAS,MAAM,EAAE,GAAG,WAAW,EAAE,KAAK;CAC7E,IAAI;EACF,MAAM,KAAK,IAAI;EACf,MAAM,OAAO,MAAM,MAAM;CAC3B,SAAS,OAAO;EACd,MAAM,GAAG,MAAM,EAAE,OAAO,KAAK,CAAC;EAC9B,MAAM;CACR;AACF;AAEA,SAAS,WAAW,MAAc,MAAsB;CACtD,MAAM,SAAS,QAAQ,MAAM,IAAI;CACjC,MAAM,MAAM,SAAS,MAAM,MAAM;CACjC,IAAI,QAAQ,QAAQ,IAAI,WAAW,KAAK,KAAK,GAC3C,MAAM,IAAI,MAAM,mCAAmC,MAAM;CAE3D,OAAO;AACT;AAEA,eAAe,KAAK,MAAc,KAAgC;CAChE,MAAM,UAAU,MAAM,QAAQ,KAAK,EAAE,eAAe,KAAK,CAAC;CAU1D,QAAO,MATc,QAAQ,IAC3B,QAAQ,IAAI,OAAO,UAAU;EAC3B,MAAM,OAAO,KAAK,KAAK,MAAM,IAAI;EACjC,IAAI,MAAM,YAAY,GACpB,OAAO,MAAM,KAAK,MAAM,IAAI;EAE9B,OAAO,CAAC,WAAW,SAAS,MAAM,IAAI,CAAC,CAAC;CAC1C,CAAC,CACH,EAAA,CACc,KAAK;AACrB;;AAGA,SAAS,mBAAmB,MAAkE;CAC5F,OAAO;EACL,MAAM,YAAY,MAAM,QAAQ;GAC9B,MAAM,gBAAgB,WAAW,MAAM,IAAI,GAAG,OAAO,SAAS;IAC5D,MAAM,SAAS,QAAQ,kBAAkB,IAAI,CAAC;GAChD,CAAC;EACH;EACA,MAAM,WAAW,MAAM;GACrB,MAAM,SAAS,WAAW,MAAM,IAAI;GACpC,IAAI,CAAE,MAAM,WAAW,MAAM,GAC3B,MAAM,IAAI,MAAM,iBAAiB,KAAK,EAAE;GAE1C,OAAO,iBAAiB,MAAM;EAChC;CACF;AACF"}
package/dist/keys.mjs ADDED
@@ -0,0 +1,18 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { sep } from "node:path";
5
+ //#region src/keys.ts
6
+ /** Storage keys for local files: `/`-separated on every host. */
7
+ /**
8
+ * A listed file's storage key. Keys are always `/`-separated, whatever the host's path separator,
9
+ * so `list()` returns the same strings `write()` was given and the database stores (on Windows,
10
+ * `relative()` yields `p1\content\x.png`, which would match no reference).
11
+ */
12
+ function storageKey(relativePath, separator = sep) {
13
+ return relativePath.split(separator).join("/");
14
+ }
15
+ //#endregion
16
+ export { storageKey };
17
+
18
+ //# sourceMappingURL=keys.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keys.mjs","names":[],"sources":["../src/keys.ts"],"sourcesContent":["/** Storage keys for local files: `/`-separated on every host. */\nimport { sep } from \"node:path\";\n\n/**\n * A listed file's storage key. Keys are always `/`-separated, whatever the host's path separator,\n * so `list()` returns the same strings `write()` was given and the database stores (on Windows,\n * `relative()` yields `p1\\content\\x.png`, which would match no reference).\n */\nexport function storageKey(relativePath: string, separator: string = sep): string {\n return relativePath.split(separator).join(\"/\");\n}\n"],"mappings":";;;;;;;;;;;AAQA,SAAgB,WAAW,cAAsB,YAAoB,KAAa;CAChF,OAAO,aAAa,MAAM,SAAS,CAAC,CAAC,KAAK,GAAG;AAC/C"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@storyshelf/storage-local",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Local filesystem storage adapter for StoryShelf.",
5
5
  "keywords": [
6
6
  "filesystem",
@@ -26,9 +26,6 @@
26
26
  "dist"
27
27
  ],
28
28
  "type": "module",
29
- "engines": {
30
- "node": ">=24"
31
- },
32
29
  "sideEffects": false,
33
30
  "types": "./dist/index.d.mts",
34
31
  "exports": {
@@ -54,7 +51,7 @@
54
51
  "prepublishOnly": "nub run build"
55
52
  },
56
53
  "dependencies": {
57
- "@storyshelf/core": "0.6.2"
54
+ "@storyshelf/core": "0.7.0"
58
55
  },
59
56
  "devDependencies": {
60
57
  "@types/node": "^26.0.0",
@@ -66,6 +63,9 @@
66
63
  "typescript": "^7.0.2",
67
64
  "vitest": "^5.0.0"
68
65
  },
66
+ "engines": {
67
+ "node": ">=24"
68
+ },
69
69
  "jsr": {
70
70
  "runtimeCompat": {
71
71
  "node": true,