@crustjs/store 0.1.1 → 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: "string", array: true, 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 }
@@ -329,11 +366,11 @@ import { z } from "zod";
329
366
  import { createStore, configDir, field } from "@crustjs/store";
330
367
 
331
368
  const store = createStore({
332
- dirPath: configDir("my-cli"),
333
- fields: {
334
- theme: field(z.enum(["light", "dark"]).default("light")),
335
- verbose: { type: "boolean", default: false },
336
- },
369
+ dirPath: configDir("my-cli"),
370
+ fields: {
371
+ theme: field(z.enum(["light", "dark"]).default("light")),
372
+ verbose: { type: "boolean", default: false },
373
+ },
337
374
  });
338
375
 
339
376
  // write() validates before persisting
@@ -352,10 +389,10 @@ import * as Schema from "effect/Schema";
352
389
  import { createStore, configDir, field } from "@crustjs/store";
353
390
 
354
391
  const store = createStore({
355
- dirPath: configDir("my-cli"),
356
- fields: {
357
- theme: field(Schema.standardSchemaV1(Schema.Literal("light", "dark"))),
358
- },
392
+ dirPath: configDir("my-cli"),
393
+ fields: {
394
+ theme: field(Schema.standardSchemaV1(Schema.Literal("light", "dark"))),
395
+ },
359
396
  });
360
397
  ```
361
398
 
@@ -397,18 +434,18 @@ issue path.
397
434
 
398
435
  ```ts
399
436
  const store = createStore({
400
- dirPath: configDir("my-cli"),
401
- fields: {
402
- theme: {
403
- type: "string",
404
- default: "light",
405
- validate(value) {
406
- if (value !== "light" && value !== "dark") {
407
- throw new Error('Must be "light" or "dark"');
408
- }
409
- },
410
- },
411
- },
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
+ },
412
449
  });
413
450
  ```
414
451
 
@@ -450,15 +487,15 @@ with `CrustStoreError("VALIDATION")` and an issue message tagged
450
487
  import { CrustStoreError } from "@crustjs/store";
451
488
 
452
489
  try {
453
- await store.read();
490
+ await store.read();
454
491
  } catch (err) {
455
- if (err instanceof CrustStoreError && err.is("VALIDATION")) {
456
- // err.details is { operation: "read" | "write" | "update" | "patch", issues: StoreValidatorIssue[] }
457
- console.error(`Validation failed during ${err.details.operation}:`);
458
- for (const issue of err.details.issues) {
459
- console.error(` ${issue.path || "(root)"}: ${issue.message}`);
460
- }
461
- }
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
+ }
462
499
  }
463
500
  ```
464
501
 
@@ -466,13 +503,13 @@ try {
466
503
 
467
504
  All errors thrown by `@crustjs/store` are instances of `CrustStoreError` with a typed `code` property:
468
505
 
469
- | Code | When | Details |
470
- | ------------ | ------------------------------------------------------- | ----------------------- |
471
- | `PATH` | Invalid `dirPath`, invalid `name`, unsupported platform | `{ path: string }` |
472
- | `PARSE` | Malformed JSON in persisted config file | `{ path: string }` |
473
- | `IO` | Filesystem read, write, or delete failure | `{ path, operation }` |
474
- | `VALIDATION` | Config fails validator on read, write, update, or patch | `{ operation, issues }` |
475
- | `DEFINITION` | `field()` received an invalid (non-Standard-Schema) input | `{ vendor? }` |
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? }` |
476
513
 
477
514
  ### Catching errors by code
478
515
 
@@ -480,19 +517,19 @@ All errors thrown by `@crustjs/store` are instances of `CrustStoreError` with a
480
517
  import { CrustStoreError } from "@crustjs/store";
481
518
 
482
519
  try {
483
- const state = await store.read();
520
+ const state = await store.read();
484
521
  } catch (err) {
485
- if (err instanceof CrustStoreError) {
486
- switch (err.code) {
487
- case "PARSE":
488
- console.error(`Corrupt file at ${err.details.path}`);
489
- console.error("Delete the file and retry, or fix the JSON manually.");
490
- break;
491
- case "IO":
492
- console.error(`File ${err.details.operation} failed: ${err.message}`);
493
- break;
494
- }
495
- }
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
+ }
496
533
  }
497
534
  ```
498
535
 
@@ -502,8 +539,8 @@ The `.is()` method narrows the error type so `details` is fully typed:
502
539
 
503
540
  ```ts
504
541
  if (err instanceof CrustStoreError && err.is("IO")) {
505
- // err.details is { path: string; operation: "read" | "write" | "delete" }
506
- 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);
507
544
  }
508
545
  ```
509
546
 
@@ -513,7 +550,7 @@ if (err instanceof CrustStoreError && err.is("IO")) {
513
550
 
514
551
  ```ts
515
552
  if (err instanceof CrustStoreError && err.is("VALIDATION")) {
516
- console.error("Validation error:", err.cause);
553
+ console.error("Validation error:", err.cause);
517
554
  }
518
555
  ```
519
556
 
@@ -527,38 +564,38 @@ All types are exported for use in your application:
527
564
 
528
565
  ```ts
529
566
  import type {
530
- CreateStoreOptions,
531
- Store,
532
- StoreUpdater,
533
- FieldDef,
534
- FieldsDef,
535
- FieldOptions,
536
- InferStoreConfig,
537
- StoreValidatorIssue,
538
- StoreErrorCode,
539
- ValidationErrorDetails,
540
- DefinitionErrorDetails,
541
- ValueType,
542
- 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,
543
580
  } from "@crustjs/store";
544
581
  ```
545
582
 
546
- | Type | Description |
547
- | ------------------------ | ---------------------------------------------------------------------------------------------- |
548
- | `CreateStoreOptions` | Options object for `createStore()`. |
549
- | `Store` | Store instance with `read`, `write`, `update`, `patch`, `reset`. |
550
- | `StoreUpdater` | Updater function type `(current: T) => T`. |
551
- | `FieldDef` | Single field definition with `type`, optional `default`, and optional `validate`. |
552
- | `FieldsDef` | Record of field names to `FieldDef` definitions. |
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. |
553
590
  | `FieldOptions` | Optional Crust metadata accepted by the `field()` factory (`type`, `default`, `array`, `description`). |
554
- | `InferStoreConfig` | Inferred store state type from a `FieldsDef` definition. |
555
- | `StoreValidatorIssue` | `{ message: string, path: string }`. |
556
- | `ValidationErrorDetails` | Error details for `VALIDATION` code: `{ operation, issues }`. |
557
- | `DefinitionErrorDetails` | Error details for `DEFINITION` code: `{ vendor? }`. |
558
- | `StoreErrorCode` | Union of error codes: `"PATH" \| "PARSE" \| "IO" \| "VALIDATION" \| "DEFINITION"`. |
559
- | `ValueType` | Supported field type literals: `"string" \| "number" \| "boolean"`. |
560
- | `PlatformEnv` | Injectable platform environment for testing path helpers. |
561
- | `CrustStoreError` | Typed error class with `code`, `details`, and `cause`. |
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`. |
562
599
 
563
600
  ## Scope & Non-Goals
564
601
 
package/dist/index.d.ts CHANGED
@@ -161,6 +161,22 @@ interface StoreValidatorIssue {
161
161
  /** Field name where the issue occurred. */
162
162
  path: string;
163
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;
164
180
  /**
165
181
  * Options for {@link createStore}.
166
182
  *
@@ -210,6 +226,25 @@ interface CreateStoreOptions<F extends FieldsDef> {
210
226
  * @default true
211
227
  */
212
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;
213
248
  }
214
249
  /**
215
250
  * Receives the current effective config and returns an updated config.
@@ -756,4 +791,4 @@ declare function cacheDir(appName: string, env?: PlatformEnv): string;
756
791
  * ```
757
792
  */
758
793
  declare function createStore<const F extends FieldsDef>(options: CreateStoreOptions<F>): Store<InferStoreConfig<F>>;
759
- 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 X extends Error{code;details;cause;constructor(q,H,G){super(H);this.name="CrustStoreError",this.code=q,this.details=G}is(q){return this.code===q}withCause(q){return this.cause=q,this}}import{isStandardSchema as m,normalizeStandardIssues as p}from"@crustjs/utils/schema";function d(q){return async(H)=>{let G=await q["~standard"].validate(H);if(G.issues){let Q=p(G.issues).map((Y)=>Y.path?`${Y.path}: ${Y.message}`:Y.message);throw Error(Q.join("; "))}return{value:G.value}}}function n(q,H){if(!m(q))throw new X("DEFINITION",`field(): argument must be a Standard Schema v1 object (got ${typeof q})`,{});let K={validate:d(q),validateMissing:!0};if(H?.type!==void 0)K.type=H.type;if(H?.array===!0)K.array=!0;if(H?.description!==void 0)K.description=H.description;if(H&&"default"in H)K.default=H.default;return K}import{homedir as o}from"os";import{join as Z}from"path";var l="config";function R(){return{platform:process.platform,env:process.env,homedir:o()}}function U(q){if(!q||q.trim().length===0)throw new X("PATH","appName must be a non-empty string",{path:q??""});if(q.includes("/")||q.includes("\\"))throw new X("PATH","appName must not contain path separators",{path:q})}function i(q){if(!q||q.trim().length===0)throw new X("PATH","name must be a non-empty string",{path:q??""});if(q.includes("/")||q.includes("\\"))throw new X("PATH","name must not contain path separators",{path:q});if(q.endsWith(".json"))throw new X("PATH","name must not include the .json extension",{path:q})}function s(q){if(!q||q.trim().length===0)throw new X("PATH","dirPath must be a non-empty string",{path:q??""});if(!(q.startsWith("/")||/^[A-Za-z]:[/\\]/.test(q)))throw new X("PATH","dirPath must be an absolute path",{path:q});if(q.endsWith(".json"))throw new X("PATH","dirPath must be a directory path, not a file path (should not end with .json)",{path:q})}function b(q,H,G,K){let Q=q.env[H],Y=Q&&Q.trim().length>0?Q:Z(q.homedir,...G);return Z(Y,K)}function A(q){throw new X("PATH",`Unsupported platform: ${q}`,{path:q})}function t(q,H){U(q);let G=H??R();switch(G.platform){case"linux":case"darwin":return b(G,"XDG_CONFIG_HOME",[".config"],q);case"win32":{let K=G.env.APPDATA,Q=K&&K.trim().length>0?K:Z(G.homedir,"AppData","Roaming");return Z(Q,q)}default:A(G.platform)}}function r(q,H){U(q);let G=H??R();switch(G.platform){case"linux":case"darwin":return b(G,"XDG_DATA_HOME",[".local","share"],q);case"win32":{let K=G.env.LOCALAPPDATA,Q=K&&K.trim().length>0?K:Z(G.homedir,"AppData","Local");return Z(Q,q,"Data")}default:A(G.platform)}}function a(q,H){U(q);let G=H??R();switch(G.platform){case"linux":case"darwin":return b(G,"XDG_STATE_HOME",[".local","state"],q);case"win32":{let K=G.env.LOCALAPPDATA,Q=K&&K.trim().length>0?K:Z(G.homedir,"AppData","Local");return Z(Q,q,"State")}default:A(G.platform)}}function e(q,H){U(q);let G=H??R();switch(G.platform){case"linux":case"darwin":return b(G,"XDG_CACHE_HOME",[".cache"],q);case"win32":{let K=G.env.LOCALAPPDATA,Q=K&&K.trim().length>0?K:Z(G.homedir,"AppData","Local");return Z(Q,q,"Cache")}default:A(G.platform)}}function k(q,H){s(q);let G=H??l;if(H!==void 0)i(H);return Z(q,`${G}.json`)}import{coerceBooleanString as Mq,tryCoerceNumber as Wq}from"@crustjs/utils";function w(q,H,G=!0){let K={};for(let[Q,Y]of Object.entries(H))if(q&&Q in q){let x=q[Q];K[Q]=Array.isArray(x)?[...x]:x}else if("default"in Y&&Y.default!==void 0)K[Q]=Array.isArray(Y.default)?[...Y.default]:Y.default;if(!G&&q!==void 0){for(let[Q,Y]of Object.entries(q))if(!(Q in H))K[Q]=Array.isArray(Y)?[...Y]:Y}return K}import{randomUUID as qq}from"crypto";import{mkdir as Gq,readFile as Hq,rename as Kq,rm as Qq,unlink as Xq,writeFile as Yq}from"fs/promises";import{dirname as $q,join as Lq}from"path";async function S(q){let H;try{H=await Hq(q,"utf-8")}catch(G){if(N(G))return;throw new X("IO",`Failed to read config file: ${q}`,{path:q,operation:"read"}).withCause(G)}try{return JSON.parse(H)}catch(G){throw new X("PARSE",`Malformed JSON in config file: ${q}`,{path:q}).withCause(G)}}async function C(q,H){let G=$q(q),K=Lq(G,`.config-${qq()}.tmp`);try{await Gq(G,{recursive:!0})}catch(Q){throw new X("IO",`Failed to create config directory: ${G}`,{path:q,operation:"write"}).withCause(Q)}try{let Q=JSON.stringify(H,null,"\t");await Yq(K,Q,"utf-8")}catch(Q){throw await y(K),new X("IO",`Failed to write config file: ${q}`,{path:q,operation:"write"}).withCause(Q)}try{await Kq(K,q)}catch(Q){throw await y(K),new X("IO",`Failed to finalize config file: ${q}`,{path:q,operation:"write"}).withCause(Q)}}async function g(q){try{await Xq(q)}catch(H){if(N(H))return;throw new X("IO",`Failed to delete config file: ${q}`,{path:q,operation:"delete"}).withCause(H)}}function N(q){return q!==null&&typeof q==="object"&&"code"in q&&q.code==="ENOENT"}async function y(q){try{await Qq(q,{force:!0})}catch{}}function E(q){return typeof q==="object"&&q!==null&&"value"in q&&!("ok"in q)}function Oq(q){let{dirPath:H,name:G,fields:K,pruneUnknown:Q}=q,Y=k(H,G),x=Q??!0;function j($,L){if(L===void 0)return $;if(L==="number"&&typeof $==="string")return Wq($)??$;if(L==="boolean"&&typeof $==="string")return Mq($);return $}function T($){let M={...$};for(let[W,O]of Object.entries(K)){if(!(W in M))continue;let _=M[W];if(O.array===!0&&Array.isArray(_)){M[W]=_.map((B)=>j(B,O.type));continue}M[W]=j(_,O.type)}return M}async function V($,L){let M=[],W=$;for(let[O,_]of Object.entries(K)){if(!_.validate)continue;let B=W[O];if(B===void 0&&_.validateMissing!==!0)continue;let F;try{F=await _.validate(B)}catch(I){let J=I instanceof Error?I.message:"Validation failed";M.push({message:J,path:O});continue}if(F===void 0)continue;if(E(F)){let I=F.value;if(L==="read"){if(B===void 0)W[O]=I;continue}if(!Bun.deepEquals(I,B)){let J;try{J=await _.validate(I)}catch(z){let u=z instanceof Error?z.message:"re-validation failed";M.push({message:`read-unstable transform: ${u}`,path:O});continue}if(J!==void 0&&(!E(J)||!Bun.deepEquals(J.value,I))){M.push({message:"read-unstable transform: output would be transformed again on re-read",path:O});continue}W[O]=I}continue}throw TypeError("FieldDef.validate must return void or { value } (see @crustjs/store validation docs).")}if(M.length>0){let O=M.map((B)=>` - ${B.path}: ${B.message}`),_=`Store validation failed (${L})
3
- ${O.join(`
4
- `)}`;throw new X("VALIDATION",_,{operation:L,issues:M})}}async function D(){let $=await S(Y),L=w($,K,x);return T(L)}async function P(){let $=await D();return await V($,"read"),$}async function c($){let L=T($);await V(L,"write"),await C(Y,L)}async function f($){let L=await D(),M=$(L),W=T(M);await V(W,"update"),await C(Y,W)}async function v($){let M={...await D(),...$},W=T(M);await V(W,"patch"),await C(Y,W)}async function h(){await g(Y)}return{read:P,write:c,update:f,patch:v,reset:h}}export{a as stateDir,n as field,r as dataDir,Oq as createStore,t as configDir,e as cacheDir,X as CrustStoreError};
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.1",
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",
@@ -55,6 +55,7 @@
55
55
  "typescript": "^6.0.3"
56
56
  },
57
57
  "dependencies": {
58
- "@crustjs/utils": "0.0.2"
58
+ "@crustjs/utils": "0.0.3",
59
+ "@standard-schema/spec": "^1.1.0"
59
60
  }
60
61
  }