@crustjs/store 0.0.2 → 0.0.4
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 +318 -126
- package/dist/index.d.ts +344 -186
- package/dist/index.js +3 -1
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @crustjs/store
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DX-first, typed persistence for CLI apps with clear config/data/state/cache separation.
|
|
4
4
|
|
|
5
|
-
`@crustjs/store` gives your CLI
|
|
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.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -28,154 +28,222 @@ const store = createStore({
|
|
|
28
28
|
dirPath: configDir("my-cli"),
|
|
29
29
|
fields: {
|
|
30
30
|
theme: { type: "string", default: "light" },
|
|
31
|
+
fontSize: { type: "number", default: 14 },
|
|
31
32
|
verbose: { type: "boolean", default: false },
|
|
32
|
-
token: { type: "string" }, // optional — no default
|
|
33
33
|
},
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
// Read
|
|
37
|
-
const
|
|
38
|
-
// → { theme: "light",
|
|
36
|
+
// Read state (returns defaults when no persisted file exists)
|
|
37
|
+
const state = await store.read();
|
|
38
|
+
// → { theme: "light", fontSize: 14, verbose: false }
|
|
39
39
|
|
|
40
|
-
// Write a full
|
|
41
|
-
await store.write({ theme: "dark",
|
|
40
|
+
// Write a full state object
|
|
41
|
+
await store.write({ theme: "dark", fontSize: 16, verbose: true });
|
|
42
42
|
|
|
43
|
-
// Update a
|
|
43
|
+
// Update with a function
|
|
44
44
|
await store.update((current) => ({ ...current, verbose: false }));
|
|
45
45
|
|
|
46
|
+
// Patch only specific keys
|
|
47
|
+
await store.patch({ theme: "solarized" });
|
|
48
|
+
|
|
46
49
|
// Reset to defaults (removes persisted file)
|
|
47
50
|
await store.reset();
|
|
48
51
|
```
|
|
49
52
|
|
|
50
|
-
No explicit generics needed — types are inferred from your
|
|
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: "array", 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.
|
|
51
117
|
|
|
52
118
|
## API
|
|
53
119
|
|
|
54
120
|
### `createStore(options)`
|
|
55
121
|
|
|
56
|
-
Creates a typed async
|
|
122
|
+
Creates a typed async store backed by a local JSON file. The file path is resolved once at creation time.
|
|
57
123
|
|
|
58
124
|
```ts
|
|
59
125
|
const store = createStore(options);
|
|
60
126
|
```
|
|
61
127
|
|
|
62
|
-
**Returns:** A `Store<
|
|
128
|
+
**Returns:** A `Store<T>` with `read()`, `write()`, `update()`, `patch()`, and `reset()` methods.
|
|
63
129
|
|
|
64
130
|
**Throws:** `CrustStoreError` with `PATH` code if `dirPath` or `name` is invalid.
|
|
65
131
|
|
|
66
132
|
#### Options
|
|
67
133
|
|
|
68
|
-
| Option
|
|
69
|
-
|
|
|
70
|
-
| `dirPath`
|
|
71
|
-
| `name`
|
|
72
|
-
| `fields`
|
|
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`). |
|
|
73
140
|
|
|
74
|
-
### `
|
|
141
|
+
### `store.read()`
|
|
75
142
|
|
|
76
|
-
|
|
143
|
+
Reads the persisted state file. Missing keys are filled from field `defaults`.
|
|
77
144
|
|
|
78
145
|
```ts
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const dir = configDir("my-cli");
|
|
82
|
-
// → "/home/user/.config/my-cli" (Linux)
|
|
83
|
-
// → "/Users/user/Library/Application Support/my-cli" (macOS)
|
|
84
|
-
// → "C:\\Users\\user\\AppData\\Roaming\\my-cli" (Windows)
|
|
146
|
+
const state = await store.read();
|
|
85
147
|
```
|
|
86
148
|
|
|
87
|
-
|
|
149
|
+
Always returns a value — never `undefined`. Does not write merged defaults back to disk.
|
|
150
|
+
|
|
151
|
+
### `store.write(state)`
|
|
152
|
+
|
|
153
|
+
Atomically persists a full state object. The entire previous state is replaced.
|
|
88
154
|
|
|
89
155
|
```ts
|
|
90
|
-
|
|
91
|
-
dirPath: configDir("my-cli"),
|
|
92
|
-
fields: { /* ... */ },
|
|
93
|
-
});
|
|
156
|
+
await store.write({ theme: "dark", fontSize: 14, verbose: true });
|
|
94
157
|
```
|
|
95
158
|
|
|
96
|
-
|
|
159
|
+
Calls per-field `validate` functions (if provided) before writing. Parent directories are created if missing.
|
|
160
|
+
|
|
161
|
+
### `store.update(updater)`
|
|
162
|
+
|
|
163
|
+
Reads the current effective state, applies the updater function, and atomically persists the result.
|
|
97
164
|
|
|
98
|
-
|
|
165
|
+
```ts
|
|
166
|
+
await store.update((current) => ({
|
|
167
|
+
...current,
|
|
168
|
+
theme: "dark",
|
|
169
|
+
}));
|
|
170
|
+
```
|
|
99
171
|
|
|
100
|
-
|
|
101
|
-
| ------------- | --------------------------------------- | -------- | --------------------------------------------------- |
|
|
102
|
-
| `type` | `"string" \| "number" \| "boolean"` | Yes | The value type. |
|
|
103
|
-
| `default` | Matches `type` | No | Default value when the field is not persisted. |
|
|
104
|
-
| `array` | `true` | No | Collect values into an array. |
|
|
105
|
-
| `description` | `string` | No | Human-readable description for tooling. |
|
|
172
|
+
When no persisted file exists, the updater receives field `defaults` as the current value. Calls per-field `validate` functions before writing.
|
|
106
173
|
|
|
107
|
-
|
|
174
|
+
### `store.patch(partial)`
|
|
108
175
|
|
|
109
|
-
|
|
110
|
-
- **No `default`** → field is optional (e.g., `string | undefined`)
|
|
111
|
-
- **`array: true`** → wraps the type in an array (e.g., `string[]`)
|
|
176
|
+
Applies a shallow partial update to the current state and persists. Only the provided keys are updated; everything else is preserved.
|
|
112
177
|
|
|
113
178
|
```ts
|
|
114
|
-
|
|
115
|
-
dirPath: configDir("my-cli"),
|
|
116
|
-
fields: {
|
|
117
|
-
theme: { type: "string", default: "light" }, // → string
|
|
118
|
-
verbose: { type: "boolean", default: false }, // → boolean
|
|
119
|
-
retries: { type: "number", default: 3 }, // → number
|
|
120
|
-
token: { type: "string" }, // → string | undefined
|
|
121
|
-
tags: { type: "string", array: true, default: [] },// → string[]
|
|
122
|
-
ids: { type: "number", array: true }, // → number[] | undefined
|
|
123
|
-
},
|
|
124
|
-
});
|
|
179
|
+
await store.patch({ theme: "solarized" });
|
|
125
180
|
```
|
|
126
181
|
|
|
127
|
-
|
|
182
|
+
Calls per-field `validate` functions before writing.
|
|
183
|
+
|
|
184
|
+
### `store.reset()`
|
|
128
185
|
|
|
129
|
-
|
|
186
|
+
Removes the persisted state file. After reset, `read()` returns `defaults`.
|
|
130
187
|
|
|
131
188
|
```ts
|
|
132
|
-
|
|
189
|
+
await store.reset();
|
|
133
190
|
```
|
|
134
191
|
|
|
135
|
-
|
|
192
|
+
Reset is idempotent — calling it when no file exists is a no-op.
|
|
193
|
+
|
|
194
|
+
### Path Helpers
|
|
136
195
|
|
|
137
|
-
|
|
196
|
+
#### `configDir(appName, env?)`
|
|
138
197
|
|
|
139
|
-
|
|
198
|
+
Resolves the platform-standard config directory for the given app name.
|
|
140
199
|
|
|
141
200
|
```ts
|
|
142
|
-
|
|
201
|
+
import { configDir } from "@crustjs/store";
|
|
202
|
+
|
|
203
|
+
const dir = configDir("my-cli");
|
|
204
|
+
// → "/home/user/.config/my-cli" (Linux / macOS)
|
|
205
|
+
// → "C:\\Users\\user\\AppData\\Roaming\\my-cli" (Windows)
|
|
143
206
|
```
|
|
144
207
|
|
|
145
|
-
|
|
208
|
+
#### `dataDir(appName, env?)`
|
|
146
209
|
|
|
147
|
-
|
|
210
|
+
Resolves the platform-standard data directory.
|
|
148
211
|
|
|
149
212
|
```ts
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
213
|
+
import { dataDir } from "@crustjs/store";
|
|
214
|
+
|
|
215
|
+
const dir = dataDir("my-cli");
|
|
216
|
+
// → "/home/user/.local/share/my-cli" (Linux / macOS)
|
|
217
|
+
// → "C:\\Users\\user\\AppData\\Local\\my-cli\\Data" (Windows)
|
|
154
218
|
```
|
|
155
219
|
|
|
156
|
-
|
|
220
|
+
#### `stateDir(appName, env?)`
|
|
157
221
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
Removes the persisted config file. After reset, `read()` returns field defaults.
|
|
222
|
+
Resolves the platform-standard state directory.
|
|
161
223
|
|
|
162
224
|
```ts
|
|
163
|
-
|
|
225
|
+
import { stateDir } from "@crustjs/store";
|
|
226
|
+
|
|
227
|
+
const dir = stateDir("my-cli");
|
|
228
|
+
// → "/home/user/.local/state/my-cli" (Linux / macOS)
|
|
229
|
+
// → "C:\\Users\\user\\AppData\\Local\\my-cli\\State" (Windows)
|
|
164
230
|
```
|
|
165
231
|
|
|
166
|
-
|
|
232
|
+
#### `cacheDir(appName, env?)`
|
|
167
233
|
|
|
168
|
-
|
|
234
|
+
Resolves the platform-standard cache directory.
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
import { cacheDir } from "@crustjs/store";
|
|
169
238
|
|
|
170
|
-
|
|
239
|
+
const dir = cacheDir("my-cli");
|
|
240
|
+
// → "/home/user/.cache/my-cli" (Linux / macOS)
|
|
241
|
+
// → "C:\\Users\\user\\AppData\\Local\\my-cli\\Cache" (Windows)
|
|
242
|
+
```
|
|
171
243
|
|
|
172
|
-
|
|
173
|
-
| -------- | ------------------------------------------------------- | ------------------ |
|
|
174
|
-
| Linux | `~/.config/<appName>/<name>.json` | `$XDG_CONFIG_HOME` |
|
|
175
|
-
| macOS | `~/Library/Application Support/<appName>/<name>.json` | — |
|
|
176
|
-
| Windows | `%APPDATA%\<appName>\<name>.json` | `%APPDATA%` |
|
|
244
|
+
All path helpers accept an optional `PlatformEnv` parameter for deterministic testing without mutating `process.env`.
|
|
177
245
|
|
|
178
|
-
|
|
246
|
+
## Multiple Stores
|
|
179
247
|
|
|
180
248
|
Use the `name` option to create multiple stores under the same directory:
|
|
181
249
|
|
|
@@ -185,10 +253,11 @@ import { createStore, configDir } from "@crustjs/store";
|
|
|
185
253
|
const dir = configDir("my-cli");
|
|
186
254
|
|
|
187
255
|
// Default store → ~/.config/my-cli/config.json
|
|
188
|
-
const
|
|
256
|
+
const settingsStore = createStore({
|
|
189
257
|
dirPath: dir,
|
|
190
258
|
fields: {
|
|
191
259
|
theme: { type: "string", default: "light" },
|
|
260
|
+
fontSize: { type: "number", default: 14 },
|
|
192
261
|
verbose: { type: "boolean", default: false },
|
|
193
262
|
},
|
|
194
263
|
});
|
|
@@ -198,7 +267,7 @@ const authStore = createStore({
|
|
|
198
267
|
dirPath: dir,
|
|
199
268
|
name: "auth",
|
|
200
269
|
fields: {
|
|
201
|
-
token: { type: "string" },
|
|
270
|
+
token: { type: "string", default: "" },
|
|
202
271
|
},
|
|
203
272
|
});
|
|
204
273
|
```
|
|
@@ -209,45 +278,157 @@ The `name` must not contain path separators or the `.json` extension.
|
|
|
209
278
|
|
|
210
279
|
## Defaults & Merge
|
|
211
280
|
|
|
212
|
-
When a persisted file exists, `read()`
|
|
281
|
+
When a persisted file exists, `read()` fills missing keys from field `defaults`:
|
|
213
282
|
|
|
214
283
|
```ts
|
|
215
284
|
const store = createStore({
|
|
216
285
|
dirPath: configDir("my-cli"),
|
|
217
286
|
fields: {
|
|
218
287
|
theme: { type: "string", default: "light" },
|
|
288
|
+
fontSize: { type: "number", default: 14 },
|
|
219
289
|
verbose: { type: "boolean", default: false },
|
|
220
|
-
retries: { type: "number", default: 3 },
|
|
221
290
|
},
|
|
222
291
|
});
|
|
223
292
|
|
|
224
|
-
// Persisted file contains: { "theme": "dark" }
|
|
225
|
-
const
|
|
226
|
-
// → { theme: "dark",
|
|
293
|
+
// Persisted file contains: { "theme": "dark", "verbose": true }
|
|
294
|
+
const state = await store.read();
|
|
295
|
+
// → { theme: "dark", fontSize: 14, verbose: true }
|
|
227
296
|
```
|
|
228
297
|
|
|
229
|
-
### Merge
|
|
298
|
+
### Merge Rules
|
|
230
299
|
|
|
231
|
-
-
|
|
232
|
-
- Missing
|
|
233
|
-
-
|
|
234
|
-
-
|
|
235
|
-
- Array defaults are shallow-copied to prevent shared mutation.
|
|
300
|
+
- Persisted values override defaults.
|
|
301
|
+
- Missing keys fall back to defaults.
|
|
302
|
+
- Unknown persisted keys are dropped by default (`pruneUnknown: true`). Set `pruneUnknown: false` to preserve them.
|
|
303
|
+
- All values are deep-cloned to prevent shared-reference mutation from defaults.
|
|
236
304
|
- Falsy values (`null`, `0`, `""`, `false`) in the persisted file are preserved — only truly missing keys trigger defaults.
|
|
237
305
|
|
|
238
306
|
### Important: defaults are not auto-persisted
|
|
239
307
|
|
|
240
|
-
Merged defaults exist only in memory. The
|
|
308
|
+
Merged defaults exist only in memory. The persisted file remains unchanged until you explicitly call `write()`, `update()`, or `patch()`.
|
|
309
|
+
|
|
310
|
+
## Validation
|
|
311
|
+
|
|
312
|
+
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.
|
|
313
|
+
|
|
314
|
+
### Using `@crustjs/validate`
|
|
315
|
+
|
|
316
|
+
The easiest way to add validation is with field adapters from `@crustjs/validate`:
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
import { z } from "zod";
|
|
320
|
+
import { field } from "@crustjs/validate/zod";
|
|
321
|
+
import { createStore, configDir } from "@crustjs/store";
|
|
322
|
+
|
|
323
|
+
const store = createStore({
|
|
324
|
+
dirPath: configDir("my-cli"),
|
|
325
|
+
fields: {
|
|
326
|
+
theme: {
|
|
327
|
+
type: "string",
|
|
328
|
+
default: "light",
|
|
329
|
+
validate: field(z.enum(["light", "dark"])),
|
|
330
|
+
},
|
|
331
|
+
verbose: { type: "boolean", default: false },
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// write() validates before persisting
|
|
336
|
+
await store.write({ theme: "neon", verbose: true });
|
|
337
|
+
// → throws CrustStoreError("VALIDATION")
|
|
338
|
+
|
|
339
|
+
// read() validates after applying defaults
|
|
340
|
+
const config = await store.read();
|
|
341
|
+
// → throws if persisted config is invalid
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
`fieldSync()` is also available for synchronous schemas.
|
|
345
|
+
|
|
346
|
+
For Effect schemas, wrap with `Schema.standardSchemaV1()`:
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
import * as Schema from "effect/Schema";
|
|
350
|
+
import { field } from "@crustjs/validate/effect";
|
|
351
|
+
|
|
352
|
+
const store = createStore({
|
|
353
|
+
dirPath: configDir("my-cli"),
|
|
354
|
+
fields: {
|
|
355
|
+
theme: {
|
|
356
|
+
type: "string",
|
|
357
|
+
default: "light",
|
|
358
|
+
validate: field(
|
|
359
|
+
Schema.standardSchemaV1(
|
|
360
|
+
Schema.Literal("light", "dark"),
|
|
361
|
+
),
|
|
362
|
+
),
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
### Custom validators
|
|
369
|
+
|
|
370
|
+
You can also provide a validator function directly on each field:
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
const store = createStore({
|
|
374
|
+
dirPath: configDir("my-cli"),
|
|
375
|
+
fields: {
|
|
376
|
+
theme: {
|
|
377
|
+
type: "string",
|
|
378
|
+
default: "light",
|
|
379
|
+
validate(value) {
|
|
380
|
+
const str = value as string;
|
|
381
|
+
if (str !== "light" && str !== "dark") {
|
|
382
|
+
return {
|
|
383
|
+
ok: false,
|
|
384
|
+
issues: [{ message: 'Must be "light" or "dark"', path: "" }],
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
return { ok: true, value: str };
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
The per-field `validate` function follows the `StoreValidator<T>` contract: `(value: unknown) => StoreValidatorResult<T> | Promise<StoreValidatorResult<T>>`, where `StoreValidatorResult<T>` is `{ ok: true, value: T } | { ok: false, issues: StoreValidatorIssue[] }`.
|
|
395
|
+
|
|
396
|
+
### Validation behavior
|
|
397
|
+
|
|
398
|
+
- **Write**: Validates each field before persisting. If a validator transforms the value, the transformed result is persisted.
|
|
399
|
+
- **Read**: Validates each field after applying defaults. Invalid persisted config fails loudly — no silent fallback to defaults.
|
|
400
|
+
- **Update**: Reads raw config (no validation), applies updater, validates each field, then persists.
|
|
401
|
+
- **Patch**: Reads raw config, applies shallow partial merge, validates each field, then persists.
|
|
402
|
+
- **Reset**: No validation — just removes the persisted file.
|
|
403
|
+
|
|
404
|
+
### Catching validation errors
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
import { CrustStoreError } from "@crustjs/store";
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
await store.read();
|
|
411
|
+
} catch (err) {
|
|
412
|
+
if (err instanceof CrustStoreError && err.is("VALIDATION")) {
|
|
413
|
+
// err.details is { operation: "read" | "write" | "update" | "patch", issues: StoreValidationIssue[] }
|
|
414
|
+
console.error(`Validation failed during ${err.details.operation}:`);
|
|
415
|
+
for (const issue of err.details.issues) {
|
|
416
|
+
console.error(` ${issue.path || "(root)"}: ${issue.message}`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
```
|
|
241
421
|
|
|
242
422
|
## Error Handling
|
|
243
423
|
|
|
244
424
|
All errors thrown by `@crustjs/store` are instances of `CrustStoreError` with a typed `code` property:
|
|
245
425
|
|
|
246
|
-
| Code
|
|
247
|
-
|
|
|
248
|
-
| `PATH`
|
|
249
|
-
| `PARSE`
|
|
250
|
-
| `IO`
|
|
426
|
+
| Code | When | Details |
|
|
427
|
+
| ------------ | --------------------------------------------------------------- | --------------------------- |
|
|
428
|
+
| `PATH` | Invalid `dirPath`, invalid `name`, unsupported platform | `{ path: string }` |
|
|
429
|
+
| `PARSE` | Malformed JSON in persisted config file | `{ path: string }` |
|
|
430
|
+
| `IO` | Filesystem read, write, or delete failure | `{ path, operation }` |
|
|
431
|
+
| `VALIDATION` | Config fails validator on read, write, update, or patch | `{ operation, issues }` |
|
|
251
432
|
|
|
252
433
|
### Catching errors by code
|
|
253
434
|
|
|
@@ -255,12 +436,12 @@ All errors thrown by `@crustjs/store` are instances of `CrustStoreError` with a
|
|
|
255
436
|
import { CrustStoreError } from "@crustjs/store";
|
|
256
437
|
|
|
257
438
|
try {
|
|
258
|
-
const
|
|
439
|
+
const state = await store.read();
|
|
259
440
|
} catch (err) {
|
|
260
441
|
if (err instanceof CrustStoreError) {
|
|
261
442
|
switch (err.code) {
|
|
262
443
|
case "PARSE":
|
|
263
|
-
console.error(`Corrupt
|
|
444
|
+
console.error(`Corrupt file at ${err.details.path}`);
|
|
264
445
|
console.error("Delete the file and retry, or fix the JSON manually.");
|
|
265
446
|
break;
|
|
266
447
|
case "IO":
|
|
@@ -287,14 +468,14 @@ if (err instanceof CrustStoreError && err.is("IO")) {
|
|
|
287
468
|
`CrustStoreError` preserves the original error as `cause` for debugging:
|
|
288
469
|
|
|
289
470
|
```ts
|
|
290
|
-
if (err instanceof CrustStoreError && err.is("
|
|
291
|
-
console.error("
|
|
471
|
+
if (err instanceof CrustStoreError && err.is("VALIDATION")) {
|
|
472
|
+
console.error("Validation error:", err.cause);
|
|
292
473
|
}
|
|
293
474
|
```
|
|
294
475
|
|
|
295
476
|
### Malformed JSON
|
|
296
477
|
|
|
297
|
-
`@crustjs/store` does **not** silently recover from malformed JSON. If the persisted
|
|
478
|
+
`@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.
|
|
298
479
|
|
|
299
480
|
## Types
|
|
300
481
|
|
|
@@ -303,38 +484,49 @@ All types are exported for use in your application:
|
|
|
303
484
|
```ts
|
|
304
485
|
import type {
|
|
305
486
|
CreateStoreOptions,
|
|
487
|
+
Store,
|
|
488
|
+
StoreUpdater,
|
|
306
489
|
FieldDef,
|
|
307
490
|
FieldsDef,
|
|
308
491
|
InferStoreConfig,
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
492
|
+
StoreValidator,
|
|
493
|
+
StoreValidatorResult,
|
|
494
|
+
StoreValidatorIssue,
|
|
312
495
|
StoreErrorCode,
|
|
496
|
+
StoreValidationIssue,
|
|
497
|
+
ValidationErrorDetails,
|
|
498
|
+
PlatformEnv,
|
|
313
499
|
} from "@crustjs/store";
|
|
314
500
|
```
|
|
315
501
|
|
|
316
|
-
| Type
|
|
317
|
-
|
|
|
318
|
-
| `CreateStoreOptions`
|
|
319
|
-
| `
|
|
320
|
-
| `
|
|
321
|
-
| `
|
|
322
|
-
| `
|
|
323
|
-
| `
|
|
324
|
-
| `
|
|
325
|
-
| `
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
502
|
+
| Type | Description |
|
|
503
|
+
| ------------------------- | ----------------------------------------------------------------------- |
|
|
504
|
+
| `CreateStoreOptions` | Options object for `createStore()`. |
|
|
505
|
+
| `Store` | Store instance with `read`, `write`, `update`, `patch`, `reset`. |
|
|
506
|
+
| `StoreUpdater` | Updater function type `(current: T) => T`. |
|
|
507
|
+
| `FieldDef` | Single field definition with `type`, optional `default`, and optional `validate`. |
|
|
508
|
+
| `FieldsDef` | Record of field names to `FieldDef` definitions. |
|
|
509
|
+
| `InferStoreConfig` | Inferred store state type from a `FieldsDef` definition. |
|
|
510
|
+
| `StoreValidator` | Validator function contract `(value: unknown) => StoreValidatorResult`. |
|
|
511
|
+
| `StoreValidatorResult` | Discriminated union: `{ ok: true, value: T } \| { ok: false, issues: StoreValidatorIssue[] }`. |
|
|
512
|
+
| `StoreValidatorIssue` | `{ message: string, path: string }`. |
|
|
513
|
+
| `StoreValidationIssue` | Validation issue in error details payload. |
|
|
514
|
+
| `ValidationErrorDetails` | Error details for `VALIDATION` code: `{ operation, issues }`. |
|
|
515
|
+
| `StoreErrorCode` | Union of error codes: `"PATH" \| "PARSE" \| "IO" \| "VALIDATION"`. |
|
|
516
|
+
| `PlatformEnv` | Injectable platform environment for testing path helpers. |
|
|
517
|
+
| `CrustStoreError` | Typed error class with `code`, `details`, and `cause`. |
|
|
518
|
+
|
|
519
|
+
## Scope & Non-Goals
|
|
520
|
+
|
|
521
|
+
`@crustjs/store` is intentionally focused. The following are **not** included:
|
|
522
|
+
|
|
523
|
+
- **Cross-process locking** — assumes single-process usage for write coordination.
|
|
332
524
|
- **Built-in encryption or keychain integration.**
|
|
333
|
-
- **Cross-process locking** — v1 assumes single-process usage for write coordination.
|
|
334
525
|
- **Sync API variants** (`readSync`, `writeSync`, etc.).
|
|
335
|
-
- **
|
|
526
|
+
- **Automatic migration of invalid persisted config** — validation fails loudly; callers handle recovery.
|
|
336
527
|
- **Alternative formats** (YAML, TOML, JSON5) — JSON is the only on-disk format.
|
|
337
|
-
- **
|
|
528
|
+
- **Remote/cloud synchronization** or multi-device sync.
|
|
529
|
+
- **Domain-specific migration frameworks** beyond simple store-level upgrade hooks.
|
|
338
530
|
|
|
339
531
|
## License
|
|
340
532
|
|