@crustjs/store 0.0.4 → 0.1.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/README.md +118 -77
- package/dist/index.d.ts +209 -6
- package/dist/index.js +2 -2
- 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({
|
|
@@ -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,20 @@ 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 complete `FieldDef` from any Standard Schema — type, default,
|
|
324
|
+
description, and the per-field validator are all derived in one call:
|
|
317
325
|
|
|
318
326
|
```ts
|
|
319
327
|
import { z } from "zod";
|
|
320
|
-
import { field } from "@crustjs/
|
|
321
|
-
import { createStore, configDir } from "@crustjs/store";
|
|
328
|
+
import { createStore, configDir, field } from "@crustjs/store";
|
|
322
329
|
|
|
323
330
|
const store = createStore({
|
|
324
331
|
dirPath: configDir("my-cli"),
|
|
325
332
|
fields: {
|
|
326
|
-
theme:
|
|
327
|
-
type: "string",
|
|
328
|
-
default: "light",
|
|
329
|
-
validate: field(z.enum(["light", "dark"])),
|
|
330
|
-
},
|
|
333
|
+
theme: field(z.enum(["light", "dark"]).default("light")),
|
|
331
334
|
verbose: { type: "boolean", default: false },
|
|
332
335
|
},
|
|
333
336
|
});
|
|
@@ -341,33 +344,54 @@ const config = await store.read();
|
|
|
341
344
|
// → throws if persisted config is invalid
|
|
342
345
|
```
|
|
343
346
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
For Effect schemas, wrap with `Schema.standardSchemaV1()`:
|
|
347
|
+
For Effect schemas, wrap once with `Schema.standardSchemaV1()`:
|
|
347
348
|
|
|
348
349
|
```ts
|
|
349
350
|
import * as Schema from "effect/Schema";
|
|
350
|
-
import { field } from "@crustjs/
|
|
351
|
+
import { createStore, configDir, field } from "@crustjs/store";
|
|
351
352
|
|
|
352
353
|
const store = createStore({
|
|
353
354
|
dirPath: configDir("my-cli"),
|
|
354
355
|
fields: {
|
|
355
|
-
theme:
|
|
356
|
-
type: "string",
|
|
357
|
-
default: "light",
|
|
358
|
-
validate: field(
|
|
359
|
-
Schema.standardSchemaV1(
|
|
360
|
-
Schema.Literal("light", "dark"),
|
|
361
|
-
),
|
|
362
|
-
),
|
|
363
|
-
},
|
|
356
|
+
theme: field(Schema.standardSchemaV1(Schema.Literal("light", "dark"))),
|
|
364
357
|
},
|
|
365
358
|
});
|
|
366
359
|
```
|
|
367
360
|
|
|
361
|
+
#### Schema-derived defaults and TypeScript
|
|
362
|
+
|
|
363
|
+
Standard Schema v1 has no spec-portable type-level access to schema
|
|
364
|
+
defaults. As a result:
|
|
365
|
+
|
|
366
|
+
- `field(z.string().default("x"))` populates `default: "x"` at runtime,
|
|
367
|
+
but the inferred config type is `string | undefined` (NOT narrowed).
|
|
368
|
+
- `field(z.string(), { default: "x" })` populates `default: "x"` AND
|
|
369
|
+
narrows the inferred config type to `string`.
|
|
370
|
+
|
|
371
|
+
Prefer the explicit form when the field is required to always have a
|
|
372
|
+
value at use sites.
|
|
373
|
+
|
|
374
|
+
#### `field()` throws on invalid input
|
|
375
|
+
|
|
376
|
+
`field()` throws `CrustStoreError("DEFINITION")` when handed a
|
|
377
|
+
non-Standard-Schema value, or when the runtime CLI type cannot be
|
|
378
|
+
inferred from the schema and `opts.type` was not supplied. The thrown
|
|
379
|
+
error's `details.vendor` field carries the schema's vendor name (when
|
|
380
|
+
available) to help locate the offending call site.
|
|
381
|
+
|
|
382
|
+
> **Effect ≥ 3.14.2 required.** Effect 3.14.2 made `standardSchemaV1(...)`
|
|
383
|
+
> wrappers expose `.ast`, which is what the validate registry walks. On
|
|
384
|
+
> Effect 3.14.0 / 3.14.1 the wrapper is a plain object and introspection
|
|
385
|
+
> silently fails. See
|
|
386
|
+
> [`@crustjs/validate` Effect setup](https://crustjs.com/docs/modules/validate#quick-start--effect)
|
|
387
|
+
> for the floor and a workaround.
|
|
388
|
+
|
|
368
389
|
### Custom validators
|
|
369
390
|
|
|
370
|
-
You can also provide a validator function directly on each field
|
|
391
|
+
You can also provide a validator function directly on each field. Validators
|
|
392
|
+
return `void` on success and **throw** on failure — the thrown `Error`'s
|
|
393
|
+
`message` is captured as the issue text and the field name is used as the
|
|
394
|
+
issue path.
|
|
371
395
|
|
|
372
396
|
```ts
|
|
373
397
|
const store = createStore({
|
|
@@ -377,28 +401,45 @@ const store = createStore({
|
|
|
377
401
|
type: "string",
|
|
378
402
|
default: "light",
|
|
379
403
|
validate(value) {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
return {
|
|
383
|
-
ok: false,
|
|
384
|
-
issues: [{ message: 'Must be "light" or "dark"', path: "" }],
|
|
385
|
-
};
|
|
404
|
+
if (value !== "light" && value !== "dark") {
|
|
405
|
+
throw new Error('Must be "light" or "dark"');
|
|
386
406
|
}
|
|
387
|
-
return { ok: true, value: str };
|
|
388
407
|
},
|
|
389
408
|
},
|
|
390
409
|
},
|
|
391
410
|
});
|
|
392
411
|
```
|
|
393
412
|
|
|
394
|
-
|
|
413
|
+
Hand-rolled `validate` callbacks are `(value: V) => void | Promise<void>`.
|
|
414
|
+
Return `void` (or `Promise<void>`) to accept; throw an `Error` to reject.
|
|
415
|
+
These remain validation-only — they cannot transform the persisted value.
|
|
416
|
+
|
|
417
|
+
Fields built via the `field()` factory carry a Standard Schema and DO
|
|
418
|
+
persist transforms on `write` / `update` / `patch` (e.g.
|
|
419
|
+
`field(z.string().transform(s => s.trim()))` writes the trimmed value to
|
|
420
|
+
disk). On `read`, the schema still validates the persisted value but its
|
|
421
|
+
transform output is discarded — the on-disk value is returned unchanged.
|
|
422
|
+
Existing on-disk values survive unchanged across upgrades; the first
|
|
423
|
+
subsequent write canonicalizes them through the transform.
|
|
424
|
+
|
|
425
|
+
A write-time **read-stability guard** rejects cross-type transforms whose
|
|
426
|
+
output would fail the schema's own re-validation. For example,
|
|
427
|
+
`field(z.string().transform(Number))` (string in, number out) is rejected
|
|
428
|
+
with `CrustStoreError("VALIDATION")` and an issue message tagged
|
|
429
|
+
`read-unstable transform`. No on-disk write occurs.
|
|
395
430
|
|
|
396
431
|
### Validation behavior
|
|
397
432
|
|
|
398
|
-
- **Write**: Validates each field before persisting.
|
|
399
|
-
|
|
400
|
-
-
|
|
401
|
-
- **
|
|
433
|
+
- **Write**: Validates each field before persisting. Schema-driven
|
|
434
|
+
transforms (via `field()`) replace the input value with the transformed
|
|
435
|
+
output; hand-rolled validators cannot transform.
|
|
436
|
+
- **Read**: Validates each field after applying defaults. Invalid persisted
|
|
437
|
+
config fails loudly — no silent fallback to defaults. Schema transform
|
|
438
|
+
output is discarded on read; the returned value matches what is on disk.
|
|
439
|
+
- **Update**: Reads raw config (no validation), applies updater, validates
|
|
440
|
+
each field (persisting any schema-transformed outputs), then persists.
|
|
441
|
+
- **Patch**: Reads raw config, applies shallow partial merge, validates each
|
|
442
|
+
field (persisting any schema-transformed outputs), then persists.
|
|
402
443
|
- **Reset**: No validation — just removes the persisted file.
|
|
403
444
|
|
|
404
445
|
### Catching validation errors
|
|
@@ -410,7 +451,7 @@ try {
|
|
|
410
451
|
await store.read();
|
|
411
452
|
} catch (err) {
|
|
412
453
|
if (err instanceof CrustStoreError && err.is("VALIDATION")) {
|
|
413
|
-
// err.details is { operation: "read" | "write" | "update" | "patch", issues:
|
|
454
|
+
// err.details is { operation: "read" | "write" | "update" | "patch", issues: StoreValidatorIssue[] }
|
|
414
455
|
console.error(`Validation failed during ${err.details.operation}:`);
|
|
415
456
|
for (const issue of err.details.issues) {
|
|
416
457
|
console.error(` ${issue.path || "(root)"}: ${issue.message}`);
|
|
@@ -423,12 +464,12 @@ try {
|
|
|
423
464
|
|
|
424
465
|
All errors thrown by `@crustjs/store` are instances of `CrustStoreError` with a typed `code` property:
|
|
425
466
|
|
|
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
|
|
467
|
+
| Code | When | Details |
|
|
468
|
+
| ------------ | ------------------------------------------------------- | ----------------------- |
|
|
469
|
+
| `PATH` | Invalid `dirPath`, invalid `name`, unsupported platform | `{ path: string }` |
|
|
470
|
+
| `PARSE` | Malformed JSON in persisted config file | `{ path: string }` |
|
|
471
|
+
| `IO` | Filesystem read, write, or delete failure | `{ path, operation }` |
|
|
472
|
+
| `VALIDATION` | Config fails validator on read, write, update, or patch | `{ operation, issues }` |
|
|
432
473
|
|
|
433
474
|
### Catching errors by code
|
|
434
475
|
|
|
@@ -499,22 +540,22 @@ import type {
|
|
|
499
540
|
} from "@crustjs/store";
|
|
500
541
|
```
|
|
501
542
|
|
|
502
|
-
| Type
|
|
503
|
-
|
|
|
504
|
-
| `CreateStoreOptions`
|
|
505
|
-
| `Store`
|
|
506
|
-
| `StoreUpdater`
|
|
507
|
-
| `FieldDef`
|
|
508
|
-
| `FieldsDef`
|
|
509
|
-
| `InferStoreConfig`
|
|
510
|
-
| `StoreValidator`
|
|
511
|
-
| `StoreValidatorResult`
|
|
512
|
-
| `StoreValidatorIssue`
|
|
513
|
-
| `StoreValidationIssue`
|
|
514
|
-
| `ValidationErrorDetails`
|
|
515
|
-
| `StoreErrorCode`
|
|
516
|
-
| `PlatformEnv`
|
|
517
|
-
| `CrustStoreError`
|
|
543
|
+
| Type | Description |
|
|
544
|
+
| ------------------------ | ---------------------------------------------------------------------------------------------- |
|
|
545
|
+
| `CreateStoreOptions` | Options object for `createStore()`. |
|
|
546
|
+
| `Store` | Store instance with `read`, `write`, `update`, `patch`, `reset`. |
|
|
547
|
+
| `StoreUpdater` | Updater function type `(current: T) => T`. |
|
|
548
|
+
| `FieldDef` | Single field definition with `type`, optional `default`, and optional `validate`. |
|
|
549
|
+
| `FieldsDef` | Record of field names to `FieldDef` definitions. |
|
|
550
|
+
| `InferStoreConfig` | Inferred store state type from a `FieldsDef` definition. |
|
|
551
|
+
| `StoreValidator` | Validator function contract `(value: unknown) => StoreValidatorResult`. |
|
|
552
|
+
| `StoreValidatorResult` | Discriminated union: `{ ok: true, value: T } \| { ok: false, issues: StoreValidatorIssue[] }`. |
|
|
553
|
+
| `StoreValidatorIssue` | `{ message: string, path: string }`. |
|
|
554
|
+
| `StoreValidationIssue` | Validation issue in error details payload. |
|
|
555
|
+
| `ValidationErrorDetails` | Error details for `VALIDATION` code: `{ operation, issues }`. |
|
|
556
|
+
| `StoreErrorCode` | Union of error codes: `"PATH" \| "PARSE" \| "IO" \| "VALIDATION"`. |
|
|
557
|
+
| `PlatformEnv` | Injectable platform environment for testing path helpers. |
|
|
558
|
+
| `CrustStoreError` | Typed error class with `code`, `details`, and `cause`. |
|
|
518
559
|
|
|
519
560
|
## Scope & Non-Goals
|
|
520
561
|
|
package/dist/index.d.ts
CHANGED
|
@@ -16,14 +16,31 @@ interface FieldDefBase<V> {
|
|
|
16
16
|
* Optional per-field validation function.
|
|
17
17
|
*
|
|
18
18
|
* Called during `read`, `write`, `update`, and `patch` operations when
|
|
19
|
-
* the field has a value (not `undefined`).
|
|
20
|
-
*
|
|
19
|
+
* the field has a value (not `undefined`).
|
|
20
|
+
*
|
|
21
|
+
* Return shapes:
|
|
22
|
+
* - `void` (or `Promise<void>`) — validation-only; the input value is
|
|
23
|
+
* persisted as-is. This is the contract for hand-rolled validators.
|
|
24
|
+
* - `{ value }` (or `Promise<{ value }>`) — the schema (or transform)
|
|
25
|
+
* produced an output value (typed as `unknown` because schema
|
|
26
|
+
* transforms may emit a value of a different runtime type than
|
|
27
|
+
* `V`; the read-stability guard enforces shape correctness at
|
|
28
|
+
* write time). On `write` / `update` / `patch`, the transformed
|
|
29
|
+
* value replaces the input before persistence and is re-validated
|
|
30
|
+
* once to catch read-unstable transforms (cross-type transforms
|
|
31
|
+
* whose output would fail the schema on the next read). On `read`,
|
|
32
|
+
* the transformed value is discarded — reads always return the
|
|
33
|
+
* on-disk value verbatim.
|
|
34
|
+
* - Throwing an error — the value is rejected; the error message is
|
|
35
|
+
* captured as a validation issue with the field name as `path`.
|
|
21
36
|
*
|
|
22
37
|
* @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
38
|
*/
|
|
26
|
-
validate?: (value: V) => void | Promise<void
|
|
39
|
+
validate?: (value: V) => void | Promise<void> | {
|
|
40
|
+
value: unknown;
|
|
41
|
+
} | Promise<{
|
|
42
|
+
value: unknown;
|
|
43
|
+
}>;
|
|
27
44
|
}
|
|
28
45
|
/** Base for scalar (non-array) fields — `array` must be omitted. */
|
|
29
46
|
interface ScalarFieldBase<V> extends FieldDefBase<V> {
|
|
@@ -318,6 +335,18 @@ interface ValidationErrorDetails {
|
|
|
318
335
|
issues: readonly StoreValidatorIssue[];
|
|
319
336
|
}
|
|
320
337
|
/**
|
|
338
|
+
* Contextual details attached to a `DEFINITION` error.
|
|
339
|
+
*
|
|
340
|
+
* Returned when a `field()` call cannot produce a valid `FieldDef` from the
|
|
341
|
+
* provided schema and options — e.g. the input is not a Standard Schema v1
|
|
342
|
+
* object, or the schema is of an unknown vendor and `opts.type` was not
|
|
343
|
+
* supplied so the runtime CLI type cannot be inferred.
|
|
344
|
+
*/
|
|
345
|
+
interface DefinitionErrorDetails {
|
|
346
|
+
/** The schema vendor name, when the failing input was a Standard Schema. */
|
|
347
|
+
vendor?: string;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
321
350
|
* Maps each {@link StoreErrorCode} to its structured details type.
|
|
322
351
|
*
|
|
323
352
|
* Used internally for conditional constructor parameters and type narrowing.
|
|
@@ -327,6 +356,7 @@ interface StoreErrorDetailsMap {
|
|
|
327
356
|
PARSE: ParseErrorDetails;
|
|
328
357
|
IO: IOErrorDetails;
|
|
329
358
|
VALIDATION: ValidationErrorDetails;
|
|
359
|
+
DEFINITION: DefinitionErrorDetails;
|
|
330
360
|
}
|
|
331
361
|
/**
|
|
332
362
|
* All possible error codes emitted by `@crustjs/store`.
|
|
@@ -335,6 +365,7 @@ interface StoreErrorDetailsMap {
|
|
|
335
365
|
* - `PARSE` — Malformed JSON in persisted config file
|
|
336
366
|
* - `IO` — Filesystem read, write, or delete failure
|
|
337
367
|
* - `VALIDATION` — Config object rejected by a store validator
|
|
368
|
+
* - `DEFINITION` — `field()` cannot produce a valid `FieldDef` from the given schema/options
|
|
338
369
|
*
|
|
339
370
|
* @example
|
|
340
371
|
* ```ts
|
|
@@ -422,6 +453,178 @@ declare class CrustStoreError<C extends StoreErrorCode = StoreErrorCode> extends
|
|
|
422
453
|
*/
|
|
423
454
|
withCause(cause: unknown): this;
|
|
424
455
|
}
|
|
456
|
+
import { InferOutput, StandardSchema } from "@crustjs/schema-utils";
|
|
457
|
+
/**
|
|
458
|
+
* Optional overrides for the introspected `FieldDef` shape produced by
|
|
459
|
+
* {@link field}.
|
|
460
|
+
*
|
|
461
|
+
* Every key is optional. When automatic introspection covers a field
|
|
462
|
+
* (`type`, `description`, `default`, `array`), explicit values override
|
|
463
|
+
* the introspection silently. For schemas with unknown vendors (e.g.
|
|
464
|
+
* Valibot, ArkType), `type` MUST be supplied explicitly because no
|
|
465
|
+
* inference is available.
|
|
466
|
+
*
|
|
467
|
+
* No `validate` key — validation flows exclusively through the schema.
|
|
468
|
+
* If extra checks are needed, refine the schema with `.refine(...)`
|
|
469
|
+
* (Zod), `Schema.filter(...)` (Effect), etc.
|
|
470
|
+
*
|
|
471
|
+
* @typeParam T - The schema's output value type. Used to type-tighten the
|
|
472
|
+
* `default` key when the user wants a non-`undefined` field.
|
|
473
|
+
*/
|
|
474
|
+
interface FieldOptions<T = unknown> {
|
|
475
|
+
type?: "string" | "number" | "boolean";
|
|
476
|
+
description?: string;
|
|
477
|
+
/**
|
|
478
|
+
* Default value for this field when the persisted state does not contain
|
|
479
|
+
* a value for it. Passing `default` explicitly here narrows the inferred
|
|
480
|
+
* config type from `T | undefined` to `T`. Schema-derived defaults
|
|
481
|
+
* (e.g. `z.string().default("x")`) populate the runtime default but do
|
|
482
|
+
* NOT narrow the TypeScript type — pass it explicitly here for tight
|
|
483
|
+
* typing.
|
|
484
|
+
*/
|
|
485
|
+
default?: T;
|
|
486
|
+
/** Mark this field as an array (collects values into an array). */
|
|
487
|
+
array?: true;
|
|
488
|
+
}
|
|
489
|
+
type ValueType2 = "string" | "number" | "boolean";
|
|
490
|
+
type StripUndefined<T> = Exclude<T, undefined>;
|
|
491
|
+
type PrimitiveToValueType<T> = [T] extends [string] ? "string" : [T] extends [number] ? "number" : [T] extends [boolean] ? "boolean" : ValueType2;
|
|
492
|
+
/**
|
|
493
|
+
* Resolve the runtime CLI value-type from a Standard Schema's output type.
|
|
494
|
+
*
|
|
495
|
+
* Array schemas are detected via `IsArrayOutput`; their element type drives
|
|
496
|
+
* the value-type literal so `field(z.array(z.string()))` resolves to
|
|
497
|
+
* `{ type: "string"; array: true }`.
|
|
498
|
+
*/
|
|
499
|
+
type IsArrayOutput<S> = S extends StandardSchema<infer _I, infer Out> ? StripUndefined<Out> extends readonly unknown[] ? true : false : false;
|
|
500
|
+
type ArrayElementOutput<S> = S extends StandardSchema<infer _I, infer Out> ? StripUndefined<Out> extends readonly (infer E)[] ? E : never : never;
|
|
501
|
+
type ResolveScalarType<S> = S extends StandardSchema<infer _I, infer Out> ? PrimitiveToValueType<StripUndefined<Out>> : ValueType2;
|
|
502
|
+
/** A scalar `FieldDef` with no narrowed default. */
|
|
503
|
+
type ScalarFieldDef<T extends ValueType2> = {
|
|
504
|
+
readonly type: T;
|
|
505
|
+
readonly description?: string;
|
|
506
|
+
readonly validate: (value: unknown) => Promise<{
|
|
507
|
+
value: unknown;
|
|
508
|
+
}>;
|
|
509
|
+
};
|
|
510
|
+
/** A scalar `FieldDef` with a narrowed default. */
|
|
511
|
+
type ScalarFieldDefWithDefault<
|
|
512
|
+
T extends ValueType2,
|
|
513
|
+
D
|
|
514
|
+
> = {
|
|
515
|
+
readonly type: T;
|
|
516
|
+
readonly description?: string;
|
|
517
|
+
readonly default: D;
|
|
518
|
+
readonly validate: (value: unknown) => Promise<{
|
|
519
|
+
value: unknown;
|
|
520
|
+
}>;
|
|
521
|
+
};
|
|
522
|
+
/** An array `FieldDef` with no narrowed default. */
|
|
523
|
+
type ArrayFieldDef<T extends ValueType2> = {
|
|
524
|
+
readonly type: T;
|
|
525
|
+
readonly array: true;
|
|
526
|
+
readonly description?: string;
|
|
527
|
+
readonly validate: (value: unknown) => Promise<{
|
|
528
|
+
value: unknown;
|
|
529
|
+
}>;
|
|
530
|
+
};
|
|
531
|
+
/** An array `FieldDef` with a narrowed default. */
|
|
532
|
+
type ArrayFieldDefWithDefault<
|
|
533
|
+
T extends ValueType2,
|
|
534
|
+
D
|
|
535
|
+
> = {
|
|
536
|
+
readonly type: T;
|
|
537
|
+
readonly array: true;
|
|
538
|
+
readonly description?: string;
|
|
539
|
+
readonly default: D;
|
|
540
|
+
readonly validate: (value: unknown) => Promise<{
|
|
541
|
+
value: unknown;
|
|
542
|
+
}>;
|
|
543
|
+
};
|
|
544
|
+
/**
|
|
545
|
+
* `FieldDef` inferred from a Standard Schema, with no narrowed default.
|
|
546
|
+
*
|
|
547
|
+
* Schema-derived defaults are populated at runtime but do NOT narrow the
|
|
548
|
+
* TypeScript type — Standard Schema v1 has no spec-portable type-level
|
|
549
|
+
* access to defaults. Pass `field(schema, { default: x })` explicitly to
|
|
550
|
+
* narrow.
|
|
551
|
+
*/
|
|
552
|
+
type SchemaFieldDef<S extends StandardSchema> = IsArrayOutput<S> extends true ? ArrayFieldDef<PrimitiveToValueType<StripUndefined<ArrayElementOutput<S>>>> : ScalarFieldDef<ResolveScalarType<S>>;
|
|
553
|
+
/**
|
|
554
|
+
* `FieldDef` inferred from a Standard Schema, with the explicit `opts.default`
|
|
555
|
+
* narrowed into the type.
|
|
556
|
+
*/
|
|
557
|
+
type SchemaFieldDefWithDefault<
|
|
558
|
+
S extends StandardSchema,
|
|
559
|
+
D
|
|
560
|
+
> = IsArrayOutput<S> extends true ? ArrayFieldDefWithDefault<PrimitiveToValueType<StripUndefined<ArrayElementOutput<S>>>, D> : ScalarFieldDefWithDefault<ResolveScalarType<S>, D>;
|
|
561
|
+
/**
|
|
562
|
+
* Define a `@crustjs/store` field from any Standard Schema v1.
|
|
563
|
+
*
|
|
564
|
+
* Returns a value that satisfies store's `FieldDef` discriminated union.
|
|
565
|
+
* Auto-derives `type`, `array`, `description`, and `default` from the
|
|
566
|
+
* schema (Zod and Effect natively; Valibot/ArkType via the
|
|
567
|
+
* `validate(undefined)` fallback for defaults). Pass `opts` to override
|
|
568
|
+
* any key explicitly — explicit values win silently.
|
|
569
|
+
*
|
|
570
|
+
* The returned `validate` is an async function that throws an `Error` with
|
|
571
|
+
* the schema's normalized issue messages on failure (matches store's
|
|
572
|
+
* `FieldDef.validate` contract).
|
|
573
|
+
*
|
|
574
|
+
* **Type-level defaults**: schema-derived defaults populate at runtime but
|
|
575
|
+
* do NOT narrow the inferred config type. For tight typing of
|
|
576
|
+
* default-bearing fields, pass `default` via `opts`:
|
|
577
|
+
*
|
|
578
|
+
* ```ts
|
|
579
|
+
* field(z.string().default("x")) // state: string | undefined
|
|
580
|
+
* field(z.string(), { default: "x" }) // state: string
|
|
581
|
+
* ```
|
|
582
|
+
*
|
|
583
|
+
* @param schema - Any Standard Schema v1 object (Zod schemas natively;
|
|
584
|
+
* Effect schemas wrapped via `Schema.standardSchemaV1`;
|
|
585
|
+
* Valibot/ArkType/Sury/etc. as-is)
|
|
586
|
+
* @param opts - Optional store-field metadata; explicit keys override
|
|
587
|
+
* the introspected values silently
|
|
588
|
+
* @throws {CrustStoreError} With code `"DEFINITION"` when the input is not
|
|
589
|
+
* a Standard Schema, or when the runtime CLI type cannot be inferred
|
|
590
|
+
* and `opts.type` was not supplied.
|
|
591
|
+
*
|
|
592
|
+
* @example Zod
|
|
593
|
+
* ```ts
|
|
594
|
+
* import { z } from "zod";
|
|
595
|
+
* import { createStore, configDir, field } from "@crustjs/store";
|
|
596
|
+
*
|
|
597
|
+
* const store = createStore({
|
|
598
|
+
* dirPath: configDir("my-cli"),
|
|
599
|
+
* fields: {
|
|
600
|
+
* theme: field(z.enum(["light", "dark"]).default("light")),
|
|
601
|
+
* verbose: field(z.boolean().default(false)),
|
|
602
|
+
* tags: field(z.array(z.string()).default([])),
|
|
603
|
+
* },
|
|
604
|
+
* });
|
|
605
|
+
* ```
|
|
606
|
+
*
|
|
607
|
+
* @example Effect
|
|
608
|
+
* ```ts
|
|
609
|
+
* import * as Schema from "effect/Schema";
|
|
610
|
+
* import { createStore, configDir, field } from "@crustjs/store";
|
|
611
|
+
*
|
|
612
|
+
* const store = createStore({
|
|
613
|
+
* dirPath: configDir("my-cli"),
|
|
614
|
+
* fields: {
|
|
615
|
+
* theme: field(Schema.standardSchemaV1(Schema.Literal("light", "dark"))),
|
|
616
|
+
* },
|
|
617
|
+
* });
|
|
618
|
+
* ```
|
|
619
|
+
*/
|
|
620
|
+
declare function field<S extends StandardSchema>(schema: S): SchemaFieldDef<S>;
|
|
621
|
+
declare function field<
|
|
622
|
+
S extends StandardSchema,
|
|
623
|
+
D extends InferOutput<S>
|
|
624
|
+
>(schema: S, opts: FieldOptions<InferOutput<S>> & {
|
|
625
|
+
default: D;
|
|
626
|
+
}): SchemaFieldDefWithDefault<S, D>;
|
|
627
|
+
declare function field<S extends StandardSchema>(schema: S, opts: FieldOptions<InferOutput<S>>): SchemaFieldDef<S>;
|
|
425
628
|
/**
|
|
426
629
|
* Encapsulates runtime environment values needed for path resolution.
|
|
427
630
|
*
|
|
@@ -576,4 +779,4 @@ declare function cacheDir(appName: string, env?: PlatformEnv): string;
|
|
|
576
779
|
* ```
|
|
577
780
|
*/
|
|
578
781
|
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 };
|
|
782
|
+
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
|
|
2
|
+
class W 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{extractDefault as m,inferOptions as d,isStandardSchema as p,normalizeStandardIssues as o}from"@crustjs/schema-utils";function n(q){return async(H)=>{let G=await q["~standard"].validate(H);if(G.issues){let L=o(G.issues).map((Q)=>Q.path?`${Q.path}: ${Q.message}`:Q.message);throw Error(L.join("; "))}return{value:G.value}}}function i(q,H){if(!p(q))throw new W("DEFINITION",`field(): argument must be a Standard Schema v1 object (got ${typeof q})`,{});let G=q["~standard"]?.vendor??"unknown",K=`field (vendor: "${G}")`,L=d(q,"field",K),Q=H?.type??L.type;if(!Q)throw new W("DEFINITION",`${K}: unable to infer field type from schema. Pass an explicit { type: "string" | "number" | "boolean" } in options. If this is an Effect schema, wrap it with Schema.standardSchemaV1(...) before passing it here.`,{vendor:G});let J=H?.array===!0||L.multiple===!0,V=H?.description??L.description,x=H&&"default"in H?{ok:!0,value:H.default}:m(q),T=n(q),O={type:Q,validate:T};if(J)O.array=!0;if(V!==void 0)O.description=V;if(x.ok)O.default=x.value;return O}import{homedir as l}from"os";import{join as B}from"path";var s="config";function U(){return{platform:process.platform,env:process.env,homedir:l()}}function j(q){if(!q||q.trim().length===0)throw new W("PATH","appName must be a non-empty string",{path:q??""});if(q.includes("/")||q.includes("\\"))throw new W("PATH","appName must not contain path separators",{path:q})}function t(q){if(!q||q.trim().length===0)throw new W("PATH","name must be a non-empty string",{path:q??""});if(q.includes("/")||q.includes("\\"))throw new W("PATH","name must not contain path separators",{path:q});if(q.endsWith(".json"))throw new W("PATH","name must not include the .json extension",{path:q})}function r(q){if(!q||q.trim().length===0)throw new W("PATH","dirPath must be a non-empty string",{path:q??""});if(!(q.startsWith("/")||/^[A-Za-z]:[/\\]/.test(q)))throw new W("PATH","dirPath must be an absolute path",{path:q});if(q.endsWith(".json"))throw new W("PATH","dirPath must be a directory path, not a file path (should not end with .json)",{path:q})}function C(q,H,G,K){let L=q.env[H],Q=L&&L.trim().length>0?L:B(q.homedir,...G);return B(Q,K)}function D(q){throw new W("PATH",`Unsupported platform: ${q}`,{path:q})}function a(q,H){j(q);let G=H??U();switch(G.platform){case"linux":case"darwin":return C(G,"XDG_CONFIG_HOME",[".config"],q);case"win32":{let K=G.env.APPDATA,L=K&&K.trim().length>0?K:B(G.homedir,"AppData","Roaming");return B(L,q)}default:D(G.platform)}}function e(q,H){j(q);let G=H??U();switch(G.platform){case"linux":case"darwin":return C(G,"XDG_DATA_HOME",[".local","share"],q);case"win32":{let K=G.env.LOCALAPPDATA,L=K&&K.trim().length>0?K:B(G.homedir,"AppData","Local");return B(L,q,"Data")}default:D(G.platform)}}function qq(q,H){j(q);let G=H??U();switch(G.platform){case"linux":case"darwin":return C(G,"XDG_STATE_HOME",[".local","state"],q);case"win32":{let K=G.env.LOCALAPPDATA,L=K&&K.trim().length>0?K:B(G.homedir,"AppData","Local");return B(L,q,"State")}default:D(G.platform)}}function Gq(q,H){j(q);let G=H??U();switch(G.platform){case"linux":case"darwin":return C(G,"XDG_CACHE_HOME",[".cache"],q);case"win32":{let K=G.env.LOCALAPPDATA,L=K&&K.trim().length>0?K:B(G.homedir,"AppData","Local");return B(L,q,"Cache")}default:D(G.platform)}}function S(q,H){r(q);let G=H??s;if(H!==void 0)t(H);return B(q,`${G}.json`)}function w(q,H,G=!0){let K={};for(let[L,Q]of Object.entries(H))if(q&&L in q){let J=q[L];K[L]=Array.isArray(J)?[...J]:J}else if("default"in Q&&Q.default!==void 0)K[L]=Array.isArray(Q.default)?[...Q.default]:Q.default;if(!G&&q!==void 0){for(let[L,Q]of Object.entries(q))if(!(L in H))K[L]=Array.isArray(Q)?[...Q]:Q}return K}import{randomUUID as Hq}from"crypto";import{mkdir as Kq,readFile as Lq,rename as Qq,rm as Wq,unlink as Xq,writeFile as Yq}from"fs/promises";import{dirname as Zq,join as $q}from"path";async function y(q){let H;try{H=await Lq(q,"utf-8")}catch(G){if(E(G))return;throw new W("IO",`Failed to read config file: ${q}`,{path:q,operation:"read"}).withCause(G)}try{return JSON.parse(H)}catch(G){throw new W("PARSE",`Malformed JSON in config file: ${q}`,{path:q}).withCause(G)}}async function z(q,H){let G=Zq(q),K=$q(G,`.config-${Hq()}.tmp`);try{await Kq(G,{recursive:!0})}catch(L){throw new W("IO",`Failed to create config directory: ${G}`,{path:q,operation:"write"}).withCause(L)}try{let L=JSON.stringify(H,null,"\t");await Yq(K,L,"utf-8")}catch(L){throw await g(K),new W("IO",`Failed to write config file: ${q}`,{path:q,operation:"write"}).withCause(L)}try{await Qq(K,q)}catch(L){throw await g(K),new W("IO",`Failed to finalize config file: ${q}`,{path:q,operation:"write"}).withCause(L)}}async function k(q){try{await Xq(q)}catch(H){if(E(H))return;throw new W("IO",`Failed to delete config file: ${q}`,{path:q,operation:"delete"}).withCause(H)}}function E(q){return q!==null&&typeof q==="object"&&"code"in q&&q.code==="ENOENT"}async function g(q){try{await Wq(q,{force:!0})}catch{}}function N(q){return typeof q==="object"&&q!==null&&"value"in q&&!("ok"in q)}function Mq(q){let{dirPath:H,name:G,fields:K,pruneUnknown:L}=q,Q=S(H,G),J=L??!0;function V(X,Z){if(Z==="number"&&typeof X==="string"){let Y=Number(X);return Number.isNaN(Y)?X:Y}if(Z==="boolean"&&typeof X==="string")return X==="true"||X==="1";return X}function x(X){let Y={...X};for(let[$,M]of Object.entries(K)){if(!($ in Y))continue;let I=Y[$];if(M.array===!0&&Array.isArray(I)){Y[$]=I.map((_)=>V(_,M.type));continue}Y[$]=V(I,M.type)}return Y}async function T(X,Z){let Y=[],$=X;for(let[M,I]of Object.entries(K)){if(!I.validate)continue;let _=$[M];if(_===void 0)continue;let R;try{R=await I.validate(_)}catch(F){let b=F instanceof Error?F.message:"Validation failed";Y.push({message:b,path:M});continue}if(R===void 0)continue;if(N(R)){let F=R.value;if(Z==="read")continue;if(!Bun.deepEquals(F,_)){let b;try{b=await I.validate(F)}catch(A){let u=A instanceof Error?A.message:"re-validation failed";Y.push({message:`read-unstable transform: ${u}`,path:M});continue}if(b!==void 0&&(!N(b)||!Bun.deepEquals(b.value,F))){Y.push({message:"read-unstable transform: output would be transformed again on re-read",path:M});continue}$[M]=F}continue}throw TypeError("FieldDef.validate must return void or { value } (see @crustjs/store validation docs).")}if(Y.length>0){let M=Y.map((_)=>` - ${_.path}: ${_.message}`),I=`Store validation failed (${Z})
|
|
3
3
|
${M.join(`
|
|
4
|
-
`)}`;throw new
|
|
4
|
+
`)}`;throw new W("VALIDATION",I,{operation:Z,issues:Y})}}async function O(){let X=await y(Q),Z=w(X,K,J);return x(Z)}async function P(){let X=await O();return await T(X,"read"),X}async function c(X){let Z=x(X);await T(Z,"write"),await z(Q,Z)}async function f(X){let Z=await O(),Y=X(Z),$=x(Y);await T($,"update"),await z(Q,$)}async function h(X){let Y={...await O(),...X},$=x(Y);await T($,"patch"),await z(Q,$)}async function v(){await k(Q)}return{read:P,write:c,update:f,patch:h,reset:v}}export{qq as stateDir,i as field,e as dataDir,Mq as createStore,a as configDir,Gq as cacheDir,W as CrustStoreError};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crustjs/store",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
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.14.2",
|
|
52
|
+
"zod": "^4.0.0"
|
|
50
53
|
},
|
|
51
54
|
"peerDependencies": {
|
|
52
|
-
"typescript": "^
|
|
55
|
+
"typescript": "^6.0.3"
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@crustjs/schema-utils": "0.0.1"
|
|
53
59
|
}
|
|
54
60
|
}
|