@crustjs/store 0.2.0 → 0.3.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/LICENSE +21 -0
- package/README.md +3 -604
- package/dist/index.d.ts +624 -717
- package/dist/index.js +727 -4
- package/package.json +21 -13
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chenxin Yan
|
|
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,614 +1,13 @@
|
|
|
1
1
|
# @crustjs/store
|
|
2
2
|
|
|
3
|
-
DX-first, typed persistence for CLI apps with
|
|
4
|
-
|
|
5
|
-
`@crustjs/store` gives your CLI production-ready local persistence with near-zero setup. Provide a `fields` definition, pick a storage intent, and get a typed store with read/write/update/patch/reset — no manual type annotations needed.
|
|
3
|
+
DX-first, typed persistence for CLI apps with config/data/state/cache separation
|
|
6
4
|
|
|
7
5
|
## Install
|
|
8
6
|
|
|
9
7
|
```sh
|
|
10
|
-
# bun
|
|
11
8
|
bun add @crustjs/store
|
|
12
|
-
|
|
13
|
-
# npm
|
|
14
|
-
npm install @crustjs/store
|
|
15
|
-
|
|
16
|
-
# pnpm
|
|
17
|
-
pnpm add @crustjs/store
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
**Requirements:** Bun 1.x or Node 18+. TypeScript 5.x recommended.
|
|
21
|
-
|
|
22
|
-
## Quick Start
|
|
23
|
-
|
|
24
|
-
```ts
|
|
25
|
-
import { createStore, configDir } from "@crustjs/store";
|
|
26
|
-
|
|
27
|
-
const store = createStore({
|
|
28
|
-
dirPath: configDir("my-cli"),
|
|
29
|
-
fields: {
|
|
30
|
-
theme: { type: "string", default: "light" },
|
|
31
|
-
fontSize: { type: "number", default: 14 },
|
|
32
|
-
verbose: { type: "boolean", default: false },
|
|
33
|
-
},
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
// Read state (returns defaults when no persisted file exists)
|
|
37
|
-
const state = await store.read();
|
|
38
|
-
// → { theme: "light", fontSize: 14, verbose: false }
|
|
39
|
-
|
|
40
|
-
// Write a full state object
|
|
41
|
-
await store.write({ theme: "dark", fontSize: 16, verbose: true });
|
|
42
|
-
|
|
43
|
-
// Update with a function
|
|
44
|
-
await store.update((current) => ({ ...current, verbose: false }));
|
|
45
|
-
|
|
46
|
-
// Patch only specific keys
|
|
47
|
-
await store.patch({ theme: "solarized" });
|
|
48
|
-
|
|
49
|
-
// Reset to defaults (removes persisted file)
|
|
50
|
-
await store.reset();
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
No explicit generics needed — types are inferred from your `fields` definition.
|
|
54
|
-
|
|
55
|
-
## Storage Intent: Choosing a Directory
|
|
56
|
-
|
|
57
|
-
`@crustjs/store` provides four path helpers that resolve platform-standard directories for different storage intents. Use the one that matches what you're persisting:
|
|
58
|
-
|
|
59
|
-
| Helper | Intent | Example Use Case |
|
|
60
|
-
| ----------- | ------------------------- | -------------------------------------- |
|
|
61
|
-
| `configDir` | User preferences & config | Theme, editor settings, API keys |
|
|
62
|
-
| `dataDir` | Important app data | Local databases, downloaded resources |
|
|
63
|
-
| `stateDir` | Runtime state | Window positions, scroll offsets, undo |
|
|
64
|
-
| `cacheDir` | Regenerable cached data | HTTP caches, compiled assets |
|
|
65
|
-
|
|
66
|
-
```ts
|
|
67
|
-
import { createStore, configDir, dataDir, stateDir, cacheDir } from "@crustjs/store";
|
|
68
|
-
|
|
69
|
-
// User preferences → ~/.config/my-cli/config.json
|
|
70
|
-
const config = createStore({
|
|
71
|
-
dirPath: configDir("my-cli"),
|
|
72
|
-
fields: {
|
|
73
|
-
theme: { type: "string", default: "light" },
|
|
74
|
-
verbose: { type: "boolean", default: false },
|
|
75
|
-
},
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
// App data → ~/.local/share/my-cli/config.json
|
|
79
|
-
const data = createStore({
|
|
80
|
-
dirPath: dataDir("my-cli"),
|
|
81
|
-
fields: {
|
|
82
|
-
bookmarks: { type: "string", array: true, default: [] },
|
|
83
|
-
},
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
// Runtime state → ~/.local/state/my-cli/config.json
|
|
87
|
-
const state = createStore({
|
|
88
|
-
dirPath: stateDir("my-cli"),
|
|
89
|
-
fields: {
|
|
90
|
-
lastOpened: { type: "string", default: "" },
|
|
91
|
-
scrollY: { type: "number", default: 0 },
|
|
92
|
-
},
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
// Cache → ~/.cache/my-cli/config.json
|
|
96
|
-
const cache = createStore({
|
|
97
|
-
dirPath: cacheDir("my-cli"),
|
|
98
|
-
fields: {
|
|
99
|
-
etag: { type: "string", default: "" },
|
|
100
|
-
payload: { type: "string", default: "" },
|
|
101
|
-
},
|
|
102
|
-
});
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
### Platform Paths
|
|
106
|
-
|
|
107
|
-
All four helpers follow XDG conventions on Linux and macOS, and Windows-native conventions on Windows:
|
|
108
|
-
|
|
109
|
-
| Helper | Linux / macOS | Windows | Env Override |
|
|
110
|
-
| ----------- | ---------------------- | ---------------------------- | ------------------ |
|
|
111
|
-
| `configDir` | `~/.config/<app>` | `%APPDATA%\<app>` | `$XDG_CONFIG_HOME` |
|
|
112
|
-
| `dataDir` | `~/.local/share/<app>` | `%LOCALAPPDATA%\<app>\Data` | `$XDG_DATA_HOME` |
|
|
113
|
-
| `stateDir` | `~/.local/state/<app>` | `%LOCALAPPDATA%\<app>\State` | `$XDG_STATE_HOME` |
|
|
114
|
-
| `cacheDir` | `~/.cache/<app>` | `%LOCALAPPDATA%\<app>\Cache` | `$XDG_CACHE_HOME` |
|
|
115
|
-
|
|
116
|
-
macOS uses XDG conventions (same as Linux) for a unified Unix mental model.
|
|
117
|
-
|
|
118
|
-
## API
|
|
119
|
-
|
|
120
|
-
### `createStore(options)`
|
|
121
|
-
|
|
122
|
-
Creates a typed async store backed by a local JSON file. The file path is resolved once at creation time.
|
|
123
|
-
|
|
124
|
-
```ts
|
|
125
|
-
const store = createStore(options);
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
**Returns:** A `Store<T>` with `read()`, `write()`, `update()`, `patch()`, and `reset()` methods.
|
|
129
|
-
|
|
130
|
-
**Throws:** `CrustStoreError` with `PATH` code if `dirPath` or `name` is invalid.
|
|
131
|
-
|
|
132
|
-
#### Options
|
|
133
|
-
|
|
134
|
-
| Option | Type | Required | Description |
|
|
135
|
-
| -------------- | ----------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------ |
|
|
136
|
-
| `dirPath` | `string` | Yes | Absolute directory path where the JSON file is stored. |
|
|
137
|
-
| `name` | `string` | No | Store name used as filename (default `"config"` → `config.json`). |
|
|
138
|
-
| `fields` | `FieldsDef` | Yes | Field definitions defining the store's data shape, types, defaults, and optional validation. |
|
|
139
|
-
| `pruneUnknown` | `boolean` | No | Drop unknown persisted keys on read (default `true`). |
|
|
140
|
-
| `access` | `"default" \| "private" \| { file?: number; directory?: number }` | No | Persistence visibility. Use `"private"` for owner-only secret stores, or pass explicit Unix permission bits. |
|
|
141
|
-
|
|
142
|
-
#### Securing secrets
|
|
143
|
-
|
|
144
|
-
Config/state stores that hold tokens or API keys should not be world-readable. Pass `access: "private"` so the file is owner-only on Unix:
|
|
145
|
-
|
|
146
|
-
```ts
|
|
147
|
-
const auth = createStore({
|
|
148
|
-
dirPath: configDir("my-cli"),
|
|
149
|
-
name: "auth",
|
|
150
|
-
fields: { token: { type: "string" } },
|
|
151
|
-
access: "private",
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
await auth.write({ token: "secret" }); // auth.json is 0600 even under a permissive umask
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
`access: "private"` maps to `0600` for the file (`rw-------`) and `0700` for the parent directory (`rwx------`, only when created). The file mode is enforced on the temp file before the atomic rename, so the persisted file is never momentarily group/other-readable.
|
|
158
|
-
|
|
159
|
-
The built-in string presets are intentionally small:
|
|
160
|
-
|
|
161
|
-
| Access | File | Directory | Use case |
|
|
162
|
-
| --------------------- | ----------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------- |
|
|
163
|
-
| omitted / `"default"` | Platform default, usually `0644` after a common umask | Platform default, usually `0755` after a common umask | Non-secret stores where the user's environment should decide permissions. |
|
|
164
|
-
| `"private"` | `0600` | `0700` | Secret-bearing stores such as tokens, API keys, and local auth state. |
|
|
165
|
-
|
|
166
|
-
Advanced callers can provide explicit permission bits without adding more named presets:
|
|
167
|
-
|
|
168
|
-
```ts
|
|
169
|
-
const store = createStore({
|
|
170
|
-
dirPath: configDir("my-cli"),
|
|
171
|
-
name: "auth",
|
|
172
|
-
fields: { token: { type: "string" } },
|
|
173
|
-
// Owner-only secret store (same as access: "private")
|
|
174
|
-
access: { file: 0o600, directory: 0o700 },
|
|
175
|
-
// Group-readable store; group ownership is managed outside @crustjs/store
|
|
176
|
-
// access: { file: 0o640, directory: 0o750 },
|
|
177
|
-
// World-readable non-secret store
|
|
178
|
-
// access: { file: 0o644, directory: 0o755 },
|
|
179
|
-
});
|
|
180
|
-
```
|
|
181
|
-
|
|
182
|
-
**Platform behavior:** `access` permission bits are enforced on **macOS and Linux** (and other Unix systems), where POSIX permission bits are checked by the OS. On **Windows** they are **not enforced** — Windows uses ACLs, not Unix bits, so `access: "private"` does not make a file owner-only there. The store does not throw or warn in this case. On Windows, confidentiality relies on the ACL inherited from the parent directory; the per-user profile location resolved by `configDir` / `stateDir` (under `%APPDATA%` / `%LOCALAPPDATA%`) is already restricted to the user. If you need strong secret confidentiality on Windows, use the OS credential store (Windows Credential Manager / DPAPI) rather than file permissions.
|
|
183
|
-
|
|
184
|
-
### `store.read()`
|
|
185
|
-
|
|
186
|
-
Reads the persisted state file. Missing keys are filled from field `defaults`.
|
|
187
|
-
|
|
188
|
-
```ts
|
|
189
|
-
const state = await store.read();
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
Always returns a value — never `undefined`. Does not write merged defaults back to disk.
|
|
193
|
-
|
|
194
|
-
### `store.write(state)`
|
|
195
|
-
|
|
196
|
-
Atomically persists a full state object. The entire previous state is replaced.
|
|
197
|
-
|
|
198
|
-
```ts
|
|
199
|
-
await store.write({ theme: "dark", fontSize: 14, verbose: true });
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
Calls per-field `validate` functions (if provided) before writing. Parent directories are created if missing.
|
|
203
|
-
|
|
204
|
-
### `store.update(updater)`
|
|
205
|
-
|
|
206
|
-
Reads the current effective state, applies the updater function, and atomically persists the result.
|
|
207
|
-
|
|
208
|
-
```ts
|
|
209
|
-
await store.update((current) => ({
|
|
210
|
-
...current,
|
|
211
|
-
theme: "dark",
|
|
212
|
-
}));
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
When no persisted file exists, the updater receives field `defaults` as the current value. Calls per-field `validate` functions before writing.
|
|
216
|
-
|
|
217
|
-
### `store.patch(partial)`
|
|
218
|
-
|
|
219
|
-
Applies a shallow partial update to the current state and persists. Only the provided keys are updated; everything else is preserved.
|
|
220
|
-
|
|
221
|
-
```ts
|
|
222
|
-
await store.patch({ theme: "solarized" });
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
Calls per-field `validate` functions before writing.
|
|
226
|
-
|
|
227
|
-
### `store.reset()`
|
|
228
|
-
|
|
229
|
-
Removes the persisted state file. After reset, `read()` returns `defaults`.
|
|
230
|
-
|
|
231
|
-
```ts
|
|
232
|
-
await store.reset();
|
|
233
|
-
```
|
|
234
|
-
|
|
235
|
-
Reset is idempotent — calling it when no file exists is a no-op.
|
|
236
|
-
|
|
237
|
-
### Path Helpers
|
|
238
|
-
|
|
239
|
-
#### `configDir(appName, env?)`
|
|
240
|
-
|
|
241
|
-
Resolves the platform-standard config directory for the given app name.
|
|
242
|
-
|
|
243
|
-
```ts
|
|
244
|
-
import { configDir } from "@crustjs/store";
|
|
245
|
-
|
|
246
|
-
const dir = configDir("my-cli");
|
|
247
|
-
// → "/home/user/.config/my-cli" (Linux / macOS)
|
|
248
|
-
// → "C:\\Users\\user\\AppData\\Roaming\\my-cli" (Windows)
|
|
249
|
-
```
|
|
250
|
-
|
|
251
|
-
#### `dataDir(appName, env?)`
|
|
252
|
-
|
|
253
|
-
Resolves the platform-standard data directory.
|
|
254
|
-
|
|
255
|
-
```ts
|
|
256
|
-
import { dataDir } from "@crustjs/store";
|
|
257
|
-
|
|
258
|
-
const dir = dataDir("my-cli");
|
|
259
|
-
// → "/home/user/.local/share/my-cli" (Linux / macOS)
|
|
260
|
-
// → "C:\\Users\\user\\AppData\\Local\\my-cli\\Data" (Windows)
|
|
261
|
-
```
|
|
262
|
-
|
|
263
|
-
#### `stateDir(appName, env?)`
|
|
264
|
-
|
|
265
|
-
Resolves the platform-standard state directory.
|
|
266
|
-
|
|
267
|
-
```ts
|
|
268
|
-
import { stateDir } from "@crustjs/store";
|
|
269
|
-
|
|
270
|
-
const dir = stateDir("my-cli");
|
|
271
|
-
// → "/home/user/.local/state/my-cli" (Linux / macOS)
|
|
272
|
-
// → "C:\\Users\\user\\AppData\\Local\\my-cli\\State" (Windows)
|
|
273
|
-
```
|
|
274
|
-
|
|
275
|
-
#### `cacheDir(appName, env?)`
|
|
276
|
-
|
|
277
|
-
Resolves the platform-standard cache directory.
|
|
278
|
-
|
|
279
|
-
```ts
|
|
280
|
-
import { cacheDir } from "@crustjs/store";
|
|
281
|
-
|
|
282
|
-
const dir = cacheDir("my-cli");
|
|
283
|
-
// → "/home/user/.cache/my-cli" (Linux / macOS)
|
|
284
|
-
// → "C:\\Users\\user\\AppData\\Local\\my-cli\\Cache" (Windows)
|
|
285
|
-
```
|
|
286
|
-
|
|
287
|
-
All path helpers accept an optional `PlatformEnv` parameter for deterministic testing without mutating `process.env`.
|
|
288
|
-
|
|
289
|
-
## Multiple Stores
|
|
290
|
-
|
|
291
|
-
Use the `name` option to create multiple stores under the same directory:
|
|
292
|
-
|
|
293
|
-
```ts
|
|
294
|
-
import { createStore, configDir } from "@crustjs/store";
|
|
295
|
-
|
|
296
|
-
const dir = configDir("my-cli");
|
|
297
|
-
|
|
298
|
-
// Default store → ~/.config/my-cli/config.json
|
|
299
|
-
const settingsStore = createStore({
|
|
300
|
-
dirPath: dir,
|
|
301
|
-
fields: {
|
|
302
|
-
theme: { type: "string", default: "light" },
|
|
303
|
-
fontSize: { type: "number", default: 14 },
|
|
304
|
-
verbose: { type: "boolean", default: false },
|
|
305
|
-
},
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
// Auth store → ~/.config/my-cli/auth.json
|
|
309
|
-
const authStore = createStore({
|
|
310
|
-
dirPath: dir,
|
|
311
|
-
name: "auth",
|
|
312
|
-
fields: {
|
|
313
|
-
token: { type: "string", default: "" },
|
|
314
|
-
},
|
|
315
|
-
});
|
|
316
|
-
```
|
|
317
|
-
|
|
318
|
-
Each store is fully independent — reading, writing, updating, and resetting one store does not affect the others.
|
|
319
|
-
|
|
320
|
-
The `name` must not contain path separators or the `.json` extension.
|
|
321
|
-
|
|
322
|
-
## Defaults & Merge
|
|
323
|
-
|
|
324
|
-
When a persisted file exists, `read()` fills missing keys from field `defaults`:
|
|
325
|
-
|
|
326
|
-
```ts
|
|
327
|
-
const store = createStore({
|
|
328
|
-
dirPath: configDir("my-cli"),
|
|
329
|
-
fields: {
|
|
330
|
-
theme: { type: "string", default: "light" },
|
|
331
|
-
fontSize: { type: "number", default: 14 },
|
|
332
|
-
verbose: { type: "boolean", default: false },
|
|
333
|
-
},
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
// Persisted file contains: { "theme": "dark", "verbose": true }
|
|
337
|
-
const state = await store.read();
|
|
338
|
-
// → { theme: "dark", fontSize: 14, verbose: true }
|
|
339
|
-
```
|
|
340
|
-
|
|
341
|
-
### Merge Rules
|
|
342
|
-
|
|
343
|
-
- Persisted values override defaults.
|
|
344
|
-
- Missing keys fall back to defaults.
|
|
345
|
-
- Unknown persisted keys are dropped by default (`pruneUnknown: true`). Set `pruneUnknown: false` to preserve them.
|
|
346
|
-
- All values are deep-cloned to prevent shared-reference mutation from defaults.
|
|
347
|
-
- Falsy values (`null`, `0`, `""`, `false`) in the persisted file are preserved — only truly missing keys trigger defaults.
|
|
348
|
-
|
|
349
|
-
### Important: defaults are not auto-persisted
|
|
350
|
-
|
|
351
|
-
Merged defaults exist only in memory. The persisted file remains unchanged until you explicitly call `write()`, `update()`, or `patch()`.
|
|
352
|
-
|
|
353
|
-
## Validation
|
|
354
|
-
|
|
355
|
-
Add per-field validation to enforce config integrity on every read, write, update, and patch. When a `validate` function is configured on a field, validation is **strict by default** — invalid values fail loudly.
|
|
356
|
-
|
|
357
|
-
### Schema-driven validation
|
|
358
|
-
|
|
359
|
-
The easiest way to add validation is with the built-in `field()` factory.
|
|
360
|
-
It builds a `FieldDef` from any [Standard Schema v1](https://standardschema.dev/)
|
|
361
|
-
object. Crust stores raw values; the schema validates and transforms them on
|
|
362
|
-
read/write.
|
|
363
|
-
|
|
364
|
-
```ts
|
|
365
|
-
import { z } from "zod";
|
|
366
|
-
import { createStore, configDir, field } from "@crustjs/store";
|
|
367
|
-
|
|
368
|
-
const store = createStore({
|
|
369
|
-
dirPath: configDir("my-cli"),
|
|
370
|
-
fields: {
|
|
371
|
-
theme: field(z.enum(["light", "dark"]).default("light")),
|
|
372
|
-
verbose: { type: "boolean", default: false },
|
|
373
|
-
},
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
// write() validates before persisting
|
|
377
|
-
await store.write({ theme: "neon", verbose: true });
|
|
378
|
-
// → throws CrustStoreError("VALIDATION")
|
|
379
|
-
|
|
380
|
-
// read() validates after applying defaults
|
|
381
|
-
const config = await store.read();
|
|
382
|
-
// → throws if persisted config is invalid
|
|
383
|
-
```
|
|
384
|
-
|
|
385
|
-
For Effect schemas, wrap once with `Schema.standardSchemaV1()`:
|
|
386
|
-
|
|
387
|
-
```ts
|
|
388
|
-
import * as Schema from "effect/Schema";
|
|
389
|
-
import { createStore, configDir, field } from "@crustjs/store";
|
|
390
|
-
|
|
391
|
-
const store = createStore({
|
|
392
|
-
dirPath: configDir("my-cli"),
|
|
393
|
-
fields: {
|
|
394
|
-
theme: field(Schema.standardSchemaV1(Schema.Literal("light", "dark"))),
|
|
395
|
-
},
|
|
396
|
-
});
|
|
397
|
-
```
|
|
398
|
-
|
|
399
|
-
Missing persisted values flow through the schema as `undefined`:
|
|
400
|
-
|
|
401
|
-
- `field(z.string().default("x"))` returns `"x"` on read when the field
|
|
402
|
-
is missing from disk.
|
|
403
|
-
- `field(z.string().optional())` returns `undefined`.
|
|
404
|
-
- `field(z.string())` fails read-time validation with a `VALIDATION`
|
|
405
|
-
error when the field is missing.
|
|
406
|
-
|
|
407
|
-
#### Schema-derived defaults and TypeScript
|
|
408
|
-
|
|
409
|
-
Standard Schema v1 has no spec-portable type-level access to schema
|
|
410
|
-
defaults. Crust does not inspect schemas to populate runtime metadata;
|
|
411
|
-
defaults are materialized on read by validating `undefined` through the
|
|
412
|
-
schema. As a result:
|
|
413
|
-
|
|
414
|
-
- `field(z.string().default("x"))` returns `"x"` on read, but the
|
|
415
|
-
inferred config type is `string | undefined` (NOT narrowed).
|
|
416
|
-
- `field(z.string(), { default: "x" })` keeps `"x"` as the Crust-level
|
|
417
|
-
default AND narrows the inferred config type to `string`.
|
|
418
|
-
|
|
419
|
-
Prefer the explicit form when the field is required to always have a
|
|
420
|
-
value at use sites.
|
|
421
|
-
|
|
422
|
-
#### `field()` throws on invalid input
|
|
423
|
-
|
|
424
|
-
`field()` throws `CrustStoreError("DEFINITION")` when handed a value that
|
|
425
|
-
is not a Standard Schema v1 object. `type` is optional for schema-backed
|
|
426
|
-
fields; pass it through `opts` only as legacy metadata for tooling.
|
|
427
|
-
|
|
428
|
-
### Custom validators
|
|
429
|
-
|
|
430
|
-
You can also provide a validator function directly on each field. Validators
|
|
431
|
-
return `void` on success and **throw** on failure — the thrown `Error`'s
|
|
432
|
-
`message` is captured as the issue text and the field name is used as the
|
|
433
|
-
issue path.
|
|
434
|
-
|
|
435
|
-
```ts
|
|
436
|
-
const store = createStore({
|
|
437
|
-
dirPath: configDir("my-cli"),
|
|
438
|
-
fields: {
|
|
439
|
-
theme: {
|
|
440
|
-
type: "string",
|
|
441
|
-
default: "light",
|
|
442
|
-
validate(value) {
|
|
443
|
-
if (value !== "light" && value !== "dark") {
|
|
444
|
-
throw new Error('Must be "light" or "dark"');
|
|
445
|
-
}
|
|
446
|
-
},
|
|
447
|
-
},
|
|
448
|
-
},
|
|
449
|
-
});
|
|
450
|
-
```
|
|
451
|
-
|
|
452
|
-
Hand-rolled `validate` callbacks are `(value: V) => void | Promise<void>`.
|
|
453
|
-
Return `void` (or `Promise<void>`) to accept; throw an `Error` to reject.
|
|
454
|
-
These remain validation-only — they cannot transform the persisted value.
|
|
455
|
-
|
|
456
|
-
Fields built via the `field()` factory carry a Standard Schema and DO
|
|
457
|
-
persist transforms on `write` / `update` / `patch` (e.g.
|
|
458
|
-
`field(z.string().transform(s => s.trim()))` writes the trimmed value to
|
|
459
|
-
disk). On `read`, the schema still validates the persisted value but its
|
|
460
|
-
transform output is discarded — the on-disk value is returned unchanged.
|
|
461
|
-
Existing on-disk values survive unchanged across upgrades; the first
|
|
462
|
-
subsequent write canonicalizes them through the transform.
|
|
463
|
-
|
|
464
|
-
A write-time **read-stability guard** rejects cross-type transforms whose
|
|
465
|
-
output would fail the schema's own re-validation. For example,
|
|
466
|
-
`field(z.string().transform(Number))` (string in, number out) is rejected
|
|
467
|
-
with `CrustStoreError("VALIDATION")` and an issue message tagged
|
|
468
|
-
`read-unstable transform`. No on-disk write occurs.
|
|
469
|
-
|
|
470
|
-
### Validation behavior
|
|
471
|
-
|
|
472
|
-
- **Write**: Validates each field before persisting. Schema-driven
|
|
473
|
-
transforms (via `field()`) replace the input value with the transformed
|
|
474
|
-
output; hand-rolled validators cannot transform.
|
|
475
|
-
- **Read**: Validates each field after applying defaults. Invalid persisted
|
|
476
|
-
config fails loudly — no silent fallback to defaults. Schema transform
|
|
477
|
-
output is discarded on read; the returned value matches what is on disk.
|
|
478
|
-
- **Update**: Reads raw config (no validation), applies updater, validates
|
|
479
|
-
each field (persisting any schema-transformed outputs), then persists.
|
|
480
|
-
- **Patch**: Reads raw config, applies shallow partial merge, validates each
|
|
481
|
-
field (persisting any schema-transformed outputs), then persists.
|
|
482
|
-
- **Reset**: No validation — just removes the persisted file.
|
|
483
|
-
|
|
484
|
-
### Catching validation errors
|
|
485
|
-
|
|
486
|
-
```ts
|
|
487
|
-
import { CrustStoreError } from "@crustjs/store";
|
|
488
|
-
|
|
489
|
-
try {
|
|
490
|
-
await store.read();
|
|
491
|
-
} catch (err) {
|
|
492
|
-
if (err instanceof CrustStoreError && err.is("VALIDATION")) {
|
|
493
|
-
// err.details is { operation: "read" | "write" | "update" | "patch", issues: StoreValidatorIssue[] }
|
|
494
|
-
console.error(`Validation failed during ${err.details.operation}:`);
|
|
495
|
-
for (const issue of err.details.issues) {
|
|
496
|
-
console.error(` ${issue.path || "(root)"}: ${issue.message}`);
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
9
|
```
|
|
501
10
|
|
|
502
|
-
##
|
|
503
|
-
|
|
504
|
-
All errors thrown by `@crustjs/store` are instances of `CrustStoreError` with a typed `code` property:
|
|
505
|
-
|
|
506
|
-
| Code | When | Details |
|
|
507
|
-
| ------------ | --------------------------------------------------------- | ----------------------- |
|
|
508
|
-
| `PATH` | Invalid `dirPath`, invalid `name`, unsupported platform | `{ path: string }` |
|
|
509
|
-
| `PARSE` | Malformed JSON in persisted config file | `{ path: string }` |
|
|
510
|
-
| `IO` | Filesystem read, write, or delete failure | `{ path, operation }` |
|
|
511
|
-
| `VALIDATION` | Config fails validator on read, write, update, or patch | `{ operation, issues }` |
|
|
512
|
-
| `DEFINITION` | `field()` received an invalid (non-Standard-Schema) input | `{ vendor? }` |
|
|
513
|
-
|
|
514
|
-
### Catching errors by code
|
|
515
|
-
|
|
516
|
-
```ts
|
|
517
|
-
import { CrustStoreError } from "@crustjs/store";
|
|
518
|
-
|
|
519
|
-
try {
|
|
520
|
-
const state = await store.read();
|
|
521
|
-
} catch (err) {
|
|
522
|
-
if (err instanceof CrustStoreError) {
|
|
523
|
-
switch (err.code) {
|
|
524
|
-
case "PARSE":
|
|
525
|
-
console.error(`Corrupt file at ${err.details.path}`);
|
|
526
|
-
console.error("Delete the file and retry, or fix the JSON manually.");
|
|
527
|
-
break;
|
|
528
|
-
case "IO":
|
|
529
|
-
console.error(`File ${err.details.operation} failed: ${err.message}`);
|
|
530
|
-
break;
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
```
|
|
535
|
-
|
|
536
|
-
### Type narrowing with `.is()`
|
|
537
|
-
|
|
538
|
-
The `.is()` method narrows the error type so `details` is fully typed:
|
|
539
|
-
|
|
540
|
-
```ts
|
|
541
|
-
if (err instanceof CrustStoreError && err.is("IO")) {
|
|
542
|
-
// err.details is { path: string; operation: "read" | "write" | "delete" }
|
|
543
|
-
console.error(err.details.operation, err.details.path);
|
|
544
|
-
}
|
|
545
|
-
```
|
|
546
|
-
|
|
547
|
-
### Cause chaining
|
|
548
|
-
|
|
549
|
-
`CrustStoreError` preserves the original error as `cause` for debugging:
|
|
550
|
-
|
|
551
|
-
```ts
|
|
552
|
-
if (err instanceof CrustStoreError && err.is("VALIDATION")) {
|
|
553
|
-
console.error("Validation error:", err.cause);
|
|
554
|
-
}
|
|
555
|
-
```
|
|
556
|
-
|
|
557
|
-
### Malformed JSON
|
|
558
|
-
|
|
559
|
-
`@crustjs/store` does **not** silently recover from malformed JSON. If the persisted file contains invalid JSON, `read()` throws a `CrustStoreError` with `PARSE` code immediately — no fallback to defaults.
|
|
560
|
-
|
|
561
|
-
## Types
|
|
562
|
-
|
|
563
|
-
All types are exported for use in your application:
|
|
564
|
-
|
|
565
|
-
```ts
|
|
566
|
-
import type {
|
|
567
|
-
CreateStoreOptions,
|
|
568
|
-
Store,
|
|
569
|
-
StoreUpdater,
|
|
570
|
-
FieldDef,
|
|
571
|
-
FieldsDef,
|
|
572
|
-
FieldOptions,
|
|
573
|
-
InferStoreConfig,
|
|
574
|
-
StoreValidatorIssue,
|
|
575
|
-
StoreErrorCode,
|
|
576
|
-
ValidationErrorDetails,
|
|
577
|
-
DefinitionErrorDetails,
|
|
578
|
-
ValueType,
|
|
579
|
-
PlatformEnv,
|
|
580
|
-
} from "@crustjs/store";
|
|
581
|
-
```
|
|
582
|
-
|
|
583
|
-
| Type | Description |
|
|
584
|
-
| ------------------------ | ------------------------------------------------------------------------------------------------------ |
|
|
585
|
-
| `CreateStoreOptions` | Options object for `createStore()`. |
|
|
586
|
-
| `Store` | Store instance with `read`, `write`, `update`, `patch`, `reset`. |
|
|
587
|
-
| `StoreUpdater` | Updater function type `(current: T) => T`. |
|
|
588
|
-
| `FieldDef` | Single field definition with `type`, optional `default`, and optional `validate`. |
|
|
589
|
-
| `FieldsDef` | Record of field names to `FieldDef` definitions. |
|
|
590
|
-
| `FieldOptions` | Optional Crust metadata accepted by the `field()` factory (`type`, `default`, `array`, `description`). |
|
|
591
|
-
| `InferStoreConfig` | Inferred store state type from a `FieldsDef` definition. |
|
|
592
|
-
| `StoreValidatorIssue` | `{ message: string, path: string }`. |
|
|
593
|
-
| `ValidationErrorDetails` | Error details for `VALIDATION` code: `{ operation, issues }`. |
|
|
594
|
-
| `DefinitionErrorDetails` | Error details for `DEFINITION` code: `{ vendor? }`. |
|
|
595
|
-
| `StoreErrorCode` | Union of error codes: `"PATH" \| "PARSE" \| "IO" \| "VALIDATION" \| "DEFINITION"`. |
|
|
596
|
-
| `ValueType` | Supported field type literals: `"string" \| "number" \| "boolean"`. |
|
|
597
|
-
| `PlatformEnv` | Injectable platform environment for testing path helpers. |
|
|
598
|
-
| `CrustStoreError` | Typed error class with `code`, `details`, and `cause`. |
|
|
599
|
-
|
|
600
|
-
## Scope & Non-Goals
|
|
601
|
-
|
|
602
|
-
`@crustjs/store` is intentionally focused. The following are **not** included:
|
|
603
|
-
|
|
604
|
-
- **Cross-process locking** — assumes single-process usage for write coordination.
|
|
605
|
-
- **Built-in encryption or keychain integration.**
|
|
606
|
-
- **Sync API variants** (`readSync`, `writeSync`, etc.).
|
|
607
|
-
- **Automatic migration of invalid persisted config** — validation fails loudly; callers handle recovery.
|
|
608
|
-
- **Alternative formats** (YAML, TOML, JSON5) — JSON is the only on-disk format.
|
|
609
|
-
- **Remote/cloud synchronization** or multi-device sync.
|
|
610
|
-
- **Domain-specific migration frameworks** beyond simple store-level upgrade hooks.
|
|
611
|
-
|
|
612
|
-
## License
|
|
11
|
+
## Documentation
|
|
613
12
|
|
|
614
|
-
|
|
13
|
+
Full docs: [crustjs.com/docs/modules/store](https://crustjs.com/docs/modules/store)
|