@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.
- package/CHANGELOG.md +97 -0
- package/CHANGELOG.zh.md +82 -0
- package/LICENSE +201 -0
- package/README.md +113 -0
- package/README.zh.md +114 -0
- package/dist/cli/bootstrap.js +190 -0
- package/dist/cli/commands/check/action.js +56 -0
- package/dist/cli/commands/check/i18n.js +26 -0
- package/dist/cli/commands/check/index.js +20 -0
- package/dist/cli/commands/config/action.js +236 -0
- package/dist/cli/commands/config/i18n.js +64 -0
- package/dist/cli/commands/config/index.js +72 -0
- package/dist/cli/commands/content/index.js +22 -0
- package/dist/cli/commands/content/lint/action.js +102 -0
- package/dist/cli/commands/content/lint/i18n.js +32 -0
- package/dist/cli/commands/content/lint/index.js +19 -0
- package/dist/cli/commands/content/search/action.js +64 -0
- package/dist/cli/commands/content/search/i18n.js +20 -0
- package/dist/cli/commands/content/search/index.js +24 -0
- package/dist/cli/commands/content/validate/action.js +145 -0
- package/dist/cli/commands/content/validate/i18n.js +32 -0
- package/dist/cli/commands/content/validate/index.js +19 -0
- package/dist/cli/commands/index.js +19 -0
- package/dist/cli/commands/init/index.js +53 -0
- package/dist/cli/commands/pack/i18n.js +20 -0
- package/dist/cli/commands/pack/index.js +48 -0
- package/dist/cli/commands/plugin/i18n.js +28 -0
- package/dist/cli/commands/plugin/index.js +41 -0
- package/dist/cli/commands/publish/build/action.js +208 -0
- package/dist/cli/commands/publish/build/i18n.js +50 -0
- package/dist/cli/commands/publish/build/index.js +24 -0
- package/dist/cli/commands/publish/build/mermaidPass.js +93 -0
- package/dist/cli/commands/publish/build/mermaidPrerender.js +199 -0
- package/dist/cli/commands/publish/index.js +21 -0
- package/dist/cli/commands/publish/init/action.js +74 -0
- package/dist/cli/commands/publish/init/i18n.js +22 -0
- package/dist/cli/commands/publish/init/index.js +26 -0
- package/dist/cli/commands/publish/password/action.js +158 -0
- package/dist/cli/commands/publish/password/i18n.js +44 -0
- package/dist/cli/commands/publish/password/index.js +35 -0
- package/dist/cli/commands/publish/serve/action.js +117 -0
- package/dist/cli/commands/publish/serve/i18n.js +32 -0
- package/dist/cli/commands/publish/serve/index.js +37 -0
- package/dist/cli/commands/repo/index.js +19 -0
- package/dist/cli/commands/repo/new/action.js +98 -0
- package/dist/cli/commands/repo/new/i18n.js +46 -0
- package/dist/cli/commands/repo/new/index.js +57 -0
- package/dist/cli/commands/repo/sync/action.js +202 -0
- package/dist/cli/commands/repo/sync/i18n.js +44 -0
- package/dist/cli/commands/repo/sync/index.js +47 -0
- package/dist/cli/commands/repo/templates.js +87 -0
- package/dist/cli/i18n/index.js +141 -0
- package/dist/cli/i18n/shared.js +142 -0
- package/dist/cli/index.js +79 -0
- package/dist/cli/registry/command.js +1 -0
- package/dist/cli/registry/commander-i18n.js +76 -0
- package/dist/cli/registry/help.js +142 -0
- package/dist/cli/registry/mount.js +71 -0
- package/dist/cli/utils/output.js +92 -0
- package/dist/cli/utils/paths.js +13 -0
- package/dist/cli/utils/prompt.js +72 -0
- package/dist/cli/utils/repoGuard.js +41 -0
- package/dist/core/encrypt.js +113 -0
- package/dist/core/index.js +8 -0
- package/dist/core/keystroke/asciiMap.js +39 -0
- package/dist/core/keystroke/chineseMap.js +32 -0
- package/dist/core/keystroke/fullwidthMap.js +41 -0
- package/dist/core/keystroke/index.js +22 -0
- package/dist/core/keystroke/textNormalize.js +68 -0
- package/dist/core/keystroke/types.js +1 -0
- package/dist/core/parser/frontmatter.js +58 -0
- package/dist/core/parser/index.js +2 -0
- package/dist/core/parser/parseMaterial.js +42 -0
- package/dist/core/types.js +1 -0
- package/dist/core/typing/engine.js +112 -0
- package/dist/core/typing/index.js +2 -0
- package/dist/core/typing/metrics.js +20 -0
- package/dist/store/index.js +44 -0
- package/dist/store/kv.js +276 -0
- package/dist/store/storage/browser.js +61 -0
- package/dist/store/storage/json.js +113 -0
- package/dist/store/storage/memory.js +24 -0
- package/dist/store/types.js +47 -0
- package/dist/store/validators.js +71 -0
- package/dist/view/assets/index-DBABH3dG.js +111 -0
- package/dist/view/assets/index-RTLZ-zOC.css +1 -0
- package/dist/view/index.html +14 -0
- package/dist/view/qtype-logo.svg +49 -0
- package/dist/view/qtype-mark.svg +21 -0
- package/docs/README.md +23 -0
- package/docs/README.zh.md +20 -0
- package/docs/en/authoring.md +138 -0
- package/docs/en/cli-reference.md +171 -0
- package/docs/zh/authoring.md +131 -0
- package/docs/zh/cli-reference.md +162 -0
- package/package.json +85 -0
- package/templates/en/QTYPE.md +618 -0
- package/templates/en/README.md +64 -0
- package/templates/en/TODO.md +7 -0
- package/templates/en/index.md +7 -0
- package/templates/en/log.md +3 -0
- package/templates/en/material/.gitkeep +0 -0
- package/templates/en/material/example.md +47 -0
- package/templates/zh/QTYPE.md +618 -0
- package/templates/zh/README.md +64 -0
- package/templates/zh/TODO.md +7 -0
- package/templates/zh/index.md +7 -0
- package/templates/zh/log.md +3 -0
- package/templates/zh/material/.gitkeep +0 -0
- 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';
|
package/dist/store/kv.js
ADDED
|
@@ -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
|
+
}
|