@sandsoftwaresolutions/json-store 0.1.0 → 0.1.1

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
@@ -10,6 +10,8 @@ A small database for the things that do not need a database: settings, state, a
10
10
  npm install @sandsoftwaresolutions/json-store
11
11
  ```
12
12
 
13
+ Works with `import` and `require()`, and ships TypeScript types for both.
14
+
13
15
  ## Usage
14
16
 
15
17
  ```js
package/dist/index.cjs ADDED
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ const { randomBytes } = require("node:crypto");
3
+ const { constants } = require("node:fs");
4
+ const { mkdir, open, readFile, rename, stat, unlink } = require("node:fs/promises");
5
+ const { basename, dirname, join } = require("node:path");
6
+
7
+ // What goes into the file is JSON, so that is also what the store holds in memory: a Date is a string, undefined fields are gone,
8
+ // and what get() returns right after an update is exactly what a reopened store returns.
9
+ const plain = (value) => {
10
+ const text = JSON.stringify(value);
11
+ return text === undefined ? undefined : JSON.parse(text);
12
+ };
13
+
14
+ async function writeAtomic(path, text) {
15
+ const temp = join(dirname(path), `.${basename(path)}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
16
+ let mode = 0o600;
17
+ let keep = false;
18
+ try {
19
+ mode = (await stat(path)).mode & 0o7777;
20
+ keep = true;
21
+ } catch {
22
+ // New file: private by default, because stores often hold settings and tokens.
23
+ }
24
+ const handle = await open(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, mode);
25
+ try {
26
+ if (keep && process.platform !== "win32") await handle.chmod(mode); // exactly as before, whatever the umask
27
+ await handle.writeFile(text);
28
+ await handle.sync();
29
+ } catch (error) {
30
+ await handle.close().catch(() => {});
31
+ await unlink(temp).catch(() => {});
32
+ throw error;
33
+ }
34
+ await handle.close();
35
+ try {
36
+ await rename(temp, path);
37
+ } catch (error) {
38
+ await unlink(temp).catch(() => {});
39
+ throw error;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * A small store for one JSON file: settings, state, a few records. Writes are atomic (a crash never leaves half a file), changes
45
+ * are applied one at a time in order (so two quick updates do not lose one), and the file has a version number so you can change
46
+ * its shape later with migrations.
47
+ *
48
+ * Options: `path`, `defaults` (value for a new store; the starting point), `version` (current version, default 1),
49
+ * `migrations` ({ 2: (data) => newData, 3: ... } each upgrades from the version before it), `backup` (keep `<path>.bak` of the
50
+ * previous file on every write, default false).
51
+ * The file looks like `{ "version": 1, "data": { ... } }`.
52
+ */
53
+ async function openStore({ path, defaults = {}, version = 1, migrations = {}, backup = false } = {}) {
54
+ if (typeof path !== "string" || path === "") throw new TypeError("json-store: path is required");
55
+ if (!Number.isInteger(version) || version < 1) throw new RangeError("json-store: version must be a whole number, 1 or more");
56
+ for (let v = 2; v <= version; v++) {
57
+ if (typeof migrations[v] !== "function") throw new TypeError(`json-store: missing migration for version ${v}`);
58
+ }
59
+ await mkdir(dirname(path), { recursive: true });
60
+
61
+ let data;
62
+ let text;
63
+ try {
64
+ text = await readFile(path, "utf8");
65
+ } catch (error) {
66
+ // Only a missing file starts a new store. Anything that goes wrong after this point (a failing migration, a failing write)
67
+ // must never be mistaken for a missing file, or the data in the file would be replaced by the defaults.
68
+ if (error.code !== "ENOENT") throw error;
69
+ }
70
+ if (text !== undefined) {
71
+ let file;
72
+ try {
73
+ file = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
74
+ } catch (error) {
75
+ throw new SyntaxError(`json-store: ${path} is not valid JSON (${error.message}). It was not touched; fix or remove it.`);
76
+ }
77
+ if (file === null || typeof file !== "object" || !Number.isInteger(file.version) || !("data" in file)) {
78
+ throw new SyntaxError(`json-store: ${path} is not a store file (expected { version, data }). It was not touched.`);
79
+ }
80
+ if (file.version < 1) throw new RangeError(`json-store: ${path} has version ${file.version}; versions start at 1. It was not touched.`);
81
+ if (file.version > version) throw new RangeError(`json-store: ${path} is version ${file.version}, newer than this program understands (${version}). It was not touched.`);
82
+ data = file.data;
83
+ for (let v = file.version + 1; v <= version; v++) data = plain(await migrations[v](structuredClone(data)));
84
+ if (file.version < version) await persist(file.version);
85
+ } else {
86
+ data = plain(defaults);
87
+ if (data === undefined) data = {};
88
+ await persist();
89
+ }
90
+
91
+ let queue = Promise.resolve();
92
+ async function persist(fromVersion) {
93
+ if (backup || fromVersion !== undefined) {
94
+ // Keep the old file before replacing it with a changed shape, and on every write when backup is on.
95
+ try {
96
+ const old = await readFile(path, "utf8");
97
+ await writeAtomic(fromVersion !== undefined ? `${path}.v${fromVersion}.bak` : `${path}.bak`, old);
98
+ } catch (error) {
99
+ if (error.code !== "ENOENT") throw error;
100
+ }
101
+ }
102
+ await writeAtomic(path, `${JSON.stringify({ version, data }, null, 2)}\n`);
103
+ }
104
+
105
+ const api = {
106
+ /** A copy of the current data. Changing the copy changes nothing; use `update`. */
107
+ get: () => structuredClone(data),
108
+ /**
109
+ * Changes the data: `await store.update((d) => { d.count++; })` (change it in place) or return a new value.
110
+ * Updates run one after the other. If `fn` throws or the write fails, the data stays as it was.
111
+ */
112
+ update(fn) {
113
+ const run = queue.then(async () => {
114
+ const draft = structuredClone(data);
115
+ const result = await fn(draft);
116
+ const before = data;
117
+ data = result === undefined ? plain(draft) : plain(result);
118
+ try {
119
+ await persist();
120
+ } catch (error) {
121
+ data = before;
122
+ throw error;
123
+ }
124
+ return structuredClone(data);
125
+ });
126
+ queue = run.catch(() => {});
127
+ return run;
128
+ },
129
+ /** Replaces everything. */
130
+ set: (value) => api.update(() => structuredClone(value)),
131
+ path
132
+ };
133
+ return api;
134
+ }
135
+
136
+ exports.openStore = openStore;
@@ -0,0 +1,22 @@
1
+ export interface StoreOptions<T> {
2
+ path: string;
3
+ /** The starting value for a new store. Default {}. */
4
+ defaults?: T;
5
+ /** Current version of the data's shape. Default 1. */
6
+ version?: number;
7
+ /** `{ 2: (old) => new, 3: ... }`: each upgrades the data from the version before it. */
8
+ migrations?: Record<number, (data: any) => any | Promise<any>>;
9
+ /** Keep `<path>.bak` of the previous file on every write. */
10
+ backup?: boolean;
11
+ }
12
+
13
+ export interface Store<T> {
14
+ /** A copy: changing it changes nothing. */
15
+ get(): T;
16
+ /** Change `draft` in place, or return a new value. Runs after earlier updates. Rolls back if it throws or the write fails. */
17
+ update(fn: (draft: T) => T | void | Promise<T | void>): Promise<T>;
18
+ set(value: T): Promise<T>;
19
+ readonly path: string;
20
+ }
21
+
22
+ export function openStore<T = Record<string, unknown>>(options: StoreOptions<T>): Promise<Store<T>>;
package/package.json CHANGED
@@ -1,16 +1,25 @@
1
1
  {
2
2
  "name": "@sandsoftwaresolutions/json-store",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A small JSON file store: atomic writes, ordered updates, rollback and versioned migrations. Never overwrites a file it does not understand.",
5
5
  "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "types": "./src/index.d.ts",
6
8
  "exports": {
7
9
  ".": {
8
- "types": "./src/index.d.ts",
9
- "import": "./src/index.js"
10
+ "import": {
11
+ "types": "./src/index.d.ts",
12
+ "default": "./src/index.js"
13
+ },
14
+ "require": {
15
+ "types": "./dist/index.d.cts",
16
+ "default": "./dist/index.cjs"
17
+ }
10
18
  }
11
19
  },
12
20
  "files": [
13
21
  "src",
22
+ "dist",
14
23
  "README.md",
15
24
  "LICENSE"
16
25
  ],
@@ -19,8 +28,10 @@
19
28
  "node": ">=20"
20
29
  },
21
30
  "scripts": {
22
- "test": "node --test",
23
- "pack:check": "npm pack --dry-run"
31
+ "test": "node --test && node scripts/build-cjs.mjs --check",
32
+ "pack:check": "npm pack --dry-run",
33
+ "build": "node scripts/build-cjs.mjs",
34
+ "prepack": "node scripts/build-cjs.mjs"
24
35
  },
25
36
  "keywords": [
26
37
  "json",