@sandsoftwaresolutions/json-store 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +58 -2
- package/dist/index.cjs +136 -0
- package/dist/index.d.cts +22 -0
- package/package.json +53 -4
- package/src/index.d.ts +22 -0
- package/src/index.js +133 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SandSoftwareSolutions
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,59 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @sandsoftwaresolutions/json-store
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/SandSoftwareSolutions/ss-json-store/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
A small database for the things that do not need a database: settings, state, a few records, in one JSON file. Safe writes, updates that never get lost, and migrations when the shape changes. No dependencies.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @sandsoftwaresolutions/json-store
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Works with `import` and `require()`, and ships TypeScript types for both.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
import { openStore } from "@sandsoftwaresolutions/json-store";
|
|
19
|
+
|
|
20
|
+
const store = await openStore({
|
|
21
|
+
path: "/var/lib/myapp/state.json",
|
|
22
|
+
defaults: { lastRun: 0, failures: 0 }
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
store.get(); // { lastRun: 0, failures: 0 } (a copy)
|
|
26
|
+
await store.update((state) => { state.failures += 1; }); // change in place...
|
|
27
|
+
await store.update((state) => ({ ...state, lastRun: Date.now() })); // ...or return a new value
|
|
28
|
+
await store.set({ lastRun: 0, failures: 0 }); // replace everything
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## What it takes care of
|
|
32
|
+
|
|
33
|
+
- **Atomic writes.** The file is written to a temporary file, flushed, and renamed over the old one, so a crash or power cut never leaves half a file.
|
|
34
|
+
- **No lost updates.** Updates run one after the other, in order, even when the callbacks are async. Fifty `update` calls at the same time give fifty changes.
|
|
35
|
+
- **Rollback.** If your callback throws or the write fails, the data stays as it was.
|
|
36
|
+
- **What is in memory is what is on disk.** The data goes through JSON on every change, so a `Date` becomes a string, `undefined` fields disappear and `NaN`/`Infinity` become `null` right away, not only after a restart. `Map`, `Set` and class instances are not supported.
|
|
37
|
+
- **Copies, not references.** `get()` returns a copy, so changing it by accident changes nothing.
|
|
38
|
+
- **A private file.** A new file is created with permissions `600`, because stores tend to hold tokens. The permissions of an existing file are kept exactly.
|
|
39
|
+
- **It refuses to destroy what it does not understand.** A corrupt file, a file that is not a store, or a file from a newer version of your program is *never* overwritten: you get an error that names the file. The same goes when a migration throws, whatever the error. A byte order mark at the start of the file is ignored.
|
|
40
|
+
|
|
41
|
+
## Migrations
|
|
42
|
+
|
|
43
|
+
The file is `{ "version": 1, "data": { ... } }`. When the shape of your data changes, raise `version` and add a migration:
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
const store = await openStore({
|
|
47
|
+
path, version: 3, defaults: { first: "", last: "", tags: [] },
|
|
48
|
+
migrations: {
|
|
49
|
+
2: (old) => ({ first: old.name.split(" ")[0], last: old.name.split(" ").slice(1).join(" ") }),
|
|
50
|
+
3: (old) => ({ ...old, tags: [] })
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Each migration upgrades from the version before it. The old file is kept as `<path>.v1.bak` before the first write of the new shape. A missing migration is an error at startup. `backup: true` also keeps `<path>.bak` of the previous content on every write.
|
|
56
|
+
|
|
57
|
+
## Not for everything
|
|
58
|
+
|
|
59
|
+
The whole file is read once and written on every change. That is fine for kilobytes, not for megabytes or heavy traffic; for that use SQLite (`@sandsoftwaresolutions/sqlite-kit`). One process should own the file; for several, add `@sandsoftwaresolutions/file-lock`.
|
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,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sandsoftwaresolutions/json-store",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "A small JSON file store: atomic writes, ordered updates, rollback and versioned migrations. Never overwrites a file it does not understand.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"types": "./src/index.d.ts",
|
|
8
|
+
"exports": {
|
|
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
|
+
}
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src",
|
|
22
|
+
"dist",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
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"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"json",
|
|
38
|
+
"store",
|
|
39
|
+
"settings",
|
|
40
|
+
"state",
|
|
41
|
+
"migrations"
|
|
42
|
+
],
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/SandSoftwareSolutions/ss-json-store.git"
|
|
47
|
+
},
|
|
48
|
+
"bugs": {
|
|
49
|
+
"url": "https://github.com/SandSoftwareSolutions/ss-json-store/issues"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/SandSoftwareSolutions/ss-json-store#readme",
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public"
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/index.d.ts
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/src/index.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { mkdir, open, readFile, rename, stat, unlink } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// 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,
|
|
7
|
+
// and what get() returns right after an update is exactly what a reopened store returns.
|
|
8
|
+
const plain = (value) => {
|
|
9
|
+
const text = JSON.stringify(value);
|
|
10
|
+
return text === undefined ? undefined : JSON.parse(text);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
async function writeAtomic(path, text) {
|
|
14
|
+
const temp = join(dirname(path), `.${basename(path)}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
|
|
15
|
+
let mode = 0o600;
|
|
16
|
+
let keep = false;
|
|
17
|
+
try {
|
|
18
|
+
mode = (await stat(path)).mode & 0o7777;
|
|
19
|
+
keep = true;
|
|
20
|
+
} catch {
|
|
21
|
+
// New file: private by default, because stores often hold settings and tokens.
|
|
22
|
+
}
|
|
23
|
+
const handle = await open(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, mode);
|
|
24
|
+
try {
|
|
25
|
+
if (keep && process.platform !== "win32") await handle.chmod(mode); // exactly as before, whatever the umask
|
|
26
|
+
await handle.writeFile(text);
|
|
27
|
+
await handle.sync();
|
|
28
|
+
} catch (error) {
|
|
29
|
+
await handle.close().catch(() => {});
|
|
30
|
+
await unlink(temp).catch(() => {});
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
await handle.close();
|
|
34
|
+
try {
|
|
35
|
+
await rename(temp, path);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
await unlink(temp).catch(() => {});
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A small store for one JSON file: settings, state, a few records. Writes are atomic (a crash never leaves half a file), changes
|
|
44
|
+
* 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
|
|
45
|
+
* its shape later with migrations.
|
|
46
|
+
*
|
|
47
|
+
* Options: `path`, `defaults` (value for a new store; the starting point), `version` (current version, default 1),
|
|
48
|
+
* `migrations` ({ 2: (data) => newData, 3: ... } each upgrades from the version before it), `backup` (keep `<path>.bak` of the
|
|
49
|
+
* previous file on every write, default false).
|
|
50
|
+
* The file looks like `{ "version": 1, "data": { ... } }`.
|
|
51
|
+
*/
|
|
52
|
+
export async function openStore({ path, defaults = {}, version = 1, migrations = {}, backup = false } = {}) {
|
|
53
|
+
if (typeof path !== "string" || path === "") throw new TypeError("json-store: path is required");
|
|
54
|
+
if (!Number.isInteger(version) || version < 1) throw new RangeError("json-store: version must be a whole number, 1 or more");
|
|
55
|
+
for (let v = 2; v <= version; v++) {
|
|
56
|
+
if (typeof migrations[v] !== "function") throw new TypeError(`json-store: missing migration for version ${v}`);
|
|
57
|
+
}
|
|
58
|
+
await mkdir(dirname(path), { recursive: true });
|
|
59
|
+
|
|
60
|
+
let data;
|
|
61
|
+
let text;
|
|
62
|
+
try {
|
|
63
|
+
text = await readFile(path, "utf8");
|
|
64
|
+
} catch (error) {
|
|
65
|
+
// Only a missing file starts a new store. Anything that goes wrong after this point (a failing migration, a failing write)
|
|
66
|
+
// must never be mistaken for a missing file, or the data in the file would be replaced by the defaults.
|
|
67
|
+
if (error.code !== "ENOENT") throw error;
|
|
68
|
+
}
|
|
69
|
+
if (text !== undefined) {
|
|
70
|
+
let file;
|
|
71
|
+
try {
|
|
72
|
+
file = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
throw new SyntaxError(`json-store: ${path} is not valid JSON (${error.message}). It was not touched; fix or remove it.`);
|
|
75
|
+
}
|
|
76
|
+
if (file === null || typeof file !== "object" || !Number.isInteger(file.version) || !("data" in file)) {
|
|
77
|
+
throw new SyntaxError(`json-store: ${path} is not a store file (expected { version, data }). It was not touched.`);
|
|
78
|
+
}
|
|
79
|
+
if (file.version < 1) throw new RangeError(`json-store: ${path} has version ${file.version}; versions start at 1. It was not touched.`);
|
|
80
|
+
if (file.version > version) throw new RangeError(`json-store: ${path} is version ${file.version}, newer than this program understands (${version}). It was not touched.`);
|
|
81
|
+
data = file.data;
|
|
82
|
+
for (let v = file.version + 1; v <= version; v++) data = plain(await migrations[v](structuredClone(data)));
|
|
83
|
+
if (file.version < version) await persist(file.version);
|
|
84
|
+
} else {
|
|
85
|
+
data = plain(defaults);
|
|
86
|
+
if (data === undefined) data = {};
|
|
87
|
+
await persist();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let queue = Promise.resolve();
|
|
91
|
+
async function persist(fromVersion) {
|
|
92
|
+
if (backup || fromVersion !== undefined) {
|
|
93
|
+
// Keep the old file before replacing it with a changed shape, and on every write when backup is on.
|
|
94
|
+
try {
|
|
95
|
+
const old = await readFile(path, "utf8");
|
|
96
|
+
await writeAtomic(fromVersion !== undefined ? `${path}.v${fromVersion}.bak` : `${path}.bak`, old);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error.code !== "ENOENT") throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
await writeAtomic(path, `${JSON.stringify({ version, data }, null, 2)}\n`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const api = {
|
|
105
|
+
/** A copy of the current data. Changing the copy changes nothing; use `update`. */
|
|
106
|
+
get: () => structuredClone(data),
|
|
107
|
+
/**
|
|
108
|
+
* Changes the data: `await store.update((d) => { d.count++; })` (change it in place) or return a new value.
|
|
109
|
+
* Updates run one after the other. If `fn` throws or the write fails, the data stays as it was.
|
|
110
|
+
*/
|
|
111
|
+
update(fn) {
|
|
112
|
+
const run = queue.then(async () => {
|
|
113
|
+
const draft = structuredClone(data);
|
|
114
|
+
const result = await fn(draft);
|
|
115
|
+
const before = data;
|
|
116
|
+
data = result === undefined ? plain(draft) : plain(result);
|
|
117
|
+
try {
|
|
118
|
+
await persist();
|
|
119
|
+
} catch (error) {
|
|
120
|
+
data = before;
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
return structuredClone(data);
|
|
124
|
+
});
|
|
125
|
+
queue = run.catch(() => {});
|
|
126
|
+
return run;
|
|
127
|
+
},
|
|
128
|
+
/** Replaces everything. */
|
|
129
|
+
set: (value) => api.update(() => structuredClone(value)),
|
|
130
|
+
path
|
|
131
|
+
};
|
|
132
|
+
return api;
|
|
133
|
+
}
|