@crustjs/store 0.0.4 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -81
- package/dist/index.d.ts +197 -17
- package/dist/index.js +3 -3
- package/package.json +10 -4
package/README.md
CHANGED
|
@@ -56,15 +56,21 @@ No explicit generics needed — types are inferred from your `fields` definition
|
|
|
56
56
|
|
|
57
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
58
|
|
|
59
|
-
| Helper | Intent
|
|
60
|
-
| ----------- |
|
|
61
|
-
| `configDir` | User preferences & config
|
|
62
|
-
| `dataDir` | Important app data
|
|
63
|
-
| `stateDir` | Runtime state
|
|
64
|
-
| `cacheDir` | Regenerable cached data
|
|
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
65
|
|
|
66
66
|
```ts
|
|
67
|
-
import {
|
|
67
|
+
import {
|
|
68
|
+
createStore,
|
|
69
|
+
configDir,
|
|
70
|
+
dataDir,
|
|
71
|
+
stateDir,
|
|
72
|
+
cacheDir,
|
|
73
|
+
} from "@crustjs/store";
|
|
68
74
|
|
|
69
75
|
// User preferences → ~/.config/my-cli/config.json
|
|
70
76
|
const config = createStore({
|
|
@@ -79,7 +85,7 @@ const config = createStore({
|
|
|
79
85
|
const data = createStore({
|
|
80
86
|
dirPath: dataDir("my-cli"),
|
|
81
87
|
fields: {
|
|
82
|
-
bookmarks: { type: "
|
|
88
|
+
bookmarks: { type: "string", array: true, default: [] },
|
|
83
89
|
},
|
|
84
90
|
});
|
|
85
91
|
|
|
@@ -106,12 +112,12 @@ const cache = createStore({
|
|
|
106
112
|
|
|
107
113
|
All four helpers follow XDG conventions on Linux and macOS, and Windows-native conventions on Windows:
|
|
108
114
|
|
|
109
|
-
| Helper | Linux / macOS
|
|
110
|
-
| ----------- |
|
|
111
|
-
| `configDir` | `~/.config/<app>`
|
|
112
|
-
| `dataDir` | `~/.local/share/<app>`
|
|
113
|
-
| `stateDir` | `~/.local/state/<app>`
|
|
114
|
-
| `cacheDir` | `~/.cache/<app>`
|
|
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` |
|
|
115
121
|
|
|
116
122
|
macOS uses XDG conventions (same as Linux) for a unified Unix mental model.
|
|
117
123
|
|
|
@@ -131,12 +137,12 @@ const store = createStore(options);
|
|
|
131
137
|
|
|
132
138
|
#### Options
|
|
133
139
|
|
|
134
|
-
| Option | Type
|
|
135
|
-
| -------------- |
|
|
136
|
-
| `dirPath` | `string`
|
|
137
|
-
| `name` | `string`
|
|
138
|
-
| `fields` | `FieldsDef`
|
|
139
|
-
| `pruneUnknown` | `boolean`
|
|
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`). |
|
|
140
146
|
|
|
141
147
|
### `store.read()`
|
|
142
148
|
|
|
@@ -311,23 +317,21 @@ Merged defaults exist only in memory. The persisted file remains unchanged until
|
|
|
311
317
|
|
|
312
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.
|
|
313
319
|
|
|
314
|
-
###
|
|
320
|
+
### Schema-driven validation
|
|
315
321
|
|
|
316
|
-
The easiest way to add validation is with
|
|
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.
|
|
317
326
|
|
|
318
327
|
```ts
|
|
319
328
|
import { z } from "zod";
|
|
320
|
-
import { field } from "@crustjs/
|
|
321
|
-
import { createStore, configDir } from "@crustjs/store";
|
|
329
|
+
import { createStore, configDir, field } from "@crustjs/store";
|
|
322
330
|
|
|
323
331
|
const store = createStore({
|
|
324
332
|
dirPath: configDir("my-cli"),
|
|
325
333
|
fields: {
|
|
326
|
-
theme:
|
|
327
|
-
type: "string",
|
|
328
|
-
default: "light",
|
|
329
|
-
validate: field(z.enum(["light", "dark"])),
|
|
330
|
-
},
|
|
334
|
+
theme: field(z.enum(["light", "dark"]).default("light")),
|
|
331
335
|
verbose: { type: "boolean", default: false },
|
|
332
336
|
},
|
|
333
337
|
});
|
|
@@ -341,33 +345,55 @@ const config = await store.read();
|
|
|
341
345
|
// → throws if persisted config is invalid
|
|
342
346
|
```
|
|
343
347
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
For Effect schemas, wrap with `Schema.standardSchemaV1()`:
|
|
348
|
+
For Effect schemas, wrap once with `Schema.standardSchemaV1()`:
|
|
347
349
|
|
|
348
350
|
```ts
|
|
349
351
|
import * as Schema from "effect/Schema";
|
|
350
|
-
import { field } from "@crustjs/
|
|
352
|
+
import { createStore, configDir, field } from "@crustjs/store";
|
|
351
353
|
|
|
352
354
|
const store = createStore({
|
|
353
355
|
dirPath: configDir("my-cli"),
|
|
354
356
|
fields: {
|
|
355
|
-
theme:
|
|
356
|
-
type: "string",
|
|
357
|
-
default: "light",
|
|
358
|
-
validate: field(
|
|
359
|
-
Schema.standardSchemaV1(
|
|
360
|
-
Schema.Literal("light", "dark"),
|
|
361
|
-
),
|
|
362
|
-
),
|
|
363
|
-
},
|
|
357
|
+
theme: field(Schema.standardSchemaV1(Schema.Literal("light", "dark"))),
|
|
364
358
|
},
|
|
365
359
|
});
|
|
366
360
|
```
|
|
367
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
|
+
|
|
368
391
|
### Custom validators
|
|
369
392
|
|
|
370
|
-
You can also provide a validator function directly on each field
|
|
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.
|
|
371
397
|
|
|
372
398
|
```ts
|
|
373
399
|
const store = createStore({
|
|
@@ -377,28 +403,45 @@ const store = createStore({
|
|
|
377
403
|
type: "string",
|
|
378
404
|
default: "light",
|
|
379
405
|
validate(value) {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
return {
|
|
383
|
-
ok: false,
|
|
384
|
-
issues: [{ message: 'Must be "light" or "dark"', path: "" }],
|
|
385
|
-
};
|
|
406
|
+
if (value !== "light" && value !== "dark") {
|
|
407
|
+
throw new Error('Must be "light" or "dark"');
|
|
386
408
|
}
|
|
387
|
-
return { ok: true, value: str };
|
|
388
409
|
},
|
|
389
410
|
},
|
|
390
411
|
},
|
|
391
412
|
});
|
|
392
413
|
```
|
|
393
414
|
|
|
394
|
-
|
|
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.
|
|
395
432
|
|
|
396
433
|
### Validation behavior
|
|
397
434
|
|
|
398
|
-
- **Write**: Validates each field before persisting.
|
|
399
|
-
|
|
400
|
-
-
|
|
401
|
-
- **
|
|
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.
|
|
402
445
|
- **Reset**: No validation — just removes the persisted file.
|
|
403
446
|
|
|
404
447
|
### Catching validation errors
|
|
@@ -410,7 +453,7 @@ try {
|
|
|
410
453
|
await store.read();
|
|
411
454
|
} catch (err) {
|
|
412
455
|
if (err instanceof CrustStoreError && err.is("VALIDATION")) {
|
|
413
|
-
// err.details is { operation: "read" | "write" | "update" | "patch", issues:
|
|
456
|
+
// err.details is { operation: "read" | "write" | "update" | "patch", issues: StoreValidatorIssue[] }
|
|
414
457
|
console.error(`Validation failed during ${err.details.operation}:`);
|
|
415
458
|
for (const issue of err.details.issues) {
|
|
416
459
|
console.error(` ${issue.path || "(root)"}: ${issue.message}`);
|
|
@@ -423,12 +466,13 @@ try {
|
|
|
423
466
|
|
|
424
467
|
All errors thrown by `@crustjs/store` are instances of `CrustStoreError` with a typed `code` property:
|
|
425
468
|
|
|
426
|
-
| Code | When
|
|
427
|
-
| ------------ |
|
|
428
|
-
| `PATH` | Invalid `dirPath`, invalid `name`, unsupported platform
|
|
429
|
-
| `PARSE` | Malformed JSON in persisted config file
|
|
430
|
-
| `IO` | Filesystem read, write, or delete failure
|
|
431
|
-
| `VALIDATION` | Config fails validator on read, write, update, or patch
|
|
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? }` |
|
|
432
476
|
|
|
433
477
|
### Catching errors by code
|
|
434
478
|
|
|
@@ -488,33 +532,33 @@ import type {
|
|
|
488
532
|
StoreUpdater,
|
|
489
533
|
FieldDef,
|
|
490
534
|
FieldsDef,
|
|
535
|
+
FieldOptions,
|
|
491
536
|
InferStoreConfig,
|
|
492
|
-
StoreValidator,
|
|
493
|
-
StoreValidatorResult,
|
|
494
537
|
StoreValidatorIssue,
|
|
495
538
|
StoreErrorCode,
|
|
496
|
-
StoreValidationIssue,
|
|
497
539
|
ValidationErrorDetails,
|
|
540
|
+
DefinitionErrorDetails,
|
|
541
|
+
ValueType,
|
|
498
542
|
PlatformEnv,
|
|
499
543
|
} from "@crustjs/store";
|
|
500
544
|
```
|
|
501
545
|
|
|
502
|
-
| Type
|
|
503
|
-
|
|
|
504
|
-
| `CreateStoreOptions`
|
|
505
|
-
| `Store`
|
|
506
|
-
| `StoreUpdater`
|
|
507
|
-
| `FieldDef`
|
|
508
|
-
| `FieldsDef`
|
|
509
|
-
| `
|
|
510
|
-
| `
|
|
511
|
-
| `
|
|
512
|
-
| `
|
|
513
|
-
| `
|
|
514
|
-
| `
|
|
515
|
-
| `
|
|
516
|
-
| `PlatformEnv`
|
|
517
|
-
| `CrustStoreError`
|
|
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`. |
|
|
518
562
|
|
|
519
563
|
## Scope & Non-Goals
|
|
520
564
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
|
+
import { BaseValueType, ResolvePrimitive } from "@crustjs/utils";
|
|
1
2
|
/** Supported type literals for store fields. */
|
|
2
|
-
type ValueType =
|
|
3
|
-
/**
|
|
4
|
-
* Resolves a type literal to its corresponding TypeScript primitive type.
|
|
5
|
-
*
|
|
6
|
-
* - `"string"` → `string`
|
|
7
|
-
* - `"number"` → `number`
|
|
8
|
-
* - `"boolean"` → `boolean`
|
|
9
|
-
*/
|
|
10
|
-
type ResolvePrimitive<T extends ValueType> = T extends "string" ? string : T extends "number" ? number : T extends "boolean" ? boolean : never;
|
|
3
|
+
type ValueType = BaseValueType;
|
|
11
4
|
/** Shared fields present on every store field definition. */
|
|
12
5
|
interface FieldDefBase<V> {
|
|
13
6
|
/** Human-readable description for documentation and tooling. */
|
|
@@ -16,14 +9,33 @@ interface FieldDefBase<V> {
|
|
|
16
9
|
* Optional per-field validation function.
|
|
17
10
|
*
|
|
18
11
|
* Called during `read`, `write`, `update`, and `patch` operations when
|
|
19
|
-
* the field has a value (not `undefined`).
|
|
20
|
-
*
|
|
12
|
+
* the field has a value (not `undefined`).
|
|
13
|
+
*
|
|
14
|
+
* Return shapes:
|
|
15
|
+
* - `void` (or `Promise<void>`) — validation-only; the input value is
|
|
16
|
+
* persisted as-is. This is the contract for hand-rolled validators.
|
|
17
|
+
* - `{ value }` (or `Promise<{ value }>`) — the schema (or transform)
|
|
18
|
+
* produced an output value (typed as `unknown` because schema
|
|
19
|
+
* transforms may emit a value of a different runtime type than
|
|
20
|
+
* `V`; the read-stability guard enforces shape correctness at
|
|
21
|
+
* write time). On `write` / `update` / `patch`, the transformed
|
|
22
|
+
* value replaces the input before persistence and is re-validated
|
|
23
|
+
* once to catch read-unstable transforms (cross-type transforms
|
|
24
|
+
* whose output would fail the schema on the next read). On `read`,
|
|
25
|
+
* the transformed value is discarded — reads always return the
|
|
26
|
+
* on-disk value verbatim.
|
|
27
|
+
* - Throwing an error — the value is rejected; the error message is
|
|
28
|
+
* captured as a validation issue with the field name as `path`.
|
|
21
29
|
*
|
|
22
30
|
* @param value - The field value to validate.
|
|
23
|
-
* @throws When the value is invalid — the error message is captured as a
|
|
24
|
-
* validation issue with the field name as `path`.
|
|
25
31
|
*/
|
|
26
|
-
validate?: (value: V) => void | Promise<void
|
|
32
|
+
validate?: (value: V) => void | Promise<void> | {
|
|
33
|
+
value: unknown;
|
|
34
|
+
} | Promise<{
|
|
35
|
+
value: unknown;
|
|
36
|
+
}>;
|
|
37
|
+
/** Internal marker: validate missing values as `undefined` for schema-backed fields. */
|
|
38
|
+
validateMissing?: true;
|
|
27
39
|
}
|
|
28
40
|
/** Base for scalar (non-array) fields — `array` must be omitted. */
|
|
29
41
|
interface ScalarFieldBase<V> extends FieldDefBase<V> {
|
|
@@ -93,9 +105,18 @@ interface BooleanArrayFieldDef extends ArrayFieldBase<boolean[]> {
|
|
|
93
105
|
* } satisfies FieldsDef;
|
|
94
106
|
* ```
|
|
95
107
|
*/
|
|
96
|
-
|
|
108
|
+
interface RawScalarFieldDef extends ScalarFieldBase<unknown> {
|
|
109
|
+
type?: never;
|
|
110
|
+
default?: unknown;
|
|
111
|
+
}
|
|
112
|
+
interface RawArrayFieldDef extends ArrayFieldBase<unknown[]> {
|
|
113
|
+
type?: never;
|
|
114
|
+
default?: readonly unknown[];
|
|
115
|
+
}
|
|
116
|
+
type FieldDef = StringFieldDef | NumberFieldDef | BooleanFieldDef | StringArrayFieldDef | NumberArrayFieldDef | BooleanArrayFieldDef | RawScalarFieldDef | RawArrayFieldDef;
|
|
97
117
|
/** Record mapping field names to their definitions. */
|
|
98
118
|
type FieldsDef = Record<string, FieldDef>;
|
|
119
|
+
declare const FIELD_SCHEMA_OUTPUT: unique symbol;
|
|
99
120
|
/**
|
|
100
121
|
* Infer the resolved type for a single field definition.
|
|
101
122
|
*
|
|
@@ -104,12 +125,20 @@ type FieldsDef = Record<string, FieldDef>;
|
|
|
104
125
|
* - **no default** → `primitive | undefined` (optional)
|
|
105
126
|
*/
|
|
106
127
|
type InferFieldValue<F extends FieldDef> = F extends {
|
|
128
|
+
readonly [FIELD_SCHEMA_OUTPUT]?: infer O;
|
|
129
|
+
} ? F extends {
|
|
130
|
+
default: infer D;
|
|
131
|
+
} ? D : O | undefined : F extends {
|
|
132
|
+
type: ValueType;
|
|
133
|
+
} ? F extends {
|
|
107
134
|
array: true;
|
|
108
135
|
} ? F extends {
|
|
109
136
|
default: readonly ResolvePrimitive<F["type"]>[];
|
|
110
137
|
} ? ResolvePrimitive<F["type"]>[] : ResolvePrimitive<F["type"]>[] | undefined : F extends {
|
|
111
138
|
default: ResolvePrimitive<F["type"]>;
|
|
112
|
-
} ? ResolvePrimitive<F["type"]> : ResolvePrimitive<F["type"]> | undefined
|
|
139
|
+
} ? ResolvePrimitive<F["type"]> : ResolvePrimitive<F["type"]> | undefined : F extends {
|
|
140
|
+
default: infer D;
|
|
141
|
+
} ? D : unknown;
|
|
113
142
|
/**
|
|
114
143
|
* Maps a full {@link FieldsDef} record to the inferred config object type.
|
|
115
144
|
*
|
|
@@ -318,6 +347,17 @@ interface ValidationErrorDetails {
|
|
|
318
347
|
issues: readonly StoreValidatorIssue[];
|
|
319
348
|
}
|
|
320
349
|
/**
|
|
350
|
+
* Contextual details attached to a `DEFINITION` error.
|
|
351
|
+
*
|
|
352
|
+
* Returned when a `field()` call cannot produce a valid `FieldDef` from the
|
|
353
|
+
* provided schema and options — e.g. the input is not a Standard Schema v1
|
|
354
|
+
* object.
|
|
355
|
+
*/
|
|
356
|
+
interface DefinitionErrorDetails {
|
|
357
|
+
/** The schema vendor name, when available. */
|
|
358
|
+
vendor?: string;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
321
361
|
* Maps each {@link StoreErrorCode} to its structured details type.
|
|
322
362
|
*
|
|
323
363
|
* Used internally for conditional constructor parameters and type narrowing.
|
|
@@ -327,6 +367,7 @@ interface StoreErrorDetailsMap {
|
|
|
327
367
|
PARSE: ParseErrorDetails;
|
|
328
368
|
IO: IOErrorDetails;
|
|
329
369
|
VALIDATION: ValidationErrorDetails;
|
|
370
|
+
DEFINITION: DefinitionErrorDetails;
|
|
330
371
|
}
|
|
331
372
|
/**
|
|
332
373
|
* All possible error codes emitted by `@crustjs/store`.
|
|
@@ -335,6 +376,7 @@ interface StoreErrorDetailsMap {
|
|
|
335
376
|
* - `PARSE` — Malformed JSON in persisted config file
|
|
336
377
|
* - `IO` — Filesystem read, write, or delete failure
|
|
337
378
|
* - `VALIDATION` — Config object rejected by a store validator
|
|
379
|
+
* - `DEFINITION` — `field()` cannot produce a valid `FieldDef` from the given schema/options
|
|
338
380
|
*
|
|
339
381
|
* @example
|
|
340
382
|
* ```ts
|
|
@@ -422,6 +464,144 @@ declare class CrustStoreError<C extends StoreErrorCode = StoreErrorCode> extends
|
|
|
422
464
|
*/
|
|
423
465
|
withCause(cause: unknown): this;
|
|
424
466
|
}
|
|
467
|
+
import { BaseValueType as BaseValueType2 } from "@crustjs/utils";
|
|
468
|
+
import { InferOutput, StandardSchema } from "@crustjs/utils/schema";
|
|
469
|
+
/**
|
|
470
|
+
* Optional Crust metadata for the `FieldDef` shape produced by {@link field}.
|
|
471
|
+
*
|
|
472
|
+
* Every key is optional. Crust does not infer metadata from schemas; schemas
|
|
473
|
+
* validate and transform actual runtime values, including missing values passed
|
|
474
|
+
* as `undefined`.
|
|
475
|
+
*
|
|
476
|
+
* No `validate` key — validation flows exclusively through the schema.
|
|
477
|
+
* If extra checks are needed, refine the schema with `.refine(...)`
|
|
478
|
+
* (Zod), `Schema.filter(...)` (Effect), etc.
|
|
479
|
+
*
|
|
480
|
+
* @typeParam T - The schema's output value type. Used to type-tighten the
|
|
481
|
+
* `default` key when the user wants a non-`undefined` field.
|
|
482
|
+
*/
|
|
483
|
+
interface FieldOptions<T = unknown> {
|
|
484
|
+
type?: BaseValueType2;
|
|
485
|
+
description?: string;
|
|
486
|
+
/**
|
|
487
|
+
* Crust metadata default for this field when the persisted state does not
|
|
488
|
+
* contain a value for it. Schema defaults should usually live in the schema;
|
|
489
|
+
* missing persisted values are validated as `undefined`, so `.default()` works
|
|
490
|
+
* naturally at read time.
|
|
491
|
+
*/
|
|
492
|
+
default?: T;
|
|
493
|
+
/** Mark this field as an array (collects values into an array). */
|
|
494
|
+
array?: true;
|
|
495
|
+
}
|
|
496
|
+
type RawSchemaFieldDef<Out> = {
|
|
497
|
+
readonly type?: never;
|
|
498
|
+
readonly array?: never;
|
|
499
|
+
readonly description?: string;
|
|
500
|
+
readonly validate: (value: unknown) => Promise<{
|
|
501
|
+
value: unknown;
|
|
502
|
+
}>;
|
|
503
|
+
readonly validateMissing: true;
|
|
504
|
+
readonly [FIELD_SCHEMA_OUTPUT]?: Out;
|
|
505
|
+
};
|
|
506
|
+
type SchemaFieldDefWithOptions<
|
|
507
|
+
Out,
|
|
508
|
+
Type extends FieldOptions["type"],
|
|
509
|
+
Array extends true | undefined,
|
|
510
|
+
D = never
|
|
511
|
+
> = Omit<RawSchemaFieldDef<Out>, "type" | "array"> & (Type extends NonNullable<FieldOptions["type"]> ? {
|
|
512
|
+
readonly type: Type;
|
|
513
|
+
} : {
|
|
514
|
+
readonly type?: never;
|
|
515
|
+
}) & (Array extends true ? {
|
|
516
|
+
readonly array: true;
|
|
517
|
+
} : {
|
|
518
|
+
readonly array?: never;
|
|
519
|
+
}) & ([D] extends [never] ? unknown : {
|
|
520
|
+
readonly default: D;
|
|
521
|
+
});
|
|
522
|
+
/**
|
|
523
|
+
* `FieldDef` inferred from a Standard Schema, with no narrowed default.
|
|
524
|
+
*
|
|
525
|
+
* Standard Schema v1 has no spec-portable type-level access to defaults, so
|
|
526
|
+
* schema `.default()` does not narrow the inferred TypeScript type. Pass
|
|
527
|
+
* `field(schema, { default: x })` explicitly to narrow.
|
|
528
|
+
*/
|
|
529
|
+
type SchemaFieldDef<S extends StandardSchema> = RawSchemaFieldDef<InferOutput<S>>;
|
|
530
|
+
/**
|
|
531
|
+
* Define a `@crustjs/store` field from any Standard Schema v1.
|
|
532
|
+
*
|
|
533
|
+
* Returns a value that satisfies store's `FieldDef` discriminated union.
|
|
534
|
+
* Crust stores raw values and lets the schema validate and transform them on
|
|
535
|
+
* read/write; missing persisted values flow through the schema as `undefined`,
|
|
536
|
+
* so `.optional()` and `.default()` behave naturally.
|
|
537
|
+
*
|
|
538
|
+
* The returned `validate` is an async function that throws an `Error` with
|
|
539
|
+
* the schema's normalized issue messages on failure (matches store's
|
|
540
|
+
* `FieldDef.validate` contract).
|
|
541
|
+
*
|
|
542
|
+
* **Type-level defaults**: Standard Schema v1 has no spec-portable type-level
|
|
543
|
+
* access to defaults. For tight typing of default-bearing fields, pass
|
|
544
|
+
* `default` via `opts`:
|
|
545
|
+
*
|
|
546
|
+
* ```ts
|
|
547
|
+
* field(z.string().default("x")) // value materializes on read; state: string | undefined
|
|
548
|
+
* field(z.string(), { default: "x" }) // Crust-level default; state: string
|
|
549
|
+
* ```
|
|
550
|
+
*
|
|
551
|
+
* @param schema - Any Standard Schema v1 object (Zod schemas natively;
|
|
552
|
+
* Effect schemas wrapped via `Schema.standardSchemaV1`;
|
|
553
|
+
* Valibot/ArkType/Sury/etc. as-is)
|
|
554
|
+
* @param opts - Optional Crust-level metadata
|
|
555
|
+
* @throws {CrustStoreError} With code `"DEFINITION"` when the input is not
|
|
556
|
+
* a Standard Schema v1 object.
|
|
557
|
+
*
|
|
558
|
+
* @example Zod
|
|
559
|
+
* ```ts
|
|
560
|
+
* import { z } from "zod";
|
|
561
|
+
* import { createStore, configDir, field } from "@crustjs/store";
|
|
562
|
+
*
|
|
563
|
+
* const store = createStore({
|
|
564
|
+
* dirPath: configDir("my-cli"),
|
|
565
|
+
* fields: {
|
|
566
|
+
* theme: field(z.enum(["light", "dark"]).default("light")),
|
|
567
|
+
* verbose: field(z.boolean().default(false)),
|
|
568
|
+
* tags: field(z.array(z.string()).default([])),
|
|
569
|
+
* },
|
|
570
|
+
* });
|
|
571
|
+
* ```
|
|
572
|
+
*
|
|
573
|
+
* @example Effect
|
|
574
|
+
* ```ts
|
|
575
|
+
* import * as Schema from "effect/Schema";
|
|
576
|
+
* import { createStore, configDir, field } from "@crustjs/store";
|
|
577
|
+
*
|
|
578
|
+
* const store = createStore({
|
|
579
|
+
* dirPath: configDir("my-cli"),
|
|
580
|
+
* fields: {
|
|
581
|
+
* theme: field(Schema.standardSchemaV1(Schema.Literal("light", "dark"))),
|
|
582
|
+
* },
|
|
583
|
+
* });
|
|
584
|
+
* ```
|
|
585
|
+
*/
|
|
586
|
+
declare function field<S extends StandardSchema>(schema: S): SchemaFieldDef<S>;
|
|
587
|
+
declare function field<
|
|
588
|
+
S extends StandardSchema,
|
|
589
|
+
D extends InferOutput<S>,
|
|
590
|
+
const Type extends FieldOptions<InferOutput<S>>["type"] = undefined,
|
|
591
|
+
const Array extends true | undefined = undefined
|
|
592
|
+
>(schema: S, opts: FieldOptions<InferOutput<S>> & {
|
|
593
|
+
default: D;
|
|
594
|
+
type?: Type;
|
|
595
|
+
array?: Array;
|
|
596
|
+
}): SchemaFieldDefWithOptions<InferOutput<S>, Type, Array, D>;
|
|
597
|
+
declare function field<
|
|
598
|
+
S extends StandardSchema,
|
|
599
|
+
const Type extends FieldOptions<InferOutput<S>>["type"] = undefined,
|
|
600
|
+
const Array extends true | undefined = undefined
|
|
601
|
+
>(schema: S, opts: FieldOptions<InferOutput<S>> & {
|
|
602
|
+
type?: Type;
|
|
603
|
+
array?: Array;
|
|
604
|
+
}): SchemaFieldDefWithOptions<InferOutput<S>, Type, Array>;
|
|
425
605
|
/**
|
|
426
606
|
* Encapsulates runtime environment values needed for path resolution.
|
|
427
607
|
*
|
|
@@ -576,4 +756,4 @@ declare function cacheDir(appName: string, env?: PlatformEnv): string;
|
|
|
576
756
|
* ```
|
|
577
757
|
*/
|
|
578
758
|
declare function createStore<const F extends FieldsDef>(options: CreateStoreOptions<F>): Store<InferStoreConfig<F>>;
|
|
579
|
-
export { stateDir, dataDir, createStore, configDir, cacheDir, ValueType, ValidationErrorDetails, StoreValidatorIssue, StoreUpdater, StoreErrorCode, Store, PlatformEnv, InferStoreConfig, FieldsDef, FieldDef, CrustStoreError, CreateStoreOptions };
|
|
759
|
+
export { stateDir, field, dataDir, createStore, configDir, cacheDir, ValueType, ValidationErrorDetails, StoreValidatorIssue, StoreUpdater, StoreErrorCode, Store, PlatformEnv, InferStoreConfig, FieldsDef, FieldOptions, FieldDef, DefinitionErrorDetails, CrustStoreError, CreateStoreOptions };
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
class
|
|
3
|
-
${
|
|
4
|
-
`)}`;throw new
|
|
2
|
+
class X extends Error{code;details;cause;constructor(q,H,G){super(H);this.name="CrustStoreError",this.code=q,this.details=G}is(q){return this.code===q}withCause(q){return this.cause=q,this}}import{isStandardSchema as m,normalizeStandardIssues as p}from"@crustjs/utils/schema";function d(q){return async(H)=>{let G=await q["~standard"].validate(H);if(G.issues){let Q=p(G.issues).map((Y)=>Y.path?`${Y.path}: ${Y.message}`:Y.message);throw Error(Q.join("; "))}return{value:G.value}}}function n(q,H){if(!m(q))throw new X("DEFINITION",`field(): argument must be a Standard Schema v1 object (got ${typeof q})`,{});let K={validate:d(q),validateMissing:!0};if(H?.type!==void 0)K.type=H.type;if(H?.array===!0)K.array=!0;if(H?.description!==void 0)K.description=H.description;if(H&&"default"in H)K.default=H.default;return K}import{homedir as o}from"os";import{join as Z}from"path";var l="config";function R(){return{platform:process.platform,env:process.env,homedir:o()}}function U(q){if(!q||q.trim().length===0)throw new X("PATH","appName must be a non-empty string",{path:q??""});if(q.includes("/")||q.includes("\\"))throw new X("PATH","appName must not contain path separators",{path:q})}function i(q){if(!q||q.trim().length===0)throw new X("PATH","name must be a non-empty string",{path:q??""});if(q.includes("/")||q.includes("\\"))throw new X("PATH","name must not contain path separators",{path:q});if(q.endsWith(".json"))throw new X("PATH","name must not include the .json extension",{path:q})}function s(q){if(!q||q.trim().length===0)throw new X("PATH","dirPath must be a non-empty string",{path:q??""});if(!(q.startsWith("/")||/^[A-Za-z]:[/\\]/.test(q)))throw new X("PATH","dirPath must be an absolute path",{path:q});if(q.endsWith(".json"))throw new X("PATH","dirPath must be a directory path, not a file path (should not end with .json)",{path:q})}function b(q,H,G,K){let Q=q.env[H],Y=Q&&Q.trim().length>0?Q:Z(q.homedir,...G);return Z(Y,K)}function A(q){throw new X("PATH",`Unsupported platform: ${q}`,{path:q})}function t(q,H){U(q);let G=H??R();switch(G.platform){case"linux":case"darwin":return b(G,"XDG_CONFIG_HOME",[".config"],q);case"win32":{let K=G.env.APPDATA,Q=K&&K.trim().length>0?K:Z(G.homedir,"AppData","Roaming");return Z(Q,q)}default:A(G.platform)}}function r(q,H){U(q);let G=H??R();switch(G.platform){case"linux":case"darwin":return b(G,"XDG_DATA_HOME",[".local","share"],q);case"win32":{let K=G.env.LOCALAPPDATA,Q=K&&K.trim().length>0?K:Z(G.homedir,"AppData","Local");return Z(Q,q,"Data")}default:A(G.platform)}}function a(q,H){U(q);let G=H??R();switch(G.platform){case"linux":case"darwin":return b(G,"XDG_STATE_HOME",[".local","state"],q);case"win32":{let K=G.env.LOCALAPPDATA,Q=K&&K.trim().length>0?K:Z(G.homedir,"AppData","Local");return Z(Q,q,"State")}default:A(G.platform)}}function e(q,H){U(q);let G=H??R();switch(G.platform){case"linux":case"darwin":return b(G,"XDG_CACHE_HOME",[".cache"],q);case"win32":{let K=G.env.LOCALAPPDATA,Q=K&&K.trim().length>0?K:Z(G.homedir,"AppData","Local");return Z(Q,q,"Cache")}default:A(G.platform)}}function k(q,H){s(q);let G=H??l;if(H!==void 0)i(H);return Z(q,`${G}.json`)}import{coerceBooleanString as Mq,tryCoerceNumber as Wq}from"@crustjs/utils";function w(q,H,G=!0){let K={};for(let[Q,Y]of Object.entries(H))if(q&&Q in q){let x=q[Q];K[Q]=Array.isArray(x)?[...x]:x}else if("default"in Y&&Y.default!==void 0)K[Q]=Array.isArray(Y.default)?[...Y.default]:Y.default;if(!G&&q!==void 0){for(let[Q,Y]of Object.entries(q))if(!(Q in H))K[Q]=Array.isArray(Y)?[...Y]:Y}return K}import{randomUUID as qq}from"crypto";import{mkdir as Gq,readFile as Hq,rename as Kq,rm as Qq,unlink as Xq,writeFile as Yq}from"fs/promises";import{dirname as $q,join as Lq}from"path";async function S(q){let H;try{H=await Hq(q,"utf-8")}catch(G){if(N(G))return;throw new X("IO",`Failed to read config file: ${q}`,{path:q,operation:"read"}).withCause(G)}try{return JSON.parse(H)}catch(G){throw new X("PARSE",`Malformed JSON in config file: ${q}`,{path:q}).withCause(G)}}async function C(q,H){let G=$q(q),K=Lq(G,`.config-${qq()}.tmp`);try{await Gq(G,{recursive:!0})}catch(Q){throw new X("IO",`Failed to create config directory: ${G}`,{path:q,operation:"write"}).withCause(Q)}try{let Q=JSON.stringify(H,null,"\t");await Yq(K,Q,"utf-8")}catch(Q){throw await y(K),new X("IO",`Failed to write config file: ${q}`,{path:q,operation:"write"}).withCause(Q)}try{await Kq(K,q)}catch(Q){throw await y(K),new X("IO",`Failed to finalize config file: ${q}`,{path:q,operation:"write"}).withCause(Q)}}async function g(q){try{await Xq(q)}catch(H){if(N(H))return;throw new X("IO",`Failed to delete config file: ${q}`,{path:q,operation:"delete"}).withCause(H)}}function N(q){return q!==null&&typeof q==="object"&&"code"in q&&q.code==="ENOENT"}async function y(q){try{await Qq(q,{force:!0})}catch{}}function E(q){return typeof q==="object"&&q!==null&&"value"in q&&!("ok"in q)}function Oq(q){let{dirPath:H,name:G,fields:K,pruneUnknown:Q}=q,Y=k(H,G),x=Q??!0;function j($,L){if(L===void 0)return $;if(L==="number"&&typeof $==="string")return Wq($)??$;if(L==="boolean"&&typeof $==="string")return Mq($);return $}function T($){let M={...$};for(let[W,O]of Object.entries(K)){if(!(W in M))continue;let _=M[W];if(O.array===!0&&Array.isArray(_)){M[W]=_.map((B)=>j(B,O.type));continue}M[W]=j(_,O.type)}return M}async function V($,L){let M=[],W=$;for(let[O,_]of Object.entries(K)){if(!_.validate)continue;let B=W[O];if(B===void 0&&_.validateMissing!==!0)continue;let F;try{F=await _.validate(B)}catch(I){let J=I instanceof Error?I.message:"Validation failed";M.push({message:J,path:O});continue}if(F===void 0)continue;if(E(F)){let I=F.value;if(L==="read"){if(B===void 0)W[O]=I;continue}if(!Bun.deepEquals(I,B)){let J;try{J=await _.validate(I)}catch(z){let u=z instanceof Error?z.message:"re-validation failed";M.push({message:`read-unstable transform: ${u}`,path:O});continue}if(J!==void 0&&(!E(J)||!Bun.deepEquals(J.value,I))){M.push({message:"read-unstable transform: output would be transformed again on re-read",path:O});continue}W[O]=I}continue}throw TypeError("FieldDef.validate must return void or { value } (see @crustjs/store validation docs).")}if(M.length>0){let O=M.map((B)=>` - ${B.path}: ${B.message}`),_=`Store validation failed (${L})
|
|
3
|
+
${O.join(`
|
|
4
|
+
`)}`;throw new X("VALIDATION",_,{operation:L,issues:M})}}async function D(){let $=await S(Y),L=w($,K,x);return T(L)}async function P(){let $=await D();return await V($,"read"),$}async function c($){let L=T($);await V(L,"write"),await C(Y,L)}async function f($){let L=await D(),M=$(L),W=T(M);await V(W,"update"),await C(Y,W)}async function v($){let M={...await D(),...$},W=T(M);await V(W,"patch"),await C(Y,W)}async function h(){await g(Y)}return{read:P,write:c,update:f,patch:v,reset:h}}export{a as stateDir,n as field,r as dataDir,Oq as createStore,t as configDir,e as cacheDir,X as CrustStoreError};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crustjs/store",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "DX-first, typed persistence for CLI apps with config/data/state/cache separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -42,13 +42,19 @@
|
|
|
42
42
|
"build": "bunup",
|
|
43
43
|
"dev": "bunup --watch",
|
|
44
44
|
"check:types": "tsc --noEmit",
|
|
45
|
-
"test": "bun test"
|
|
45
|
+
"test": "bun test",
|
|
46
|
+
"publish": "bun publish --no-git-checks || true"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@crustjs/config": "0.0.0",
|
|
49
|
-
"bunup": "^0.16.
|
|
50
|
+
"bunup": "^0.16.31",
|
|
51
|
+
"effect": "3.21.2",
|
|
52
|
+
"zod": "^4.0.0"
|
|
50
53
|
},
|
|
51
54
|
"peerDependencies": {
|
|
52
|
-
"typescript": "^
|
|
55
|
+
"typescript": "^6.0.3"
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@crustjs/utils": "0.0.2"
|
|
53
59
|
}
|
|
54
60
|
}
|