@crustjs/store 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +341 -0
- package/dist/index.d.ts +421 -0
- package/dist/index.js +231 -0
- package/package.json +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
# @crustjs/store
|
|
2
|
+
|
|
3
|
+
Minimal, DX-first, type-safe config persistence for CLI apps.
|
|
4
|
+
|
|
5
|
+
`@crustjs/store` gives your CLI a production-ready config store with near-zero setup. Declare your fields, get full type inference, and let the store handle path resolution, file IO, defaults, and error mapping.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
# bun
|
|
11
|
+
bun add @crustjs/store
|
|
12
|
+
|
|
13
|
+
# npm
|
|
14
|
+
npm install @crustjs/store
|
|
15
|
+
|
|
16
|
+
# pnpm
|
|
17
|
+
pnpm add @crustjs/store
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
**Requirements:** Bun 1.x or Node 18+. TypeScript 5.x recommended.
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { createStore, configDir } from "@crustjs/store";
|
|
26
|
+
|
|
27
|
+
const store = createStore({
|
|
28
|
+
dirPath: configDir("my-cli"),
|
|
29
|
+
fields: {
|
|
30
|
+
theme: { type: "string", default: "light" },
|
|
31
|
+
verbose: { type: "boolean", default: false },
|
|
32
|
+
token: { type: "string" }, // optional — no default
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Read config (returns defaults when no persisted file exists)
|
|
37
|
+
const config = await store.read();
|
|
38
|
+
// → { theme: "light", verbose: false, token: undefined }
|
|
39
|
+
|
|
40
|
+
// Write a full config object
|
|
41
|
+
await store.write({ theme: "dark", verbose: true, token: "abc123" });
|
|
42
|
+
|
|
43
|
+
// Update a single field
|
|
44
|
+
await store.update((current) => ({ ...current, verbose: false }));
|
|
45
|
+
|
|
46
|
+
// Reset to defaults (removes persisted file)
|
|
47
|
+
await store.reset();
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
No explicit generics needed — types are inferred from your field definitions.
|
|
51
|
+
|
|
52
|
+
## API
|
|
53
|
+
|
|
54
|
+
### `createStore(options)`
|
|
55
|
+
|
|
56
|
+
Creates a typed async config store backed by a local JSON file. The file path is resolved once at creation time.
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
const store = createStore(options);
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Returns:** A `Store<TConfig>` with `read()`, `write()`, `update()`, and `reset()` methods.
|
|
63
|
+
|
|
64
|
+
**Throws:** `CrustStoreError` with `PATH` code if `dirPath` or `name` is invalid.
|
|
65
|
+
|
|
66
|
+
#### Options
|
|
67
|
+
|
|
68
|
+
| Option | Type | Required | Description |
|
|
69
|
+
| --------- | ---------- | -------- | ------------------------------------------------------------------ |
|
|
70
|
+
| `dirPath` | `string` | Yes | Absolute directory path where the JSON file is stored. |
|
|
71
|
+
| `name` | `string` | No | Store name used as filename (default `"config"` → `config.json`). |
|
|
72
|
+
| `fields` | `FieldsDef`| Yes | Field definitions that declare the config schema. |
|
|
73
|
+
|
|
74
|
+
### `configDir(appName, env?)`
|
|
75
|
+
|
|
76
|
+
Resolves the platform-standard config directory for the given app name.
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { configDir } from "@crustjs/store";
|
|
80
|
+
|
|
81
|
+
const dir = configDir("my-cli");
|
|
82
|
+
// → "/home/user/.config/my-cli" (Linux)
|
|
83
|
+
// → "/Users/user/Library/Application Support/my-cli" (macOS)
|
|
84
|
+
// → "C:\\Users\\user\\AppData\\Roaming\\my-cli" (Windows)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Use it with `dirPath`:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
const store = createStore({
|
|
91
|
+
dirPath: configDir("my-cli"),
|
|
92
|
+
fields: { /* ... */ },
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Field Definitions
|
|
97
|
+
|
|
98
|
+
Each field in the `fields` record is a `FieldDef` with a `type` discriminant:
|
|
99
|
+
|
|
100
|
+
| Property | Type | Required | Description |
|
|
101
|
+
| ------------- | --------------------------------------- | -------- | --------------------------------------------------- |
|
|
102
|
+
| `type` | `"string" \| "number" \| "boolean"` | Yes | The value type. |
|
|
103
|
+
| `default` | Matches `type` | No | Default value when the field is not persisted. |
|
|
104
|
+
| `array` | `true` | No | Collect values into an array. |
|
|
105
|
+
| `description` | `string` | No | Human-readable description for tooling. |
|
|
106
|
+
|
|
107
|
+
Type inference rules:
|
|
108
|
+
|
|
109
|
+
- **Has `default`** → field is guaranteed present (e.g., `string`)
|
|
110
|
+
- **No `default`** → field is optional (e.g., `string | undefined`)
|
|
111
|
+
- **`array: true`** → wraps the type in an array (e.g., `string[]`)
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
const store = createStore({
|
|
115
|
+
dirPath: configDir("my-cli"),
|
|
116
|
+
fields: {
|
|
117
|
+
theme: { type: "string", default: "light" }, // → string
|
|
118
|
+
verbose: { type: "boolean", default: false }, // → boolean
|
|
119
|
+
retries: { type: "number", default: 3 }, // → number
|
|
120
|
+
token: { type: "string" }, // → string | undefined
|
|
121
|
+
tags: { type: "string", array: true, default: [] },// → string[]
|
|
122
|
+
ids: { type: "number", array: true }, // → number[] | undefined
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### `store.read()`
|
|
128
|
+
|
|
129
|
+
Reads the persisted config file. Missing fields are filled from their `default` values. Fields without defaults are `undefined` when not persisted.
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
const config = await store.read();
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Always returns a value — never `undefined`.
|
|
136
|
+
|
|
137
|
+
### `store.write(config)`
|
|
138
|
+
|
|
139
|
+
Atomically persists a full config object. The entire previous config is replaced.
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
await store.write({ theme: "dark", verbose: true, token: "abc123" });
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### `store.update(updater)`
|
|
146
|
+
|
|
147
|
+
Reads the current effective config, applies the updater function, and atomically persists the result.
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
await store.update((current) => ({
|
|
151
|
+
...current,
|
|
152
|
+
theme: "dark",
|
|
153
|
+
}));
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
When no persisted file exists, the updater receives field defaults as the current value.
|
|
157
|
+
|
|
158
|
+
### `store.reset()`
|
|
159
|
+
|
|
160
|
+
Removes the persisted config file. After reset, `read()` returns field defaults.
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
await store.reset();
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Reset is idempotent — calling it when no file exists is a no-op.
|
|
167
|
+
|
|
168
|
+
## Config File Path
|
|
169
|
+
|
|
170
|
+
`configDir()` resolves the platform-standard config directory. The `name` option defaults to `"config"`, producing `config.json`:
|
|
171
|
+
|
|
172
|
+
| Platform | Default Path | Env Override |
|
|
173
|
+
| -------- | ------------------------------------------------------- | ------------------ |
|
|
174
|
+
| Linux | `~/.config/<appName>/<name>.json` | `$XDG_CONFIG_HOME` |
|
|
175
|
+
| macOS | `~/Library/Application Support/<appName>/<name>.json` | — |
|
|
176
|
+
| Windows | `%APPDATA%\<appName>\<name>.json` | `%APPDATA%` |
|
|
177
|
+
|
|
178
|
+
### Multiple Stores
|
|
179
|
+
|
|
180
|
+
Use the `name` option to create multiple stores under the same directory:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
import { createStore, configDir } from "@crustjs/store";
|
|
184
|
+
|
|
185
|
+
const dir = configDir("my-cli");
|
|
186
|
+
|
|
187
|
+
// Default store → ~/.config/my-cli/config.json
|
|
188
|
+
const configStore = createStore({
|
|
189
|
+
dirPath: dir,
|
|
190
|
+
fields: {
|
|
191
|
+
theme: { type: "string", default: "light" },
|
|
192
|
+
verbose: { type: "boolean", default: false },
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Auth store → ~/.config/my-cli/auth.json
|
|
197
|
+
const authStore = createStore({
|
|
198
|
+
dirPath: dir,
|
|
199
|
+
name: "auth",
|
|
200
|
+
fields: {
|
|
201
|
+
token: { type: "string" },
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Each store is fully independent — reading, writing, updating, and resetting one store does not affect the others.
|
|
207
|
+
|
|
208
|
+
The `name` must not contain path separators or the `.json` extension.
|
|
209
|
+
|
|
210
|
+
## Defaults & Merge
|
|
211
|
+
|
|
212
|
+
When a persisted file exists, `read()` applies field defaults for any missing keys:
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
const store = createStore({
|
|
216
|
+
dirPath: configDir("my-cli"),
|
|
217
|
+
fields: {
|
|
218
|
+
theme: { type: "string", default: "light" },
|
|
219
|
+
verbose: { type: "boolean", default: false },
|
|
220
|
+
retries: { type: "number", default: 3 },
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Persisted file contains: { "theme": "dark" }
|
|
225
|
+
const config = await store.read();
|
|
226
|
+
// → { theme: "dark", verbose: false, retries: 3 }
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Merge rules
|
|
230
|
+
|
|
231
|
+
- Fields present in the persisted file use the persisted value.
|
|
232
|
+
- Missing fields with a `default` use the default value.
|
|
233
|
+
- Missing fields without a `default` are omitted (typed as `T | undefined`).
|
|
234
|
+
- Extra keys in the persisted file that are not defined in `fields` are dropped.
|
|
235
|
+
- Array defaults are shallow-copied to prevent shared mutation.
|
|
236
|
+
- Falsy values (`null`, `0`, `""`, `false`) in the persisted file are preserved — only truly missing keys trigger defaults.
|
|
237
|
+
|
|
238
|
+
### Important: defaults are not auto-persisted
|
|
239
|
+
|
|
240
|
+
Merged defaults exist only in memory. The merged result is **not** written back to disk. The persisted file remains unchanged until you explicitly call `write()` or `update()`.
|
|
241
|
+
|
|
242
|
+
## Error Handling
|
|
243
|
+
|
|
244
|
+
All errors thrown by `@crustjs/store` are instances of `CrustStoreError` with a typed `code` property:
|
|
245
|
+
|
|
246
|
+
| Code | When | Details |
|
|
247
|
+
| ------- | ----------------------------------------------------------- | --------------------------- |
|
|
248
|
+
| `PATH` | Invalid `dirPath`, invalid `name`, unsupported platform | `{ path: string }` |
|
|
249
|
+
| `PARSE` | Malformed JSON in persisted config file | `{ path: string }` |
|
|
250
|
+
| `IO` | Filesystem read, write, or delete failure | `{ path, operation }` |
|
|
251
|
+
|
|
252
|
+
### Catching errors by code
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
import { CrustStoreError } from "@crustjs/store";
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const config = await store.read();
|
|
259
|
+
} catch (err) {
|
|
260
|
+
if (err instanceof CrustStoreError) {
|
|
261
|
+
switch (err.code) {
|
|
262
|
+
case "PARSE":
|
|
263
|
+
console.error(`Corrupt config at ${err.details.path}`);
|
|
264
|
+
console.error("Delete the file and retry, or fix the JSON manually.");
|
|
265
|
+
break;
|
|
266
|
+
case "IO":
|
|
267
|
+
console.error(`File ${err.details.operation} failed: ${err.message}`);
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Type narrowing with `.is()`
|
|
275
|
+
|
|
276
|
+
The `.is()` method narrows the error type so `details` is fully typed:
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
if (err instanceof CrustStoreError && err.is("IO")) {
|
|
280
|
+
// err.details is { path: string; operation: "read" | "write" | "delete" }
|
|
281
|
+
console.error(err.details.operation, err.details.path);
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Cause chaining
|
|
286
|
+
|
|
287
|
+
`CrustStoreError` preserves the original error as `cause` for debugging:
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
if (err instanceof CrustStoreError && err.is("PARSE")) {
|
|
291
|
+
console.error("Original parse error:", err.cause);
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Malformed JSON
|
|
296
|
+
|
|
297
|
+
`@crustjs/store` does **not** silently recover from malformed JSON. If the persisted config file contains invalid JSON, `read()` throws a `CrustStoreError` with `PARSE` code immediately — no fallback to defaults.
|
|
298
|
+
|
|
299
|
+
## Types
|
|
300
|
+
|
|
301
|
+
All types are exported for use in your application:
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
import type {
|
|
305
|
+
CreateStoreOptions,
|
|
306
|
+
FieldDef,
|
|
307
|
+
FieldsDef,
|
|
308
|
+
InferStoreConfig,
|
|
309
|
+
Store,
|
|
310
|
+
StoreUpdater,
|
|
311
|
+
ValueType,
|
|
312
|
+
StoreErrorCode,
|
|
313
|
+
} from "@crustjs/store";
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
| Type | Description |
|
|
317
|
+
| -------------------- | ----------------------------------------------------------------------- |
|
|
318
|
+
| `CreateStoreOptions` | Options object for `createStore()`. |
|
|
319
|
+
| `FieldDef` | Single field definition (discriminated by `type` and `array`). |
|
|
320
|
+
| `FieldsDef` | Record mapping field names to `FieldDef`. |
|
|
321
|
+
| `InferStoreConfig` | Utility type: infers the config shape from a `FieldsDef`. |
|
|
322
|
+
| `Store` | Store instance with `read`, `write`, `update`, `reset`. |
|
|
323
|
+
| `StoreUpdater` | Updater function type `(current: TConfig) => TConfig`. |
|
|
324
|
+
| `ValueType` | Supported type literals: `"string" \| "number" \| "boolean"`. |
|
|
325
|
+
| `StoreErrorCode` | Union of error codes: `"PATH" \| "PARSE" \| "IO"`. |
|
|
326
|
+
|
|
327
|
+
## v1 Scope & Non-Goals
|
|
328
|
+
|
|
329
|
+
`@crustjs/store` v1 is intentionally minimal. The following are **not** included:
|
|
330
|
+
|
|
331
|
+
- **Generic state/cache abstractions** — v1 is config-only (single typed object per store).
|
|
332
|
+
- **Built-in encryption or keychain integration.**
|
|
333
|
+
- **Cross-process locking** — v1 assumes single-process usage for write coordination.
|
|
334
|
+
- **Sync API variants** (`readSync`, `writeSync`, etc.).
|
|
335
|
+
- **Built-in validation** — will be added later via `@crustjs/validate` integration.
|
|
336
|
+
- **Alternative formats** (YAML, TOML, JSON5) — JSON is the only on-disk format.
|
|
337
|
+
- **Deep `@crustjs/core` integration** — the store is a standalone package.
|
|
338
|
+
|
|
339
|
+
## License
|
|
340
|
+
|
|
341
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contextual details attached to a `PATH` error.
|
|
3
|
+
*
|
|
4
|
+
* Returned when the resolved or provided config file path is invalid.
|
|
5
|
+
*/
|
|
6
|
+
interface PathErrorDetails {
|
|
7
|
+
/** The path value that caused the error. */
|
|
8
|
+
path: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Contextual details attached to a `PARSE` error.
|
|
12
|
+
*
|
|
13
|
+
* Returned when the persisted JSON file cannot be parsed.
|
|
14
|
+
*/
|
|
15
|
+
interface ParseErrorDetails {
|
|
16
|
+
/** Absolute path to the file that failed parsing. */
|
|
17
|
+
path: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Contextual details attached to an `IO` error.
|
|
21
|
+
*
|
|
22
|
+
* Returned on filesystem read, write, or delete failures.
|
|
23
|
+
*/
|
|
24
|
+
interface IOErrorDetails {
|
|
25
|
+
/** Absolute path to the file involved in the failed operation. */
|
|
26
|
+
path: string;
|
|
27
|
+
/** The filesystem operation that failed (e.g. `"read"`, `"write"`, `"delete"`). */
|
|
28
|
+
operation: "read" | "write" | "delete";
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Maps each {@link StoreErrorCode} to its structured details type.
|
|
32
|
+
*
|
|
33
|
+
* Used internally for conditional constructor parameters and type narrowing.
|
|
34
|
+
*/
|
|
35
|
+
interface StoreErrorDetailsMap {
|
|
36
|
+
PATH: PathErrorDetails;
|
|
37
|
+
PARSE: ParseErrorDetails;
|
|
38
|
+
IO: IOErrorDetails;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* All possible error codes emitted by `@crustjs/store`.
|
|
42
|
+
*
|
|
43
|
+
* - `PATH` — Invalid or unsupported config file path
|
|
44
|
+
* - `PARSE` — Malformed JSON in persisted config file
|
|
45
|
+
* - `IO` — Filesystem read, write, or delete failure
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```ts
|
|
49
|
+
* import { CrustStoreError } from "@crustjs/store";
|
|
50
|
+
*
|
|
51
|
+
* try {
|
|
52
|
+
* const config = await store.read();
|
|
53
|
+
* } catch (err) {
|
|
54
|
+
* if (err instanceof CrustStoreError) {
|
|
55
|
+
* switch (err.code) {
|
|
56
|
+
* case "PARSE":
|
|
57
|
+
* console.error(`Corrupt config at ${err.details.path}`);
|
|
58
|
+
* break;
|
|
59
|
+
* case "IO":
|
|
60
|
+
* console.error(`File ${err.details.operation} failed: ${err.message}`);
|
|
61
|
+
* break;
|
|
62
|
+
* }
|
|
63
|
+
* }
|
|
64
|
+
* }
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
type StoreErrorCode = keyof StoreErrorDetailsMap;
|
|
68
|
+
/** Resolves the details type for a given {@link StoreErrorCode}. */
|
|
69
|
+
type StoreErrorDetails<C extends StoreErrorCode> = StoreErrorDetailsMap[C];
|
|
70
|
+
/**
|
|
71
|
+
* A typed error thrown by `@crustjs/store` for path, parse, and IO failures.
|
|
72
|
+
*
|
|
73
|
+
* Every `CrustStoreError` carries a {@link StoreErrorCode} that identifies the failure
|
|
74
|
+
* category, along with structured {@link StoreErrorDetails} for programmatic handling
|
|
75
|
+
* without fragile message parsing.
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* ```ts
|
|
79
|
+
* import { CrustStoreError } from "@crustjs/store";
|
|
80
|
+
*
|
|
81
|
+
* try {
|
|
82
|
+
* const config = await store.read();
|
|
83
|
+
* } catch (err) {
|
|
84
|
+
* if (err instanceof CrustStoreError && err.is("PARSE")) {
|
|
85
|
+
* console.error(`Corrupt config at ${err.details.path}`);
|
|
86
|
+
* }
|
|
87
|
+
* }
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
declare class CrustStoreError<C extends StoreErrorCode = StoreErrorCode> extends Error {
|
|
91
|
+
/** Machine-readable error code for programmatic handling. */
|
|
92
|
+
readonly code: C;
|
|
93
|
+
/** Structured payload with context about the failure. */
|
|
94
|
+
readonly details: StoreErrorDetails<C>;
|
|
95
|
+
/** Optional wrapped original error or value. */
|
|
96
|
+
cause?: unknown;
|
|
97
|
+
constructor(code: C, message: string, details: StoreErrorDetails<C>);
|
|
98
|
+
/**
|
|
99
|
+
* Type-guard that narrows this error to a specific {@link StoreErrorCode}.
|
|
100
|
+
*
|
|
101
|
+
* When the guard returns `true`, TypeScript narrows the `details` property
|
|
102
|
+
* to the corresponding details type.
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* ```ts
|
|
106
|
+
* if (err.is("IO")) {
|
|
107
|
+
* // err.details is IOErrorDetails
|
|
108
|
+
* console.error(err.details.operation, err.details.path);
|
|
109
|
+
* }
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
is<T extends StoreErrorCode>(code: T): this is CrustStoreError<T>;
|
|
113
|
+
/**
|
|
114
|
+
* Attaches a cause to this error for stack-trace chaining.
|
|
115
|
+
*
|
|
116
|
+
* Returns `this` for fluent usage.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```ts
|
|
120
|
+
* throw new CrustStoreError("IO", "write failed", {
|
|
121
|
+
* path: "/path/to/config.json",
|
|
122
|
+
* operation: "write",
|
|
123
|
+
* }).withCause(originalError);
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
withCause(cause: unknown): this;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Encapsulates runtime environment values needed for path resolution.
|
|
130
|
+
*
|
|
131
|
+
* Exposed as a parameter to allow deterministic testing without mutating
|
|
132
|
+
* `process.env` or `process.platform`.
|
|
133
|
+
*/
|
|
134
|
+
interface PlatformEnv {
|
|
135
|
+
/** Operating system platform identifier (e.g. `"linux"`, `"darwin"`, `"win32"`). */
|
|
136
|
+
platform: string;
|
|
137
|
+
/** Environment variables map. */
|
|
138
|
+
env: Record<string, string | undefined>;
|
|
139
|
+
/** User home directory path. */
|
|
140
|
+
homedir: string;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Resolves the platform-standard config directory for the given app.
|
|
144
|
+
*
|
|
145
|
+
* Platform conventions:
|
|
146
|
+
* - **Linux**: `$XDG_CONFIG_HOME/<appName>` or `~/.config/<appName>`
|
|
147
|
+
* - **macOS**: `~/Library/Application Support/<appName>`
|
|
148
|
+
* - **Windows**: `%APPDATA%/<appName>` or `~/AppData/Roaming/<appName>`
|
|
149
|
+
*
|
|
150
|
+
* @param appName - Application name used as directory name. Must be a non-empty
|
|
151
|
+
* string without path separators.
|
|
152
|
+
* @param env - Optional platform environment override for testing.
|
|
153
|
+
* @returns Absolute path to the app's config directory.
|
|
154
|
+
* @throws {CrustStoreError} `PATH` if `appName` is invalid or platform is unsupported.
|
|
155
|
+
*
|
|
156
|
+
* @example
|
|
157
|
+
* ```ts
|
|
158
|
+
* import { configDir } from "@crustjs/store";
|
|
159
|
+
*
|
|
160
|
+
* const dir = configDir("my-cli");
|
|
161
|
+
* // → "/home/user/.config/my-cli" (Linux)
|
|
162
|
+
* // → "/Users/user/Library/Application Support/my-cli" (macOS)
|
|
163
|
+
* // → "C:\\Users\\user\\AppData\\Roaming\\my-cli" (Windows)
|
|
164
|
+
* ```
|
|
165
|
+
*/
|
|
166
|
+
declare function configDir(appName: string, env?: PlatformEnv): string;
|
|
167
|
+
/** Supported type literals for store fields. */
|
|
168
|
+
type ValueType = "string" | "number" | "boolean";
|
|
169
|
+
/**
|
|
170
|
+
* Resolves a type literal to its corresponding TypeScript primitive type.
|
|
171
|
+
*
|
|
172
|
+
* - `"string"` → `string`
|
|
173
|
+
* - `"number"` → `number`
|
|
174
|
+
* - `"boolean"` → `boolean`
|
|
175
|
+
*/
|
|
176
|
+
type ResolvePrimitive<T extends ValueType> = T extends "string" ? string : T extends "number" ? number : T extends "boolean" ? boolean : never;
|
|
177
|
+
/** Shared fields present on every store field definition. */
|
|
178
|
+
interface FieldDefBase {
|
|
179
|
+
/** Human-readable description for documentation and tooling. */
|
|
180
|
+
description?: string;
|
|
181
|
+
}
|
|
182
|
+
/** Base for scalar (non-array) fields — `array` must be omitted. */
|
|
183
|
+
interface ScalarFieldBase extends FieldDefBase {
|
|
184
|
+
/** Must be omitted for scalar fields — set to `true` for array fields. */
|
|
185
|
+
array?: never;
|
|
186
|
+
}
|
|
187
|
+
/** A scalar string field. */
|
|
188
|
+
interface StringFieldDef extends ScalarFieldBase {
|
|
189
|
+
type: "string";
|
|
190
|
+
/** Default string value when the field is not persisted. */
|
|
191
|
+
default?: string;
|
|
192
|
+
}
|
|
193
|
+
/** A scalar number field. */
|
|
194
|
+
interface NumberFieldDef extends ScalarFieldBase {
|
|
195
|
+
type: "number";
|
|
196
|
+
/** Default number value when the field is not persisted. */
|
|
197
|
+
default?: number;
|
|
198
|
+
}
|
|
199
|
+
/** A scalar boolean field. */
|
|
200
|
+
interface BooleanFieldDef extends ScalarFieldBase {
|
|
201
|
+
type: "boolean";
|
|
202
|
+
/** Default boolean value when the field is not persisted. */
|
|
203
|
+
default?: boolean;
|
|
204
|
+
}
|
|
205
|
+
/** Base for array fields — `array` is required as `true`. */
|
|
206
|
+
interface ArrayFieldBase extends FieldDefBase {
|
|
207
|
+
/** Collect values into an array. */
|
|
208
|
+
array: true;
|
|
209
|
+
}
|
|
210
|
+
/** An array of strings field. */
|
|
211
|
+
interface StringArrayFieldDef extends ArrayFieldBase {
|
|
212
|
+
type: "string";
|
|
213
|
+
/** Default string array value when the field is not persisted. */
|
|
214
|
+
default?: readonly string[];
|
|
215
|
+
}
|
|
216
|
+
/** An array of numbers field. */
|
|
217
|
+
interface NumberArrayFieldDef extends ArrayFieldBase {
|
|
218
|
+
type: "number";
|
|
219
|
+
/** Default number array value when the field is not persisted. */
|
|
220
|
+
default?: readonly number[];
|
|
221
|
+
}
|
|
222
|
+
/** An array of booleans field. */
|
|
223
|
+
interface BooleanArrayFieldDef extends ArrayFieldBase {
|
|
224
|
+
type: "boolean";
|
|
225
|
+
/** Default boolean array value when the field is not persisted. */
|
|
226
|
+
default?: readonly boolean[];
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Defines a single field in a store's config schema.
|
|
230
|
+
*
|
|
231
|
+
* Discriminated by `type` and `array` for type-safe `default` values.
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* ```ts
|
|
235
|
+
* const fields = {
|
|
236
|
+
* theme: { type: "string", default: "light" },
|
|
237
|
+
* verbose: { type: "boolean", default: false },
|
|
238
|
+
* tags: { type: "string", array: true, default: [] },
|
|
239
|
+
* token: { type: "string" }, // optional — no default
|
|
240
|
+
* } satisfies FieldsDef;
|
|
241
|
+
* ```
|
|
242
|
+
*/
|
|
243
|
+
type FieldDef = StringFieldDef | NumberFieldDef | BooleanFieldDef | StringArrayFieldDef | NumberArrayFieldDef | BooleanArrayFieldDef;
|
|
244
|
+
/** Record mapping field names to their definitions. */
|
|
245
|
+
type FieldsDef = Record<string, FieldDef>;
|
|
246
|
+
/**
|
|
247
|
+
* Infer the resolved type for a single field definition.
|
|
248
|
+
*
|
|
249
|
+
* - **array** → `primitive[]` (or `primitive[] | undefined` if no default)
|
|
250
|
+
* - **has default** → `primitive` (guaranteed present)
|
|
251
|
+
* - **no default** → `primitive | undefined` (optional)
|
|
252
|
+
*/
|
|
253
|
+
type InferFieldValue<F extends FieldDef> = F extends {
|
|
254
|
+
array: true;
|
|
255
|
+
} ? F extends {
|
|
256
|
+
default: readonly ResolvePrimitive<F["type"]>[];
|
|
257
|
+
} ? ResolvePrimitive<F["type"]>[] : ResolvePrimitive<F["type"]>[] | undefined : F extends {
|
|
258
|
+
default: ResolvePrimitive<F["type"]>;
|
|
259
|
+
} ? ResolvePrimitive<F["type"]> : ResolvePrimitive<F["type"]> | undefined;
|
|
260
|
+
/**
|
|
261
|
+
* Maps a full {@link FieldsDef} record to the inferred config object type.
|
|
262
|
+
*
|
|
263
|
+
* @example
|
|
264
|
+
* ```ts
|
|
265
|
+
* type Config = InferStoreConfig<{
|
|
266
|
+
* theme: { type: "string"; default: "light" };
|
|
267
|
+
* verbose: { type: "boolean" };
|
|
268
|
+
* }>;
|
|
269
|
+
* // → { theme: string; verbose: boolean | undefined }
|
|
270
|
+
* ```
|
|
271
|
+
*/
|
|
272
|
+
type InferStoreConfig<F extends FieldsDef> = { [K in keyof F] : InferFieldValue<F[K]> };
|
|
273
|
+
/**
|
|
274
|
+
* Options for {@link createStore}.
|
|
275
|
+
*
|
|
276
|
+
* @typeParam F - The field definitions record (inferred via `const` generic).
|
|
277
|
+
*
|
|
278
|
+
* @example
|
|
279
|
+
* ```ts
|
|
280
|
+
* const store = createStore({
|
|
281
|
+
* dirPath: configDir("my-cli"),
|
|
282
|
+
* fields: {
|
|
283
|
+
* theme: { type: "string", default: "light" },
|
|
284
|
+
* verbose: { type: "boolean", default: false },
|
|
285
|
+
* },
|
|
286
|
+
* });
|
|
287
|
+
* ```
|
|
288
|
+
*/
|
|
289
|
+
interface CreateStoreOptions<F extends FieldsDef> {
|
|
290
|
+
/**
|
|
291
|
+
* Absolute directory path where the store JSON file is persisted.
|
|
292
|
+
*
|
|
293
|
+
* Use the {@link configDir} helper to resolve the platform-standard
|
|
294
|
+
* config directory from an app name.
|
|
295
|
+
*/
|
|
296
|
+
dirPath: string;
|
|
297
|
+
/**
|
|
298
|
+
* Store name used as the JSON filename in the directory.
|
|
299
|
+
*
|
|
300
|
+
* Defaults to `"config"`, producing `config.json`. Set to a different value
|
|
301
|
+
* to create multiple stores under the same directory
|
|
302
|
+
* (e.g. `name: "auth"` → `auth.json`).
|
|
303
|
+
*
|
|
304
|
+
* Must not contain path separators or the `.json` extension.
|
|
305
|
+
*/
|
|
306
|
+
name?: string;
|
|
307
|
+
/**
|
|
308
|
+
* Field definitions that declare the store's config schema.
|
|
309
|
+
*
|
|
310
|
+
* Each key maps to a {@link FieldDef} with a `type` discriminant and optional
|
|
311
|
+
* `default`. Fields without a `default` are optional (`T | undefined`).
|
|
312
|
+
*/
|
|
313
|
+
fields: F;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Receives the current effective config and returns an updated config.
|
|
317
|
+
*
|
|
318
|
+
* Used by {@link Store.update} to apply partial mutations atomically.
|
|
319
|
+
*
|
|
320
|
+
* @example
|
|
321
|
+
* ```ts
|
|
322
|
+
* await store.update((current) => ({
|
|
323
|
+
* ...current,
|
|
324
|
+
* theme: "dark",
|
|
325
|
+
* }));
|
|
326
|
+
* ```
|
|
327
|
+
*/
|
|
328
|
+
type StoreUpdater<TConfig> = (current: TConfig) => NoInfer<TConfig>;
|
|
329
|
+
/**
|
|
330
|
+
* A typed async config store returned by {@link createStore}.
|
|
331
|
+
*
|
|
332
|
+
* Provides `read`, `write`, `update`, and `reset` operations for a single
|
|
333
|
+
* typed config object persisted as JSON on the local filesystem.
|
|
334
|
+
*
|
|
335
|
+
* @typeParam TConfig - The inferred config shape from field definitions.
|
|
336
|
+
*
|
|
337
|
+
* @example
|
|
338
|
+
* ```ts
|
|
339
|
+
* const store = createStore({
|
|
340
|
+
* dirPath: configDir("my-cli"),
|
|
341
|
+
* fields: {
|
|
342
|
+
* theme: { type: "string", default: "light" },
|
|
343
|
+
* },
|
|
344
|
+
* });
|
|
345
|
+
*
|
|
346
|
+
* const config = await store.read();
|
|
347
|
+
* await store.write({ theme: "dark" });
|
|
348
|
+
* await store.update((c) => ({ ...c, theme: "dark" }));
|
|
349
|
+
* await store.reset();
|
|
350
|
+
* ```
|
|
351
|
+
*/
|
|
352
|
+
interface Store<TConfig> {
|
|
353
|
+
/**
|
|
354
|
+
* Reads the persisted config, applying field defaults for missing keys.
|
|
355
|
+
*
|
|
356
|
+
* Always returns a value — fields with defaults are guaranteed present,
|
|
357
|
+
* fields without defaults may be `undefined`.
|
|
358
|
+
*
|
|
359
|
+
* @returns The effective config value.
|
|
360
|
+
* @throws {CrustStoreError} `PARSE` if persisted JSON is malformed.
|
|
361
|
+
* @throws {CrustStoreError} `IO` on filesystem read failures.
|
|
362
|
+
*/
|
|
363
|
+
read(): Promise<TConfig>;
|
|
364
|
+
/**
|
|
365
|
+
* Atomically persists the full config object.
|
|
366
|
+
*
|
|
367
|
+
* @param config - The complete config to persist.
|
|
368
|
+
* @throws {CrustStoreError} `IO` on filesystem write failures.
|
|
369
|
+
*/
|
|
370
|
+
write(config: NoInfer<TConfig>): Promise<void>;
|
|
371
|
+
/**
|
|
372
|
+
* Reads current effective config, applies the updater, and persists.
|
|
373
|
+
*
|
|
374
|
+
* @param updater - Function receiving current config and returning updated config.
|
|
375
|
+
* @throws {CrustStoreError} `PARSE` if persisted JSON is malformed.
|
|
376
|
+
* @throws {CrustStoreError} `IO` on filesystem failures.
|
|
377
|
+
*/
|
|
378
|
+
update(updater: StoreUpdater<TConfig>): Promise<void>;
|
|
379
|
+
/**
|
|
380
|
+
* Removes the persisted config file, returning the store to defaults-on-read behavior.
|
|
381
|
+
*
|
|
382
|
+
* @throws {CrustStoreError} `IO` on filesystem deletion failures.
|
|
383
|
+
*/
|
|
384
|
+
reset(): Promise<void>;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Creates a typed async config store backed by a local JSON file.
|
|
388
|
+
*
|
|
389
|
+
* The store resolves its file path once at creation time from `dirPath` and
|
|
390
|
+
* optional `name`. Field definitions declare the config schema — each field's
|
|
391
|
+
* `type` determines its TypeScript type, and the presence of `default`
|
|
392
|
+
* determines whether the field is guaranteed or optional (`T | undefined`).
|
|
393
|
+
*
|
|
394
|
+
* @typeParam F - Field definitions record (inferred via `const` generic).
|
|
395
|
+
* @param options - Store configuration options.
|
|
396
|
+
* @returns A {@link Store} instance with `read`, `write`, `update`, and `reset` methods.
|
|
397
|
+
* @throws {CrustStoreError} `PATH` if `dirPath` or `name` is invalid.
|
|
398
|
+
*
|
|
399
|
+
* @example
|
|
400
|
+
* ```ts
|
|
401
|
+
* import { createStore, configDir } from "@crustjs/store";
|
|
402
|
+
*
|
|
403
|
+
* const store = createStore({
|
|
404
|
+
* dirPath: configDir("my-cli"),
|
|
405
|
+
* fields: {
|
|
406
|
+
* theme: { type: "string", default: "light" },
|
|
407
|
+
* verbose: { type: "boolean", default: false },
|
|
408
|
+
* token: { type: "string" },
|
|
409
|
+
* },
|
|
410
|
+
* });
|
|
411
|
+
*
|
|
412
|
+
* const config = await store.read();
|
|
413
|
+
* // → { theme: "light", verbose: false } (defaults when no persisted file)
|
|
414
|
+
*
|
|
415
|
+
* await store.write({ theme: "dark", verbose: true, token: "abc" });
|
|
416
|
+
* await store.update((c) => ({ ...c, theme: "light" }));
|
|
417
|
+
* await store.reset();
|
|
418
|
+
* ```
|
|
419
|
+
*/
|
|
420
|
+
declare function createStore<const F extends FieldsDef>(options: CreateStoreOptions<F>): Store<InferStoreConfig<F>>;
|
|
421
|
+
export { createStore, configDir, ValueType, StoreUpdater, StoreErrorCode, Store, PlatformEnv, InferStoreConfig, FieldsDef, FieldDef, CrustStoreError, CreateStoreOptions };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/errors.ts
|
|
3
|
+
class CrustStoreError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
details;
|
|
6
|
+
cause;
|
|
7
|
+
constructor(code, message, details) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "CrustStoreError";
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.details = details;
|
|
12
|
+
}
|
|
13
|
+
is(code) {
|
|
14
|
+
return this.code === code;
|
|
15
|
+
}
|
|
16
|
+
withCause(cause) {
|
|
17
|
+
this.cause = cause;
|
|
18
|
+
return this;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// src/path.ts
|
|
22
|
+
import { homedir } from "os";
|
|
23
|
+
import { join } from "path";
|
|
24
|
+
var DEFAULT_STORE_NAME = "config";
|
|
25
|
+
function getRuntimeEnv() {
|
|
26
|
+
return {
|
|
27
|
+
platform: process.platform,
|
|
28
|
+
env: process.env,
|
|
29
|
+
homedir: homedir()
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function validateAppName(appName) {
|
|
33
|
+
if (!appName || appName.trim().length === 0) {
|
|
34
|
+
throw new CrustStoreError("PATH", "appName must be a non-empty string", {
|
|
35
|
+
path: appName ?? ""
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (appName.includes("/") || appName.includes("\\")) {
|
|
39
|
+
throw new CrustStoreError("PATH", "appName must not contain path separators", {
|
|
40
|
+
path: appName
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function validateName(name) {
|
|
45
|
+
if (!name || name.trim().length === 0) {
|
|
46
|
+
throw new CrustStoreError("PATH", "name must be a non-empty string", {
|
|
47
|
+
path: name ?? ""
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (name.includes("/") || name.includes("\\")) {
|
|
51
|
+
throw new CrustStoreError("PATH", "name must not contain path separators", {
|
|
52
|
+
path: name
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (name.endsWith(".json")) {
|
|
56
|
+
throw new CrustStoreError("PATH", "name must not include the .json extension", {
|
|
57
|
+
path: name
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function validateDirPath(dirPath) {
|
|
62
|
+
if (!dirPath || dirPath.trim().length === 0) {
|
|
63
|
+
throw new CrustStoreError("PATH", "dirPath must be a non-empty string", {
|
|
64
|
+
path: dirPath ?? ""
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const isAbsolute = dirPath.startsWith("/") || /^[A-Za-z]:[/\\]/.test(dirPath);
|
|
68
|
+
if (!isAbsolute) {
|
|
69
|
+
throw new CrustStoreError("PATH", "dirPath must be an absolute path", {
|
|
70
|
+
path: dirPath
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (dirPath.endsWith(".json")) {
|
|
74
|
+
throw new CrustStoreError("PATH", "dirPath must be a directory path, not a file path (should not end with .json)", {
|
|
75
|
+
path: dirPath
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function configDir(appName, env) {
|
|
80
|
+
validateAppName(appName);
|
|
81
|
+
const resolvedEnv = env ?? getRuntimeEnv();
|
|
82
|
+
switch (resolvedEnv.platform) {
|
|
83
|
+
case "linux": {
|
|
84
|
+
const xdgConfig = resolvedEnv.env.XDG_CONFIG_HOME;
|
|
85
|
+
const base = xdgConfig && xdgConfig.trim().length > 0 ? xdgConfig : join(resolvedEnv.homedir, ".config");
|
|
86
|
+
return join(base, appName);
|
|
87
|
+
}
|
|
88
|
+
case "darwin": {
|
|
89
|
+
return join(resolvedEnv.homedir, "Library", "Application Support", appName);
|
|
90
|
+
}
|
|
91
|
+
case "win32": {
|
|
92
|
+
const appData = resolvedEnv.env.APPDATA;
|
|
93
|
+
const base = appData && appData.trim().length > 0 ? appData : join(resolvedEnv.homedir, "AppData", "Roaming");
|
|
94
|
+
return join(base, appName);
|
|
95
|
+
}
|
|
96
|
+
default:
|
|
97
|
+
throw new CrustStoreError("PATH", `Unsupported platform: ${resolvedEnv.platform}`, {
|
|
98
|
+
path: resolvedEnv.platform
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function resolveStorePath(dirPath, name) {
|
|
103
|
+
validateDirPath(dirPath);
|
|
104
|
+
const storeName = name ?? DEFAULT_STORE_NAME;
|
|
105
|
+
if (name !== undefined) {
|
|
106
|
+
validateName(name);
|
|
107
|
+
}
|
|
108
|
+
return join(dirPath, `${storeName}.json`);
|
|
109
|
+
}
|
|
110
|
+
// src/merge.ts
|
|
111
|
+
function applyFieldDefaults(persisted, fields) {
|
|
112
|
+
const result = {};
|
|
113
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
114
|
+
if (persisted && key in persisted) {
|
|
115
|
+
result[key] = persisted[key];
|
|
116
|
+
} else if ("default" in def && def.default !== undefined) {
|
|
117
|
+
result[key] = Array.isArray(def.default) ? [...def.default] : def.default;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/persistence.ts
|
|
124
|
+
import { randomUUID } from "crypto";
|
|
125
|
+
import {
|
|
126
|
+
mkdir,
|
|
127
|
+
readFile,
|
|
128
|
+
rename,
|
|
129
|
+
rm,
|
|
130
|
+
unlink,
|
|
131
|
+
writeFile
|
|
132
|
+
} from "fs/promises";
|
|
133
|
+
import { dirname, join as join2 } from "path";
|
|
134
|
+
async function readJson(filePath) {
|
|
135
|
+
let raw;
|
|
136
|
+
try {
|
|
137
|
+
raw = await readFile(filePath, "utf-8");
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (isEnoent(err)) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
throw new CrustStoreError("IO", `Failed to read config file: ${filePath}`, {
|
|
143
|
+
path: filePath,
|
|
144
|
+
operation: "read"
|
|
145
|
+
}).withCause(err);
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
return JSON.parse(raw);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
throw new CrustStoreError("PARSE", `Malformed JSON in config file: ${filePath}`, { path: filePath }).withCause(err);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async function writeJson(filePath, data) {
|
|
154
|
+
const dir = dirname(filePath);
|
|
155
|
+
const tempPath = join2(dir, `.config-${randomUUID()}.tmp`);
|
|
156
|
+
try {
|
|
157
|
+
await mkdir(dir, { recursive: true });
|
|
158
|
+
} catch (err) {
|
|
159
|
+
throw new CrustStoreError("IO", `Failed to create config directory: ${dir}`, {
|
|
160
|
+
path: filePath,
|
|
161
|
+
operation: "write"
|
|
162
|
+
}).withCause(err);
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const json = JSON.stringify(data, null, "\t");
|
|
166
|
+
await writeFile(tempPath, json, "utf-8");
|
|
167
|
+
} catch (err) {
|
|
168
|
+
await cleanupTempFile(tempPath);
|
|
169
|
+
throw new CrustStoreError("IO", `Failed to write config file: ${filePath}`, {
|
|
170
|
+
path: filePath,
|
|
171
|
+
operation: "write"
|
|
172
|
+
}).withCause(err);
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
await rename(tempPath, filePath);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
await cleanupTempFile(tempPath);
|
|
178
|
+
throw new CrustStoreError("IO", `Failed to finalize config file: ${filePath}`, {
|
|
179
|
+
path: filePath,
|
|
180
|
+
operation: "write"
|
|
181
|
+
}).withCause(err);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async function deleteJson(filePath) {
|
|
185
|
+
try {
|
|
186
|
+
await unlink(filePath);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (isEnoent(err)) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
throw new CrustStoreError("IO", `Failed to delete config file: ${filePath}`, {
|
|
192
|
+
path: filePath,
|
|
193
|
+
operation: "delete"
|
|
194
|
+
}).withCause(err);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function isEnoent(err) {
|
|
198
|
+
return err !== null && typeof err === "object" && "code" in err && err.code === "ENOENT";
|
|
199
|
+
}
|
|
200
|
+
async function cleanupTempFile(tempPath) {
|
|
201
|
+
try {
|
|
202
|
+
await rm(tempPath, { force: true });
|
|
203
|
+
} catch {}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// src/store.ts
|
|
207
|
+
function createStore(options) {
|
|
208
|
+
const { dirPath, name, fields } = options;
|
|
209
|
+
const filePath = resolveStorePath(dirPath, name);
|
|
210
|
+
async function read() {
|
|
211
|
+
const persisted = await readJson(filePath);
|
|
212
|
+
return applyFieldDefaults(persisted, fields);
|
|
213
|
+
}
|
|
214
|
+
async function write(config) {
|
|
215
|
+
await writeJson(filePath, config);
|
|
216
|
+
}
|
|
217
|
+
async function update(updater) {
|
|
218
|
+
const current = await read();
|
|
219
|
+
const updated = updater(current);
|
|
220
|
+
await writeJson(filePath, updated);
|
|
221
|
+
}
|
|
222
|
+
async function reset() {
|
|
223
|
+
await deleteJson(filePath);
|
|
224
|
+
}
|
|
225
|
+
return { read, write, update, reset };
|
|
226
|
+
}
|
|
227
|
+
export {
|
|
228
|
+
createStore,
|
|
229
|
+
configDir,
|
|
230
|
+
CrustStoreError
|
|
231
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crustjs/store",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Minimal, type-safe config persistence for CLI apps",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "chenxin-yan",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/chenxin-yan/crust.git",
|
|
11
|
+
"directory": "packages/store"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://crustjs.com",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/chenxin-yan/crust/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"cli",
|
|
19
|
+
"config",
|
|
20
|
+
"store",
|
|
21
|
+
"persistence",
|
|
22
|
+
"bun",
|
|
23
|
+
"typescript"
|
|
24
|
+
],
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"import": "./dist/index.js",
|
|
31
|
+
"types": "./dist/index.d.ts"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "bunup",
|
|
39
|
+
"dev": "bunup --watch",
|
|
40
|
+
"check:types": "tsc --noEmit",
|
|
41
|
+
"test": "bun test"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@crustjs/config": "0.0.0",
|
|
45
|
+
"bunup": "^0.16.29"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"typescript": "^5"
|
|
49
|
+
}
|
|
50
|
+
}
|