dreamteamer 0.6.4 → 0.7.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 +123 -51
- package/package.json +62 -58
- package/skills/building-dreamteamer/references/collections.md +32 -2
- package/skills/using-dreamteamer/references/records.md +17 -0
- package/src/check.js +12 -6
- package/src/cli.js +7 -1
- package/src/collections-cli.js +28 -2
- package/src/compile.js +61 -3
- package/src/harnesses.js +29 -8
- package/src/namespace.js +181 -0
- package/src/runtime.js +29 -3
- package/src/schema-ops.js +260 -11
- package/src/server.js +9 -0
- package/src/store.js +56 -20
package/src/schema-ops.js
CHANGED
|
@@ -10,6 +10,12 @@ import { execFileSync } from 'node:child_process';
|
|
|
10
10
|
import { load, dump } from './yaml.js';
|
|
11
11
|
import { compile, kindDir, titleCase } from './compile.js';
|
|
12
12
|
import { readManifest, runtimeKindDir } from './runtime.js';
|
|
13
|
+
import { normalizeNamespaces, namespaceOf, baseNameOf, qualify, defaultStoragePath } from './namespace.js';
|
|
14
|
+
|
|
15
|
+
// Same rule as store.js: a git failure we CATCH must not also print git's own error on top of the
|
|
16
|
+
// clean message we throw. stdout stays piped because some callers read it.
|
|
17
|
+
const GIT_QUIET = ['ignore', 'pipe', 'ignore'];
|
|
18
|
+
import { walk, EXT } from './records.js';
|
|
13
19
|
|
|
14
20
|
// ---- the gate -------------------------------------------------------------------
|
|
15
21
|
|
|
@@ -39,10 +45,10 @@ function writeGated(ws, store, files, subject, mutate) {
|
|
|
39
45
|
// to record directories, so a deferred source edit would be publishable by nothing.
|
|
40
46
|
// Extending `dt commit` to module sources is the natural follow-on; it is not this wave.
|
|
41
47
|
try {
|
|
42
|
-
execFileSync('git', ['add', '--', ...rels], { cwd: ws.root });
|
|
43
|
-
execFileSync('git', ['commit', '--quiet', '-m', subject, '--', ...rels], { cwd: ws.root });
|
|
48
|
+
execFileSync('git', ['add', '--', ...rels], { cwd: ws.root, stdio: GIT_QUIET });
|
|
49
|
+
execFileSync('git', ['commit', '--quiet', '-m', subject, '--', ...rels], { cwd: ws.root, stdio: GIT_QUIET });
|
|
44
50
|
} catch (e) {
|
|
45
|
-
try { execFileSync('git', ['reset', '--quiet', '--', ...rels], { cwd: ws.root }); } catch { /* nothing staged */ }
|
|
51
|
+
try { execFileSync('git', ['reset', '--quiet', '--', ...rels], { cwd: ws.root, stdio: GIT_QUIET }); } catch { /* nothing staged */ }
|
|
46
52
|
restore();
|
|
47
53
|
try { compile(ws); } catch { /* pre-op sources were compilable */ }
|
|
48
54
|
throw new Error(`git commit failed — the schema change was rolled back, nothing was changed. (${e.message.split('\n')[0]})`);
|
|
@@ -60,29 +66,46 @@ export function workspaceSystemDir(ws, kind) {
|
|
|
60
66
|
|
|
61
67
|
// ---- ops ------------------------------------------------------------------------
|
|
62
68
|
|
|
63
|
-
export function createCollection(ws, store, { name, template }) {
|
|
69
|
+
export function createCollection(ws, store, { name, template, namespace }) {
|
|
64
70
|
if (!name) throw new Error('missing collection name');
|
|
65
|
-
|
|
66
|
-
|
|
71
|
+
// `--namespace health --name doctors` and `--name health/doctors` are the SAME collection, because
|
|
72
|
+
// the qualified name IS the identity everywhere else in the engine. Accepting both keeps the CLI
|
|
73
|
+
// honest about that rather than making the operator learn which spelling a verb wants.
|
|
74
|
+
const declared = normalizeNamespaces(ws.pkg.dreamteamer?.namespaces);
|
|
75
|
+
const qualified = namespace ? qualify(namespace, name) : name;
|
|
76
|
+
const ns = namespaceOf(qualified, declared);
|
|
77
|
+
if (qualified.includes('/') && !ns) {
|
|
78
|
+
throw new Error(`namespace "${qualified.slice(0, qualified.lastIndexOf('/'))}" is not declared — add it to dreamteamer.namespaces in package.json first, or the collection will not compile.`);
|
|
79
|
+
}
|
|
80
|
+
if (store.descriptors.has(qualified)) throw new Error(`collection "${qualified}" already exists`);
|
|
81
|
+
// NESTED, mirroring where compile puts it in the runtime: `collections/health/doctors.collection.yaml`.
|
|
82
|
+
// compile enumerates this kind recursively for exactly this reason — and `upsertField` derives the
|
|
83
|
+
// same path from the same name, which is what keeps a later `add-field` editing the base descriptor
|
|
84
|
+
// instead of quietly creating an overlay beside it.
|
|
85
|
+
const dest = path.join(workspaceSystemDir(ws, 'collections'), `${qualified}.collection.yaml`);
|
|
67
86
|
if (fs.existsSync(dest)) throw new Error(`${path.relative(ws.root, dest)} already exists`);
|
|
68
87
|
|
|
69
|
-
let descriptor = { name };
|
|
88
|
+
let descriptor = { name: qualified };
|
|
70
89
|
if (template) {
|
|
71
90
|
const tplFile = path.join(runtimeKindDir(ws.root, 'collection-templates'), `${template}.collection-template.yaml`);
|
|
72
91
|
if (!fs.existsSync(tplFile)) throw new Error(`unknown collection-template "${template}"`);
|
|
73
|
-
descriptor = { name, ...structuredClone(load(fs.readFileSync(tplFile, 'utf8')).template) };
|
|
92
|
+
descriptor = { name: qualified, ...structuredClone(load(fs.readFileSync(tplFile, 'utf8')).template) };
|
|
74
93
|
} else {
|
|
75
94
|
// templateless: MINIMAL but compilable — grow it with add-field
|
|
76
95
|
descriptor.id = { generate: '{{ name | slug }}' };
|
|
77
96
|
descriptor.schema = { type: 'object', required: ['name'], properties: { name: { type: 'string' } } };
|
|
78
97
|
}
|
|
79
98
|
descriptor.storage = {
|
|
80
|
-
|
|
99
|
+
// AUTHORED even though compile would derive the same value, because a descriptor a human opens
|
|
100
|
+
// should say where its records live without them having to know the derivation rule.
|
|
101
|
+
path: defaultStoragePath(qualified, declared, ws.pkg.dreamteamer?.['data-path'] ?? 'data'),
|
|
81
102
|
codec: 'md', shape: 'file',
|
|
82
103
|
...descriptor.storage,
|
|
83
|
-
|
|
104
|
+
// the SUFFIX comes off the bare name — `health/doctors` records are `<id>.doctor.md`, not
|
|
105
|
+
// `<id>.health/doctor.md`
|
|
106
|
+
suffix: descriptor.storage?.suffix ?? singular(baseNameOf(qualified, declared)),
|
|
84
107
|
};
|
|
85
|
-
writeGated(ws, store, [dest], `dreamteamer: collections add ${
|
|
108
|
+
writeGated(ws, store, [dest], `dreamteamer: collections add ${qualified}`, () => {
|
|
86
109
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
87
110
|
fs.writeFileSync(dest, dump(descriptor));
|
|
88
111
|
});
|
|
@@ -100,6 +123,232 @@ export function removeCollection(ws, store, name, { force = false } = {}) {
|
|
|
100
123
|
return { removed: name };
|
|
101
124
|
}
|
|
102
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Rename a collection — descriptor, records, and every inbound reference, in ONE commit.
|
|
128
|
+
*
|
|
129
|
+
* This exists because namespacing EXISTING data was otherwise a hand migration: `git mv` the
|
|
130
|
+
* descriptor, edit `name` and `storage.path`, `git mv` the record folder, re-suffix every file, then
|
|
131
|
+
* find and rewrite every reference — six steps with no gate, where forgetting the last one dangles
|
|
132
|
+
* every link silently. `dt collections rename doctors health/doctors` is the whole thing.
|
|
133
|
+
*
|
|
134
|
+
* DERIVED-VS-AUTHORED is the rule for both moving parts, the same rule `createCollection` uses:
|
|
135
|
+
* - `storage.path` moves only if it was DERIVED (equal to the default for the old name). An authored
|
|
136
|
+
* path is a deliberate choice about where records live and a rename must not overrule it.
|
|
137
|
+
* - `storage.suffix` is re-derived only if it was DERIVED (the singular of the old base name), because
|
|
138
|
+
* otherwise the filenames would start lying about what they hold. `doctors` → `health/doctors` keeps
|
|
139
|
+
* the base name, so nothing is re-suffixed — which is the common case and the cheap one.
|
|
140
|
+
*
|
|
141
|
+
* References are rewritten by asking the STORE to do it, once per record id, rather than by matching
|
|
142
|
+
* the collection prefix with a new regex. `store.rewriteRefs` already knows the boundary rules and
|
|
143
|
+
* already scopes prose to `[[wikilinks]]` (decision 7) — a fresh `oldName/` pattern would have to
|
|
144
|
+
* relearn both, and would corrupt `data/tasks/` in a path or a URL on its first outing. N passes over
|
|
145
|
+
* the record files is the price, and at human scale it is worth paying for reusing the correct code.
|
|
146
|
+
*/
|
|
147
|
+
export function renameCollection(ws, store, oldName, newName) {
|
|
148
|
+
const d = store.descriptor(oldName); // throws with the known-collection list if absent
|
|
149
|
+
if (!newName) throw new Error('missing new collection name');
|
|
150
|
+
if (oldName === newName) return { renamed: false, name: newName };
|
|
151
|
+
|
|
152
|
+
const declared = normalizeNamespaces(ws.pkg.dreamteamer?.namespaces);
|
|
153
|
+
if (newName.includes('/') && !namespaceOf(newName, declared)) {
|
|
154
|
+
throw new Error(`namespace "${newName.slice(0, newName.lastIndexOf('/'))}" is not declared — add it to dreamteamer.namespaces in package.json first.`);
|
|
155
|
+
}
|
|
156
|
+
if (store.descriptors.has(newName)) throw new Error(`collection "${newName}" already exists`);
|
|
157
|
+
if (d.storage.base === 'runtime') throw new Error(`"${oldName}" is a compiled source, not a data collection — it cannot be renamed`);
|
|
158
|
+
|
|
159
|
+
const src = path.join(workspaceSystemDir(ws, 'collections'), `${oldName}.collection.yaml`);
|
|
160
|
+
const dest = path.join(workspaceSystemDir(ws, 'collections'), `${newName}.collection.yaml`);
|
|
161
|
+
if (!fs.existsSync(src)) {
|
|
162
|
+
throw new Error(`"${oldName}" is not workspace-owned — it ships with a module, so rename it there (or overlay it with \`extends\`)`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const doc = load(fs.readFileSync(src, 'utf8'));
|
|
166
|
+
const dataPath = ws.pkg.dreamteamer?.['data-path'] ?? 'data';
|
|
167
|
+
// `d` is the COMPILED descriptor, so its storage.path already carries any module prefix; the
|
|
168
|
+
// authored source is what we compare against, and what we rewrite.
|
|
169
|
+
const authoredPath = String(doc.storage?.path ?? '');
|
|
170
|
+
const pathWasDerived = authoredPath === '' || authoredPath === defaultStoragePath(oldName, declared, dataPath);
|
|
171
|
+
const newPath = pathWasDerived ? defaultStoragePath(newName, declared, dataPath) : authoredPath;
|
|
172
|
+
|
|
173
|
+
const oldBase = baseNameOf(oldName, declared);
|
|
174
|
+
const newBase = baseNameOf(newName, declared);
|
|
175
|
+
const oldSuffix = d.storage.suffix;
|
|
176
|
+
const suffixWasDerived = oldSuffix === singular(oldBase);
|
|
177
|
+
const newSuffix = suffixWasDerived ? singular(newBase) : oldSuffix;
|
|
178
|
+
|
|
179
|
+
// Every id BEFORE anything moves — the store's index is keyed on the old collection.
|
|
180
|
+
const ids = [...store.ids(oldName).keys()];
|
|
181
|
+
const oldDir = store.dir(d);
|
|
182
|
+
const newDir = path.join(ws.root, newPath);
|
|
183
|
+
if (newDir !== oldDir && fs.existsSync(newDir)) {
|
|
184
|
+
throw new Error(`${newPath} already exists on disk — move or remove it first; nothing was renamed`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---- rollback state, captured before the first mutation --------------------------------------
|
|
188
|
+
const srcBytes = fs.readFileSync(src);
|
|
189
|
+
let movedData = false;
|
|
190
|
+
let resuffixed = [];
|
|
191
|
+
const undo = () => {
|
|
192
|
+
for (const [from, to] of resuffixed) { if (fs.existsSync(to)) fs.renameSync(to, from); }
|
|
193
|
+
if (movedData && fs.existsSync(newDir)) {
|
|
194
|
+
fs.mkdirSync(path.dirname(oldDir), { recursive: true });
|
|
195
|
+
fs.renameSync(newDir, oldDir);
|
|
196
|
+
pruneEmpty(path.dirname(newDir), path.join(ws.root, dataPath));
|
|
197
|
+
}
|
|
198
|
+
fs.mkdirSync(path.dirname(src), { recursive: true });
|
|
199
|
+
fs.writeFileSync(src, srcBytes);
|
|
200
|
+
if (dest !== src) fs.rmSync(dest, { force: true });
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
return store.withWriteLock(() => {
|
|
204
|
+
// referencing files are snapshotted by the store's own helper via rewriteRefs' touched list, so
|
|
205
|
+
// they are captured here the same way `store.rename` does it: read before, restore on failure.
|
|
206
|
+
const refFiles = new Map();
|
|
207
|
+
const captureRefs = (ref) => {
|
|
208
|
+
for (const f of store.findInboundRefs(ref)) {
|
|
209
|
+
const abs = path.join(ws.root, f);
|
|
210
|
+
if (!refFiles.has(abs)) refFiles.set(abs, fs.readFileSync(abs));
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
for (const id of ids) captureRefs(`${oldName}/${id}`);
|
|
214
|
+
captureRefs(`collections/${oldName}`);
|
|
215
|
+
const restoreRefs = () => { for (const [f, bytes] of refFiles) fs.writeFileSync(f, bytes); };
|
|
216
|
+
|
|
217
|
+
const touched = new Set();
|
|
218
|
+
let rewrites = 0;
|
|
219
|
+
try {
|
|
220
|
+
// 1. the descriptor source, at its new path
|
|
221
|
+
doc.name = newName;
|
|
222
|
+
doc.storage = { ...doc.storage, path: newPath, suffix: newSuffix };
|
|
223
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
224
|
+
fs.writeFileSync(dest, dump(doc));
|
|
225
|
+
if (dest !== src) fs.rmSync(src);
|
|
226
|
+
touched.add(src);
|
|
227
|
+
touched.add(dest);
|
|
228
|
+
|
|
229
|
+
// 2. the record folder, then the per-file suffix if it was derived
|
|
230
|
+
if (newDir !== oldDir && fs.existsSync(oldDir)) {
|
|
231
|
+
fs.mkdirSync(path.dirname(newDir), { recursive: true });
|
|
232
|
+
fs.renameSync(oldDir, newDir);
|
|
233
|
+
movedData = true;
|
|
234
|
+
pruneEmpty(path.dirname(oldDir), path.join(ws.root, dataPath));
|
|
235
|
+
}
|
|
236
|
+
if (newSuffix !== oldSuffix && fs.existsSync(newDir)) {
|
|
237
|
+
const ext = EXT[d.storage.codec ?? 'md'];
|
|
238
|
+
for (const file of walk(newDir)) {
|
|
239
|
+
if (!file.endsWith(`.${oldSuffix}${ext}`)) continue;
|
|
240
|
+
const to = file.slice(0, -(oldSuffix.length + ext.length + 1)) + `.${newSuffix}${ext}`;
|
|
241
|
+
fs.renameSync(file, to);
|
|
242
|
+
resuffixed.push([file, to]);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (movedData) { touched.add(oldDir); touched.add(newDir); }
|
|
246
|
+
|
|
247
|
+
// 3. inbound references: per record id, plus the collection's own id in `collections`
|
|
248
|
+
// (which is what ui-views and command-bindings point at).
|
|
249
|
+
for (const id of ids) {
|
|
250
|
+
const out = store.rewriteRefs(`${oldName}/${id}`, `${newName}/${id}`);
|
|
251
|
+
rewrites += out.rewrites;
|
|
252
|
+
for (const f of out.touched) touched.add(f);
|
|
253
|
+
}
|
|
254
|
+
const collOut = store.rewriteRefs(`collections/${oldName}`, `collections/${newName}`);
|
|
255
|
+
rewrites += collOut.rewrites;
|
|
256
|
+
for (const f of collOut.touched) touched.add(f);
|
|
257
|
+
|
|
258
|
+
// 4. bare `x-reference: <oldName>` in every descriptor SOURCE. Not a `<collection>/<id>`
|
|
259
|
+
// ref, so step 3 cannot see it — and leaving it makes compile fail on an unknown target.
|
|
260
|
+
for (const f of descriptorSources(ws, store)) {
|
|
261
|
+
const before = fs.readFileSync(f, 'utf8');
|
|
262
|
+
const doc2 = load(before);
|
|
263
|
+
if (!doc2 || !retargetRefs(doc2.schema, oldName, newName)) continue;
|
|
264
|
+
if (!refFiles.has(f)) refFiles.set(f, Buffer.from(before));
|
|
265
|
+
fs.writeFileSync(f, dump(doc2));
|
|
266
|
+
touched.add(f);
|
|
267
|
+
rewrites++;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
compile(ws); // the gate: an uncompilable rename never reaches history
|
|
271
|
+
} catch (e) {
|
|
272
|
+
restoreRefs();
|
|
273
|
+
undo();
|
|
274
|
+
try { compile(ws); } catch { /* pre-rename sources were compilable */ }
|
|
275
|
+
throw e;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// `git add -- <path>` FAILS OUTRIGHT on a pathspec that is neither on disk nor in the index —
|
|
279
|
+
// which is exactly what the old descriptor becomes when it was never committed in the first
|
|
280
|
+
// place (a collection added but not yet published). One bad entry aborts the whole `add`, so
|
|
281
|
+
// the rename rolled back over a file git simply did not care about. Filter, don't assume.
|
|
282
|
+
const rels = [...touched]
|
|
283
|
+
.map((f) => path.relative(ws.root, f))
|
|
284
|
+
.filter((rel) => fs.existsSync(path.join(ws.root, rel)) || isTracked(ws.root, rel));
|
|
285
|
+
try {
|
|
286
|
+
execFileSync('git', ['add', '--all', '--', ...rels], { cwd: ws.root, stdio: GIT_QUIET });
|
|
287
|
+
execFileSync('git', ['commit', '--quiet', '-m', `dreamteamer: collections rename ${oldName} → ${newName}`, '--', ...rels], { cwd: ws.root, stdio: GIT_QUIET });
|
|
288
|
+
} catch (e) {
|
|
289
|
+
try { execFileSync('git', ['reset', '--quiet', '--', ...rels], { cwd: ws.root, stdio: GIT_QUIET }); } catch { /* nothing staged */ }
|
|
290
|
+
restoreRefs();
|
|
291
|
+
undo();
|
|
292
|
+
try { compile(ws); } catch { /* pre-rename sources were compilable */ }
|
|
293
|
+
throw new Error(`git commit failed — the rename was rolled back, nothing was changed. (${e.message.split('\n')[0]})`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
renamed: true, name: newName, records: ids.length, rewrites,
|
|
298
|
+
from: path.relative(ws.root, oldDir), to: path.relative(ws.root, newDir),
|
|
299
|
+
suffix: newSuffix !== oldSuffix ? { from: oldSuffix, to: newSuffix } : null,
|
|
300
|
+
pathKept: pathWasDerived ? null : authoredPath,
|
|
301
|
+
};
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Does git know this path? A deleted-and-never-committed file must be dropped from a pathspec. */
|
|
306
|
+
function isTracked(root, rel) {
|
|
307
|
+
try {
|
|
308
|
+
execFileSync('git', ['ls-files', '--error-unmatch', '--', rel], { cwd: root, stdio: ['ignore', 'ignore', 'ignore'] });
|
|
309
|
+
return true;
|
|
310
|
+
} catch { return false; }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Every workspace-owned descriptor source, recursively (namespaced descriptors are nested). */
|
|
314
|
+
function descriptorSources(ws, store) {
|
|
315
|
+
const out = [];
|
|
316
|
+
for (const root of store.sourceRoots()) {
|
|
317
|
+
const dir = kindDir(root, 'collections');
|
|
318
|
+
// ⚠ SPREAD FIRST. `walk` is a GENERATOR, and `Iterator.prototype.filter` is a Node 22 iterator
|
|
319
|
+
// helper — so `walk(dir).filter(...)` works on 22 and throws "filter is not a function" on 20,
|
|
320
|
+
// which package.json still supports (`"node": ">=20"`). Caught by the CI matrix, not by local runs.
|
|
321
|
+
if (fs.existsSync(dir)) out.push(...[...walk(dir)].filter((f) => f.endsWith('.collection.yaml')));
|
|
322
|
+
}
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Rewrite `x-reference: old` → new anywhere in a schema. Returns true if anything changed. */
|
|
327
|
+
function retargetRefs(schema, oldName, newName) {
|
|
328
|
+
let changed = false;
|
|
329
|
+
for (const prop of Object.values(schema?.properties ?? {})) {
|
|
330
|
+
if (!prop || typeof prop !== 'object') continue;
|
|
331
|
+
for (const holder of [prop, prop.items]) {
|
|
332
|
+
if (holder && typeof holder === 'object' && holder['x-reference'] === oldName) {
|
|
333
|
+
holder['x-reference'] = newName;
|
|
334
|
+
changed = true;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (prop.properties && retargetRefs(prop, oldName, newName)) changed = true;
|
|
338
|
+
if (prop.items?.properties && retargetRefs(prop.items, oldName, newName)) changed = true;
|
|
339
|
+
}
|
|
340
|
+
return changed;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Remove now-empty parents up to (not including) the data root — a moved collection leaves its
|
|
344
|
+
* namespace folder behind otherwise. */
|
|
345
|
+
function pruneEmpty(dir, stopAt) {
|
|
346
|
+
while (dir !== stopAt && dir.startsWith(stopAt) && fs.existsSync(dir) && fs.readdirSync(dir).length === 0) {
|
|
347
|
+
fs.rmdirSync(dir);
|
|
348
|
+
dir = path.dirname(dir);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
103
352
|
export function addField(ws, store, collection, { name: fieldName, prop, required }) {
|
|
104
353
|
store.descriptor(collection); // must exist in the compiled runtime
|
|
105
354
|
if (!fieldName) throw new Error('missing field name');
|
package/src/server.js
CHANGED
|
@@ -70,6 +70,15 @@ export function startServer(ws, { port = 8080, host = '127.0.0.1' } = {}) {
|
|
|
70
70
|
res.json([...store.descriptors.values()].sort((a, b) => (a.order ?? 999) - (b.order ?? 999)));
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
+
// ⚠ A NAMESPACED COLLECTION NAME CONTAINS A SLASH (`health/doctors`), and `:name` is one path
|
|
74
|
+
// segment — so a client MUST percent-encode it: `/collections/health%2Fdoctors/records`. Express
|
|
75
|
+
// matches on the still-encoded path and decodes params afterwards, so `req.params.name` arrives as
|
|
76
|
+
// `health/doctors` and every route below works unchanged.
|
|
77
|
+
//
|
|
78
|
+
// The alternative was `*name`, and it is wrong: `/collections/*name/records/*id` puts a greedy
|
|
79
|
+
// wildcard, a literal and a second wildcard in one pattern, so `/collections/a/b/records/c` has
|
|
80
|
+
// several readings and the router picks one. Encoding keeps the boundary explicit at the caller,
|
|
81
|
+
// which is the same reason references declare their namespace instead of having it inferred.
|
|
73
82
|
api.get('/collections/:name/records', (req, res) => {
|
|
74
83
|
const d = store.descriptor(req.params.name);
|
|
75
84
|
const bf = bodyField(d);
|
package/src/store.js
CHANGED
|
@@ -11,7 +11,8 @@ import { dump } from './yaml.js';
|
|
|
11
11
|
import { generateId } from './template.js';
|
|
12
12
|
import { parseRecord, parseRecordText, patternRe, fmtAjvError, unknownFields, walk, EXT, assertSafeId } from './records.js';
|
|
13
13
|
import { normalizeRecord } from './temporal.js';
|
|
14
|
-
import { NO_RUNTIME, sourceHint, loadDescriptors, runtimeDir, sourceRoots as compiledSourceRoots } from './runtime.js';
|
|
14
|
+
import { NO_RUNTIME, sourceHint, loadDescriptors, runtimeDir, namespaces as compiledNamespaces, sourceRoots as compiledSourceRoots } from './runtime.js';
|
|
15
|
+
import { parseRef } from './namespace.js';
|
|
15
16
|
|
|
16
17
|
// git calls whose failure we CATCH must not print git's own error: execFileSync forwards the
|
|
17
18
|
// child's stderr to ours unless told otherwise, so a handled "not a git repository" still
|
|
@@ -33,6 +34,9 @@ export class Store {
|
|
|
33
34
|
const descriptors = loadDescriptors(root);
|
|
34
35
|
if (!descriptors) throw new Error(NO_RUNTIME);
|
|
35
36
|
this.descriptors = descriptors;
|
|
37
|
+
// The closed set every reference is split against (see src/namespace.js). Read once per Store:
|
|
38
|
+
// it is compile output, and a Store is already rebuilt whenever the runtime changes.
|
|
39
|
+
this.namespaces = compiledNamespaces(root);
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
descriptor(collection) {
|
|
@@ -186,10 +190,12 @@ export class Store {
|
|
|
186
190
|
if (raw == null) continue;
|
|
187
191
|
for (const value of Array.isArray(raw) ? raw : [raw]) {
|
|
188
192
|
if (typeof value !== 'string' || value.startsWith('@')) continue;
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
+
// ONE parser for the collection/id boundary, shared with `check` and the extension —
|
|
194
|
+
// a namespace that meant one thing on write and another on read would be worse than
|
|
195
|
+
// no namespaces at all.
|
|
196
|
+
const parsed = parseRef(value, this.namespaces);
|
|
197
|
+
if (!parsed) throw new Error(`${key}: reference "${value}" is not <collection>/<id> — nothing was written.`);
|
|
198
|
+
const { collection: coll, id } = parsed;
|
|
193
199
|
if (target !== '*' && coll !== target) throw new Error(`${key}: reference "${value}" must target collection "${target}" — nothing was written.`);
|
|
194
200
|
if (!this.descriptors.has(coll)) throw new Error(`${key}: reference "${value}" targets unknown collection "${coll}" — nothing was written.`);
|
|
195
201
|
if (!this.ids(coll).has(id)) throw new Error(`${key}: dangling reference "${value}" — no such record. nothing was written.`);
|
|
@@ -243,9 +249,6 @@ export class Store {
|
|
|
243
249
|
throw new Error(`${collection}/${id} is referenced by:\n${inbound.map((f) => ` ${f}`).join('\n')}\nfix the references or pass --force. nothing was removed.`);
|
|
244
250
|
}
|
|
245
251
|
const unit = this.recordRoot(d, id); // folder-shape: the whole folder goes, not just the entry file
|
|
246
|
-
// Folder-shape records would need a recursive snapshot; the only folder-shape collection
|
|
247
|
-
// is `skills`, which is system-stored and so never reaches rm (writableDescriptor refuses
|
|
248
|
-
// first). Not built for a case that cannot occur.
|
|
249
252
|
// snapshot BEFORE the delete, or there is nothing left to read
|
|
250
253
|
const restore = snapshot([unit]);
|
|
251
254
|
return this.withWriteLock(() => {
|
|
@@ -389,10 +392,14 @@ export class Store {
|
|
|
389
392
|
const cwd = path.resolve(this.root, repo);
|
|
390
393
|
const rel = files.map((f) => path.relative(cwd, f));
|
|
391
394
|
try {
|
|
392
|
-
|
|
393
|
-
|
|
395
|
+
// QUIET, per the rule at the top of this file: a failure we CATCH must not also print git's
|
|
396
|
+
// own error. These three were the exception — a caught `git add` failure dumped git's raw
|
|
397
|
+
// multi-line advice ("Another git process seems to be running…") on top of the clean message
|
|
398
|
+
// this function throws, so the user read the scary one and not the accurate one.
|
|
399
|
+
execFileSync('git', ['add', '--all', '--', ...rel], { cwd, stdio: QUIET });
|
|
400
|
+
execFileSync('git', ['commit', '--quiet', '-m', subject, '--', ...rel], { cwd, stdio: QUIET });
|
|
394
401
|
} catch (e) {
|
|
395
|
-
try { execFileSync('git', ['reset', '--quiet', '--', ...rel], { cwd }); } catch { /* nothing staged */ }
|
|
402
|
+
try { execFileSync('git', ['reset', '--quiet', '--', ...rel], { cwd, stdio: QUIET }); } catch { /* nothing staged */ }
|
|
396
403
|
if (undo) {
|
|
397
404
|
try { undo(); } catch (u) {
|
|
398
405
|
throw new Error(`git commit failed AND rollback failed (${u.message}) — inspect the working tree. original: ${e.message.split('\n')[0]}`);
|
|
@@ -442,21 +449,50 @@ function readPkg(root) {
|
|
|
442
449
|
try { return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); } catch { return {}; }
|
|
443
450
|
}
|
|
444
451
|
|
|
445
|
-
/** Byte snapshot of a set of files, and a restore closure. The undo mechanism
|
|
446
|
-
* used for source writes since it was written (schema-ops.js:20). Record writes used
|
|
452
|
+
/** Byte snapshot of a set of files OR DIRECTORIES, and a restore closure. The undo mechanism
|
|
453
|
+
* schema-ops has used for source writes since it was written (schema-ops.js:20). Record writes used
|
|
447
454
|
* `git checkout HEAD -- <paths>` instead, which is only correct while HEAD is guaranteed to be
|
|
448
455
|
* the last good state — it is not, once writes stop committing, and it silently discarded
|
|
449
|
-
* uncommitted hand-edits even before that.
|
|
456
|
+
* uncommitted hand-edits even before that.
|
|
457
|
+
*
|
|
458
|
+
* A DIRECTORY unit is snapshotted recursively. It used to be skipped with a comment arguing the case
|
|
459
|
+
* could not occur — the only folder-shape collection was `skills`, which is system-stored, so
|
|
460
|
+
* `writableDescriptor` refused before `rm` was reached. That reasoning was true and is the wrong kind
|
|
461
|
+
* of true: it depended on a fact about the CURRENT set of collections rather than on anything the code
|
|
462
|
+
* enforces, and `shape: folder` is an ordinary descriptor option any workspace can choose. The failure
|
|
463
|
+
* it left behind was silent and total — `rm` would delete the folder and the "restore" closure would
|
|
464
|
+
* do nothing, so a failed commit meant the record was simply gone. Twelve lines, no such hole. */
|
|
450
465
|
function snapshot(units) {
|
|
451
|
-
const snaps = units.map((u) =>
|
|
452
|
-
u
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
466
|
+
const snaps = units.map((u) => {
|
|
467
|
+
const existed = fs.existsSync(u);
|
|
468
|
+
const isDir = existed && fs.statSync(u).isDirectory();
|
|
469
|
+
return {
|
|
470
|
+
u, existed, isDir,
|
|
471
|
+
prev: existed && !isDir ? fs.readFileSync(u) : null,
|
|
472
|
+
tree: isDir ? snapshotTree(u) : null,
|
|
473
|
+
};
|
|
474
|
+
});
|
|
456
475
|
return () => {
|
|
457
|
-
for (const { u, prev, existed } of snaps) {
|
|
476
|
+
for (const { u, prev, tree, existed, isDir } of snaps) {
|
|
458
477
|
if (!existed) { fs.rmSync(u, { force: true, recursive: true }); continue; }
|
|
478
|
+
if (isDir) {
|
|
479
|
+
fs.rmSync(u, { force: true, recursive: true }); // partial state from a failed op
|
|
480
|
+
fs.mkdirSync(u, { recursive: true });
|
|
481
|
+
for (const [rel, bytes] of tree) {
|
|
482
|
+
const dest = path.join(u, rel);
|
|
483
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
484
|
+
fs.writeFileSync(dest, bytes);
|
|
485
|
+
}
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
459
488
|
if (prev !== null) { fs.mkdirSync(path.dirname(u), { recursive: true }); fs.writeFileSync(u, prev); }
|
|
460
489
|
}
|
|
461
490
|
};
|
|
462
491
|
}
|
|
492
|
+
|
|
493
|
+
/** Every file under `dir` as [relative path, bytes] — the whole of a folder-shape record. */
|
|
494
|
+
function snapshotTree(dir) {
|
|
495
|
+
const out = [];
|
|
496
|
+
for (const file of walk(dir)) out.push([path.relative(dir, file), fs.readFileSync(file)]);
|
|
497
|
+
return out;
|
|
498
|
+
}
|