@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 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({
@@ -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,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
- ### 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 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/validate/zod";
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
- `fieldSync()` is also available for synchronous schemas.
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/validate/effect";
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
- 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
- };
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
- 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[] }`.
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. 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.
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: StoreValidationIssue[] }
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 | 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 }` |
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 | 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`. |
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`). Throw an error to reject the
20
- * value.
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 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})
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 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};
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.4",
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.29"
50
+ "bunup": "^0.16.31",
51
+ "effect": "3.14.2",
52
+ "zod": "^4.0.0"
50
53
  },
51
54
  "peerDependencies": {
52
- "typescript": "^5"
55
+ "typescript": "^6.0.3"
56
+ },
57
+ "dependencies": {
58
+ "@crustjs/schema-utils": "0.0.1"
53
59
  }
54
60
  }