@crustjs/store 0.1.0 → 0.2.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
@@ -25,12 +25,12 @@ pnpm add @crustjs/store
25
25
  import { createStore, configDir } from "@crustjs/store";
26
26
 
27
27
  const store = createStore({
28
- dirPath: configDir("my-cli"),
29
- fields: {
30
- theme: { type: "string", default: "light" },
31
- fontSize: { type: "number", default: 14 },
32
- verbose: { type: "boolean", default: false },
33
- },
28
+ dirPath: configDir("my-cli"),
29
+ fields: {
30
+ theme: { type: "string", default: "light" },
31
+ fontSize: { type: "number", default: 14 },
32
+ verbose: { type: "boolean", default: false },
33
+ },
34
34
  });
35
35
 
36
36
  // Read state (returns defaults when no persisted file exists)
@@ -64,47 +64,41 @@ No explicit generics needed — types are inferred from your `fields` definition
64
64
  | `cacheDir` | Regenerable cached data | HTTP caches, compiled assets |
65
65
 
66
66
  ```ts
67
- import {
68
- createStore,
69
- configDir,
70
- dataDir,
71
- stateDir,
72
- cacheDir,
73
- } from "@crustjs/store";
67
+ import { createStore, configDir, dataDir, stateDir, cacheDir } from "@crustjs/store";
74
68
 
75
69
  // User preferences → ~/.config/my-cli/config.json
76
70
  const config = createStore({
77
- dirPath: configDir("my-cli"),
78
- fields: {
79
- theme: { type: "string", default: "light" },
80
- verbose: { type: "boolean", default: false },
81
- },
71
+ dirPath: configDir("my-cli"),
72
+ fields: {
73
+ theme: { type: "string", default: "light" },
74
+ verbose: { type: "boolean", default: false },
75
+ },
82
76
  });
83
77
 
84
78
  // App data → ~/.local/share/my-cli/config.json
85
79
  const data = createStore({
86
- dirPath: dataDir("my-cli"),
87
- fields: {
88
- bookmarks: { type: "array", default: [] },
89
- },
80
+ dirPath: dataDir("my-cli"),
81
+ fields: {
82
+ bookmarks: { type: "string", array: true, default: [] },
83
+ },
90
84
  });
91
85
 
92
86
  // Runtime state → ~/.local/state/my-cli/config.json
93
87
  const state = createStore({
94
- dirPath: stateDir("my-cli"),
95
- fields: {
96
- lastOpened: { type: "string", default: "" },
97
- scrollY: { type: "number", default: 0 },
98
- },
88
+ dirPath: stateDir("my-cli"),
89
+ fields: {
90
+ lastOpened: { type: "string", default: "" },
91
+ scrollY: { type: "number", default: 0 },
92
+ },
99
93
  });
100
94
 
101
95
  // Cache → ~/.cache/my-cli/config.json
102
96
  const cache = createStore({
103
- dirPath: cacheDir("my-cli"),
104
- fields: {
105
- etag: { type: "string", default: "" },
106
- payload: { type: "string", default: "" },
107
- },
97
+ dirPath: cacheDir("my-cli"),
98
+ fields: {
99
+ etag: { type: "string", default: "" },
100
+ payload: { type: "string", default: "" },
101
+ },
108
102
  });
109
103
  ```
110
104
 
@@ -137,12 +131,55 @@ const store = createStore(options);
137
131
 
138
132
  #### Options
139
133
 
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`). |
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
+ | `access` | `"default" \| "private" \| { file?: number; directory?: number }` | No | Persistence visibility. Use `"private"` for owner-only secret stores, or pass explicit Unix permission bits. |
141
+
142
+ #### Securing secrets
143
+
144
+ Config/state stores that hold tokens or API keys should not be world-readable. Pass `access: "private"` so the file is owner-only on Unix:
145
+
146
+ ```ts
147
+ const auth = createStore({
148
+ dirPath: configDir("my-cli"),
149
+ name: "auth",
150
+ fields: { token: { type: "string" } },
151
+ access: "private",
152
+ });
153
+
154
+ await auth.write({ token: "secret" }); // auth.json is 0600 even under a permissive umask
155
+ ```
156
+
157
+ `access: "private"` maps to `0600` for the file (`rw-------`) and `0700` for the parent directory (`rwx------`, only when created). The file mode is enforced on the temp file before the atomic rename, so the persisted file is never momentarily group/other-readable.
158
+
159
+ The built-in string presets are intentionally small:
160
+
161
+ | Access | File | Directory | Use case |
162
+ | --------------------- | ----------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------- |
163
+ | omitted / `"default"` | Platform default, usually `0644` after a common umask | Platform default, usually `0755` after a common umask | Non-secret stores where the user's environment should decide permissions. |
164
+ | `"private"` | `0600` | `0700` | Secret-bearing stores such as tokens, API keys, and local auth state. |
165
+
166
+ Advanced callers can provide explicit permission bits without adding more named presets:
167
+
168
+ ```ts
169
+ const store = createStore({
170
+ dirPath: configDir("my-cli"),
171
+ name: "auth",
172
+ fields: { token: { type: "string" } },
173
+ // Owner-only secret store (same as access: "private")
174
+ access: { file: 0o600, directory: 0o700 },
175
+ // Group-readable store; group ownership is managed outside @crustjs/store
176
+ // access: { file: 0o640, directory: 0o750 },
177
+ // World-readable non-secret store
178
+ // access: { file: 0o644, directory: 0o755 },
179
+ });
180
+ ```
181
+
182
+ **Platform behavior:** `access` permission bits are enforced on **macOS and Linux** (and other Unix systems), where POSIX permission bits are checked by the OS. On **Windows** they are **not enforced** — Windows uses ACLs, not Unix bits, so `access: "private"` does not make a file owner-only there. The store does not throw or warn in this case. On Windows, confidentiality relies on the ACL inherited from the parent directory; the per-user profile location resolved by `configDir` / `stateDir` (under `%APPDATA%` / `%LOCALAPPDATA%`) is already restricted to the user. If you need strong secret confidentiality on Windows, use the OS credential store (Windows Credential Manager / DPAPI) rather than file permissions.
146
183
 
147
184
  ### `store.read()`
148
185
 
@@ -170,8 +207,8 @@ Reads the current effective state, applies the updater function, and atomically
170
207
 
171
208
  ```ts
172
209
  await store.update((current) => ({
173
- ...current,
174
- theme: "dark",
210
+ ...current,
211
+ theme: "dark",
175
212
  }));
176
213
  ```
177
214
 
@@ -260,21 +297,21 @@ const dir = configDir("my-cli");
260
297
 
261
298
  // Default store → ~/.config/my-cli/config.json
262
299
  const settingsStore = createStore({
263
- dirPath: dir,
264
- fields: {
265
- theme: { type: "string", default: "light" },
266
- fontSize: { type: "number", default: 14 },
267
- verbose: { type: "boolean", default: false },
268
- },
300
+ dirPath: dir,
301
+ fields: {
302
+ theme: { type: "string", default: "light" },
303
+ fontSize: { type: "number", default: 14 },
304
+ verbose: { type: "boolean", default: false },
305
+ },
269
306
  });
270
307
 
271
308
  // Auth store → ~/.config/my-cli/auth.json
272
309
  const authStore = createStore({
273
- dirPath: dir,
274
- name: "auth",
275
- fields: {
276
- token: { type: "string", default: "" },
277
- },
310
+ dirPath: dir,
311
+ name: "auth",
312
+ fields: {
313
+ token: { type: "string", default: "" },
314
+ },
278
315
  });
279
316
  ```
280
317
 
@@ -288,12 +325,12 @@ When a persisted file exists, `read()` fills missing keys from field `defaults`:
288
325
 
289
326
  ```ts
290
327
  const store = createStore({
291
- dirPath: configDir("my-cli"),
292
- fields: {
293
- theme: { type: "string", default: "light" },
294
- fontSize: { type: "number", default: 14 },
295
- verbose: { type: "boolean", default: false },
296
- },
328
+ dirPath: configDir("my-cli"),
329
+ fields: {
330
+ theme: { type: "string", default: "light" },
331
+ fontSize: { type: "number", default: 14 },
332
+ verbose: { type: "boolean", default: false },
333
+ },
297
334
  });
298
335
 
299
336
  // Persisted file contains: { "theme": "dark", "verbose": true }
@@ -320,19 +357,20 @@ Add per-field validation to enforce config integrity on every read, write, updat
320
357
  ### Schema-driven validation
321
358
 
322
359
  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:
360
+ It builds a `FieldDef` from any [Standard Schema v1](https://standardschema.dev/)
361
+ object. Crust stores raw values; the schema validates and transforms them on
362
+ read/write.
325
363
 
326
364
  ```ts
327
365
  import { z } from "zod";
328
366
  import { createStore, configDir, field } from "@crustjs/store";
329
367
 
330
368
  const store = createStore({
331
- dirPath: configDir("my-cli"),
332
- fields: {
333
- theme: field(z.enum(["light", "dark"]).default("light")),
334
- verbose: { type: "boolean", default: false },
335
- },
369
+ dirPath: configDir("my-cli"),
370
+ fields: {
371
+ theme: field(z.enum(["light", "dark"]).default("light")),
372
+ verbose: { type: "boolean", default: false },
373
+ },
336
374
  });
337
375
 
338
376
  // write() validates before persisting
@@ -351,40 +389,41 @@ import * as Schema from "effect/Schema";
351
389
  import { createStore, configDir, field } from "@crustjs/store";
352
390
 
353
391
  const store = createStore({
354
- dirPath: configDir("my-cli"),
355
- fields: {
356
- theme: field(Schema.standardSchemaV1(Schema.Literal("light", "dark"))),
357
- },
392
+ dirPath: configDir("my-cli"),
393
+ fields: {
394
+ theme: field(Schema.standardSchemaV1(Schema.Literal("light", "dark"))),
395
+ },
358
396
  });
359
397
  ```
360
398
 
399
+ Missing persisted values flow through the schema as `undefined`:
400
+
401
+ - `field(z.string().default("x"))` returns `"x"` on read when the field
402
+ is missing from disk.
403
+ - `field(z.string().optional())` returns `undefined`.
404
+ - `field(z.string())` fails read-time validation with a `VALIDATION`
405
+ error when the field is missing.
406
+
361
407
  #### Schema-derived defaults and TypeScript
362
408
 
363
409
  Standard Schema v1 has no spec-portable type-level access to schema
364
- defaults. As a result:
410
+ defaults. Crust does not inspect schemas to populate runtime metadata;
411
+ defaults are materialized on read by validating `undefined` through the
412
+ schema. As a result:
365
413
 
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`.
414
+ - `field(z.string().default("x"))` returns `"x"` on read, but the
415
+ inferred config type is `string | undefined` (NOT narrowed).
416
+ - `field(z.string(), { default: "x" })` keeps `"x"` as the Crust-level
417
+ default AND narrows the inferred config type to `string`.
370
418
 
371
419
  Prefer the explicit form when the field is required to always have a
372
420
  value at use sites.
373
421
 
374
422
  #### `field()` throws on invalid input
375
423
 
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.
424
+ `field()` throws `CrustStoreError("DEFINITION")` when handed a value that
425
+ is not a Standard Schema v1 object. `type` is optional for schema-backed
426
+ fields; pass it through `opts` only as legacy metadata for tooling.
388
427
 
389
428
  ### Custom validators
390
429
 
@@ -395,18 +434,18 @@ issue path.
395
434
 
396
435
  ```ts
397
436
  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
- },
437
+ dirPath: configDir("my-cli"),
438
+ fields: {
439
+ theme: {
440
+ type: "string",
441
+ default: "light",
442
+ validate(value) {
443
+ if (value !== "light" && value !== "dark") {
444
+ throw new Error('Must be "light" or "dark"');
445
+ }
446
+ },
447
+ },
448
+ },
410
449
  });
411
450
  ```
412
451
 
@@ -448,15 +487,15 @@ with `CrustStoreError("VALIDATION")` and an issue message tagged
448
487
  import { CrustStoreError } from "@crustjs/store";
449
488
 
450
489
  try {
451
- await store.read();
490
+ await store.read();
452
491
  } catch (err) {
453
- if (err instanceof CrustStoreError && err.is("VALIDATION")) {
454
- // err.details is { operation: "read" | "write" | "update" | "patch", issues: StoreValidatorIssue[] }
455
- console.error(`Validation failed during ${err.details.operation}:`);
456
- for (const issue of err.details.issues) {
457
- console.error(` ${issue.path || "(root)"}: ${issue.message}`);
458
- }
459
- }
492
+ if (err instanceof CrustStoreError && err.is("VALIDATION")) {
493
+ // err.details is { operation: "read" | "write" | "update" | "patch", issues: StoreValidatorIssue[] }
494
+ console.error(`Validation failed during ${err.details.operation}:`);
495
+ for (const issue of err.details.issues) {
496
+ console.error(` ${issue.path || "(root)"}: ${issue.message}`);
497
+ }
498
+ }
460
499
  }
461
500
  ```
462
501
 
@@ -464,12 +503,13 @@ try {
464
503
 
465
504
  All errors thrown by `@crustjs/store` are instances of `CrustStoreError` with a typed `code` property:
466
505
 
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 }` |
506
+ | Code | When | Details |
507
+ | ------------ | --------------------------------------------------------- | ----------------------- |
508
+ | `PATH` | Invalid `dirPath`, invalid `name`, unsupported platform | `{ path: string }` |
509
+ | `PARSE` | Malformed JSON in persisted config file | `{ path: string }` |
510
+ | `IO` | Filesystem read, write, or delete failure | `{ path, operation }` |
511
+ | `VALIDATION` | Config fails validator on read, write, update, or patch | `{ operation, issues }` |
512
+ | `DEFINITION` | `field()` received an invalid (non-Standard-Schema) input | `{ vendor? }` |
473
513
 
474
514
  ### Catching errors by code
475
515
 
@@ -477,19 +517,19 @@ All errors thrown by `@crustjs/store` are instances of `CrustStoreError` with a
477
517
  import { CrustStoreError } from "@crustjs/store";
478
518
 
479
519
  try {
480
- const state = await store.read();
520
+ const state = await store.read();
481
521
  } catch (err) {
482
- if (err instanceof CrustStoreError) {
483
- switch (err.code) {
484
- case "PARSE":
485
- console.error(`Corrupt file at ${err.details.path}`);
486
- console.error("Delete the file and retry, or fix the JSON manually.");
487
- break;
488
- case "IO":
489
- console.error(`File ${err.details.operation} failed: ${err.message}`);
490
- break;
491
- }
492
- }
522
+ if (err instanceof CrustStoreError) {
523
+ switch (err.code) {
524
+ case "PARSE":
525
+ console.error(`Corrupt file at ${err.details.path}`);
526
+ console.error("Delete the file and retry, or fix the JSON manually.");
527
+ break;
528
+ case "IO":
529
+ console.error(`File ${err.details.operation} failed: ${err.message}`);
530
+ break;
531
+ }
532
+ }
493
533
  }
494
534
  ```
495
535
 
@@ -499,8 +539,8 @@ The `.is()` method narrows the error type so `details` is fully typed:
499
539
 
500
540
  ```ts
501
541
  if (err instanceof CrustStoreError && err.is("IO")) {
502
- // err.details is { path: string; operation: "read" | "write" | "delete" }
503
- console.error(err.details.operation, err.details.path);
542
+ // err.details is { path: string; operation: "read" | "write" | "delete" }
543
+ console.error(err.details.operation, err.details.path);
504
544
  }
505
545
  ```
506
546
 
@@ -510,7 +550,7 @@ if (err instanceof CrustStoreError && err.is("IO")) {
510
550
 
511
551
  ```ts
512
552
  if (err instanceof CrustStoreError && err.is("VALIDATION")) {
513
- console.error("Validation error:", err.cause);
553
+ console.error("Validation error:", err.cause);
514
554
  }
515
555
  ```
516
556
 
@@ -524,38 +564,38 @@ All types are exported for use in your application:
524
564
 
525
565
  ```ts
526
566
  import type {
527
- CreateStoreOptions,
528
- Store,
529
- StoreUpdater,
530
- FieldDef,
531
- FieldsDef,
532
- InferStoreConfig,
533
- StoreValidator,
534
- StoreValidatorResult,
535
- StoreValidatorIssue,
536
- StoreErrorCode,
537
- StoreValidationIssue,
538
- ValidationErrorDetails,
539
- PlatformEnv,
567
+ CreateStoreOptions,
568
+ Store,
569
+ StoreUpdater,
570
+ FieldDef,
571
+ FieldsDef,
572
+ FieldOptions,
573
+ InferStoreConfig,
574
+ StoreValidatorIssue,
575
+ StoreErrorCode,
576
+ ValidationErrorDetails,
577
+ DefinitionErrorDetails,
578
+ ValueType,
579
+ PlatformEnv,
540
580
  } from "@crustjs/store";
541
581
  ```
542
582
 
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`. |
583
+ | Type | Description |
584
+ | ------------------------ | ------------------------------------------------------------------------------------------------------ |
585
+ | `CreateStoreOptions` | Options object for `createStore()`. |
586
+ | `Store` | Store instance with `read`, `write`, `update`, `patch`, `reset`. |
587
+ | `StoreUpdater` | Updater function type `(current: T) => T`. |
588
+ | `FieldDef` | Single field definition with `type`, optional `default`, and optional `validate`. |
589
+ | `FieldsDef` | Record of field names to `FieldDef` definitions. |
590
+ | `FieldOptions` | Optional Crust metadata accepted by the `field()` factory (`type`, `default`, `array`, `description`). |
591
+ | `InferStoreConfig` | Inferred store state type from a `FieldsDef` definition. |
592
+ | `StoreValidatorIssue` | `{ message: string, path: string }`. |
593
+ | `ValidationErrorDetails` | Error details for `VALIDATION` code: `{ operation, issues }`. |
594
+ | `DefinitionErrorDetails` | Error details for `DEFINITION` code: `{ vendor? }`. |
595
+ | `StoreErrorCode` | Union of error codes: `"PATH" \| "PARSE" \| "IO" \| "VALIDATION" \| "DEFINITION"`. |
596
+ | `ValueType` | Supported field type literals: `"string" \| "number" \| "boolean"`. |
597
+ | `PlatformEnv` | Injectable platform environment for testing path helpers. |
598
+ | `CrustStoreError` | Typed error class with `code`, `details`, and `cause`. |
559
599
 
560
600
  ## Scope & Non-Goals
561
601
 
package/dist/index.d.ts CHANGED
@@ -1,13 +1,6 @@
1
+ import { BaseValueType, ResolvePrimitive } from "@crustjs/utils";
1
2
  /** Supported type literals for store fields. */
2
- type ValueType = "string" | "number" | "boolean";
3
- /**
4
- * Resolves a type literal to its corresponding TypeScript primitive type.
5
- *
6
- * - `"string"` → `string`
7
- * - `"number"` → `number`
8
- * - `"boolean"` → `boolean`
9
- */
10
- type ResolvePrimitive<T extends ValueType> = T extends "string" ? string : T extends "number" ? number : T extends "boolean" ? boolean : never;
3
+ type ValueType = BaseValueType;
11
4
  /** Shared fields present on every store field definition. */
12
5
  interface FieldDefBase<V> {
13
6
  /** Human-readable description for documentation and tooling. */
@@ -41,6 +34,8 @@ interface FieldDefBase<V> {
41
34
  } | Promise<{
42
35
  value: unknown;
43
36
  }>;
37
+ /** Internal marker: validate missing values as `undefined` for schema-backed fields. */
38
+ validateMissing?: true;
44
39
  }
45
40
  /** Base for scalar (non-array) fields — `array` must be omitted. */
46
41
  interface ScalarFieldBase<V> extends FieldDefBase<V> {
@@ -110,9 +105,18 @@ interface BooleanArrayFieldDef extends ArrayFieldBase<boolean[]> {
110
105
  * } satisfies FieldsDef;
111
106
  * ```
112
107
  */
113
- type FieldDef = StringFieldDef | NumberFieldDef | BooleanFieldDef | StringArrayFieldDef | NumberArrayFieldDef | BooleanArrayFieldDef;
108
+ interface RawScalarFieldDef extends ScalarFieldBase<unknown> {
109
+ type?: never;
110
+ default?: unknown;
111
+ }
112
+ interface RawArrayFieldDef extends ArrayFieldBase<unknown[]> {
113
+ type?: never;
114
+ default?: readonly unknown[];
115
+ }
116
+ type FieldDef = StringFieldDef | NumberFieldDef | BooleanFieldDef | StringArrayFieldDef | NumberArrayFieldDef | BooleanArrayFieldDef | RawScalarFieldDef | RawArrayFieldDef;
114
117
  /** Record mapping field names to their definitions. */
115
118
  type FieldsDef = Record<string, FieldDef>;
119
+ declare const FIELD_SCHEMA_OUTPUT: unique symbol;
116
120
  /**
117
121
  * Infer the resolved type for a single field definition.
118
122
  *
@@ -121,12 +125,20 @@ type FieldsDef = Record<string, FieldDef>;
121
125
  * - **no default** → `primitive | undefined` (optional)
122
126
  */
123
127
  type InferFieldValue<F extends FieldDef> = F extends {
128
+ readonly [FIELD_SCHEMA_OUTPUT]?: infer O;
129
+ } ? F extends {
130
+ default: infer D;
131
+ } ? D : O | undefined : F extends {
132
+ type: ValueType;
133
+ } ? F extends {
124
134
  array: true;
125
135
  } ? F extends {
126
136
  default: readonly ResolvePrimitive<F["type"]>[];
127
137
  } ? ResolvePrimitive<F["type"]>[] : ResolvePrimitive<F["type"]>[] | undefined : F extends {
128
138
  default: ResolvePrimitive<F["type"]>;
129
- } ? ResolvePrimitive<F["type"]> : ResolvePrimitive<F["type"]> | undefined;
139
+ } ? ResolvePrimitive<F["type"]> : ResolvePrimitive<F["type"]> | undefined : F extends {
140
+ default: infer D;
141
+ } ? D : unknown;
130
142
  /**
131
143
  * Maps a full {@link FieldsDef} record to the inferred config object type.
132
144
  *
@@ -149,6 +161,22 @@ interface StoreValidatorIssue {
149
161
  /** Field name where the issue occurred. */
150
162
  path: string;
151
163
  }
164
+ /** Explicit Unix permission bits for store persistence. */
165
+ interface StorePermissionBits {
166
+ /** Permission bits for the persisted store file (e.g. `0o600`). */
167
+ file?: number;
168
+ /** Permission bits for the store's parent directory (e.g. `0o700`). */
169
+ directory?: number;
170
+ }
171
+ /**
172
+ * Persistence visibility for store writes.
173
+ *
174
+ * - `"default"` or omitted uses platform defaults.
175
+ * - `"private"` keeps the store owner-only on Unix (`0600` file, `0700` directory).
176
+ * - An object lets advanced callers provide explicit Unix permission bits for
177
+ * group-readable or public non-secret stores without expanding the preset list.
178
+ */
179
+ type StoreAccess = "default" | "private" | StorePermissionBits;
152
180
  /**
153
181
  * Options for {@link createStore}.
154
182
  *
@@ -198,6 +226,25 @@ interface CreateStoreOptions<F extends FieldsDef> {
198
226
  * @default true
199
227
  */
200
228
  pruneUnknown?: boolean;
229
+ /**
230
+ * Persistence visibility for store writes.
231
+ *
232
+ * Use `"private"` for config/state stores holding secrets (tokens, API keys)
233
+ * that should stay owner-only. On Unix this writes the file as `0600` and
234
+ * creates the parent directory as `0700`; on Windows it is not enforced
235
+ * because Windows uses ACLs instead of Unix permission bits.
236
+ *
237
+ * The only built-in presets are `"default"` and `"private"`. Advanced callers
238
+ * can pass explicit bits with `{ file, directory }` for group-readable or public
239
+ * non-secret stores. The file bits are enforced exactly on every write
240
+ * regardless of `umask`. Directory bits apply only when a write creates the
241
+ * directory; pre-existing directories are left untouched.
242
+ *
243
+ * @default "default"
244
+ * @example "private"
245
+ * @example { file: 0o600, directory: 0o700 }
246
+ */
247
+ access?: StoreAccess;
201
248
  }
202
249
  /**
203
250
  * Receives the current effective config and returns an updated config.
@@ -339,11 +386,10 @@ interface ValidationErrorDetails {
339
386
  *
340
387
  * Returned when a `field()` call cannot produce a valid `FieldDef` from the
341
388
  * 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.
389
+ * object.
344
390
  */
345
391
  interface DefinitionErrorDetails {
346
- /** The schema vendor name, when the failing input was a Standard Schema. */
392
+ /** The schema vendor name, when available. */
347
393
  vendor?: string;
348
394
  }
349
395
  /**
@@ -453,16 +499,14 @@ declare class CrustStoreError<C extends StoreErrorCode = StoreErrorCode> extends
453
499
  */
454
500
  withCause(cause: unknown): this;
455
501
  }
456
- import { InferOutput, StandardSchema } from "@crustjs/schema-utils";
502
+ import { BaseValueType as BaseValueType2 } from "@crustjs/utils";
503
+ import { InferOutput, StandardSchema } from "@crustjs/utils/schema";
457
504
  /**
458
- * Optional overrides for the introspected `FieldDef` shape produced by
459
- * {@link field}.
505
+ * Optional Crust metadata for the `FieldDef` shape produced by {@link field}.
460
506
  *
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.
507
+ * Every key is optional. Crust does not infer metadata from schemas; schemas
508
+ * validate and transform actual runtime values, including missing values passed
509
+ * as `undefined`.
466
510
  *
467
511
  * No `validate` key — validation flows exclusively through the schema.
468
512
  * If extra checks are needed, refine the schema with `.refine(...)`
@@ -472,122 +516,79 @@ import { InferOutput, StandardSchema } from "@crustjs/schema-utils";
472
516
  * `default` key when the user wants a non-`undefined` field.
473
517
  */
474
518
  interface FieldOptions<T = unknown> {
475
- type?: "string" | "number" | "boolean";
519
+ type?: BaseValueType2;
476
520
  description?: string;
477
521
  /**
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.
522
+ * Crust metadata default for this field when the persisted state does not
523
+ * contain a value for it. Schema defaults should usually live in the schema;
524
+ * missing persisted values are validated as `undefined`, so `.default()` works
525
+ * naturally at read time.
484
526
  */
485
527
  default?: T;
486
528
  /** Mark this field as an array (collects values into an array). */
487
529
  array?: true;
488
530
  }
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;
531
+ type RawSchemaFieldDef<Out> = {
532
+ readonly type?: never;
533
+ readonly array?: never;
516
534
  readonly description?: string;
517
- readonly default: D;
518
535
  readonly validate: (value: unknown) => Promise<{
519
536
  value: unknown;
520
537
  }>;
538
+ readonly validateMissing: true;
539
+ readonly [FIELD_SCHEMA_OUTPUT]?: Out;
521
540
  };
522
- /** An array `FieldDef` with no narrowed default. */
523
- type ArrayFieldDef<T extends ValueType2> = {
524
- readonly type: T;
541
+ type SchemaFieldDefWithOptions<
542
+ Out,
543
+ Type extends FieldOptions["type"],
544
+ Array extends true | undefined,
545
+ D = never
546
+ > = Omit<RawSchemaFieldDef<Out>, "type" | "array"> & (Type extends NonNullable<FieldOptions["type"]> ? {
547
+ readonly type: Type;
548
+ } : {
549
+ readonly type?: never;
550
+ }) & (Array extends true ? {
525
551
  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;
552
+ } : {
553
+ readonly array?: never;
554
+ }) & ([D] extends [never] ? unknown : {
539
555
  readonly default: D;
540
- readonly validate: (value: unknown) => Promise<{
541
- value: unknown;
542
- }>;
543
- };
556
+ });
544
557
  /**
545
558
  * `FieldDef` inferred from a Standard Schema, with no narrowed default.
546
559
  *
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.
560
+ * Standard Schema v1 has no spec-portable type-level access to defaults, so
561
+ * schema `.default()` does not narrow the inferred TypeScript type. Pass
562
+ * `field(schema, { default: x })` explicitly to narrow.
556
563
  */
557
- type SchemaFieldDefWithDefault<
558
- S extends StandardSchema,
559
- D
560
- > = IsArrayOutput<S> extends true ? ArrayFieldDefWithDefault<PrimitiveToValueType<StripUndefined<ArrayElementOutput<S>>>, D> : ScalarFieldDefWithDefault<ResolveScalarType<S>, D>;
564
+ type SchemaFieldDef<S extends StandardSchema> = RawSchemaFieldDef<InferOutput<S>>;
561
565
  /**
562
566
  * Define a `@crustjs/store` field from any Standard Schema v1.
563
567
  *
564
568
  * 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
+ * Crust stores raw values and lets the schema validate and transform them on
570
+ * read/write; missing persisted values flow through the schema as `undefined`,
571
+ * so `.optional()` and `.default()` behave naturally.
569
572
  *
570
573
  * The returned `validate` is an async function that throws an `Error` with
571
574
  * the schema's normalized issue messages on failure (matches store's
572
575
  * `FieldDef.validate` contract).
573
576
  *
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
+ * **Type-level defaults**: Standard Schema v1 has no spec-portable type-level
578
+ * access to defaults. For tight typing of default-bearing fields, pass
579
+ * `default` via `opts`:
577
580
  *
578
581
  * ```ts
579
- * field(z.string().default("x")) // state: string | undefined
580
- * field(z.string(), { default: "x" }) // state: string
582
+ * field(z.string().default("x")) // value materializes on read; state: string | undefined
583
+ * field(z.string(), { default: "x" }) // Crust-level default; state: string
581
584
  * ```
582
585
  *
583
586
  * @param schema - Any Standard Schema v1 object (Zod schemas natively;
584
587
  * Effect schemas wrapped via `Schema.standardSchemaV1`;
585
588
  * Valibot/ArkType/Sury/etc. as-is)
586
- * @param opts - Optional store-field metadata; explicit keys override
587
- * the introspected values silently
589
+ * @param opts - Optional Crust-level metadata
588
590
  * @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
+ * a Standard Schema v1 object.
591
592
  *
592
593
  * @example Zod
593
594
  * ```ts
@@ -620,11 +621,22 @@ type SchemaFieldDefWithDefault<
620
621
  declare function field<S extends StandardSchema>(schema: S): SchemaFieldDef<S>;
621
622
  declare function field<
622
623
  S extends StandardSchema,
623
- D extends InferOutput<S>
624
+ D extends InferOutput<S>,
625
+ const Type extends FieldOptions<InferOutput<S>>["type"] = undefined,
626
+ const Array extends true | undefined = undefined
624
627
  >(schema: S, opts: FieldOptions<InferOutput<S>> & {
625
628
  default: D;
626
- }): SchemaFieldDefWithDefault<S, D>;
627
- declare function field<S extends StandardSchema>(schema: S, opts: FieldOptions<InferOutput<S>>): SchemaFieldDef<S>;
629
+ type?: Type;
630
+ array?: Array;
631
+ }): SchemaFieldDefWithOptions<InferOutput<S>, Type, Array, D>;
632
+ declare function field<
633
+ S extends StandardSchema,
634
+ const Type extends FieldOptions<InferOutput<S>>["type"] = undefined,
635
+ const Array extends true | undefined = undefined
636
+ >(schema: S, opts: FieldOptions<InferOutput<S>> & {
637
+ type?: Type;
638
+ array?: Array;
639
+ }): SchemaFieldDefWithOptions<InferOutput<S>, Type, Array>;
628
640
  /**
629
641
  * Encapsulates runtime environment values needed for path resolution.
630
642
  *
@@ -779,4 +791,4 @@ declare function cacheDir(appName: string, env?: PlatformEnv): string;
779
791
  * ```
780
792
  */
781
793
  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 };
794
+ export { stateDir, field, dataDir, createStore, configDir, cacheDir, ValueType, ValidationErrorDetails, StoreValidatorIssue, StoreUpdater, StorePermissionBits, StoreErrorCode, StoreAccess, Store, PlatformEnv, InferStoreConfig, FieldsDef, FieldOptions, FieldDef, DefinitionErrorDetails, CrustStoreError, CreateStoreOptions };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  // @bun
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};
2
+ class X extends Error{code;details;cause;constructor(q,H,G){super(H);this.name="CrustStoreError",this.code=q,this.details=G}is(q){return this.code===q}withCause(q){return this.cause=q,this}}import{isStandardSchema as d,normalizeStandardIssues as o}from"@crustjs/utils/schema";function l(q){return async(H)=>{let G=await q["~standard"].validate(H);if(G.issues){let Q=o(G.issues).map((Y)=>Y.path?`${Y.path}: ${Y.message}`:Y.message);throw Error(Q.join("; "))}return{value:G.value}}}function i(q,H){if(!d(q))throw new X("DEFINITION",`field(): argument must be a Standard Schema v1 object (got ${typeof q})`,{});let K={validate:l(q),validateMissing:!0};if(H?.type!==void 0)K.type=H.type;if(H?.array===!0)K.array=!0;if(H?.description!==void 0)K.description=H.description;if(H&&"default"in H)K.default=H.default;return K}import{homedir as s}from"os";import{join as J}from"path";var t="config";function b(){return{platform:process.platform,env:process.env,homedir:s()}}function j(q){if(!q||q.trim().length===0)throw new X("PATH","appName must be a non-empty string",{path:q??""});if(q.includes("/")||q.includes("\\"))throw new X("PATH","appName must not contain path separators",{path:q})}function r(q){if(!q||q.trim().length===0)throw new X("PATH","name must be a non-empty string",{path:q??""});if(q.includes("/")||q.includes("\\"))throw new X("PATH","name must not contain path separators",{path:q});if(q.endsWith(".json"))throw new X("PATH","name must not include the .json extension",{path:q})}function a(q){if(!q||q.trim().length===0)throw new X("PATH","dirPath must be a non-empty string",{path:q??""});if(!(q.startsWith("/")||/^[A-Za-z]:[/\\]/.test(q)))throw new X("PATH","dirPath must be an absolute path",{path:q});if(q.endsWith(".json"))throw new X("PATH","dirPath must be a directory path, not a file path (should not end with .json)",{path:q})}function C(q,H,G,K){let Q=q.env[H],Y=Q&&Q.trim().length>0?Q:J(q.homedir,...G);return J(Y,K)}function D(q){throw new X("PATH",`Unsupported platform: ${q}`,{path:q})}function e(q,H){j(q);let G=H??b();switch(G.platform){case"linux":case"darwin":return C(G,"XDG_CONFIG_HOME",[".config"],q);case"win32":{let K=G.env.APPDATA,Q=K&&K.trim().length>0?K:J(G.homedir,"AppData","Roaming");return J(Q,q)}default:D(G.platform)}}function qq(q,H){j(q);let G=H??b();switch(G.platform){case"linux":case"darwin":return C(G,"XDG_DATA_HOME",[".local","share"],q);case"win32":{let K=G.env.LOCALAPPDATA,Q=K&&K.trim().length>0?K:J(G.homedir,"AppData","Local");return J(Q,q,"Data")}default:D(G.platform)}}function Gq(q,H){j(q);let G=H??b();switch(G.platform){case"linux":case"darwin":return C(G,"XDG_STATE_HOME",[".local","state"],q);case"win32":{let K=G.env.LOCALAPPDATA,Q=K&&K.trim().length>0?K:J(G.homedir,"AppData","Local");return J(Q,q,"State")}default:D(G.platform)}}function Hq(q,H){j(q);let G=H??b();switch(G.platform){case"linux":case"darwin":return C(G,"XDG_CACHE_HOME",[".cache"],q);case"win32":{let K=G.env.LOCALAPPDATA,Q=K&&K.trim().length>0?K:J(G.homedir,"AppData","Local");return J(Q,q,"Cache")}default:D(G.platform)}}function S(q,H){a(q);let G=H??t;if(H!==void 0)r(H);return J(q,`${G}.json`)}import{coerceBooleanString as Iq,tryCoerceNumber as Wq}from"@crustjs/utils";function y(q,H,G=!0){let K={};for(let[Q,Y]of Object.entries(H))if(q&&Q in q){let Z=q[Q];K[Q]=Array.isArray(Z)?[...Z]:Z}else if("default"in Y&&Y.default!==void 0)K[Q]=Array.isArray(Y.default)?[...Y.default]:Y.default;if(!G&&q!==void 0){for(let[Q,Y]of Object.entries(q))if(!(Q in H))K[Q]=Array.isArray(Y)?[...Y]:Y}return K}import{randomUUID as Kq}from"crypto";import{chmod as g,mkdir as Qq,readFile as Xq,rename as Yq,rm as $q,unlink as Lq,writeFile as Bq}from"fs/promises";import{dirname as Zq,join as _q}from"path";async function E(q){let H;try{H=await Xq(q,"utf-8")}catch(G){if(v(G))return;throw new X("IO",`Failed to read config file: ${q}`,{path:q,operation:"read"}).withCause(G)}try{return JSON.parse(H)}catch(G){throw new X("PARSE",`Malformed JSON in config file: ${q}`,{path:q}).withCause(G)}}async function z(q,H,G={}){let K=process.platform==="win32",Q=K?void 0:G.fileMode,Y=K?void 0:G.directoryMode,Z=Zq(q),T=_q(Z,`.config-${Kq()}.tmp`),V;try{V=await Qq(Z,{recursive:!0,mode:Y})}catch(I){throw new X("IO",`Failed to create config directory: ${Z}`,{path:q,operation:"write"}).withCause(I)}if(Y!==void 0&&V!==void 0)try{await g(Z,Y)}catch(I){throw new X("IO",`Failed to set permissions on config directory: ${Z}`,{path:q,operation:"write"}).withCause(I)}try{let I=JSON.stringify(H,null,"\t");if(await Bq(T,I,{encoding:"utf-8",mode:Q}),Q!==void 0)await g(T,Q)}catch(I){throw await N(T),new X("IO",`Failed to write config file: ${q}`,{path:q,operation:"write"}).withCause(I)}try{await Yq(T,q)}catch(I){throw await N(T),new X("IO",`Failed to finalize config file: ${q}`,{path:q,operation:"write"}).withCause(I)}}async function P(q){try{await Lq(q)}catch(H){if(v(H))return;throw new X("IO",`Failed to delete config file: ${q}`,{path:q,operation:"delete"}).withCause(H)}}function v(q){return q!==null&&typeof q==="object"&&"code"in q&&q.code==="ENOENT"}async function N(q){try{await $q(q,{force:!0})}catch{}}function f(q){return typeof q==="object"&&q!==null&&"value"in q&&!("ok"in q)}function Jq(q){if(q==="private")return{fileMode:384,directoryMode:448};if(q===void 0||q==="default")return{};return{fileMode:q.file,directoryMode:q.directory}}function Oq(q){let{dirPath:H,name:G,fields:K,pruneUnknown:Q,access:Y}=q,Z=S(H,G),T=Q??!0,V=Jq(Y);function I($,L){if(L===void 0)return $;if(L==="number"&&typeof $==="string")return Wq($)??$;if(L==="boolean"&&typeof $==="string")return Iq($);return $}function R($){let B={...$};for(let[_,W]of Object.entries(K)){if(!(_ in B))continue;let O=B[_];if(W.array===!0&&Array.isArray(O)){B[_]=O.map((M)=>I(M,W.type));continue}B[_]=I(O,W.type)}return B}async function U($,L){let B=[],_=$;for(let[W,O]of Object.entries(K)){if(!O.validate)continue;let M=_[W];if(M===void 0&&O.validateMissing!==!0)continue;let A;try{A=await O.validate(M)}catch(x){let F=x instanceof Error?x.message:"Validation failed";B.push({message:F,path:W});continue}if(A===void 0)continue;if(f(A)){let x=A.value;if(L==="read"){if(M===void 0)_[W]=x;continue}if(!Bun.deepEquals(x,M)){let F;try{F=await O.validate(x)}catch(w){let n=w instanceof Error?w.message:"re-validation failed";B.push({message:`read-unstable transform: ${n}`,path:W});continue}if(F!==void 0&&(!f(F)||!Bun.deepEquals(F.value,x))){B.push({message:"read-unstable transform: output would be transformed again on re-read",path:W});continue}_[W]=x}continue}throw TypeError("FieldDef.validate must return void or { value } (see @crustjs/store validation docs).")}if(B.length>0){let W=B.map((M)=>` - ${M.path}: ${M.message}`),O=`Store validation failed (${L})
3
+ ${W.join(`
4
+ `)}`;throw new X("VALIDATION",O,{operation:L,issues:B})}}async function k(){let $=await E(Z),L=y($,K,T);return R(L)}async function c(){let $=await k();return await U($,"read"),$}async function h($){let L=R($);await U(L,"write"),await z(Z,L,V)}async function u($){let L=await k(),B=$(L),_=R(B);await U(_,"update"),await z(Z,_,V)}async function m($){let B={...await k(),...$},_=R(B);await U(_,"patch"),await z(Z,_,V)}async function p(){await P(Z)}return{read:c,write:h,update:u,patch:m,reset:p}}export{Gq as stateDir,i as field,qq as dataDir,Oq as createStore,e as configDir,Hq as cacheDir,X as CrustStoreError};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crustjs/store",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
@@ -48,13 +48,14 @@
48
48
  "devDependencies": {
49
49
  "@crustjs/config": "0.0.0",
50
50
  "bunup": "^0.16.31",
51
- "effect": "3.14.2",
51
+ "effect": "3.21.2",
52
52
  "zod": "^4.0.0"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "typescript": "^6.0.3"
56
56
  },
57
57
  "dependencies": {
58
- "@crustjs/schema-utils": "0.0.1"
58
+ "@crustjs/utils": "0.0.3",
59
+ "@standard-schema/spec": "^1.1.0"
59
60
  }
60
61
  }