dreamteamer 0.6.3 → 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.
@@ -5,6 +5,8 @@
5
5
  // the client adapter shrinks to a thin path translator. shapes deliberately match what
6
6
  // the studio components already speak (field {field,type,meta,schema}).
7
7
 
8
+ import { sourceHint } from './runtime.js';
9
+
8
10
  /** the projection for every collection: rows keyed by collection name + collection meta. */
9
11
  export function presentation(descriptors) {
10
12
  const collections = [];
@@ -60,7 +62,16 @@ function collectionRow(d) {
60
62
  if (typeof d.icon === 'string') meta.icon = d.icon;
61
63
  if (typeof d.group === 'string') meta.group = d.group;
62
64
  if (typeof d.description === 'string' && d.description.length > 0) meta.description = d.description;
63
- return { collection: d.name, meta, system: d.storage?.base === 'runtime' };
65
+ // A compiled collection is READ-ONLY through the record layer, and the UI needs to say so
66
+ // BEFORE offering a button — an error after the click is a worse answer than a disabled
67
+ // control with a reason. The sentence comes from runtime.js so the store's refusal and this
68
+ // hint can never disagree.
69
+ const system = d.storage?.base === 'runtime';
70
+ if (system) {
71
+ meta.readonly = true;
72
+ meta.readonly_hint = sourceHint(d);
73
+ }
74
+ return { collection: d.name, meta, system };
64
75
  }
65
76
 
66
77
  function referenceTargetOf(prop) {
package/src/runtime.js CHANGED
@@ -13,9 +13,31 @@
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { load } from './yaml.js';
16
+ import { normalizeNamespaces } from './namespace.js';
16
17
 
17
18
  export const RUNTIME_DIR = '.dreamteamer';
18
19
 
20
+ /**
21
+ * Runtime kinds compile PROJECTS rather than stages — they have no source folder under a module
22
+ * root, so nothing can be "edited and recompiled" in the usual place. Lives here, in the boundary,
23
+ * because it is a fact about the runtime's SHAPE: the compiler writes them and the record layer has
24
+ * to describe them, and neither half should learn it from the other (the `storage.base` precedent).
25
+ */
26
+ export const DERIVED_KINDS = ['modules'];
27
+
28
+ /**
29
+ * Where a human edits a compiled collection, as one sentence. ONE definition, because there are two
30
+ * consumers who must never drift: the store's refusal (`dt modules set …`) and the presentation
31
+ * projection the UI reads to explain a disabled button. This repo's own history is the argument —
32
+ * `git log`/`git diff` and the `?sort=` comparator were each hand-copied into the extension and
33
+ * went wrong in both places.
34
+ */
35
+ export function sourceHint(d) {
36
+ return DERIVED_KINDS.includes(d?.storage?.path)
37
+ ? "the source it was projected from (for `modules`, the module's package.json)"
38
+ : `the file under the owning module (modules/<module>/${d?.storage?.path}/)`;
39
+ }
40
+
19
41
  /** One message, two callers with different manners: the store throws it, `check` prints it. */
20
42
  export const NO_RUNTIME = 'no compiled runtime — run `dreamteamer compile` first';
21
43
 
@@ -51,9 +73,13 @@ export function loadDescriptors(root) {
51
73
  const dir = runtimeKindDir(root, 'collections');
52
74
  if (!fs.existsSync(dir)) return null;
53
75
  const out = new Map();
54
- for (const f of fs.readdirSync(dir).sort()) {
55
- if (!f.endsWith('.collection.yaml')) continue;
56
- const d = load(fs.readFileSync(path.join(dir, f), 'utf8'));
76
+ // RECURSIVE, because a namespaced collection compiles to `collections/<ns>/<name>.collection.yaml`
77
+ // and this loop used to read exactly one directory level. ⚠ That was a SILENT failure, not an
78
+ // error: compile wrote the nested file and reported ✔, this returned a Map without it, and the
79
+ // collection was simply absent — `dt <c> list` said "unknown collection" for something that had
80
+ // just compiled successfully. Keep the walk.
81
+ for (const f of walkDescriptors(dir)) {
82
+ const d = load(fs.readFileSync(f, 'utf8'));
57
83
  d.storage ??= {};
58
84
  d.storage.base ??= derivedBase(d);
59
85
  out.set(d.name, d);
@@ -61,6 +87,27 @@ export function loadDescriptors(root) {
61
87
  return out;
62
88
  }
63
89
 
90
+ /** Every `*.collection.yaml` under a directory, at any depth, in a stable order. */
91
+ function walkDescriptors(dir, out = []) {
92
+ for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
93
+ if (e.name.startsWith('.')) continue;
94
+ const p = path.join(dir, e.name);
95
+ if (e.isDirectory()) walkDescriptors(p, out);
96
+ else if (e.name.endsWith('.collection.yaml')) out.push(p);
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /**
102
+ * The workspace's declared namespaces, longest-first — the closed set every reference is split
103
+ * against. Read off the MANIFEST rather than package.json so the record layer keeps its single
104
+ * dependency on the compiled artifact (the `sourceRoots()` precedent), and so a runtime compiled
105
+ * before namespaces existed answers `[]` instead of throwing.
106
+ */
107
+ export function namespaces(root) {
108
+ return normalizeNamespaces(readManifest(root)?.namespaces);
109
+ }
110
+
64
111
  /**
65
112
  * Which root `storage.path` is relative to — the whole of what the record layer needs to know about
66
113
  * the system/data distinction, as DATA rather than as a string test it has to perform. `runtime` =
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
- if (store.descriptors.has(name)) throw new Error(`collection "${name}" already exists`);
66
- const dest = path.join(workspaceSystemDir(ws, 'collections'), `${name}.collection.yaml`);
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
- path: `${ws.pkg.dreamteamer?.['data-path'] ?? 'data'}/${name}`,
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
- suffix: descriptor.storage?.suffix ?? singular(name),
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 ${name}`, () => {
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, 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) {
@@ -46,7 +50,13 @@ export class Store {
46
50
  writableDescriptor(collection) {
47
51
  const d = this.descriptor(collection);
48
52
  if (d.storage.base === 'runtime') {
49
- throw new Error(`"${collection}" records are compiled sources — edit the file under the owning module (modules/<module>/${d.storage.path}/) and run \`dreamteamer compile\``);
53
+ // Two different runtime shapes, and pointing at the wrong one is worse than saying
54
+ // nothing: a STAGED kind (skills, commands, ui-views…) really does have a source file
55
+ // under `modules/<module>/<kind>/`, while a PROJECTED one (modules) has no such folder
56
+ // and never should — its source is the module's package.json. `x-source` says which,
57
+ // stated as data on the descriptor so the store never has to know what a module is.
58
+ const from = sourceHint(d);
59
+ throw new Error(`"${collection}" records are compiled sources — edit ${from} and run \`dreamteamer compile\``);
50
60
  }
51
61
  return d;
52
62
  }
@@ -180,10 +190,12 @@ export class Store {
180
190
  if (raw == null) continue;
181
191
  for (const value of Array.isArray(raw) ? raw : [raw]) {
182
192
  if (typeof value !== 'string' || value.startsWith('@')) continue;
183
- const slash = value.indexOf('/');
184
- if (slash < 1) throw new Error(`${key}: reference "${value}" is not <collection>/<id> — nothing was written.`);
185
- const coll = value.slice(0, slash);
186
- const id = value.slice(slash + 1);
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;
187
199
  if (target !== '*' && coll !== target) throw new Error(`${key}: reference "${value}" must target collection "${target}" — nothing was written.`);
188
200
  if (!this.descriptors.has(coll)) throw new Error(`${key}: reference "${value}" targets unknown collection "${coll}" — nothing was written.`);
189
201
  if (!this.ids(coll).has(id)) throw new Error(`${key}: dangling reference "${value}" — no such record. nothing was written.`);
@@ -237,9 +249,6 @@ export class Store {
237
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.`);
238
250
  }
239
251
  const unit = this.recordRoot(d, id); // folder-shape: the whole folder goes, not just the entry file
240
- // Folder-shape records would need a recursive snapshot; the only folder-shape collection
241
- // is `skills`, which is system-stored and so never reaches rm (writableDescriptor refuses
242
- // first). Not built for a case that cannot occur.
243
252
  // snapshot BEFORE the delete, or there is nothing left to read
244
253
  const restore = snapshot([unit]);
245
254
  return this.withWriteLock(() => {
@@ -383,10 +392,14 @@ export class Store {
383
392
  const cwd = path.resolve(this.root, repo);
384
393
  const rel = files.map((f) => path.relative(cwd, f));
385
394
  try {
386
- execFileSync('git', ['add', '--all', '--', ...rel], { cwd });
387
- execFileSync('git', ['commit', '--quiet', '-m', subject, '--', ...rel], { cwd });
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 });
388
401
  } catch (e) {
389
- try { execFileSync('git', ['reset', '--quiet', '--', ...rel], { cwd }); } catch { /* nothing staged */ }
402
+ try { execFileSync('git', ['reset', '--quiet', '--', ...rel], { cwd, stdio: QUIET }); } catch { /* nothing staged */ }
390
403
  if (undo) {
391
404
  try { undo(); } catch (u) {
392
405
  throw new Error(`git commit failed AND rollback failed (${u.message}) — inspect the working tree. original: ${e.message.split('\n')[0]}`);
@@ -436,21 +449,50 @@ function readPkg(root) {
436
449
  try { return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); } catch { return {}; }
437
450
  }
438
451
 
439
- /** Byte snapshot of a set of files, and a restore closure. The undo mechanism schema-ops has
440
- * 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
441
454
  * `git checkout HEAD -- <paths>` instead, which is only correct while HEAD is guaranteed to be
442
455
  * the last good state — it is not, once writes stop committing, and it silently discarded
443
- * 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. */
444
465
  function snapshot(units) {
445
- const snaps = units.map((u) => ({
446
- u,
447
- prev: fs.existsSync(u) && fs.statSync(u).isFile() ? fs.readFileSync(u) : null,
448
- existed: fs.existsSync(u),
449
- }));
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
+ });
450
475
  return () => {
451
- for (const { u, prev, existed } of snaps) {
476
+ for (const { u, prev, tree, existed, isDir } of snaps) {
452
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
+ }
453
488
  if (prev !== null) { fs.mkdirSync(path.dirname(u), { recursive: true }); fs.writeFileSync(u, prev); }
454
489
  }
455
490
  };
456
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
+ }