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.
- package/README.md +123 -51
- package/collections/collections.collection.yaml +13 -1
- package/collections/modules.collection.yaml +81 -0
- package/package.json +10 -4
- package/skills/building-dreamteamer/references/collections.md +32 -2
- package/skills/using-dreamteamer/references/records.md +17 -0
- package/src/check.js +32 -9
- package/src/cli.js +7 -1
- package/src/collections-cli.js +28 -2
- package/src/compile.js +264 -5
- package/src/harnesses.js +29 -8
- package/src/namespace.js +181 -0
- package/src/presentation.js +12 -1
- package/src/runtime.js +50 -3
- package/src/schema-ops.js +260 -11
- package/src/server.js +9 -0
- package/src/store.js +63 -21
package/src/presentation.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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, 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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
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;
|
|
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
|
-
|
|
387
|
-
|
|
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
|
|
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
|
-
|
|
448
|
-
|
|
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
|
+
}
|