@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 +2 -0
- package/dist/index.cjs +136 -0
- package/dist/index.d.cts +22 -0
- package/package.json +16 -5
package/README.md
CHANGED
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;
|
package/dist/index.d.cts
ADDED
|
@@ -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.
|
|
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
|
-
"
|
|
9
|
-
|
|
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",
|