@zodal/dials-store-jsonc 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # @zodal/dials-store-jsonc
2
+
3
+ JSONC-file [`LayerStore`](https://github.com/i2mint/zodal-dials) for **zodal-dials** with **format-preserving** writes (VS Code `settings.json` style). **Node-only.**
4
+
5
+ ```bash
6
+ npm i @zodal/dials-store-jsonc @zodal/dials-core
7
+ ```
8
+
9
+ ```ts
10
+ import { createJsoncStore } from '@zodal/dials-store-jsonc';
11
+
12
+ const store = createJsoncStore({ path: '~/.config/myapp/settings.jsonc' });
13
+ await store.load(); // { 'editor.fontSize': 14, ... }
14
+ await store.save({ 'editor.fontSize': 16 }); // comments & key order preserved
15
+ ```
16
+
17
+ The file is a **flat** map of dotted keys → values (`{"editor.fontSize": 14}`), comments allowed. Writes apply targeted `jsonc-parser` edits (comments/order/whitespace preserved), `mkdir` the parent, and write atomically (temp + rename). Saves on one store are **serialized**. The `UNSET` sentinel deletes a key; a plain `undefined` is skipped.
18
+
19
+ | Capability | |
20
+ |---|---|
21
+ | readable | ✅ |
22
+ | writable | ✅ |
23
+ | watchable | ❌ |
24
+
25
+ ## Security
26
+
27
+ A settings file is plaintext. Either pass **`sensitivityFor`** so the store redacts `secret` keys on save (fail-closed, they never hit disk), or split secrets out yourself (dials-core's `splitBySensitivity`) **before** calling `save`.
28
+
29
+ ```ts
30
+ createJsoncStore({ path, sensitivityFor: dials.sensitivityFor }); // secret keys never written
31
+ ```
32
+
33
+ Part of the [zodal-dials](https://github.com/i2mint/zodal-dials) ecosystem.
package/dist/index.cjs ADDED
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ createJsoncStore: () => createJsoncStore
34
+ });
35
+ module.exports = __toCommonJS(index_exports);
36
+ var import_jsonc_parser = require("jsonc-parser");
37
+ var import_dials_core = require("@zodal/dials-core");
38
+ function isPlainObject(value) {
39
+ return !!value && typeof value === "object" && !Array.isArray(value);
40
+ }
41
+ var tmpCounter = 0;
42
+ function nodeFileIO() {
43
+ return {
44
+ async read(path) {
45
+ try {
46
+ const fs = await import("fs/promises");
47
+ return await fs.readFile(path, "utf8");
48
+ } catch (error) {
49
+ if (error.code === "ENOENT") return void 0;
50
+ throw error;
51
+ }
52
+ },
53
+ async write(path, text) {
54
+ const fs = await import("fs/promises");
55
+ const nodePath = await import("path");
56
+ await fs.mkdir(nodePath.dirname(path), { recursive: true });
57
+ const pid = globalThis.process?.pid ?? 0;
58
+ const tmp = `${path}.tmp-${pid}-${tmpCounter += 1}`;
59
+ await fs.writeFile(tmp, text, "utf8");
60
+ await fs.rename(tmp, path);
61
+ }
62
+ };
63
+ }
64
+ function createJsoncStore(options) {
65
+ const scope = options.scope ?? "file";
66
+ const io = options.fs ?? nodeFileIO();
67
+ const sensitivityFor = options.sensitivityFor;
68
+ let writeQueue = Promise.resolve();
69
+ return {
70
+ scope,
71
+ getCapabilities: () => ({ readable: true, writable: true, watchable: false }),
72
+ async load() {
73
+ const text = await io.read(options.path);
74
+ if (text === void 0 || text.trim() === "") return {};
75
+ const parsed = (0, import_jsonc_parser.parse)(text);
76
+ return isPlainObject(parsed) ? parsed : {};
77
+ },
78
+ save(layer) {
79
+ const run = async () => {
80
+ let text = await io.read(options.path) ?? "{}";
81
+ if (text.trim() === "" || !isPlainObject((0, import_jsonc_parser.parse)(text))) text = "{}";
82
+ const formattingOptions = { tabSize: 2, insertSpaces: true, eol: "\n" };
83
+ for (const [key, value] of Object.entries(layer)) {
84
+ if (sensitivityFor && sensitivityFor(key) === "secret") continue;
85
+ let newValue;
86
+ if ((0, import_dials_core.isUnset)(value)) {
87
+ newValue = void 0;
88
+ } else if (value === void 0) {
89
+ continue;
90
+ } else {
91
+ newValue = value;
92
+ }
93
+ const edits = (0, import_jsonc_parser.modify)(text, [key], newValue, { formattingOptions });
94
+ text = (0, import_jsonc_parser.applyEdits)(text, edits);
95
+ }
96
+ await io.write(options.path, text);
97
+ };
98
+ writeQueue = writeQueue.then(run, run);
99
+ return writeQueue;
100
+ }
101
+ };
102
+ }
103
+ // Annotate the CommonJS export names for ESM import in node:
104
+ 0 && (module.exports = {
105
+ createJsoncStore
106
+ });
107
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * @zodal/dials-store-jsonc — a JSONC-file `LayerStore` for zodal-dials. **Node-only** (it owns a file).\n *\n * The file is a FLAT map of dotted setting keys -> values (the VS Code `settings.json` convention),\n * with `//` and block comments allowed. Reads parse it to a layer; writes are FORMAT-PRESERVING —\n * comments, key order, and whitespace are kept — by applying targeted `jsonc-parser` edits rather\n * than re-stringifying. The UNSET sentinel removes a key; a plain `undefined` is skipped (it is NOT\n * a delete). File IO is injectable (defaults to Node fs, which mkdir's the parent and writes\n * atomically via temp-file + rename). Saves on one store are serialized to avoid lost updates.\n *\n * SECURITY: a settings file is plaintext. Pass `sensitivityFor` to REDACT secret keys on save (they\n * are never written to disk); otherwise the caller MUST split secrets out (e.g. dials-core's\n * `splitBySensitivity`) before calling `save`. See the README.\n */\n\nimport { applyEdits, modify, parse } from 'jsonc-parser';\nimport { isUnset } from '@zodal/dials-core';\nimport type { Layer, LayerStore, LayerStoreCapabilities, Sensitivity } from '@zodal/dials-core';\n\n/** Minimal file IO contract (injectable for tests / non-Node hosts). `read` returns undefined when\n * the file is absent. */\nexport interface FileIO {\n read(path: string): Promise<string | undefined>;\n write(path: string, text: string): Promise<void>;\n}\n\nexport interface JsoncStoreOptions {\n /** Scope id. Default: 'file'. */\n scope?: string;\n /** Path to the JSONC file. */\n path: string;\n /** File IO. Default: Node fs (mkdir parent + atomic temp-then-rename; read returns undefined on ENOENT). */\n fs?: FileIO;\n /** Classify a setting's sensitivity. When provided, `secret` keys are REDACTED on save (never\n * written to disk) — fail-closed. Without it, the caller is responsible for excluding secrets. */\n sensitivityFor?: (key: string) => Sensitivity;\n}\n\nfunction isPlainObject(value: unknown): boolean {\n return !!value && typeof value === 'object' && !Array.isArray(value);\n}\n\nlet tmpCounter = 0;\n\nfunction nodeFileIO(): FileIO {\n return {\n async read(path: string): Promise<string | undefined> {\n try {\n const fs = await import('node:fs/promises');\n return await fs.readFile(path, 'utf8');\n } catch (error) {\n if ((error as { code?: string }).code === 'ENOENT') return undefined;\n throw error;\n }\n },\n async write(path: string, text: string): Promise<void> {\n const fs = await import('node:fs/promises');\n const nodePath = await import('node:path');\n await fs.mkdir(nodePath.dirname(path), { recursive: true });\n // Atomic-ish: write a temp sibling then rename over the target so a crash can't truncate it.\n const pid = (globalThis as { process?: { pid?: number } }).process?.pid ?? 0;\n const tmp = `${path}.tmp-${pid}-${(tmpCounter += 1)}`;\n await fs.writeFile(tmp, text, 'utf8');\n await fs.rename(tmp, path);\n },\n };\n}\n\n/** Create a JSONC-file LayerStore with format-preserving, secret-aware, serialized writes. */\nexport function createJsoncStore(options: JsoncStoreOptions): LayerStore {\n const scope = options.scope ?? 'file';\n const io = options.fs ?? nodeFileIO();\n const sensitivityFor = options.sensitivityFor;\n let writeQueue: Promise<void> = Promise.resolve();\n\n return {\n scope,\n getCapabilities: (): LayerStoreCapabilities => ({ readable: true, writable: true, watchable: false }),\n\n async load(): Promise<Layer> {\n const text = await io.read(options.path);\n if (text === undefined || text.trim() === '') return {};\n const parsed = parse(text) as unknown;\n return isPlainObject(parsed) ? (parsed as Layer) : {};\n },\n\n save(layer: Layer): Promise<void> {\n const run = async (): Promise<void> => {\n let text = (await io.read(options.path)) ?? '{}';\n // Reset a non-object root (array/scalar/null/empty) to an empty object so edits never throw.\n if (text.trim() === '' || !isPlainObject(parse(text))) text = '{}';\n const formattingOptions = { tabSize: 2, insertSpaces: true, eol: '\\n' };\n for (const [key, value] of Object.entries(layer)) {\n if (sensitivityFor && sensitivityFor(key) === 'secret') continue; // never persist secrets\n let newValue: unknown;\n if (isUnset(value)) {\n newValue = undefined; // jsonc modify with undefined deletes the property\n } else if (value === undefined) {\n continue; // a plain `undefined` is NOT a delete (distinct from the UNSET sentinel) — skip\n } else {\n newValue = value;\n }\n const edits = modify(text, [key], newValue, { formattingOptions });\n text = applyEdits(text, edits);\n }\n await io.write(options.path, text);\n };\n // Serialize saves to this store so concurrent calls can't lose updates (read-modify-write).\n writeQueue = writeQueue.then(run, run);\n return writeQueue;\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAeA,0BAA0C;AAC1C,wBAAwB;AAsBxB,SAAS,cAAc,OAAyB;AAC9C,SAAO,CAAC,CAAC,SAAS,OAAO,UAAU,YAAY,CAAC,MAAM,QAAQ,KAAK;AACrE;AAEA,IAAI,aAAa;AAEjB,SAAS,aAAqB;AAC5B,SAAO;AAAA,IACL,MAAM,KAAK,MAA2C;AACpD,UAAI;AACF,cAAM,KAAK,MAAM,OAAO,aAAkB;AAC1C,eAAO,MAAM,GAAG,SAAS,MAAM,MAAM;AAAA,MACvC,SAAS,OAAO;AACd,YAAK,MAA4B,SAAS,SAAU,QAAO;AAC3D,cAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,MAAM,MAAM,MAAc,MAA6B;AACrD,YAAM,KAAK,MAAM,OAAO,aAAkB;AAC1C,YAAM,WAAW,MAAM,OAAO,MAAW;AACzC,YAAM,GAAG,MAAM,SAAS,QAAQ,IAAI,GAAG,EAAE,WAAW,KAAK,CAAC;AAE1D,YAAM,MAAO,WAA8C,SAAS,OAAO;AAC3E,YAAM,MAAM,GAAG,IAAI,QAAQ,GAAG,IAAK,cAAc,CAAE;AACnD,YAAM,GAAG,UAAU,KAAK,MAAM,MAAM;AACpC,YAAM,GAAG,OAAO,KAAK,IAAI;AAAA,IAC3B;AAAA,EACF;AACF;AAGO,SAAS,iBAAiB,SAAwC;AACvE,QAAM,QAAQ,QAAQ,SAAS;AAC/B,QAAM,KAAK,QAAQ,MAAM,WAAW;AACpC,QAAM,iBAAiB,QAAQ;AAC/B,MAAI,aAA4B,QAAQ,QAAQ;AAEhD,SAAO;AAAA,IACL;AAAA,IACA,iBAAiB,OAA+B,EAAE,UAAU,MAAM,UAAU,MAAM,WAAW,MAAM;AAAA,IAEnG,MAAM,OAAuB;AAC3B,YAAM,OAAO,MAAM,GAAG,KAAK,QAAQ,IAAI;AACvC,UAAI,SAAS,UAAa,KAAK,KAAK,MAAM,GAAI,QAAO,CAAC;AACtD,YAAM,aAAS,2BAAM,IAAI;AACzB,aAAO,cAAc,MAAM,IAAK,SAAmB,CAAC;AAAA,IACtD;AAAA,IAEA,KAAK,OAA6B;AAChC,YAAM,MAAM,YAA2B;AACrC,YAAI,OAAQ,MAAM,GAAG,KAAK,QAAQ,IAAI,KAAM;AAE5C,YAAI,KAAK,KAAK,MAAM,MAAM,CAAC,kBAAc,2BAAM,IAAI,CAAC,EAAG,QAAO;AAC9D,cAAM,oBAAoB,EAAE,SAAS,GAAG,cAAc,MAAM,KAAK,KAAK;AACtE,mBAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,cAAI,kBAAkB,eAAe,GAAG,MAAM,SAAU;AACxD,cAAI;AACJ,kBAAI,2BAAQ,KAAK,GAAG;AAClB,uBAAW;AAAA,UACb,WAAW,UAAU,QAAW;AAC9B;AAAA,UACF,OAAO;AACL,uBAAW;AAAA,UACb;AACA,gBAAM,YAAQ,4BAAO,MAAM,CAAC,GAAG,GAAG,UAAU,EAAE,kBAAkB,CAAC;AACjE,qBAAO,gCAAW,MAAM,KAAK;AAAA,QAC/B;AACA,cAAM,GAAG,MAAM,QAAQ,MAAM,IAAI;AAAA,MACnC;AAEA,mBAAa,WAAW,KAAK,KAAK,GAAG;AACrC,aAAO;AAAA,IACT;AAAA,EACF;AACF;","names":[]}
@@ -0,0 +1,38 @@
1
+ import { Sensitivity, LayerStore } from '@zodal/dials-core';
2
+
3
+ /**
4
+ * @zodal/dials-store-jsonc — a JSONC-file `LayerStore` for zodal-dials. **Node-only** (it owns a file).
5
+ *
6
+ * The file is a FLAT map of dotted setting keys -> values (the VS Code `settings.json` convention),
7
+ * with `//` and block comments allowed. Reads parse it to a layer; writes are FORMAT-PRESERVING —
8
+ * comments, key order, and whitespace are kept — by applying targeted `jsonc-parser` edits rather
9
+ * than re-stringifying. The UNSET sentinel removes a key; a plain `undefined` is skipped (it is NOT
10
+ * a delete). File IO is injectable (defaults to Node fs, which mkdir's the parent and writes
11
+ * atomically via temp-file + rename). Saves on one store are serialized to avoid lost updates.
12
+ *
13
+ * SECURITY: a settings file is plaintext. Pass `sensitivityFor` to REDACT secret keys on save (they
14
+ * are never written to disk); otherwise the caller MUST split secrets out (e.g. dials-core's
15
+ * `splitBySensitivity`) before calling `save`. See the README.
16
+ */
17
+
18
+ /** Minimal file IO contract (injectable for tests / non-Node hosts). `read` returns undefined when
19
+ * the file is absent. */
20
+ interface FileIO {
21
+ read(path: string): Promise<string | undefined>;
22
+ write(path: string, text: string): Promise<void>;
23
+ }
24
+ interface JsoncStoreOptions {
25
+ /** Scope id. Default: 'file'. */
26
+ scope?: string;
27
+ /** Path to the JSONC file. */
28
+ path: string;
29
+ /** File IO. Default: Node fs (mkdir parent + atomic temp-then-rename; read returns undefined on ENOENT). */
30
+ fs?: FileIO;
31
+ /** Classify a setting's sensitivity. When provided, `secret` keys are REDACTED on save (never
32
+ * written to disk) — fail-closed. Without it, the caller is responsible for excluding secrets. */
33
+ sensitivityFor?: (key: string) => Sensitivity;
34
+ }
35
+ /** Create a JSONC-file LayerStore with format-preserving, secret-aware, serialized writes. */
36
+ declare function createJsoncStore(options: JsoncStoreOptions): LayerStore;
37
+
38
+ export { type FileIO, type JsoncStoreOptions, createJsoncStore };
@@ -0,0 +1,38 @@
1
+ import { Sensitivity, LayerStore } from '@zodal/dials-core';
2
+
3
+ /**
4
+ * @zodal/dials-store-jsonc — a JSONC-file `LayerStore` for zodal-dials. **Node-only** (it owns a file).
5
+ *
6
+ * The file is a FLAT map of dotted setting keys -> values (the VS Code `settings.json` convention),
7
+ * with `//` and block comments allowed. Reads parse it to a layer; writes are FORMAT-PRESERVING —
8
+ * comments, key order, and whitespace are kept — by applying targeted `jsonc-parser` edits rather
9
+ * than re-stringifying. The UNSET sentinel removes a key; a plain `undefined` is skipped (it is NOT
10
+ * a delete). File IO is injectable (defaults to Node fs, which mkdir's the parent and writes
11
+ * atomically via temp-file + rename). Saves on one store are serialized to avoid lost updates.
12
+ *
13
+ * SECURITY: a settings file is plaintext. Pass `sensitivityFor` to REDACT secret keys on save (they
14
+ * are never written to disk); otherwise the caller MUST split secrets out (e.g. dials-core's
15
+ * `splitBySensitivity`) before calling `save`. See the README.
16
+ */
17
+
18
+ /** Minimal file IO contract (injectable for tests / non-Node hosts). `read` returns undefined when
19
+ * the file is absent. */
20
+ interface FileIO {
21
+ read(path: string): Promise<string | undefined>;
22
+ write(path: string, text: string): Promise<void>;
23
+ }
24
+ interface JsoncStoreOptions {
25
+ /** Scope id. Default: 'file'. */
26
+ scope?: string;
27
+ /** Path to the JSONC file. */
28
+ path: string;
29
+ /** File IO. Default: Node fs (mkdir parent + atomic temp-then-rename; read returns undefined on ENOENT). */
30
+ fs?: FileIO;
31
+ /** Classify a setting's sensitivity. When provided, `secret` keys are REDACTED on save (never
32
+ * written to disk) — fail-closed. Without it, the caller is responsible for excluding secrets. */
33
+ sensitivityFor?: (key: string) => Sensitivity;
34
+ }
35
+ /** Create a JSONC-file LayerStore with format-preserving, secret-aware, serialized writes. */
36
+ declare function createJsoncStore(options: JsoncStoreOptions): LayerStore;
37
+
38
+ export { type FileIO, type JsoncStoreOptions, createJsoncStore };
package/dist/index.js ADDED
@@ -0,0 +1,72 @@
1
+ // src/index.ts
2
+ import { applyEdits, modify, parse } from "jsonc-parser";
3
+ import { isUnset } from "@zodal/dials-core";
4
+ function isPlainObject(value) {
5
+ return !!value && typeof value === "object" && !Array.isArray(value);
6
+ }
7
+ var tmpCounter = 0;
8
+ function nodeFileIO() {
9
+ return {
10
+ async read(path) {
11
+ try {
12
+ const fs = await import("fs/promises");
13
+ return await fs.readFile(path, "utf8");
14
+ } catch (error) {
15
+ if (error.code === "ENOENT") return void 0;
16
+ throw error;
17
+ }
18
+ },
19
+ async write(path, text) {
20
+ const fs = await import("fs/promises");
21
+ const nodePath = await import("path");
22
+ await fs.mkdir(nodePath.dirname(path), { recursive: true });
23
+ const pid = globalThis.process?.pid ?? 0;
24
+ const tmp = `${path}.tmp-${pid}-${tmpCounter += 1}`;
25
+ await fs.writeFile(tmp, text, "utf8");
26
+ await fs.rename(tmp, path);
27
+ }
28
+ };
29
+ }
30
+ function createJsoncStore(options) {
31
+ const scope = options.scope ?? "file";
32
+ const io = options.fs ?? nodeFileIO();
33
+ const sensitivityFor = options.sensitivityFor;
34
+ let writeQueue = Promise.resolve();
35
+ return {
36
+ scope,
37
+ getCapabilities: () => ({ readable: true, writable: true, watchable: false }),
38
+ async load() {
39
+ const text = await io.read(options.path);
40
+ if (text === void 0 || text.trim() === "") return {};
41
+ const parsed = parse(text);
42
+ return isPlainObject(parsed) ? parsed : {};
43
+ },
44
+ save(layer) {
45
+ const run = async () => {
46
+ let text = await io.read(options.path) ?? "{}";
47
+ if (text.trim() === "" || !isPlainObject(parse(text))) text = "{}";
48
+ const formattingOptions = { tabSize: 2, insertSpaces: true, eol: "\n" };
49
+ for (const [key, value] of Object.entries(layer)) {
50
+ if (sensitivityFor && sensitivityFor(key) === "secret") continue;
51
+ let newValue;
52
+ if (isUnset(value)) {
53
+ newValue = void 0;
54
+ } else if (value === void 0) {
55
+ continue;
56
+ } else {
57
+ newValue = value;
58
+ }
59
+ const edits = modify(text, [key], newValue, { formattingOptions });
60
+ text = applyEdits(text, edits);
61
+ }
62
+ await io.write(options.path, text);
63
+ };
64
+ writeQueue = writeQueue.then(run, run);
65
+ return writeQueue;
66
+ }
67
+ };
68
+ }
69
+ export {
70
+ createJsoncStore
71
+ };
72
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * @zodal/dials-store-jsonc — a JSONC-file `LayerStore` for zodal-dials. **Node-only** (it owns a file).\n *\n * The file is a FLAT map of dotted setting keys -> values (the VS Code `settings.json` convention),\n * with `//` and block comments allowed. Reads parse it to a layer; writes are FORMAT-PRESERVING —\n * comments, key order, and whitespace are kept — by applying targeted `jsonc-parser` edits rather\n * than re-stringifying. The UNSET sentinel removes a key; a plain `undefined` is skipped (it is NOT\n * a delete). File IO is injectable (defaults to Node fs, which mkdir's the parent and writes\n * atomically via temp-file + rename). Saves on one store are serialized to avoid lost updates.\n *\n * SECURITY: a settings file is plaintext. Pass `sensitivityFor` to REDACT secret keys on save (they\n * are never written to disk); otherwise the caller MUST split secrets out (e.g. dials-core's\n * `splitBySensitivity`) before calling `save`. See the README.\n */\n\nimport { applyEdits, modify, parse } from 'jsonc-parser';\nimport { isUnset } from '@zodal/dials-core';\nimport type { Layer, LayerStore, LayerStoreCapabilities, Sensitivity } from '@zodal/dials-core';\n\n/** Minimal file IO contract (injectable for tests / non-Node hosts). `read` returns undefined when\n * the file is absent. */\nexport interface FileIO {\n read(path: string): Promise<string | undefined>;\n write(path: string, text: string): Promise<void>;\n}\n\nexport interface JsoncStoreOptions {\n /** Scope id. Default: 'file'. */\n scope?: string;\n /** Path to the JSONC file. */\n path: string;\n /** File IO. Default: Node fs (mkdir parent + atomic temp-then-rename; read returns undefined on ENOENT). */\n fs?: FileIO;\n /** Classify a setting's sensitivity. When provided, `secret` keys are REDACTED on save (never\n * written to disk) — fail-closed. Without it, the caller is responsible for excluding secrets. */\n sensitivityFor?: (key: string) => Sensitivity;\n}\n\nfunction isPlainObject(value: unknown): boolean {\n return !!value && typeof value === 'object' && !Array.isArray(value);\n}\n\nlet tmpCounter = 0;\n\nfunction nodeFileIO(): FileIO {\n return {\n async read(path: string): Promise<string | undefined> {\n try {\n const fs = await import('node:fs/promises');\n return await fs.readFile(path, 'utf8');\n } catch (error) {\n if ((error as { code?: string }).code === 'ENOENT') return undefined;\n throw error;\n }\n },\n async write(path: string, text: string): Promise<void> {\n const fs = await import('node:fs/promises');\n const nodePath = await import('node:path');\n await fs.mkdir(nodePath.dirname(path), { recursive: true });\n // Atomic-ish: write a temp sibling then rename over the target so a crash can't truncate it.\n const pid = (globalThis as { process?: { pid?: number } }).process?.pid ?? 0;\n const tmp = `${path}.tmp-${pid}-${(tmpCounter += 1)}`;\n await fs.writeFile(tmp, text, 'utf8');\n await fs.rename(tmp, path);\n },\n };\n}\n\n/** Create a JSONC-file LayerStore with format-preserving, secret-aware, serialized writes. */\nexport function createJsoncStore(options: JsoncStoreOptions): LayerStore {\n const scope = options.scope ?? 'file';\n const io = options.fs ?? nodeFileIO();\n const sensitivityFor = options.sensitivityFor;\n let writeQueue: Promise<void> = Promise.resolve();\n\n return {\n scope,\n getCapabilities: (): LayerStoreCapabilities => ({ readable: true, writable: true, watchable: false }),\n\n async load(): Promise<Layer> {\n const text = await io.read(options.path);\n if (text === undefined || text.trim() === '') return {};\n const parsed = parse(text) as unknown;\n return isPlainObject(parsed) ? (parsed as Layer) : {};\n },\n\n save(layer: Layer): Promise<void> {\n const run = async (): Promise<void> => {\n let text = (await io.read(options.path)) ?? '{}';\n // Reset a non-object root (array/scalar/null/empty) to an empty object so edits never throw.\n if (text.trim() === '' || !isPlainObject(parse(text))) text = '{}';\n const formattingOptions = { tabSize: 2, insertSpaces: true, eol: '\\n' };\n for (const [key, value] of Object.entries(layer)) {\n if (sensitivityFor && sensitivityFor(key) === 'secret') continue; // never persist secrets\n let newValue: unknown;\n if (isUnset(value)) {\n newValue = undefined; // jsonc modify with undefined deletes the property\n } else if (value === undefined) {\n continue; // a plain `undefined` is NOT a delete (distinct from the UNSET sentinel) — skip\n } else {\n newValue = value;\n }\n const edits = modify(text, [key], newValue, { formattingOptions });\n text = applyEdits(text, edits);\n }\n await io.write(options.path, text);\n };\n // Serialize saves to this store so concurrent calls can't lose updates (read-modify-write).\n writeQueue = writeQueue.then(run, run);\n return writeQueue;\n },\n };\n}\n"],"mappings":";AAeA,SAAS,YAAY,QAAQ,aAAa;AAC1C,SAAS,eAAe;AAsBxB,SAAS,cAAc,OAAyB;AAC9C,SAAO,CAAC,CAAC,SAAS,OAAO,UAAU,YAAY,CAAC,MAAM,QAAQ,KAAK;AACrE;AAEA,IAAI,aAAa;AAEjB,SAAS,aAAqB;AAC5B,SAAO;AAAA,IACL,MAAM,KAAK,MAA2C;AACpD,UAAI;AACF,cAAM,KAAK,MAAM,OAAO,aAAkB;AAC1C,eAAO,MAAM,GAAG,SAAS,MAAM,MAAM;AAAA,MACvC,SAAS,OAAO;AACd,YAAK,MAA4B,SAAS,SAAU,QAAO;AAC3D,cAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,MAAM,MAAM,MAAc,MAA6B;AACrD,YAAM,KAAK,MAAM,OAAO,aAAkB;AAC1C,YAAM,WAAW,MAAM,OAAO,MAAW;AACzC,YAAM,GAAG,MAAM,SAAS,QAAQ,IAAI,GAAG,EAAE,WAAW,KAAK,CAAC;AAE1D,YAAM,MAAO,WAA8C,SAAS,OAAO;AAC3E,YAAM,MAAM,GAAG,IAAI,QAAQ,GAAG,IAAK,cAAc,CAAE;AACnD,YAAM,GAAG,UAAU,KAAK,MAAM,MAAM;AACpC,YAAM,GAAG,OAAO,KAAK,IAAI;AAAA,IAC3B;AAAA,EACF;AACF;AAGO,SAAS,iBAAiB,SAAwC;AACvE,QAAM,QAAQ,QAAQ,SAAS;AAC/B,QAAM,KAAK,QAAQ,MAAM,WAAW;AACpC,QAAM,iBAAiB,QAAQ;AAC/B,MAAI,aAA4B,QAAQ,QAAQ;AAEhD,SAAO;AAAA,IACL;AAAA,IACA,iBAAiB,OAA+B,EAAE,UAAU,MAAM,UAAU,MAAM,WAAW,MAAM;AAAA,IAEnG,MAAM,OAAuB;AAC3B,YAAM,OAAO,MAAM,GAAG,KAAK,QAAQ,IAAI;AACvC,UAAI,SAAS,UAAa,KAAK,KAAK,MAAM,GAAI,QAAO,CAAC;AACtD,YAAM,SAAS,MAAM,IAAI;AACzB,aAAO,cAAc,MAAM,IAAK,SAAmB,CAAC;AAAA,IACtD;AAAA,IAEA,KAAK,OAA6B;AAChC,YAAM,MAAM,YAA2B;AACrC,YAAI,OAAQ,MAAM,GAAG,KAAK,QAAQ,IAAI,KAAM;AAE5C,YAAI,KAAK,KAAK,MAAM,MAAM,CAAC,cAAc,MAAM,IAAI,CAAC,EAAG,QAAO;AAC9D,cAAM,oBAAoB,EAAE,SAAS,GAAG,cAAc,MAAM,KAAK,KAAK;AACtE,mBAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,cAAI,kBAAkB,eAAe,GAAG,MAAM,SAAU;AACxD,cAAI;AACJ,cAAI,QAAQ,KAAK,GAAG;AAClB,uBAAW;AAAA,UACb,WAAW,UAAU,QAAW;AAC9B;AAAA,UACF,OAAO;AACL,uBAAW;AAAA,UACb;AACA,gBAAM,QAAQ,OAAO,MAAM,CAAC,GAAG,GAAG,UAAU,EAAE,kBAAkB,CAAC;AACjE,iBAAO,WAAW,MAAM,KAAK;AAAA,QAC/B;AACA,cAAM,GAAG,MAAM,QAAQ,MAAM,IAAI;AAAA,MACnC;AAEA,mBAAa,WAAW,KAAK,KAAK,GAAG;AACrC,aAAO;AAAA,IACT;AAAA,EACF;AACF;","names":[]}
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@zodal/dials-store-jsonc",
3
+ "version": "0.1.0",
4
+ "description": "JSONC-file LayerStore for zodal-dials with format-preserving writes (VS Code settings.json style)",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "require": {
16
+ "types": "./dist/index.d.cts",
17
+ "default": "./dist/index.cjs"
18
+ }
19
+ }
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "dependencies": {
28
+ "jsonc-parser": "^3.3.1"
29
+ },
30
+ "peerDependencies": {
31
+ "@zodal/core": "^0.1.2",
32
+ "@zodal/dials-core": "^0.1.0"
33
+ },
34
+ "devDependencies": {
35
+ "@zodal/core": "^0.1.2",
36
+ "@types/node": "^22.0.0",
37
+ "tsup": "^8.0.0",
38
+ "typescript": "^5.7.0",
39
+ "vitest": "^3.0.0",
40
+ "@zodal/dials-core": "0.1.0"
41
+ },
42
+ "scripts": {
43
+ "build": "tsup",
44
+ "test": "vitest run",
45
+ "typecheck": "tsc --noEmit"
46
+ }
47
+ }