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