@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 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 | 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 |
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 { createStore, configDir, dataDir, stateDir, cacheDir } from "@crustjs/store";
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: "array", default: [] },
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 | Windows | Env Override |
110
- | ----------- | ----------------------------------- | ------------------------------------------- | -------------------- |
111
- | `configDir` | `~/.config/<app>` | `%APPDATA%\<app>` | `$XDG_CONFIG_HOME` |
112
- | `dataDir` | `~/.local/share/<app>` | `%LOCALAPPDATA%\<app>\Data` | `$XDG_DATA_HOME` |
113
- | `stateDir` | `~/.local/state/<app>` | `%LOCALAPPDATA%\<app>\State` | `$XDG_STATE_HOME` |
114
- | `cacheDir` | `~/.cache/<app>` | `%LOCALAPPDATA%\<app>\Cache` | `$XDG_CACHE_HOME` |
115
+ | 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 | Required | Description |
135
- | -------------- | -------------------------- | -------- | ------------------------------------------------------------------ |
136
- | `dirPath` | `string` | Yes | Absolute directory path where the JSON file is stored. |
137
- | `name` | `string` | No | Store name used as filename (default `"config"` → `config.json`). |
138
- | `fields` | `FieldsDef` | Yes | Field definitions defining the store's data shape, types, defaults, and optional validation. |
139
- | `pruneUnknown` | `boolean` | No | Drop unknown persisted keys on read (default `true`). |
140
+ | 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
- ### Using `@crustjs/validate`
320
+ ### Schema-driven validation
315
321
 
316
- The easiest way to add validation is with field adapters from `@crustjs/validate`:
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/validate/zod";
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
- `fieldSync()` is also available for synchronous schemas.
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/validate/effect";
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
- const str = value as string;
381
- if (str !== "light" && str !== "dark") {
382
- return {
383
- ok: false,
384
- issues: [{ message: 'Must be "light" or "dark"', path: "" }],
385
- };
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
- The per-field `validate` function follows the `StoreValidator<T>` contract: `(value: unknown) => StoreValidatorResult<T> | Promise<StoreValidatorResult<T>>`, where `StoreValidatorResult<T>` is `{ ok: true, value: T } | { ok: false, issues: StoreValidatorIssue[] }`.
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. If a validator transforms the value, the transformed result is persisted.
399
- - **Read**: Validates each field after applying defaults. Invalid persisted config fails loudly — no silent fallback to defaults.
400
- - **Update**: Reads raw config (no validation), applies updater, validates each field, then persists.
401
- - **Patch**: Reads raw config, applies shallow partial merge, validates each field, then persists.
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: StoreValidationIssue[] }
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 | Details |
427
- | ------------ | --------------------------------------------------------------- | --------------------------- |
428
- | `PATH` | Invalid `dirPath`, invalid `name`, unsupported platform | `{ path: string }` |
429
- | `PARSE` | Malformed JSON in persisted config file | `{ path: string }` |
430
- | `IO` | Filesystem read, write, or delete failure | `{ path, operation }` |
431
- | `VALIDATION` | Config fails validator on read, write, update, or patch | `{ operation, issues }` |
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 | Description |
503
- | ------------------------- | ----------------------------------------------------------------------- |
504
- | `CreateStoreOptions` | Options object for `createStore()`. |
505
- | `Store` | Store instance with `read`, `write`, `update`, `patch`, `reset`. |
506
- | `StoreUpdater` | Updater function type `(current: T) => T`. |
507
- | `FieldDef` | Single field definition with `type`, optional `default`, and optional `validate`. |
508
- | `FieldsDef` | Record of field names to `FieldDef` definitions. |
509
- | `InferStoreConfig` | Inferred store state type from a `FieldsDef` definition. |
510
- | `StoreValidator` | Validator function contract `(value: unknown) => StoreValidatorResult`. |
511
- | `StoreValidatorResult` | Discriminated union: `{ ok: true, value: T } \| { ok: false, issues: StoreValidatorIssue[] }`. |
512
- | `StoreValidatorIssue` | `{ message: string, path: string }`. |
513
- | `StoreValidationIssue` | Validation issue in error details payload. |
514
- | `ValidationErrorDetails` | Error details for `VALIDATION` code: `{ operation, issues }`. |
515
- | `StoreErrorCode` | Union of error codes: `"PATH" \| "PARSE" \| "IO" \| "VALIDATION"`. |
516
- | `PlatformEnv` | Injectable platform environment for testing path helpers. |
517
- | `CrustStoreError` | Typed error class with `code`, `details`, and `cause`. |
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 = "string" | "number" | "boolean";
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`). Throw an error to reject the
20
- * value.
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
- type FieldDef = StringFieldDef | NumberFieldDef | BooleanFieldDef | StringArrayFieldDef | NumberArrayFieldDef | BooleanArrayFieldDef;
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 Q extends Error{code;details;cause;constructor(q,I,G){super(I);this.name="CrustStoreError",this.code=q,this.details=G}is(q){return this.code===q}withCause(q){return this.cause=q,this}}import{homedir as P}from"os";import{join as $}from"path";var h="config";function V(){return{platform:process.platform,env:process.env,homedir:P()}}function F(q){if(!q||q.trim().length===0)throw new Q("PATH","appName must be a non-empty string",{path:q??""});if(q.includes("/")||q.includes("\\"))throw new Q("PATH","appName must not contain path separators",{path:q})}function u(q){if(!q||q.trim().length===0)throw new Q("PATH","name must be a non-empty string",{path:q??""});if(q.includes("/")||q.includes("\\"))throw new Q("PATH","name must not contain path separators",{path:q});if(q.endsWith(".json"))throw new Q("PATH","name must not include the .json extension",{path:q})}function v(q){if(!q||q.trim().length===0)throw new Q("PATH","dirPath must be a non-empty string",{path:q??""});if(!(q.startsWith("/")||/^[A-Za-z]:[/\\]/.test(q)))throw new Q("PATH","dirPath must be an absolute path",{path:q});if(q.endsWith(".json"))throw new Q("PATH","dirPath must be a directory path, not a file path (should not end with .json)",{path:q})}function T(q,I,G,H){let K=q.env[I],W=K&&K.trim().length>0?K:$(q.homedir,...G);return $(W,H)}function b(q){throw new Q("PATH",`Unsupported platform: ${q}`,{path:q})}function m(q,I){F(q);let G=I??V();switch(G.platform){case"linux":case"darwin":return T(G,"XDG_CONFIG_HOME",[".config"],q);case"win32":{let H=G.env.APPDATA,K=H&&H.trim().length>0?H:$(G.homedir,"AppData","Roaming");return $(K,q)}default:b(G.platform)}}function d(q,I){F(q);let G=I??V();switch(G.platform){case"linux":case"darwin":return T(G,"XDG_DATA_HOME",[".local","share"],q);case"win32":{let H=G.env.LOCALAPPDATA,K=H&&H.trim().length>0?H:$(G.homedir,"AppData","Local");return $(K,q,"Data")}default:b(G.platform)}}function o(q,I){F(q);let G=I??V();switch(G.platform){case"linux":case"darwin":return T(G,"XDG_STATE_HOME",[".local","state"],q);case"win32":{let H=G.env.LOCALAPPDATA,K=H&&H.trim().length>0?H:$(G.homedir,"AppData","Local");return $(K,q,"State")}default:b(G.platform)}}function s(q,I){F(q);let G=I??V();switch(G.platform){case"linux":case"darwin":return T(G,"XDG_CACHE_HOME",[".cache"],q);case"win32":{let H=G.env.LOCALAPPDATA,K=H&&H.trim().length>0?H:$(G.homedir,"AppData","Local");return $(K,q,"Cache")}default:b(G.platform)}}function U(q,I){v(q);let G=I??h;if(I!==void 0)u(I);return $(q,`${G}.json`)}function z(q,I,G=!0){let H={};for(let[K,W]of Object.entries(I))if(q&&K in q){let _=q[K];H[K]=Array.isArray(_)?[..._]:_}else if("default"in W&&W.default!==void 0)H[K]=Array.isArray(W.default)?[...W.default]:W.default;if(!G&&q!==void 0){for(let[K,W]of Object.entries(q))if(!(K in I))H[K]=Array.isArray(W)?[...W]:W}return H}import{randomUUID as p}from"crypto";import{mkdir as n,readFile as i,rename as l,rm as t,unlink as r,writeFile as a}from"fs/promises";import{dirname as e,join as qq}from"path";async function S(q){let I;try{I=await i(q,"utf-8")}catch(G){if(y(G))return;throw new Q("IO",`Failed to read config file: ${q}`,{path:q,operation:"read"}).withCause(G)}try{return JSON.parse(I)}catch(G){throw new Q("PARSE",`Malformed JSON in config file: ${q}`,{path:q}).withCause(G)}}async function R(q,I){let G=e(q),H=qq(G,`.config-${p()}.tmp`);try{await n(G,{recursive:!0})}catch(K){throw new Q("IO",`Failed to create config directory: ${G}`,{path:q,operation:"write"}).withCause(K)}try{let K=JSON.stringify(I,null,"\t");await a(H,K,"utf-8")}catch(K){throw await A(H),new Q("IO",`Failed to write config file: ${q}`,{path:q,operation:"write"}).withCause(K)}try{await l(H,q)}catch(K){throw await A(H),new Q("IO",`Failed to finalize config file: ${q}`,{path:q,operation:"write"}).withCause(K)}}async function k(q){try{await r(q)}catch(I){if(y(I))return;throw new Q("IO",`Failed to delete config file: ${q}`,{path:q,operation:"delete"}).withCause(I)}}function y(q){return q!==null&&typeof q==="object"&&"code"in q&&q.code==="ENOENT"}async function A(q){try{await t(q,{force:!0})}catch{}}function Gq(q){let{dirPath:I,name:G,fields:H,pruneUnknown:K}=q,W=U(I,G),_=K??!0;function C(L,Y){if(Y==="number"&&typeof L==="string"){let X=Number(L);return Number.isNaN(X)?L:X}if(Y==="boolean"&&typeof L==="string")return L==="true"||L==="1";return L}function x(L){let X={...L};for(let[Z,M]of Object.entries(H)){if(!(Z in X))continue;let O=X[Z];if(M.array===!0&&Array.isArray(O)){X[Z]=O.map((B)=>C(B,M.type));continue}X[Z]=C(O,M.type)}return X}async function J(L,Y){let X=[],Z=L;for(let[M,O]of Object.entries(H)){if(!O.validate)continue;let B=Z[M];if(B===void 0)continue;try{await O.validate(B)}catch(D){let f=D instanceof Error?D.message:"Validation failed";X.push({message:f,path:M})}}if(X.length>0){let M=X.map((B)=>` - ${B.path}: ${B.message}`),O=`Store validation failed (${Y})
3
- ${M.join(`
4
- `)}`;throw new Q("VALIDATION",O,{operation:Y,issues:X})}}async function j(){let L=await S(W),Y=z(L,H,_);return x(Y)}async function g(){let L=await j();return await J(L,"read"),L}async function w(L){let Y=x(L);await J(Y,"write"),await R(W,Y)}async function c(L){let Y=await j(),X=L(Y),Z=x(X);await J(Z,"update"),await R(W,Z)}async function E(L){let X={...await j(),...L},Z=x(X);await J(Z,"patch"),await R(W,Z)}async function N(){await k(W)}return{read:g,write:w,update:c,patch:E,reset:N}}export{o as stateDir,d as dataDir,Gq as createStore,m as configDir,s as cacheDir,Q as CrustStoreError};
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.0.4",
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.29"
50
+ "bunup": "^0.16.31",
51
+ "effect": "3.21.2",
52
+ "zod": "^4.0.0"
50
53
  },
51
54
  "peerDependencies": {
52
- "typescript": "^5"
55
+ "typescript": "^6.0.3"
56
+ },
57
+ "dependencies": {
58
+ "@crustjs/utils": "0.0.2"
53
59
  }
54
60
  }