@crustjs/store 0.0.3 → 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
@@ -2,7 +2,7 @@
2
2
 
3
3
  DX-first, typed persistence for CLI apps with clear config/data/state/cache separation.
4
4
 
5
- `@crustjs/store` gives your CLI production-ready local persistence with near-zero setup. Provide a defaults object, pick a storage intent, and get a typed store with read/write/update/patch/reset — no manual type annotations needed.
5
+ `@crustjs/store` gives your CLI production-ready local persistence with near-zero setup. Provide a `fields` definition, pick a storage intent, and get a typed store with read/write/update/patch/reset — no manual type annotations needed.
6
6
 
7
7
  ## Install
8
8
 
@@ -26,67 +26,85 @@ import { createStore, configDir } from "@crustjs/store";
26
26
 
27
27
  const store = createStore({
28
28
  dirPath: configDir("my-cli"),
29
- defaults: {
30
- ui: { theme: "light" as string, fontSize: 14 },
31
- verbose: false,
29
+ fields: {
30
+ theme: { type: "string", default: "light" },
31
+ fontSize: { type: "number", default: 14 },
32
+ verbose: { type: "boolean", default: false },
32
33
  },
33
34
  });
34
35
 
35
36
  // Read state (returns defaults when no persisted file exists)
36
37
  const state = await store.read();
37
- // → { ui: { theme: "light", fontSize: 14 }, verbose: false }
38
+ // → { theme: "light", fontSize: 14, verbose: false }
38
39
 
39
40
  // Write a full state object
40
- await store.write({ ui: { theme: "dark", fontSize: 16 }, verbose: true });
41
+ await store.write({ theme: "dark", fontSize: 16, verbose: true });
41
42
 
42
43
  // Update with a function
43
44
  await store.update((current) => ({ ...current, verbose: false }));
44
45
 
45
- // Patch only specific nested keys
46
- await store.patch({ ui: { theme: "solarized" } });
46
+ // Patch only specific keys
47
+ await store.patch({ theme: "solarized" });
47
48
 
48
49
  // Reset to defaults (removes persisted file)
49
50
  await store.reset();
50
51
  ```
51
52
 
52
- No explicit generics needed — types are inferred from your `defaults` object.
53
+ No explicit generics needed — types are inferred from your `fields` definition.
53
54
 
54
55
  ## Storage Intent: Choosing a Directory
55
56
 
56
57
  `@crustjs/store` provides four path helpers that resolve platform-standard directories for different storage intents. Use the one that matches what you're persisting:
57
58
 
58
- | Helper | Intent | Example Use Case |
59
- | ----------- | -------------------------- | --------------------------------------- |
60
- | `configDir` | User preferences & config | Theme, editor settings, API keys |
61
- | `dataDir` | Important app data | Local databases, downloaded resources |
62
- | `stateDir` | Runtime state | Window positions, scroll offsets, undo |
63
- | `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 |
64
65
 
65
66
  ```ts
66
- 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";
67
74
 
68
75
  // User preferences → ~/.config/my-cli/config.json
69
76
  const config = createStore({
70
77
  dirPath: configDir("my-cli"),
71
- defaults: { theme: "light" as string, verbose: false },
78
+ fields: {
79
+ theme: { type: "string", default: "light" },
80
+ verbose: { type: "boolean", default: false },
81
+ },
72
82
  });
73
83
 
74
84
  // App data → ~/.local/share/my-cli/config.json
75
85
  const data = createStore({
76
86
  dirPath: dataDir("my-cli"),
77
- defaults: { bookmarks: [] as string[] },
87
+ fields: {
88
+ bookmarks: { type: "array", default: [] },
89
+ },
78
90
  });
79
91
 
80
92
  // Runtime state → ~/.local/state/my-cli/config.json
81
93
  const state = createStore({
82
94
  dirPath: stateDir("my-cli"),
83
- defaults: { lastOpened: "" as string, scrollY: 0 },
95
+ fields: {
96
+ lastOpened: { type: "string", default: "" },
97
+ scrollY: { type: "number", default: 0 },
98
+ },
84
99
  });
85
100
 
86
101
  // Cache → ~/.cache/my-cli/config.json
87
102
  const cache = createStore({
88
103
  dirPath: cacheDir("my-cli"),
89
- defaults: { etag: "" as string, payload: "" as string },
104
+ fields: {
105
+ etag: { type: "string", default: "" },
106
+ payload: { type: "string", default: "" },
107
+ },
90
108
  });
91
109
  ```
92
110
 
@@ -94,12 +112,12 @@ const cache = createStore({
94
112
 
95
113
  All four helpers follow XDG conventions on Linux and macOS, and Windows-native conventions on Windows:
96
114
 
97
- | Helper | Linux / macOS | Windows | Env Override |
98
- | ----------- | ----------------------------------- | ------------------------------------------- | -------------------- |
99
- | `configDir` | `~/.config/<app>` | `%APPDATA%\<app>` | `$XDG_CONFIG_HOME` |
100
- | `dataDir` | `~/.local/share/<app>` | `%LOCALAPPDATA%\<app>\Data` | `$XDG_DATA_HOME` |
101
- | `stateDir` | `~/.local/state/<app>` | `%LOCALAPPDATA%\<app>\State` | `$XDG_STATE_HOME` |
102
- | `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` |
103
121
 
104
122
  macOS uses XDG conventions (same as Linux) for a unified Unix mental model.
105
123
 
@@ -119,17 +137,16 @@ const store = createStore(options);
119
137
 
120
138
  #### Options
121
139
 
122
- | Option | Type | Required | Description |
123
- | -------------- | -------------------------- | -------- | ------------------------------------------------------------------ |
124
- | `dirPath` | `string` | Yes | Absolute directory path where the JSON file is stored. |
125
- | `name` | `string` | No | Store name used as filename (default `"config"` → `config.json`). |
126
- | `defaults` | `T` | Yes | Default values defining the store's data shape and fallbacks. |
127
- | `validator` | `StoreValidator<T>` | No | Validation function applied on read, write, update, and patch. |
128
- | `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`). |
129
146
 
130
147
  ### `store.read()`
131
148
 
132
- Reads the persisted state file. Missing keys are filled from `defaults` via deep merge. When no file exists, returns `defaults`.
149
+ Reads the persisted state file. Missing keys are filled from field `defaults`.
133
150
 
134
151
  ```ts
135
152
  const state = await store.read();
@@ -142,10 +159,10 @@ Always returns a value — never `undefined`. Does not write merged defaults bac
142
159
  Atomically persists a full state object. The entire previous state is replaced.
143
160
 
144
161
  ```ts
145
- await store.write({ ui: { theme: "dark", fontSize: 14 }, verbose: true });
162
+ await store.write({ theme: "dark", fontSize: 14, verbose: true });
146
163
  ```
147
164
 
148
- Calls `validator` (if provided) before writing. Parent directories are created if missing.
165
+ Calls per-field `validate` functions (if provided) before writing. Parent directories are created if missing.
149
166
 
150
167
  ### `store.update(updater)`
151
168
 
@@ -154,21 +171,21 @@ Reads the current effective state, applies the updater function, and atomically
154
171
  ```ts
155
172
  await store.update((current) => ({
156
173
  ...current,
157
- ui: { ...current.ui, theme: "dark" },
174
+ theme: "dark",
158
175
  }));
159
176
  ```
160
177
 
161
- When no persisted file exists, the updater receives `defaults` as the current value. Calls `validator` before writing.
178
+ When no persisted file exists, the updater receives field `defaults` as the current value. Calls per-field `validate` functions before writing.
162
179
 
163
180
  ### `store.patch(partial)`
164
181
 
165
- Applies a deep partial update to the current state and persists. Only the provided keys are updated; everything else is preserved.
182
+ Applies a shallow partial update to the current state and persists. Only the provided keys are updated; everything else is preserved.
166
183
 
167
184
  ```ts
168
- await store.patch({ ui: { theme: "solarized" } });
185
+ await store.patch({ theme: "solarized" });
169
186
  ```
170
187
 
171
- Arrays are replaced wholesale (not merged element-by-element). Calls `validator` before writing.
188
+ Calls per-field `validate` functions before writing.
172
189
 
173
190
  ### `store.reset()`
174
191
 
@@ -244,9 +261,10 @@ const dir = configDir("my-cli");
244
261
  // Default store → ~/.config/my-cli/config.json
245
262
  const settingsStore = createStore({
246
263
  dirPath: dir,
247
- defaults: {
248
- ui: { theme: "light" as string, fontSize: 14 },
249
- verbose: false,
264
+ fields: {
265
+ theme: { type: "string", default: "light" },
266
+ fontSize: { type: "number", default: 14 },
267
+ verbose: { type: "boolean", default: false },
250
268
  },
251
269
  });
252
270
 
@@ -254,7 +272,9 @@ const settingsStore = createStore({
254
272
  const authStore = createStore({
255
273
  dirPath: dir,
256
274
  name: "auth",
257
- defaults: { token: "" as string },
275
+ fields: {
276
+ token: { type: "string", default: "" },
277
+ },
258
278
  });
259
279
  ```
260
280
 
@@ -264,27 +284,27 @@ The `name` must not contain path separators or the `.json` extension.
264
284
 
265
285
  ## Defaults & Merge
266
286
 
267
- When a persisted file exists, `read()` deep-merges defaults for any missing keys:
287
+ When a persisted file exists, `read()` fills missing keys from field `defaults`:
268
288
 
269
289
  ```ts
270
290
  const store = createStore({
271
291
  dirPath: configDir("my-cli"),
272
- defaults: {
273
- ui: { theme: "light", fontSize: 14 },
274
- verbose: false,
292
+ fields: {
293
+ theme: { type: "string", default: "light" },
294
+ fontSize: { type: "number", default: 14 },
295
+ verbose: { type: "boolean", default: false },
275
296
  },
276
297
  });
277
298
 
278
- // Persisted file contains: { "ui": { "theme": "dark" }, "verbose": true }
299
+ // Persisted file contains: { "theme": "dark", "verbose": true }
279
300
  const state = await store.read();
280
- // → { ui: { theme: "dark", fontSize: 14 }, verbose: true }
301
+ // → { theme: "dark", fontSize: 14, verbose: true }
281
302
  ```
282
303
 
283
304
  ### Merge Rules
284
305
 
285
- - Persisted values override defaults at every nesting level.
286
- - Missing keys fall back to defaults (recursively for nested objects).
287
- - Arrays are replaced wholesale — not merged element-by-element.
306
+ - Persisted values override defaults.
307
+ - Missing keys fall back to defaults.
288
308
  - Unknown persisted keys are dropped by default (`pruneUnknown: true`). Set `pruneUnknown: false` to preserve them.
289
309
  - All values are deep-cloned to prevent shared-reference mutation from defaults.
290
310
  - Falsy values (`null`, `0`, `""`, `false`) in the persisted file are preserved — only truly missing keys trigger defaults.
@@ -295,26 +315,24 @@ Merged defaults exist only in memory. The persisted file remains unchanged until
295
315
 
296
316
  ## Validation
297
317
 
298
- Add schema validation to enforce config integrity on every read, write, update, and patch. When a `validator` is configured, validation is **strict by default** — invalid config fails loudly.
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.
299
319
 
300
- ### Using `@crustjs/validate`
320
+ ### Schema-driven validation
301
321
 
302
- The easiest way to add validation is with store 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:
303
325
 
304
326
  ```ts
305
327
  import { z } from "zod";
306
- import { storeValidator } from "@crustjs/validate/zod";
307
- import { createStore, configDir } from "@crustjs/store";
328
+ import { createStore, configDir, field } from "@crustjs/store";
308
329
 
309
330
  const store = createStore({
310
331
  dirPath: configDir("my-cli"),
311
- defaults: { theme: "light" as string, verbose: false },
312
- validator: storeValidator(
313
- z.object({
314
- theme: z.enum(["light", "dark"]),
315
- verbose: z.boolean(),
316
- }),
317
- ),
332
+ fields: {
333
+ theme: field(z.enum(["light", "dark"]).default("light")),
334
+ verbose: { type: "boolean", default: false },
335
+ },
318
336
  });
319
337
 
320
338
  // write() validates before persisting
@@ -326,52 +344,102 @@ const config = await store.read();
326
344
  // → throws if persisted config is invalid
327
345
  ```
328
346
 
329
- `storeValidatorSync()` is also available for synchronous schemas.
330
-
331
- For Effect schemas, wrap with `Schema.standardSchemaV1()`:
347
+ For Effect schemas, wrap once with `Schema.standardSchemaV1()`:
332
348
 
333
349
  ```ts
334
350
  import * as Schema from "effect/Schema";
335
- import { storeValidator } from "@crustjs/validate/effect";
351
+ import { createStore, configDir, field } from "@crustjs/store";
336
352
 
337
353
  const store = createStore({
338
354
  dirPath: configDir("my-cli"),
339
- defaults: { theme: "light" as string },
340
- validator: storeValidator(
341
- Schema.standardSchemaV1(
342
- Schema.Struct({ theme: Schema.Literal("light", "dark") }),
343
- ),
344
- ),
355
+ fields: {
356
+ theme: field(Schema.standardSchemaV1(Schema.Literal("light", "dark"))),
357
+ },
345
358
  });
346
359
  ```
347
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
+
348
389
  ### Custom validators
349
390
 
350
- You can also provide a validator function directly without `@crustjs/validate`:
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.
351
395
 
352
396
  ```ts
353
- import type { StoreValidator } from "@crustjs/store";
354
-
355
- const validator: StoreValidator<{ theme: string }> = (value) => {
356
- const config = value as { theme: string };
357
- if (config.theme !== "light" && config.theme !== "dark") {
358
- return {
359
- ok: false,
360
- issues: [{ message: 'Must be "light" or "dark"', path: "theme" }],
361
- };
362
- }
363
- return { ok: true, value: config };
364
- };
397
+ const store = createStore({
398
+ dirPath: configDir("my-cli"),
399
+ fields: {
400
+ theme: {
401
+ type: "string",
402
+ default: "light",
403
+ validate(value) {
404
+ if (value !== "light" && value !== "dark") {
405
+ throw new Error('Must be "light" or "dark"');
406
+ }
407
+ },
408
+ },
409
+ },
410
+ });
365
411
  ```
366
412
 
367
- The `StoreValidator<T>` contract is `(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.
368
430
 
369
431
  ### Validation behavior
370
432
 
371
- - **Write**: Validates before persisting. If the validator transforms the value, the transformed result is persisted.
372
- - **Read**: Validates after applying defaults. Invalid persisted config fails loudly — no silent fallback to defaults.
373
- - **Update**: Reads raw config (no validation), applies updater, validates the result, then persists.
374
- - **Patch**: Reads raw config, applies deep partial merge, validates the result, 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.
375
443
  - **Reset**: No validation — just removes the persisted file.
376
444
 
377
445
  ### Catching validation errors
@@ -383,7 +451,7 @@ try {
383
451
  await store.read();
384
452
  } catch (err) {
385
453
  if (err instanceof CrustStoreError && err.is("VALIDATION")) {
386
- // err.details is { operation: "read" | "write" | "update" | "patch", issues: StoreValidationIssue[] }
454
+ // err.details is { operation: "read" | "write" | "update" | "patch", issues: StoreValidatorIssue[] }
387
455
  console.error(`Validation failed during ${err.details.operation}:`);
388
456
  for (const issue of err.details.issues) {
389
457
  console.error(` ${issue.path || "(root)"}: ${issue.message}`);
@@ -396,12 +464,12 @@ try {
396
464
 
397
465
  All errors thrown by `@crustjs/store` are instances of `CrustStoreError` with a typed `code` property:
398
466
 
399
- | Code | When | Details |
400
- | ------------ | --------------------------------------------------------------- | --------------------------- |
401
- | `PATH` | Invalid `dirPath`, invalid `name`, unsupported platform | `{ path: string }` |
402
- | `PARSE` | Malformed JSON in persisted config file | `{ path: string }` |
403
- | `IO` | Filesystem read, write, or delete failure | `{ path, operation }` |
404
- | `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 }` |
405
473
 
406
474
  ### Catching errors by code
407
475
 
@@ -459,10 +527,11 @@ import type {
459
527
  CreateStoreOptions,
460
528
  Store,
461
529
  StoreUpdater,
530
+ FieldDef,
531
+ FieldsDef,
532
+ InferStoreConfig,
462
533
  StoreValidator,
463
534
  StoreValidatorResult,
464
- StoreValidatorSuccess,
465
- StoreValidatorFailure,
466
535
  StoreValidatorIssue,
467
536
  StoreErrorCode,
468
537
  StoreValidationIssue,
@@ -471,21 +540,22 @@ import type {
471
540
  } from "@crustjs/store";
472
541
  ```
473
542
 
474
- | Type | Description |
475
- | ------------------------- | ----------------------------------------------------------------------- |
476
- | `CreateStoreOptions` | Options object for `createStore()`. |
477
- | `Store` | Store instance with `read`, `write`, `update`, `patch`, `reset`. |
478
- | `StoreUpdater` | Updater function type `(current: T) => T`. |
479
- | `StoreValidator` | Validator function contract `(value: unknown) => StoreValidatorResult`. |
480
- | `StoreValidatorResult` | Discriminated union: `StoreValidatorSuccess \| StoreValidatorFailure`. |
481
- | `StoreValidatorSuccess` | `{ ok: true, value: T }`. |
482
- | `StoreValidatorFailure` | `{ ok: false, issues: StoreValidatorIssue[] }`. |
483
- | `StoreValidatorIssue` | `{ message: string, path: string }`. |
484
- | `StoreValidationIssue` | Validation issue in error details payload. |
485
- | `ValidationErrorDetails` | Error details for `VALIDATION` code: `{ operation, issues }`. |
486
- | `StoreErrorCode` | Union of error codes: `"PATH" \| "PARSE" \| "IO" \| "VALIDATION"`. |
487
- | `PlatformEnv` | Injectable platform environment for testing path helpers. |
488
- | `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`. |
489
559
 
490
560
  ## Scope & Non-Goals
491
561
 
package/dist/index.d.ts CHANGED
@@ -1,94 +1,172 @@
1
+ /** Supported type literals for store fields. */
2
+ type ValueType = "string" | "number" | "boolean";
1
3
  /**
2
- * Recursively makes all properties of `T` optional.
4
+ * Resolves a type literal to its corresponding TypeScript primitive type.
3
5
  *
4
- * - Plain objects are recursed into — each nested key becomes optional.
5
- * - Arrays are left as-is (replaced wholesale, not partially updated).
6
- * - Primitives pass through unchanged.
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;
11
+ /** Shared fields present on every store field definition. */
12
+ interface FieldDefBase<V> {
13
+ /** Human-readable description for documentation and tooling. */
14
+ description?: string;
15
+ /**
16
+ * Optional per-field validation function.
17
+ *
18
+ * Called during `read`, `write`, `update`, and `patch` operations when
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`.
36
+ *
37
+ * @param value - The field value to validate.
38
+ */
39
+ validate?: (value: V) => void | Promise<void> | {
40
+ value: unknown;
41
+ } | Promise<{
42
+ value: unknown;
43
+ }>;
44
+ }
45
+ /** Base for scalar (non-array) fields — `array` must be omitted. */
46
+ interface ScalarFieldBase<V> extends FieldDefBase<V> {
47
+ /** Must be omitted for scalar fields — set to `true` for array fields. */
48
+ array?: never;
49
+ }
50
+ /** A scalar string field. */
51
+ interface StringFieldDef extends ScalarFieldBase<string> {
52
+ type: "string";
53
+ /** Default string value when the field is not persisted. */
54
+ default?: string;
55
+ }
56
+ /** A scalar number field. */
57
+ interface NumberFieldDef extends ScalarFieldBase<number> {
58
+ type: "number";
59
+ /** Default number value when the field is not persisted. */
60
+ default?: number;
61
+ }
62
+ /** A scalar boolean field. */
63
+ interface BooleanFieldDef extends ScalarFieldBase<boolean> {
64
+ type: "boolean";
65
+ /** Default boolean value when the field is not persisted. */
66
+ default?: boolean;
67
+ }
68
+ /** Base for array fields — `array` is required as `true`. */
69
+ interface ArrayFieldBase<V> extends FieldDefBase<V> {
70
+ /** Collect values into an array. */
71
+ array: true;
72
+ }
73
+ /** An array of strings field. */
74
+ interface StringArrayFieldDef extends ArrayFieldBase<string[]> {
75
+ type: "string";
76
+ /** Default string array value when the field is not persisted. */
77
+ default?: readonly string[];
78
+ }
79
+ /** An array of numbers field. */
80
+ interface NumberArrayFieldDef extends ArrayFieldBase<number[]> {
81
+ type: "number";
82
+ /** Default number array value when the field is not persisted. */
83
+ default?: readonly number[];
84
+ }
85
+ /** An array of booleans field. */
86
+ interface BooleanArrayFieldDef extends ArrayFieldBase<boolean[]> {
87
+ type: "boolean";
88
+ /** Default boolean array value when the field is not persisted. */
89
+ default?: readonly boolean[];
90
+ }
91
+ /**
92
+ * Defines a single field in a store's config schema.
7
93
  *
8
- * Used by {@link Store.patch} for deep partial updates.
94
+ * Discriminated by `type` and `array` for type-safe `default` values.
95
+ * Each field can optionally include a `validate` function for per-field
96
+ * validation and a `default` value for fallback behavior.
9
97
  *
10
98
  * @example
11
99
  * ```ts
12
- * type Config = { ui: { theme: string; fontSize: number }; verbose: boolean };
13
- * type Partial = DeepPartial<Config>;
14
- * // → { ui?: { theme?: string; fontSize?: number }; verbose?: boolean }
100
+ * const fields = {
101
+ * theme: { type: "string", default: "light" },
102
+ * verbose: { type: "boolean", default: false },
103
+ * tags: { type: "string", array: true, default: [] },
104
+ * token: { type: "string" }, // optional — no default
105
+ * port: {
106
+ * type: "number",
107
+ * default: 3000,
108
+ * validate: (v) => { if (v < 1 || v > 65535) throw new Error("invalid port"); },
109
+ * },
110
+ * } satisfies FieldsDef;
15
111
  * ```
16
112
  */
17
- type DeepPartial<T> = T extends readonly unknown[] ? T : T extends Record<string, unknown> ? { [K in keyof T]? : DeepPartial<T[K]> } : T;
113
+ type FieldDef = StringFieldDef | NumberFieldDef | BooleanFieldDef | StringArrayFieldDef | NumberArrayFieldDef | BooleanArrayFieldDef;
114
+ /** Record mapping field names to their definitions. */
115
+ type FieldsDef = Record<string, FieldDef>;
18
116
  /**
19
- * Result returned by a {@link StoreValidator} when validation succeeds.
117
+ * Infer the resolved type for a single field definition.
20
118
  *
21
- * Carries the (possibly transformed) config value from the schema.
119
+ * - **array** → `primitive[]` (or `primitive[] | undefined` if no default)
120
+ * - **has default** → `primitive` (guaranteed present)
121
+ * - **no default** → `primitive | undefined` (optional)
22
122
  */
23
- interface StoreValidatorSuccess<T> {
24
- readonly ok: true;
25
- readonly value: T;
26
- }
123
+ type InferFieldValue<F extends FieldDef> = F extends {
124
+ array: true;
125
+ } ? F extends {
126
+ default: readonly ResolvePrimitive<F["type"]>[];
127
+ } ? ResolvePrimitive<F["type"]>[] : ResolvePrimitive<F["type"]>[] | undefined : F extends {
128
+ default: ResolvePrimitive<F["type"]>;
129
+ } ? ResolvePrimitive<F["type"]> : ResolvePrimitive<F["type"]> | undefined;
27
130
  /**
28
- * A single validation issue reported by a {@link StoreValidator}.
131
+ * Maps a full {@link FieldsDef} record to the inferred config object type.
29
132
  *
30
- * Compatible with the canonical `ValidationIssue` shape used across the Crust
31
- * validation platform.
133
+ * @example
134
+ * ```ts
135
+ * type Config = InferStoreConfig<{
136
+ * theme: { type: "string"; default: "light" };
137
+ * verbose: { type: "boolean" };
138
+ * }>;
139
+ * // → { theme: string; verbose: boolean | undefined }
140
+ * ```
141
+ */
142
+ type InferStoreConfig<F extends FieldsDef> = { [K in keyof F] : InferFieldValue<F[K]> };
143
+ /**
144
+ * A single validation issue reported during store operations.
32
145
  */
33
146
  interface StoreValidatorIssue {
34
147
  /** Human-readable description of the validation failure. */
35
148
  message: string;
36
- /** Dot-path to the invalid field, or empty string for root-level issues. */
149
+ /** Field name where the issue occurred. */
37
150
  path: string;
38
151
  }
39
152
  /**
40
- * Result returned by a {@link StoreValidator} when validation fails.
41
- *
42
- * Carries structured issues for programmatic handling and error rendering.
43
- */
44
- interface StoreValidatorFailure {
45
- readonly ok: false;
46
- readonly issues: readonly StoreValidatorIssue[];
47
- }
48
- /**
49
- * Discriminated union result from a {@link StoreValidator}.
50
- */
51
- type StoreValidatorResult<T> = StoreValidatorSuccess<T> | StoreValidatorFailure;
52
- /**
53
- * A provider-agnostic validator function for full config objects.
54
- *
55
- * Accepts a config value and returns a discriminated result indicating
56
- * success (with a possibly-transformed value) or failure (with structured issues).
57
- *
58
- * Store adapters in `@crustjs/validate` produce functions matching this contract
59
- * from Standard Schema, Zod, or Effect schemas.
60
- *
61
- * @typeParam T - The expected config type.
62
- *
63
- * @example
64
- * ```ts
65
- * const validator: StoreValidator<Config> = (config) => {
66
- * if (typeof config.theme !== "string") {
67
- * return { ok: false, issues: [{ message: "Expected string", path: "theme" }] };
68
- * }
69
- * return { ok: true, value: config };
70
- * };
71
- * ```
72
- */
73
- type StoreValidator<T> = (value: unknown) => StoreValidatorResult<T> | Promise<StoreValidatorResult<T>>;
74
- /**
75
153
  * Options for {@link createStore}.
76
154
  *
77
- * @typeParam T - The store data shape, inferred from `defaults`.
155
+ * @typeParam F - The field definitions record (inferred via `const` generic).
78
156
  *
79
157
  * @example
80
158
  * ```ts
81
159
  * const store = createStore({
82
160
  * dirPath: configDir("my-cli"),
83
- * defaults: {
84
- * ui: { theme: "light", fontSize: 14 },
85
- * verbose: false,
86
- * tags: ["default"],
161
+ * fields: {
162
+ * theme: { type: "string", default: "light" },
163
+ * verbose: { type: "boolean", default: false },
164
+ * token: { type: "string" },
87
165
  * },
88
166
  * });
89
167
  * ```
90
168
  */
91
- interface CreateStoreOptions<T extends Record<string, unknown>> {
169
+ interface CreateStoreOptions<F extends FieldsDef> {
92
170
  /**
93
171
  * Absolute directory path where the store JSON file is persisted.
94
172
  *
@@ -107,38 +185,14 @@ interface CreateStoreOptions<T extends Record<string, unknown>> {
107
185
  */
108
186
  name?: string;
109
187
  /**
110
- * Default values for the store. Defines the data shape and provides
111
- * fallback values when no persisted file exists or keys are missing.
112
- *
113
- * The TypeScript type of `defaults` determines the store's `T` parameter.
114
- */
115
- defaults: T;
116
- /**
117
- * Optional validator function applied to config objects during read, write,
118
- * update, and patch operations.
119
- *
120
- * When provided, validation is **strict by default**: invalid config will
121
- * cause a `CrustStoreError` with `VALIDATION` code to be thrown on both
122
- * persistence (write/update/patch) and load (read) paths.
123
- *
124
- * Use store adapters from `@crustjs/validate/standard`, `@crustjs/validate/zod`,
125
- * or `@crustjs/validate/effect` to create validators from schema definitions.
126
- *
127
- * @example
128
- * ```ts
129
- * import { storeValidator } from "@crustjs/validate/zod";
130
- * import { z } from "zod";
188
+ * Field definitions that declare the store's config schema.
131
189
  *
132
- * const store = createStore({
133
- * dirPath: configDir("my-cli"),
134
- * defaults: { theme: "light", verbose: false },
135
- * validator: storeValidator(z.object({ theme: z.enum(["light", "dark"]) })),
136
- * });
137
- * ```
190
+ * Each key maps to a {@link FieldDef} with a `type` discriminant and optional
191
+ * `default` and `validate`. Fields without a `default` are optional (`T | undefined`).
138
192
  */
139
- validator?: StoreValidator<T>;
193
+ fields: F;
140
194
  /**
141
- * When `true` (the default), persisted keys not present in `defaults`
195
+ * When `true` (the default), persisted keys not present in `fields`
142
196
  * are dropped on read. Set to `false` to preserve unknown keys.
143
197
  *
144
198
  * @default true
@@ -146,7 +200,7 @@ interface CreateStoreOptions<T extends Record<string, unknown>> {
146
200
  pruneUnknown?: boolean;
147
201
  }
148
202
  /**
149
- * Receives the current effective state and returns an updated state.
203
+ * Receives the current effective config and returns an updated config.
150
204
  *
151
205
  * Used by {@link Store.update} to apply mutations atomically.
152
206
  *
@@ -154,91 +208,85 @@ interface CreateStoreOptions<T extends Record<string, unknown>> {
154
208
  * ```ts
155
209
  * await store.update((current) => ({
156
210
  * ...current,
157
- * ui: { ...current.ui, theme: "dark" },
211
+ * theme: "dark",
158
212
  * }));
159
213
  * ```
160
214
  */
161
- type StoreUpdater<T> = (current: T) => NoInfer<T>;
215
+ type StoreUpdater<TConfig> = (current: TConfig) => NoInfer<TConfig>;
162
216
  /**
163
- * A typed async store returned by {@link createStore}.
217
+ * A typed async config store returned by {@link createStore}.
164
218
  *
165
219
  * Provides `read`, `write`, `update`, `patch`, and `reset` operations for a
166
- * typed state object persisted as JSON on the local filesystem.
220
+ * single typed config object persisted as JSON on the local filesystem.
167
221
  *
168
- * @typeParam T - The store data shape, inferred from `defaults`.
222
+ * @typeParam TConfig - The inferred config shape from field definitions.
169
223
  *
170
224
  * @example
171
225
  * ```ts
172
226
  * const store = createStore({
173
227
  * dirPath: configDir("my-cli"),
174
- * defaults: {
175
- * ui: { theme: "light", fontSize: 14 },
176
- * verbose: false,
228
+ * fields: {
229
+ * theme: { type: "string", default: "light" },
230
+ * verbose: { type: "boolean", default: false },
177
231
  * },
178
232
  * });
179
233
  *
180
- * const state = await store.read();
181
- * await store.write({ ui: { theme: "dark", fontSize: 14 }, verbose: true });
182
- * await store.update((s) => ({ ...s, verbose: false }));
183
- * await store.patch({ ui: { theme: "dark" } });
234
+ * const config = await store.read();
235
+ * await store.write({ theme: "dark", verbose: true });
236
+ * await store.update((c) => ({ ...c, theme: "dark" }));
237
+ * await store.patch({ theme: "dark" });
184
238
  * await store.reset();
185
239
  * ```
186
240
  */
187
- interface Store<T> {
241
+ interface Store<TConfig> {
188
242
  /**
189
- * Reads the persisted state, merging defaults for missing keys.
243
+ * Reads the persisted config, applying field defaults for missing keys.
190
244
  *
191
- * Always returns a complete `T` — missing persisted keys are filled
192
- * from `defaults`.
245
+ * Always returns a value — fields with defaults are guaranteed present,
246
+ * fields without defaults may be `undefined`.
193
247
  *
194
- * When a validator is configured, the merged config is validated after
195
- * defaults are applied. Invalid persisted config fails loudly.
248
+ * When fields have `validate` functions, the merged config is validated
249
+ * after defaults are applied. Invalid persisted config fails loudly.
196
250
  *
197
- * @returns The effective config value (possibly transformed by the validator).
251
+ * @returns The effective config value.
198
252
  * @throws {CrustStoreError} `PARSE` if persisted JSON is malformed.
199
- * @throws {CrustStoreError} `VALIDATION` if the config fails validation.
253
+ * @throws {CrustStoreError} `VALIDATION` if field validation fails.
200
254
  * @throws {CrustStoreError} `IO` on filesystem read failures.
201
255
  */
202
- read(): Promise<T>;
256
+ read(): Promise<TConfig>;
203
257
  /**
204
- * Atomically persists the full state object.
258
+ * Atomically persists the full config object.
205
259
  *
206
- * When a validator is configured, the config is validated before persistence.
260
+ * When fields have `validate` functions, the config is validated before
261
+ * persistence.
207
262
  *
208
- * @param state - The complete state to persist.
209
- * @throws {CrustStoreError} `VALIDATION` if the config fails validation.
263
+ * @param config - The complete config to persist.
264
+ * @throws {CrustStoreError} `VALIDATION` if field validation fails.
210
265
  * @throws {CrustStoreError} `IO` on filesystem write failures.
211
266
  */
212
- write(state: NoInfer<T>): Promise<void>;
267
+ write(config: NoInfer<TConfig>): Promise<void>;
213
268
  /**
214
- * Reads current effective state, applies the updater, and persists.
215
- *
216
- * When a validator is configured, the updated config is validated before
217
- * persistence. The read step during update does **not** validate separately
218
- * to avoid double-validation overhead — the `current` value passed to the
219
- * updater may not have been validated (e.g. if the persisted file was
220
- * manually edited).
269
+ * Reads current effective config, applies the updater, and persists.
221
270
  *
222
- * @param updater - Function receiving current state and returning updated state.
271
+ * @param updater - Function receiving current config and returning updated config.
223
272
  * @throws {CrustStoreError} `PARSE` if persisted JSON is malformed.
224
- * @throws {CrustStoreError} `VALIDATION` if the updated config fails validation.
273
+ * @throws {CrustStoreError} `VALIDATION` if field validation fails.
225
274
  * @throws {CrustStoreError} `IO` on filesystem failures.
226
275
  */
227
- update(updater: StoreUpdater<T>): Promise<void>;
276
+ update(updater: StoreUpdater<TConfig>): Promise<void>;
228
277
  /**
229
- * Applies a deep partial update to the current state and persists.
278
+ * Applies a partial update to the current config and persists.
230
279
  *
231
280
  * Only the provided keys are updated; everything else is preserved.
232
- * Arrays are replaced wholesale (not merged element-by-element).
233
281
  *
234
- * @param partial - A deep-partial subset of the state to merge in.
235
- * @throws {CrustStoreError} `VALIDATION` if the merged config fails validation.
282
+ * @param partial - A partial subset of the config to merge in.
283
+ * @throws {CrustStoreError} `VALIDATION` if field validation fails.
236
284
  * @throws {CrustStoreError} `PARSE` if persisted JSON is malformed.
237
285
  * @throws {CrustStoreError} `IO` on filesystem failures.
238
286
  */
239
- patch(partial: DeepPartial<NoInfer<T>>): Promise<void>;
287
+ patch(partial: Partial<NoInfer<TConfig>>): Promise<void>;
240
288
  /**
241
- * Removes the persisted state file, returning the store to
289
+ * Removes the persisted config file, returning the store to
242
290
  * defaults-on-read behavior.
243
291
  *
244
292
  * @throws {CrustStoreError} `IO` on filesystem deletion failures.
@@ -287,6 +335,18 @@ interface ValidationErrorDetails {
287
335
  issues: readonly StoreValidatorIssue[];
288
336
  }
289
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
+ /**
290
350
  * Maps each {@link StoreErrorCode} to its structured details type.
291
351
  *
292
352
  * Used internally for conditional constructor parameters and type narrowing.
@@ -296,6 +356,7 @@ interface StoreErrorDetailsMap {
296
356
  PARSE: ParseErrorDetails;
297
357
  IO: IOErrorDetails;
298
358
  VALIDATION: ValidationErrorDetails;
359
+ DEFINITION: DefinitionErrorDetails;
299
360
  }
300
361
  /**
301
362
  * All possible error codes emitted by `@crustjs/store`.
@@ -304,6 +365,7 @@ interface StoreErrorDetailsMap {
304
365
  * - `PARSE` — Malformed JSON in persisted config file
305
366
  * - `IO` — Filesystem read, write, or delete failure
306
367
  * - `VALIDATION` — Config object rejected by a store validator
368
+ * - `DEFINITION` — `field()` cannot produce a valid `FieldDef` from the given schema/options
307
369
  *
308
370
  * @example
309
371
  * ```ts
@@ -391,6 +453,178 @@ declare class CrustStoreError<C extends StoreErrorCode = StoreErrorCode> extends
391
453
  */
392
454
  withCause(cause: unknown): this;
393
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>;
394
628
  /**
395
629
  * Encapsulates runtime environment values needed for path resolution.
396
630
  *
@@ -510,13 +744,14 @@ declare function stateDir(appName: string, env?: PlatformEnv): string;
510
744
  */
511
745
  declare function cacheDir(appName: string, env?: PlatformEnv): string;
512
746
  /**
513
- * Creates a typed async store backed by a local JSON file.
747
+ * Creates a typed async config store backed by a local JSON file.
514
748
  *
515
749
  * The store resolves its file path once at creation time from `dirPath` and
516
- * optional `name`. The `defaults` object declares the data shape — its
517
- * TypeScript type determines the store's type parameter `T`.
750
+ * optional `name`. Field definitions declare the config schema — each field's
751
+ * `type` determines its TypeScript type, and the presence of `default`
752
+ * determines whether the field is guaranteed or optional (`T | undefined`).
518
753
  *
519
- * @typeParam T - The store data shape (inferred from `defaults`).
754
+ * @typeParam F - Field definitions record (inferred via `const` generic).
520
755
  * @param options - Store configuration options.
521
756
  * @returns A {@link Store} instance with `read`, `write`, `update`, `patch`, and `reset` methods.
522
757
  * @throws {CrustStoreError} `PATH` if `dirPath` or `name` is invalid.
@@ -527,20 +762,21 @@ declare function cacheDir(appName: string, env?: PlatformEnv): string;
527
762
  *
528
763
  * const store = createStore({
529
764
  * dirPath: configDir("my-cli"),
530
- * defaults: {
531
- * ui: { theme: "light", fontSize: 14 },
532
- * verbose: false,
765
+ * fields: {
766
+ * theme: { type: "string", default: "light" },
767
+ * verbose: { type: "boolean", default: false },
768
+ * token: { type: "string" },
533
769
  * },
534
770
  * });
535
771
  *
536
- * const state = await store.read();
537
- * // → { ui: { theme: "light", fontSize: 14 }, verbose: false }
772
+ * const config = await store.read();
773
+ * // → { theme: "light", verbose: false, token: undefined }
538
774
  *
539
- * await store.write({ ui: { theme: "dark", fontSize: 14 }, verbose: true });
540
- * await store.update((s) => ({ ...s, verbose: false }));
541
- * await store.patch({ ui: { theme: "dark" } });
775
+ * await store.write({ theme: "dark", verbose: true, token: "abc" });
776
+ * await store.update((c) => ({ ...c, theme: "light" }));
777
+ * await store.patch({ theme: "solarized" });
542
778
  * await store.reset();
543
779
  * ```
544
780
  */
545
- declare function createStore<const T extends Record<string, unknown>>(options: CreateStoreOptions<T>): Store<T>;
546
- export { stateDir, dataDir, createStore, configDir, cacheDir, ValidationErrorDetails, StoreValidatorSuccess, StoreValidatorResult, StoreValidatorIssue, StoreValidatorFailure, StoreValidator, StoreUpdater, StoreErrorCode, Store, PlatformEnv, CrustStoreError, CreateStoreOptions };
781
+ declare function createStore<const F extends FieldsDef>(options: CreateStoreOptions<F>): Store<InferStoreConfig<F>>;
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 K extends Error{code;details;cause;constructor(q,B,z){super(B);this.name="CrustStoreError",this.code=q,this.details=z}is(q){return this.code===q}withCause(q){return this.cause=q,this}}import{homedir as f}from"os";import{join as Q}from"path";var P="config";function _(){return{platform:process.platform,env:process.env,homedir:f()}}function T(q){if(!q||q.trim().length===0)throw new K("PATH","appName must be a non-empty string",{path:q??""});if(q.includes("/")||q.includes("\\"))throw new K("PATH","appName must not contain path separators",{path:q})}function h(q){if(!q||q.trim().length===0)throw new K("PATH","name must be a non-empty string",{path:q??""});if(q.includes("/")||q.includes("\\"))throw new K("PATH","name must not contain path separators",{path:q});if(q.endsWith(".json"))throw new K("PATH","name must not include the .json extension",{path:q})}function v(q){if(!q||q.trim().length===0)throw new K("PATH","dirPath must be a non-empty string",{path:q??""});if(!(q.startsWith("/")||/^[A-Za-z]:[/\\]/.test(q)))throw new K("PATH","dirPath must be an absolute path",{path:q});if(q.endsWith(".json"))throw new K("PATH","dirPath must be a directory path, not a file path (should not end with .json)",{path:q})}function F(q,B,z,G){let H=q.env[B],W=H&&H.trim().length>0?H:Q(q.homedir,...z);return Q(W,G)}function O(q){throw new K("PATH",`Unsupported platform: ${q}`,{path:q})}function m(q,B){T(q);let z=B??_();switch(z.platform){case"linux":case"darwin":return F(z,"XDG_CONFIG_HOME",[".config"],q);case"win32":{let G=z.env.APPDATA,H=G&&G.trim().length>0?G:Q(z.homedir,"AppData","Roaming");return Q(H,q)}default:O(z.platform)}}function u(q,B){T(q);let z=B??_();switch(z.platform){case"linux":case"darwin":return F(z,"XDG_DATA_HOME",[".local","share"],q);case"win32":{let G=z.env.LOCALAPPDATA,H=G&&G.trim().length>0?G:Q(z.homedir,"AppData","Local");return Q(H,q,"Data")}default:O(z.platform)}}function d(q,B){T(q);let z=B??_();switch(z.platform){case"linux":case"darwin":return F(z,"XDG_STATE_HOME",[".local","state"],q);case"win32":{let G=z.env.LOCALAPPDATA,H=G&&G.trim().length>0?G:Q(z.homedir,"AppData","Local");return Q(H,q,"State")}default:O(z.platform)}}function o(q,B){T(q);let z=B??_();switch(z.platform){case"linux":case"darwin":return F(z,"XDG_CACHE_HOME",[".cache"],q);case"win32":{let G=z.env.LOCALAPPDATA,H=G&&G.trim().length>0?G:Q(z.homedir,"AppData","Local");return Q(H,q,"Cache")}default:O(z.platform)}}function b(q,B){v(q);let z=B??P;if(B!==void 0)h(B);return Q(q,`${z}.json`)}function V(q){return q!==null&&typeof q==="object"&&!Array.isArray(q)}function x(q){if(Array.isArray(q))return[...q];if(V(q)){let B={};for(let[z,G]of Object.entries(q))B[z]=x(G);return B}return q}function J(q,B,z=!0){let G={};for(let[H,W]of Object.entries(B))if(q!==void 0&&H in q){let X=q[H];if(V(W)&&V(X))G[H]=J(X,W,z);else G[H]=x(X)}else G[H]=x(W);if(!z&&q!==void 0){for(let[H,W]of Object.entries(q))if(!(H in B))G[H]=x(W)}return G}import{randomUUID as s}from"crypto";import{mkdir as i,readFile as p,rename as n,rm as l,unlink as t,writeFile as r}from"fs/promises";import{dirname as a,join as e}from"path";async function j(q){let B;try{B=await p(q,"utf-8")}catch(z){if(D(z))return;throw new K("IO",`Failed to read config file: ${q}`,{path:q,operation:"read"}).withCause(z)}try{return JSON.parse(B)}catch(z){throw new K("PARSE",`Malformed JSON in config file: ${q}`,{path:q}).withCause(z)}}async function R(q,B){let z=a(q),G=e(z,`.config-${s()}.tmp`);try{await i(z,{recursive:!0})}catch(H){throw new K("IO",`Failed to create config directory: ${z}`,{path:q,operation:"write"}).withCause(H)}try{let H=JSON.stringify(B,null,"\t");await r(G,H,"utf-8")}catch(H){throw await U(G),new K("IO",`Failed to write config file: ${q}`,{path:q,operation:"write"}).withCause(H)}try{await n(G,q)}catch(H){throw await U(G),new K("IO",`Failed to finalize config file: ${q}`,{path:q,operation:"write"}).withCause(H)}}async function A(q){try{await t(q)}catch(B){if(D(B))return;throw new K("IO",`Failed to delete config file: ${q}`,{path:q,operation:"delete"}).withCause(B)}}function D(q){return q!==null&&typeof q==="object"&&"code"in q&&q.code==="ENOENT"}async function U(q){try{await l(q,{force:!0})}catch{}}function qq(q){let{dirPath:B,name:z,defaults:G,validator:H,pruneUnknown:W}=q,X=b(B,z),C=W??!0;async function M(L,Y){if(!H)return L;let Z=await H(L);if(Z.ok)return Z.value;let I=Z.issues.map(($)=>({message:$.message,path:$.path})),y=I.map(($)=>$.path?` - ${$.path}: ${$.message}`:` - ${$.message}`),N=y.length>0?`Store validation failed (${Y})
3
- ${y.join(`
4
- `)}`:`Store validation failed (${Y})`;throw new K("VALIDATION",N,{operation:Y,issues:I})}async function S(){let L=await j(X);return J(L,G,C)}async function k(){let L=await S();return M(L,"read")}async function g(L){let Y=await M(L,"write");await R(X,Y)}async function c(L){let Y=await S(),Z=L(Y),I=await M(Z,"update");await R(X,I)}async function w(L){let Y=await S(),Z=J(L,Y,!1),I=await M(Z,"patch");await R(X,I)}async function E(){await A(X)}return{read:k,write:g,update:c,patch:w,reset:E}}export{d as stateDir,u as dataDir,qq as createStore,m as configDir,o as cacheDir,K as CrustStoreError};
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
+ ${M.join(`
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",
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
  }