@qnroa/qtype 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.
Files changed (110) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/CHANGELOG.zh.md +82 -0
  3. package/LICENSE +201 -0
  4. package/README.md +113 -0
  5. package/README.zh.md +114 -0
  6. package/dist/cli/bootstrap.js +190 -0
  7. package/dist/cli/commands/check/action.js +56 -0
  8. package/dist/cli/commands/check/i18n.js +26 -0
  9. package/dist/cli/commands/check/index.js +20 -0
  10. package/dist/cli/commands/config/action.js +236 -0
  11. package/dist/cli/commands/config/i18n.js +64 -0
  12. package/dist/cli/commands/config/index.js +72 -0
  13. package/dist/cli/commands/content/index.js +22 -0
  14. package/dist/cli/commands/content/lint/action.js +102 -0
  15. package/dist/cli/commands/content/lint/i18n.js +32 -0
  16. package/dist/cli/commands/content/lint/index.js +19 -0
  17. package/dist/cli/commands/content/search/action.js +64 -0
  18. package/dist/cli/commands/content/search/i18n.js +20 -0
  19. package/dist/cli/commands/content/search/index.js +24 -0
  20. package/dist/cli/commands/content/validate/action.js +145 -0
  21. package/dist/cli/commands/content/validate/i18n.js +32 -0
  22. package/dist/cli/commands/content/validate/index.js +19 -0
  23. package/dist/cli/commands/index.js +19 -0
  24. package/dist/cli/commands/init/index.js +53 -0
  25. package/dist/cli/commands/pack/i18n.js +20 -0
  26. package/dist/cli/commands/pack/index.js +48 -0
  27. package/dist/cli/commands/plugin/i18n.js +28 -0
  28. package/dist/cli/commands/plugin/index.js +41 -0
  29. package/dist/cli/commands/publish/build/action.js +208 -0
  30. package/dist/cli/commands/publish/build/i18n.js +50 -0
  31. package/dist/cli/commands/publish/build/index.js +24 -0
  32. package/dist/cli/commands/publish/build/mermaidPass.js +93 -0
  33. package/dist/cli/commands/publish/build/mermaidPrerender.js +199 -0
  34. package/dist/cli/commands/publish/index.js +21 -0
  35. package/dist/cli/commands/publish/init/action.js +74 -0
  36. package/dist/cli/commands/publish/init/i18n.js +22 -0
  37. package/dist/cli/commands/publish/init/index.js +26 -0
  38. package/dist/cli/commands/publish/password/action.js +158 -0
  39. package/dist/cli/commands/publish/password/i18n.js +44 -0
  40. package/dist/cli/commands/publish/password/index.js +35 -0
  41. package/dist/cli/commands/publish/serve/action.js +117 -0
  42. package/dist/cli/commands/publish/serve/i18n.js +32 -0
  43. package/dist/cli/commands/publish/serve/index.js +37 -0
  44. package/dist/cli/commands/repo/index.js +19 -0
  45. package/dist/cli/commands/repo/new/action.js +98 -0
  46. package/dist/cli/commands/repo/new/i18n.js +46 -0
  47. package/dist/cli/commands/repo/new/index.js +57 -0
  48. package/dist/cli/commands/repo/sync/action.js +202 -0
  49. package/dist/cli/commands/repo/sync/i18n.js +44 -0
  50. package/dist/cli/commands/repo/sync/index.js +47 -0
  51. package/dist/cli/commands/repo/templates.js +87 -0
  52. package/dist/cli/i18n/index.js +141 -0
  53. package/dist/cli/i18n/shared.js +142 -0
  54. package/dist/cli/index.js +79 -0
  55. package/dist/cli/registry/command.js +1 -0
  56. package/dist/cli/registry/commander-i18n.js +76 -0
  57. package/dist/cli/registry/help.js +142 -0
  58. package/dist/cli/registry/mount.js +71 -0
  59. package/dist/cli/utils/output.js +92 -0
  60. package/dist/cli/utils/paths.js +13 -0
  61. package/dist/cli/utils/prompt.js +72 -0
  62. package/dist/cli/utils/repoGuard.js +41 -0
  63. package/dist/core/encrypt.js +113 -0
  64. package/dist/core/index.js +8 -0
  65. package/dist/core/keystroke/asciiMap.js +39 -0
  66. package/dist/core/keystroke/chineseMap.js +32 -0
  67. package/dist/core/keystroke/fullwidthMap.js +41 -0
  68. package/dist/core/keystroke/index.js +22 -0
  69. package/dist/core/keystroke/textNormalize.js +68 -0
  70. package/dist/core/keystroke/types.js +1 -0
  71. package/dist/core/parser/frontmatter.js +58 -0
  72. package/dist/core/parser/index.js +2 -0
  73. package/dist/core/parser/parseMaterial.js +42 -0
  74. package/dist/core/types.js +1 -0
  75. package/dist/core/typing/engine.js +112 -0
  76. package/dist/core/typing/index.js +2 -0
  77. package/dist/core/typing/metrics.js +20 -0
  78. package/dist/store/index.js +44 -0
  79. package/dist/store/kv.js +276 -0
  80. package/dist/store/storage/browser.js +61 -0
  81. package/dist/store/storage/json.js +113 -0
  82. package/dist/store/storage/memory.js +24 -0
  83. package/dist/store/types.js +47 -0
  84. package/dist/store/validators.js +71 -0
  85. package/dist/view/assets/index-DBABH3dG.js +111 -0
  86. package/dist/view/assets/index-RTLZ-zOC.css +1 -0
  87. package/dist/view/index.html +14 -0
  88. package/dist/view/qtype-logo.svg +49 -0
  89. package/dist/view/qtype-mark.svg +21 -0
  90. package/docs/README.md +23 -0
  91. package/docs/README.zh.md +20 -0
  92. package/docs/en/authoring.md +138 -0
  93. package/docs/en/cli-reference.md +171 -0
  94. package/docs/zh/authoring.md +131 -0
  95. package/docs/zh/cli-reference.md +162 -0
  96. package/package.json +85 -0
  97. package/templates/en/QTYPE.md +618 -0
  98. package/templates/en/README.md +64 -0
  99. package/templates/en/TODO.md +7 -0
  100. package/templates/en/index.md +7 -0
  101. package/templates/en/log.md +3 -0
  102. package/templates/en/material/.gitkeep +0 -0
  103. package/templates/en/material/example.md +47 -0
  104. package/templates/zh/QTYPE.md +618 -0
  105. package/templates/zh/README.md +64 -0
  106. package/templates/zh/TODO.md +7 -0
  107. package/templates/zh/index.md +7 -0
  108. package/templates/zh/log.md +3 -0
  109. package/templates/zh/material/.gitkeep +0 -0
  110. package/templates/zh/material/example.md +47 -0
@@ -0,0 +1,20 @@
1
+ const MS_PER_MINUTE = 60_000;
2
+ export function computeMetrics(s, now = Date.now()) {
3
+ const startedAt = s.startedAt ?? now;
4
+ const endedAt = s.finishedAt ?? now;
5
+ const elapsedMs = Math.max(1, endedAt - startedAt);
6
+ const totalTyped = s.correctCount + s.wrongCount;
7
+ const accuracy = totalTyped === 0 ? 1 : s.correctCount / totalTyped;
8
+ const cpm = (s.correctCount / elapsedMs) * MS_PER_MINUTE;
9
+ const wpm = cpm / 5;
10
+ return {
11
+ cpm: round(cpm),
12
+ wpm: round(wpm),
13
+ accuracy: round(accuracy, 4),
14
+ elapsedMs,
15
+ };
16
+ }
17
+ function round(n, digits = 2) {
18
+ const p = 10 ** digits;
19
+ return Math.round(n * p) / p;
20
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Persistent KV store module.
3
+ *
4
+ * ## Two-layer architecture
5
+ *
6
+ * ```
7
+ * KvStore ← business API (get/set/delete/list + optional schema)
8
+ * ↓ depends on
9
+ * Storage ← medium abstraction (read/write entire blob)
10
+ * ↓ implemented by
11
+ * JsonStorage / BrowserStorage / MemoryStorage
12
+ * ```
13
+ *
14
+ * ## Typical usage
15
+ *
16
+ * ```ts
17
+ * import { KvStore, JsonStorage } from '../../store/index.js';
18
+ *
19
+ * const kv = new KvStore(new JsonStorage('user', '/path/to/config.json'));
20
+ * await kv.set('cli.lang', 'zh');
21
+ * const lang = await kv.get('cli.lang');
22
+ * ```
23
+ *
24
+ * ## With schema (used by the `qtype config` CLI)
25
+ *
26
+ * ```ts
27
+ * import { KvStore, enumValidator } from '../../store/index.js';
28
+ *
29
+ * const kv = new KvStore(storage, {
30
+ * 'cli.lang': {
31
+ * validator: enumValidator(['zh', 'en']),
32
+ * default: 'en',
33
+ * },
34
+ * }, { strict: true });
35
+ * ```
36
+ *
37
+ * See `types.ts` / `kv.ts` for detailed design.
38
+ */
39
+ export * from './types.js';
40
+ export { KvStore, parseKey, getAt, setAt, unsetAt, flatten, } from './kv.js';
41
+ export { enumValidator, nonEmptyStringValidator, boolValidator, positiveIntValidator, absolutePathValidator, predicateValidator, } from './validators.js';
42
+ export { JsonStorage } from './storage/json.js';
43
+ export { BrowserStorage } from './storage/browser.js';
44
+ export { MemoryStorage } from './storage/memory.js';
@@ -0,0 +1,276 @@
1
+ import { KvError } from './types.js';
2
+ // ═══════════════════════════════════════════════════════════════
3
+ // Pure function utilities
4
+ // ═══════════════════════════════════════════════════════════════
5
+ /**
6
+ * Segment names that would let a hostile `setAt` reach the prototype chain
7
+ * and pollute every object in the process (`__proto__`, `constructor`,
8
+ * `prototype`). Reject them at parse time.
9
+ *
10
+ * Without this guard, `qtype config set __proto__.poisoned OWNED` would
11
+ * write `Object.prototype.poisoned = 'OWNED'` and contaminate downstream
12
+ * code that uses object literals (the entire app, basically).
13
+ */
14
+ const FORBIDDEN_SEGMENTS = new Set([
15
+ '__proto__',
16
+ 'constructor',
17
+ 'prototype',
18
+ ]);
19
+ /** Parse `a.b.c` → `{ path: ['a','b','c'], raw: 'a.b.c' }`. */
20
+ export function parseKey(raw) {
21
+ // Splitting an empty string yields [''], length===1 but segment is empty —
22
+ // caught by the `some` check below. Pure dots like 'a..b' are also caught
23
+ // by `some`. No need to special-case length===0.
24
+ const parts = raw.split('.');
25
+ if (parts.some((p) => p === '')) {
26
+ throw new KvError('err.kv.key.invalid', { raw }, `invalid key: '${raw}'. Expected dot-separated non-empty segments (e.g. 'cli.lang')`);
27
+ }
28
+ if (parts.some((p) => FORBIDDEN_SEGMENTS.has(p))) {
29
+ throw new KvError('err.kv.key.invalid', { raw }, `invalid key: '${raw}'. Segments cannot be reserved names (__proto__ / constructor / prototype).`);
30
+ }
31
+ return { path: parts, raw };
32
+ }
33
+ /**
34
+ * Defense-in-depth: even though parseKey rejects __proto__ / constructor /
35
+ * prototype, a config file that already contains those keys (manually edited
36
+ * or written by an older buggy version) would still hit them via getAt/setAt.
37
+ * Refusing to traverse forbidden segments at access time keeps the store
38
+ * safe regardless of how the path was constructed.
39
+ */
40
+ function isForbidden(seg) {
41
+ return seg === '__proto__' || seg === 'constructor' || seg === 'prototype';
42
+ }
43
+ /**
44
+ * Use `Object.hasOwn` instead of `seg in obj` / `obj[seg]` directly so we
45
+ * never read prototype-chain properties (e.g. inherited toString).
46
+ */
47
+ function getOwn(obj, seg) {
48
+ if (isForbidden(seg))
49
+ return undefined;
50
+ return Object.prototype.hasOwnProperty.call(obj, seg) ? obj[seg] : undefined;
51
+ }
52
+ /** Get a value from a nested object by path. Returns undefined if any intermediate level is missing. */
53
+ export function getAt(obj, path) {
54
+ let cur = obj;
55
+ for (const seg of path) {
56
+ if (cur == null || typeof cur !== 'object' || Array.isArray(cur))
57
+ return undefined;
58
+ cur = getOwn(cur, seg);
59
+ if (cur === undefined)
60
+ return undefined;
61
+ }
62
+ return cur;
63
+ }
64
+ /** Set a value in a nested object by path. Auto-creates intermediate levels. */
65
+ export function setAt(obj, path, value) {
66
+ if (path.length === 0)
67
+ throw new Error('setAt: empty path');
68
+ // Refuse forbidden segments at write time too (parseKey is the front
69
+ // door, this is the back door).
70
+ for (const seg of path) {
71
+ if (isForbidden(seg)) {
72
+ throw new Error(`setAt: forbidden segment '${seg}'`);
73
+ }
74
+ }
75
+ let cur = obj;
76
+ for (let i = 0; i < path.length - 1; i++) {
77
+ const seg = path[i];
78
+ // Use hasOwn to avoid reading inherited properties; if the segment is
79
+ // not own, treat it as missing and create a fresh object.
80
+ const ownNext = Object.prototype.hasOwnProperty.call(cur, seg)
81
+ ? cur[seg]
82
+ : undefined;
83
+ if (ownNext == null || typeof ownNext !== 'object' || Array.isArray(ownNext)) {
84
+ cur[seg] = {};
85
+ }
86
+ cur = cur[seg];
87
+ }
88
+ cur[path[path.length - 1]] = value;
89
+ }
90
+ /** Delete a value from a nested object by path. Empty intermediate levels are cleaned up upward. Returns whether anything was actually deleted. */
91
+ export function unsetAt(obj, path) {
92
+ if (path.length === 0)
93
+ return false;
94
+ // Refuse forbidden segments at delete time too — without this, a
95
+ // pre-poisoned config object's `__proto__` segment would let callers
96
+ // walk into the prototype chain and `delete` properties off Object.prototype.
97
+ if (path.some((seg) => isForbidden(seg)))
98
+ return false;
99
+ const chain = [];
100
+ let cur = obj;
101
+ for (let i = 0; i < path.length - 1; i++) {
102
+ const seg = path[i];
103
+ if (!Object.prototype.hasOwnProperty.call(cur, seg))
104
+ return false;
105
+ const next = cur[seg];
106
+ if (next == null || typeof next !== 'object' || Array.isArray(next))
107
+ return false;
108
+ chain.push({ parent: cur, key: seg });
109
+ cur = next;
110
+ }
111
+ const leaf = path[path.length - 1];
112
+ if (!Object.prototype.hasOwnProperty.call(cur, leaf))
113
+ return false;
114
+ delete cur[leaf];
115
+ for (let i = chain.length - 1; i >= 0; i--) {
116
+ const { parent, key } = chain[i];
117
+ const child = parent[key];
118
+ if (child != null && typeof child === 'object' && !Array.isArray(child)) {
119
+ if (Object.keys(child).length === 0)
120
+ delete parent[key];
121
+ else
122
+ break;
123
+ }
124
+ }
125
+ return true;
126
+ }
127
+ /** Flatten a nested object into dot-separated keys. Arrays are not descended into. */
128
+ export function flatten(obj, prefix = [], out = []) {
129
+ // Object.entries is "own enumerable string-keyed" — already excludes
130
+ // properties inherited via the prototype chain, so a poisoned global
131
+ // Object.prototype won't leak into list output here.
132
+ for (const [k, v] of Object.entries(obj)) {
133
+ if (isForbidden(k))
134
+ continue; // belt-and-suspenders
135
+ const p = [...prefix, k];
136
+ if (v != null && typeof v === 'object' && !Array.isArray(v)) {
137
+ flatten(v, p, out);
138
+ }
139
+ else {
140
+ out.push([p.join('.'), v]);
141
+ }
142
+ }
143
+ return out;
144
+ }
145
+ // ═══════════════════════════════════════════════════════════════
146
+ // KvStore class
147
+ // ═══════════════════════════════════════════════════════════════
148
+ export class KvStore {
149
+ storage;
150
+ schema;
151
+ strict;
152
+ constructor(storage, schema = {}, options = {}) {
153
+ this.storage = storage;
154
+ this.schema = { ...schema };
155
+ this.strict = options.strict === true;
156
+ }
157
+ /** Scope tag for this KV (from the underlying storage). */
158
+ get scope() {
159
+ return this.storage.scope;
160
+ }
161
+ /** Origin string for this KV (file path, etc.), shown by --show-origin. */
162
+ get origin() {
163
+ return this.storage.origin;
164
+ }
165
+ /** Whether the underlying storage is available. */
166
+ async available() {
167
+ return this.storage.available();
168
+ }
169
+ // ── Schema registration ─────────────────────────────────
170
+ /** Register the schema for a single key. Re-registering overrides. */
171
+ registerSchema(key, entry) {
172
+ this.schema[key] = entry;
173
+ }
174
+ /** Bulk-extend the schema. Later registrations override earlier ones for the same key. */
175
+ extendSchema(schema) {
176
+ for (const [k, v] of Object.entries(schema)) {
177
+ this.schema[k] = v;
178
+ }
179
+ }
180
+ /** Return the schema entry for a key (can be used to look up default values, scope hints, etc.). */
181
+ getSchema(key) {
182
+ return this.schema[key];
183
+ }
184
+ /** List all keys known to the schema. */
185
+ knownKeys() {
186
+ return Object.keys(this.schema);
187
+ }
188
+ // ── CRUD ─────────────────────────────────────────────────
189
+ /**
190
+ * Read the value for a key.
191
+ *
192
+ * Order:
193
+ * 1. storage unavailable → fall back to schema default
194
+ * 2. underlying storage has a value → return it
195
+ * 3. schema has a default → return default
196
+ * 4. neither → undefined
197
+ */
198
+ async get(rawKey) {
199
+ const key = parseKey(rawKey);
200
+ if (!(await this.storage.available())) {
201
+ return this.schema[rawKey]?.default;
202
+ }
203
+ const data = await this.storage.read();
204
+ const v = getAt(data, key.path);
205
+ if (v !== undefined)
206
+ return v;
207
+ return this.schema[rawKey]?.default;
208
+ }
209
+ /**
210
+ * Write the value for a key.
211
+ *
212
+ * Order:
213
+ * 1. Parse the key
214
+ * 2. In strict mode, key not in schema → throw
215
+ * 3. Schema has a validator → validate; throw on failure
216
+ * 4. Storage unavailable → throw
217
+ * 5. Read-modify-write
218
+ */
219
+ async set(rawKey, value) {
220
+ const key = parseKey(rawKey);
221
+ const entry = this.schema[rawKey];
222
+ if (this.strict && !entry) {
223
+ const known = Object.keys(this.schema).join(', ') || '(none)';
224
+ throw new KvError('err.kv.unknown.key', { key: rawKey, known }, `unknown key '${rawKey}'. Known: ${known}`);
225
+ }
226
+ if (entry?.validator) {
227
+ const err = entry.validator(value);
228
+ if (err) {
229
+ // Inject the key into vars. Spread validator's vars first, then overwrite `key`,
230
+ // so that even if a validator author misuses `key` as a variable name they
231
+ // can't override KvStore's rawKey.
232
+ throw new KvError(err.code, { ...(err.vars ?? {}), key: rawKey }, `invalid value for '${rawKey}': ${err.fallback}`);
233
+ }
234
+ }
235
+ if (!(await this.storage.available())) {
236
+ throw new KvError('err.kv.storage.unavailable', { scope: this.storage.scope, origin: this.storage.origin }, `storage unavailable: ${this.storage.origin}`);
237
+ }
238
+ const data = await this.storage.read();
239
+ setAt(data, key.path, value);
240
+ await this.storage.write(data);
241
+ }
242
+ /**
243
+ * Delete a key. Returns whether anything was actually deleted (false if the key didn't exist).
244
+ */
245
+ async delete(rawKey) {
246
+ const key = parseKey(rawKey);
247
+ if (!(await this.storage.available()))
248
+ return false;
249
+ const data = await this.storage.read();
250
+ const removed = unsetAt(data, key.path);
251
+ if (removed)
252
+ await this.storage.write(data);
253
+ return removed;
254
+ }
255
+ /**
256
+ * List all keys that have been set (flattened to dot-separated keys).
257
+ *
258
+ * Does not include keys that have a default in schema but were never actually written.
259
+ */
260
+ async list() {
261
+ if (!(await this.storage.available()))
262
+ return [];
263
+ const data = await this.storage.read();
264
+ const entries = flatten(data);
265
+ entries.sort((a, b) => a[0].localeCompare(b[0]));
266
+ return entries;
267
+ }
268
+ /** Check whether a key actually exists in storage (ignoring defaults). */
269
+ async has(rawKey) {
270
+ const key = parseKey(rawKey);
271
+ if (!(await this.storage.available()))
272
+ return false;
273
+ const data = await this.storage.read();
274
+ return getAt(data, key.path) !== undefined;
275
+ }
276
+ }
@@ -0,0 +1,61 @@
1
+ import { KvError } from '../types.js';
2
+ function getLocalStorage() {
3
+ try {
4
+ const ls = globalThis.localStorage;
5
+ return ls ?? null;
6
+ }
7
+ catch {
8
+ // Some browsers throw on accessing localStorage in private mode
9
+ return null;
10
+ }
11
+ }
12
+ export class BrowserStorage {
13
+ scope;
14
+ storageKey;
15
+ constructor(scope, storageKey = `qtype.config.${scope}`) {
16
+ this.scope = scope;
17
+ this.storageKey = storageKey;
18
+ }
19
+ get origin() {
20
+ return `localStorage:${this.storageKey}`;
21
+ }
22
+ async available() {
23
+ return getLocalStorage() !== null;
24
+ }
25
+ async read() {
26
+ const ls = getLocalStorage();
27
+ if (!ls)
28
+ return {};
29
+ const raw = ls.getItem(this.storageKey);
30
+ if (!raw)
31
+ return {};
32
+ let data;
33
+ try {
34
+ data = JSON.parse(raw);
35
+ }
36
+ catch (e) {
37
+ throw new KvError('err.kv.storage.parseFailed', { origin: this.origin, reason: e.message }, `${this.origin}: JSON parse error — ${e.message}`);
38
+ }
39
+ if (data == null)
40
+ return {}; // treat literal null as empty, aligned with JsonStorage
41
+ if (typeof data !== 'object' || Array.isArray(data)) {
42
+ throw new KvError('err.kv.storage.parseFailed', {
43
+ origin: this.origin,
44
+ reason: `root must be a JSON object, got ${Array.isArray(data) ? 'array' : typeof data}`,
45
+ }, `${this.origin}: root must be a JSON object`);
46
+ }
47
+ return data;
48
+ }
49
+ async write(data) {
50
+ const ls = getLocalStorage();
51
+ if (!ls) {
52
+ throw new KvError('err.kv.storage.unavailable', { scope: this.scope, origin: this.origin }, `${this.origin}: localStorage not available`);
53
+ }
54
+ try {
55
+ ls.setItem(this.storageKey, JSON.stringify(data));
56
+ }
57
+ catch (e) {
58
+ throw new KvError('err.kv.storage.writeFailed', { origin: this.origin, reason: e.message }, `${this.origin}: write failed — ${e.message}`);
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * JSON file storage.
3
+ *
4
+ * Read: exists → parse JSON; missing → return {}; parse failure → throw `KvError`.
5
+ * Write: atomic (write .tmp then rename), auto-creates parent directory;
6
+ * IO failure → throw `KvError`. Keys are recursively sorted for
7
+ * diff-friendly output.
8
+ *
9
+ * ## About `available()`
10
+ *
11
+ * Always available by default (write will `mkdir -p` the parent directory).
12
+ * Business judgments like "are we in a repo" are decided by the caller —
13
+ * just pass a `guard` function to the constructor:
14
+ *
15
+ * ```ts
16
+ * new JsonStorage('repo', path, { guard: () => fs.existsSync(repoRoot) });
17
+ * ```
18
+ *
19
+ * Without `guard`, it is always available.
20
+ */
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { KvError } from '../types.js';
24
+ export class JsonStorage {
25
+ scope;
26
+ filePath;
27
+ guard;
28
+ constructor(scope, filePath, options = {}) {
29
+ this.scope = scope;
30
+ this.filePath = filePath;
31
+ this.guard = options.guard;
32
+ }
33
+ get origin() {
34
+ return this.filePath;
35
+ }
36
+ async available() {
37
+ if (!this.guard)
38
+ return true;
39
+ try {
40
+ return this.guard();
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ async read() {
47
+ if (!fs.existsSync(this.filePath))
48
+ return {};
49
+ const text = fs.readFileSync(this.filePath, 'utf8').trim();
50
+ if (!text)
51
+ return {};
52
+ let data;
53
+ try {
54
+ data = JSON.parse(text);
55
+ }
56
+ catch (e) {
57
+ throw new KvError('err.kv.storage.parseFailed', { origin: this.filePath, reason: e.message }, `${this.filePath}: JSON parse error — ${e.message}`);
58
+ }
59
+ if (data == null)
60
+ return {};
61
+ if (typeof data !== 'object' || Array.isArray(data)) {
62
+ throw new KvError('err.kv.storage.parseFailed', { origin: this.filePath, reason: `root must be a JSON object, got ${typeof data}` }, `${this.filePath}: root must be a JSON object, got ${typeof data}`);
63
+ }
64
+ return data;
65
+ }
66
+ async write(data) {
67
+ const sorted = sortKeys(data);
68
+ const text = JSON.stringify(sorted, null, 2) + '\n';
69
+ // Per-process unique tmp name. Without this, two qtype processes
70
+ // writing the same file concurrently (e.g. a CLI run overlapping
71
+ // with another long-running qtype process) would both target
72
+ // `<file>.tmp` — the later writer's rename clobbers the earlier
73
+ // .tmp midway, so the earlier writer either errors out or silently
74
+ // produces a corrupt file. The pid + intra-process counter
75
+ // combination keeps the rename's atomic last-writer-wins semantics
76
+ // on the *final* file
77
+ // but eliminates the tmp-collision corruption.
78
+ const tmp = `${this.filePath}.${process.pid}.${nextWriteSeq()}.tmp`;
79
+ try {
80
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
81
+ fs.writeFileSync(tmp, text, 'utf8');
82
+ fs.renameSync(tmp, this.filePath);
83
+ }
84
+ catch (e) {
85
+ // Best-effort cleanup of .tmp (it may not have been created)
86
+ try {
87
+ if (fs.existsSync(tmp))
88
+ fs.unlinkSync(tmp);
89
+ }
90
+ catch {
91
+ /* ignore */
92
+ }
93
+ throw new KvError('err.kv.storage.writeFailed', { origin: this.filePath, reason: e.message }, `${this.filePath}: write failed — ${e.message}`);
94
+ }
95
+ }
96
+ }
97
+ /** Per-process counter so concurrent writes within the same process also get
98
+ * distinct tmp names. */
99
+ let writeSeq = 0;
100
+ function nextWriteSeq() {
101
+ return ++writeSeq;
102
+ }
103
+ /** Recursively sort by key for stable output (diff-friendly). */
104
+ function sortKeys(v) {
105
+ if (v == null || typeof v !== 'object' || Array.isArray(v))
106
+ return v;
107
+ const obj = v;
108
+ const out = {};
109
+ for (const k of Object.keys(obj).sort()) {
110
+ out[k] = sortKeys(obj[k]);
111
+ }
112
+ return out;
113
+ }
@@ -0,0 +1,24 @@
1
+ export class MemoryStorage {
2
+ scope;
3
+ origin;
4
+ _available;
5
+ data = {};
6
+ constructor(scope, options = {}) {
7
+ this.scope = scope;
8
+ this.origin = options.origin ?? `memory:${scope}`;
9
+ this._available = options.available ?? true;
10
+ }
11
+ /** Test helper: toggle availability at runtime. */
12
+ setAvailable(v) {
13
+ this._available = v;
14
+ }
15
+ async available() {
16
+ return this._available;
17
+ }
18
+ async read() {
19
+ return structuredClone(this.data);
20
+ }
21
+ async write(data) {
22
+ this.data = structuredClone(data);
23
+ }
24
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Type definitions for the KV storage layer.
3
+ *
4
+ * ## Two-layer architecture
5
+ *
6
+ * ```
7
+ * KvStore ← business API (get/set/delete/list, optional schema)
8
+ * ↓ depends on
9
+ * Storage ← medium abstraction (read entire blob / write entire blob)
10
+ * ↓ implemented by
11
+ * JsonStorage / BrowserStorage / MemoryStorage
12
+ * ```
13
+ *
14
+ * KvStore doesn't care where data is stored or how it's serialized; Storage
15
+ * doesn't care how the data is used. Decoupled via the Storage interface.
16
+ */
17
+ // ═══════════════════════════════════════════════════════════════
18
+ // Error types
19
+ // ═══════════════════════════════════════════════════════════════
20
+ /**
21
+ * Structured error thrown by the KV layer.
22
+ *
23
+ * Carries `code` + `vars`: the CLI's `wrapAction` catches it and runs i18n
24
+ * translation; other hosts (tests / app) just display `message` (the English
25
+ * fallback).
26
+ *
27
+ * ## Defined codes
28
+ *
29
+ * - `err.kv.key.invalid` — invalid key format (empty / pure dots / ...)
30
+ * - `err.kv.unknown.key` — key not in schema under strict mode
31
+ * - `err.kv.storage.unavailable` — storage.available() === false
32
+ * - `err.kv.storage.parseFailed` — failed to parse the blob in storage (JSON corruption, etc.)
33
+ * - `err.kv.storage.writeFailed` — failed to write storage (localStorage quota exceeded, etc.)
34
+ *
35
+ * Validator-thrown errors keep their own `code` (typically `err.schema.*`);
36
+ * KvStore does not wrap them, keeping the message context direct.
37
+ */
38
+ export class KvError extends Error {
39
+ code;
40
+ vars;
41
+ constructor(code, vars, fallback) {
42
+ super(fallback);
43
+ this.name = 'KvError';
44
+ this.code = code;
45
+ this.vars = vars;
46
+ }
47
+ }
@@ -0,0 +1,71 @@
1
+ /** Value must be one of the specified enum strings. */
2
+ export function enumValidator(allowed) {
3
+ return (v) => {
4
+ if (typeof v === 'string' && allowed.includes(v)) {
5
+ return null;
6
+ }
7
+ return {
8
+ code: 'err.schema.enum',
9
+ vars: { allowed: allowed.map((x) => `'${x}'`).join(' | ') },
10
+ fallback: `must be one of: ${allowed.join(' | ')}`,
11
+ };
12
+ };
13
+ }
14
+ /** Value must be a non-empty string. */
15
+ export function nonEmptyStringValidator() {
16
+ return (v) => {
17
+ if (typeof v === 'string' && v.length > 0)
18
+ return null;
19
+ return {
20
+ code: 'err.schema.nonEmptyString',
21
+ fallback: 'must be a non-empty string',
22
+ };
23
+ };
24
+ }
25
+ /** Value must be a boolean. */
26
+ export function boolValidator() {
27
+ return (v) => {
28
+ if (typeof v === 'boolean')
29
+ return null;
30
+ return {
31
+ code: 'err.schema.bool',
32
+ fallback: 'must be true or false',
33
+ };
34
+ };
35
+ }
36
+ /**
37
+ * Value must be a positive integer (also accepts numeric string form, so the
38
+ * CLI can pass things like `--port 4000`).
39
+ */
40
+ export function positiveIntValidator() {
41
+ return (v) => {
42
+ if (typeof v === 'number' && Number.isInteger(v) && v > 0)
43
+ return null;
44
+ if (typeof v === 'string' && /^\d+$/.test(v) && Number(v) > 0)
45
+ return null;
46
+ return {
47
+ code: 'err.schema.positiveInt',
48
+ fallback: 'must be a positive integer',
49
+ };
50
+ };
51
+ }
52
+ /** Value must be a string starting with `/` (path base). */
53
+ export function absolutePathValidator() {
54
+ return (v) => {
55
+ if (typeof v === 'string' && v.startsWith('/'))
56
+ return null;
57
+ return {
58
+ code: 'err.schema.absolutePath',
59
+ fallback: "must be a path starting with '/'",
60
+ };
61
+ };
62
+ }
63
+ /**
64
+ * Custom validator: pass a predicate + i18n error key.
65
+ *
66
+ * Fallback for when the built-in helpers aren't enough; business code should
67
+ * generally prefer the specific functions above.
68
+ */
69
+ export function predicateValidator(predicate, error) {
70
+ return (v) => (predicate(v) ? null : error);
71
+ }