dreamteamer 0.31.0 → 0.32.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/collections/collections.collection.yaml +26 -0
- package/package.json +1 -1
- package/skills/using-dreamteamer/SKILL.md +6 -2
- package/skills/using-dreamteamer/references/collections.md +71 -1
- package/skills/using-dreamteamer/references/data-modeling.md +9 -1
- package/skills/using-dreamteamer/references/records.md +17 -0
- package/src/check.js +57 -2
- package/src/checkout.js +2 -2
- package/src/cli.js +31 -2
- package/src/commit.js +7 -0
- package/src/compile.js +103 -1
- package/src/events.js +7 -2
- package/src/extensions.js +2 -2
- package/src/harnesses.js +2 -2
- package/src/placement.js +209 -0
- package/src/records-api.d.ts +10 -2
- package/src/schema-ops.js +10 -2
- package/src/store.js +396 -35
package/src/placement.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// RELATIONSHIP-BASED STORAGE — a record stored UNDER the record it belongs to.
|
|
2
|
+
//
|
|
3
|
+
// A child collection declares, on its own `storage` block, which scalar reference field names its
|
|
4
|
+
// parent and where inside the parent's folder its records go:
|
|
5
|
+
//
|
|
6
|
+
// storage: { path: data/meetings, under: { field: company, path: meetings } }
|
|
7
|
+
//
|
|
8
|
+
// so a meeting whose `company` is `companies/northwind` lives at
|
|
9
|
+
// `data/companies/northwind/meetings/<id>.meeting.md`, and one with no company stays in the
|
|
10
|
+
// collection's own root (`data/meetings/`). The parent must be a folder-shape collection, because
|
|
11
|
+
// only a folder can hold anything beside the record itself. compile derives `under.collection`
|
|
12
|
+
// from the field's `x-reference`, so the record layer never reads a schema to find the parent.
|
|
13
|
+
//
|
|
14
|
+
// Three invariants every reader and writer here holds:
|
|
15
|
+
// - ONE logical collection. Listing walks the fallback root and every parent's child folder;
|
|
16
|
+
// a reference is `<collection>/<id>` wherever the file sits. The id is the path inside
|
|
17
|
+
// WHICHEVER root the file is in, so moving a record between parents changes no id.
|
|
18
|
+
// - PLACEMENT FOLLOWS THE FIELD, never the other way round. The field is the intended owner and
|
|
19
|
+
// the folder is observed placement; `check` reports the two disagreeing and `relocate` reconciles
|
|
20
|
+
// them. Nothing ever infers an owner from where a file was found.
|
|
21
|
+
// - ONE LEVEL. A placed collection cannot itself be a parent — enough for company → meetings /
|
|
22
|
+
// contacts / projects, and the nesting a second level needs can be added when something wants it.
|
|
23
|
+
//
|
|
24
|
+
// This module is in the RECORD layer and reads compiled descriptors only: nothing here knows what a
|
|
25
|
+
// module, an overlay or a schema source is. Store and check both enumerate through it so they cannot
|
|
26
|
+
// disagree about which files are records.
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { walk, idFromRecordPath } from './records.js';
|
|
30
|
+
|
|
31
|
+
/** The compiled `storage.under` of a descriptor, or null for conventional storage. */
|
|
32
|
+
export function placementOf(d) {
|
|
33
|
+
return d?.storage?.under ?? null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Why `p` is not an acceptable `under.path`, as a sentence — or null. A relative subfolder of the
|
|
38
|
+
* parent record's folder: no absolute path, no traversal, no empty segment, no backslash (the same
|
|
39
|
+
* alphabet `assertSafeId` holds ids to, because this path is joined onto the filesystem too).
|
|
40
|
+
*/
|
|
41
|
+
export function subpathProblem(p) {
|
|
42
|
+
if (typeof p !== 'string' || p === '') return 'must be a relative folder path inside the parent record\'s folder (e.g. `meetings`)';
|
|
43
|
+
if (p.startsWith('/') || p.includes('\\')) return `"${p}" must be relative to the parent record's folder — no leading slash, no backslashes`;
|
|
44
|
+
if (p.split('/').some((s) => s === '' || s === '.' || s === '..')) return `"${p}" may not contain "." or ".." segments or an empty one`;
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `[id, folder]` for every record folder in a folder-shape collection's directory, sorted by id.
|
|
49
|
+
* A folder without its entry file is still listed: records placed under it must stay discoverable
|
|
50
|
+
* (`check` reports the missing entry on the parent and the dangling owner on each child). */
|
|
51
|
+
export function parentFolders(parentDir) {
|
|
52
|
+
if (!fs.existsSync(parentDir)) return [];
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const e of fs.readdirSync(parentDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
55
|
+
if (e.name.startsWith('.') || !e.isDirectory()) continue;
|
|
56
|
+
out.push([e.name, path.join(parentDir, e.name)]);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The folder a placed record of `d` lives in when its owner is `parentId` — or the fallback root. */
|
|
62
|
+
export function placedRoot(under, fallbackDir, parentDir, parentId) {
|
|
63
|
+
return parentId ? path.join(parentDir, parentId, under.path) : fallbackDir;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Every record file of `d` under ONE root, as `{ id, file, root, parentId }`. A placed root (one with
|
|
67
|
+
* a `parentId`) is walked through REAL entries only — a symlinked sub-folder or file inside it is
|
|
68
|
+
* skipped and reported to `onLink` (R2b); the collection's own root keeps the ordinary walk. */
|
|
69
|
+
export function* rootRecords(d, root, parentId = null, onLink = null) {
|
|
70
|
+
if (!fs.existsSync(root)) return;
|
|
71
|
+
for (const f of parentId === null ? walk(root) : walkReal(root, onLink)) {
|
|
72
|
+
const id = idFromRecordPath(d, path.relative(root, f));
|
|
73
|
+
if (id !== null) yield { id, file: f, root, parentId };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `walk`, lstat-ing each entry: a symlink — to a folder or a file — is not followed. */
|
|
78
|
+
function* walkReal(dir, onLink) {
|
|
79
|
+
for (const name of fs.readdirSync(dir).sort()) {
|
|
80
|
+
if (name.startsWith('.')) continue;
|
|
81
|
+
const p = path.join(dir, name);
|
|
82
|
+
const st = fs.lstatSync(p);
|
|
83
|
+
if (st.isSymbolicLink()) { onLink?.(p); continue; }
|
|
84
|
+
if (st.isDirectory()) yield* walkReal(p, onLink);
|
|
85
|
+
else yield p;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Every record file of a placed collection across all its roots: the fallback root first, then each
|
|
91
|
+
* parent folder's child root in parent-id order. Duplicates are NOT resolved here — a caller that
|
|
92
|
+
* keeps a map keeps the first and reports the rest; last-one-wins is the one answer this must never give.
|
|
93
|
+
*/
|
|
94
|
+
export function* placedRecords(d, fallbackDir, parentDir, onLink = null) {
|
|
95
|
+
const under = placementOf(d);
|
|
96
|
+
yield* rootRecords(d, fallbackDir, null);
|
|
97
|
+
for (const [pid, folder] of parentFolders(parentDir)) {
|
|
98
|
+
const root = path.join(folder, under.path);
|
|
99
|
+
// a child root reached through a symlink is not read: whatever it points at is not this
|
|
100
|
+
// parent's folder (see symlinkBelow) — `check` names it, the store simply does not see it
|
|
101
|
+
if (symlinkBelow(parentDir, root)) continue;
|
|
102
|
+
yield* rootRecords(d, root, pid, onLink);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The child roots under `parentDir` that are symlinks (or sit behind one), for `check` to report. */
|
|
107
|
+
export function symlinkedChildRoots(under, parentDir) {
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const [, folder] of parentFolders(parentDir)) {
|
|
110
|
+
const link = symlinkBelow(parentDir, path.join(folder, under.path));
|
|
111
|
+
if (link) out.push(link);
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The parent id a record's owner field names, or null when the field is empty. `parseRef` is passed
|
|
118
|
+
* in (bound to the workspace's namespaces) so this stays pure. A value that parses to some OTHER
|
|
119
|
+
* collection is not an owner either — the reference check reports it; placement does not guess.
|
|
120
|
+
*/
|
|
121
|
+
export function ownerIdOf(fields, under, parseRef) {
|
|
122
|
+
const v = fields?.[under.field];
|
|
123
|
+
if (typeof v !== 'string' || v === '') return null;
|
|
124
|
+
const p = parseRef(v);
|
|
125
|
+
return p && p.collection === under.collection ? p.id : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Where a FILE of a placed collection sits: `{ root, parentId }` — the fallback root, or the child
|
|
130
|
+
* root inside one parent's folder — or null when the path is under neither. The inverse of
|
|
131
|
+
* `placedRoot`, used to prune the right directories after a move and to compare observed placement
|
|
132
|
+
* with the owner the record declares.
|
|
133
|
+
*/
|
|
134
|
+
export function placementOfFile(under, file, fallbackDir, parentDir) {
|
|
135
|
+
const inside = (dir) => {
|
|
136
|
+
const r = path.relative(dir, file);
|
|
137
|
+
return r && !r.startsWith('..') && !path.isAbsolute(r) ? r : null;
|
|
138
|
+
};
|
|
139
|
+
if (inside(fallbackDir)) return { root: fallbackDir, parentId: null };
|
|
140
|
+
const rel = inside(parentDir);
|
|
141
|
+
if (!rel) return null;
|
|
142
|
+
const parentId = rel.split(path.sep)[0];
|
|
143
|
+
return { root: path.join(parentDir, parentId, under.path), parentId };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Map a path INSIDE a folder-shape parent's directory to the placed child record it holds, if any.
|
|
148
|
+
* `rest` is the path relative to the parent collection's `storage.path` (`<parentId>/<under.path>/…`).
|
|
149
|
+
* Shared by `events.pathToRecord` (git paths → records) and nothing else re-derives it.
|
|
150
|
+
*/
|
|
151
|
+
export function placedChildAt(descriptors, parentName, rest) {
|
|
152
|
+
const slash = rest.indexOf('/');
|
|
153
|
+
if (slash < 1) return null;
|
|
154
|
+
const sub = rest.slice(slash + 1);
|
|
155
|
+
for (const c of descriptors.values()) {
|
|
156
|
+
const under = placementOf(c);
|
|
157
|
+
if (under?.collection !== parentName) continue;
|
|
158
|
+
const pre = `${under.path}/`;
|
|
159
|
+
if (!sub.startsWith(pre)) continue;
|
|
160
|
+
const id = idFromRecordPath(c, sub.slice(pre.length));
|
|
161
|
+
if (id !== null) return { collection: c.name, id };
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The first SYMLINK on the way from `base` (exclusive) down to `target` (inclusive), or null.
|
|
168
|
+
*
|
|
169
|
+
* ⚠ Lexical validation of `under.path` does not establish containment: a symlink dropped at
|
|
170
|
+
* `data/companies/acme/meetings` points every "placed" write at wherever it likes, and a walk reads
|
|
171
|
+
* whatever sits there as records. So a placed record is written and read only through REAL
|
|
172
|
+
* directories below the parent collection's root — every existing component is `lstat`ed, and the
|
|
173
|
+
* first link ends the enquiry. Components that do not exist yet are fine: they are about to be
|
|
174
|
+
* created as real directories. The collection's own roots (`storage.path`, `data/` itself) are not
|
|
175
|
+
* subject to this — a workspace may legitimately keep its data folder behind a link; the rule is
|
|
176
|
+
* about what a RECORD FOLDER may contain.
|
|
177
|
+
*/
|
|
178
|
+
export function symlinkBelow(base, target) {
|
|
179
|
+
const rel = path.relative(base, target);
|
|
180
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
|
181
|
+
let p = base;
|
|
182
|
+
for (const seg of rel.split(path.sep)) {
|
|
183
|
+
p = path.join(p, seg);
|
|
184
|
+
let st;
|
|
185
|
+
try { st = fs.lstatSync(p); } catch { return null; } // not there yet — nothing below can be a link either
|
|
186
|
+
if (st.isSymbolicLink()) return p;
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* One string that changes whenever a directory anywhere under `dir` gains or loses an entry — the
|
|
193
|
+
* mtime of every directory in the tree, in walk order. Directories only: files are not stat'ed, so
|
|
194
|
+
* this costs O(directories), not O(records). What a placed collection's id memo is keyed on, because
|
|
195
|
+
* a root directory's own mtime says nothing about a file dropped three levels down (R4).
|
|
196
|
+
*/
|
|
197
|
+
export function dirTreeStamps(dir) {
|
|
198
|
+
const out = [];
|
|
199
|
+
const visit = (d) => {
|
|
200
|
+
let st;
|
|
201
|
+
try { st = fs.statSync(d); } catch { return; }
|
|
202
|
+
out.push(`${path.basename(d)}@${st.mtimeMs}`);
|
|
203
|
+
let entries;
|
|
204
|
+
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
205
|
+
for (const e of entries) if (e.isDirectory() && !e.name.startsWith('.')) visit(path.join(d, e.name));
|
|
206
|
+
};
|
|
207
|
+
visit(dir);
|
|
208
|
+
return out.join(',');
|
|
209
|
+
}
|
package/src/records-api.d.ts
CHANGED
|
@@ -28,9 +28,13 @@ export class Store {
|
|
|
28
28
|
root: string;
|
|
29
29
|
descriptors: Descriptors;
|
|
30
30
|
descriptor(collection: string): Descriptor;
|
|
31
|
-
/** the
|
|
31
|
+
/** the collection's OWN folder — for a collection stored under another (`storage.under`) this is
|
|
32
|
+
* its fallback root, not an enumeration; records are wherever `ids()` says */
|
|
32
33
|
dir(d: Descriptor): string;
|
|
33
|
-
/**
|
|
34
|
+
/** every folder a record of this collection can sit in: its own, plus the parent collection's when
|
|
35
|
+
* it is stored under one — what to ask git about */
|
|
36
|
+
recordDirs(d: Descriptor): string[];
|
|
37
|
+
/** id → record file, across every folder the collection's records sit in */
|
|
34
38
|
ids(collection: string): Map<string, string>;
|
|
35
39
|
read(collection: string, id: string): { fields: Fields; file: string };
|
|
36
40
|
readAll(collection: string): Iterable<{ id: string; fields: Fields; file: string }>;
|
|
@@ -40,6 +44,10 @@ export class Store {
|
|
|
40
44
|
rm(collection: string, id: string, opts?: { force?: boolean }): { inboundIgnored: number };
|
|
41
45
|
rename(collection: string, oldId: string, newId: string): { id: string; rewrites: number; touched: number };
|
|
42
46
|
revert(collection: string, id: string, hash: string): { reverted: boolean };
|
|
47
|
+
/** the files `relocate` would move — read-only */
|
|
48
|
+
relocatePlan(collection: string, only?: string[] | null): { collection: string; moves: { id: string; from: string; to: string; why: 'placement' | 'shape' }[]; problems: string[] };
|
|
49
|
+
/** move record files to where the compiled descriptor puts them; ids and references unchanged */
|
|
50
|
+
relocate(collection: string, opts?: { only?: string[] | null; dryRun?: boolean }): { collection: string; moves: { id: string; from: string; to: string; why: 'placement' | 'shape' }[]; problems: string[]; applied: boolean };
|
|
43
51
|
findInboundRefs(ref: string): unknown[];
|
|
44
52
|
[k: string]: any;
|
|
45
53
|
}
|
package/src/schema-ops.js
CHANGED
|
@@ -1398,8 +1398,13 @@ export function removeCollection(ws, store, name, { force = false } = {}) {
|
|
|
1398
1398
|
}
|
|
1399
1399
|
const dest = path.join(ws.root, base);
|
|
1400
1400
|
const dataDir = path.join(ws.root, d.storage.path);
|
|
1401
|
-
|
|
1402
|
-
|
|
1401
|
+
// the index, not only the folder: a collection stored UNDER another keeps most of its records
|
|
1402
|
+
// inside the parent's folders, where a readdir of its own root sees nothing
|
|
1403
|
+
const hasRecords = store.ids(name).size > 0 || (fs.existsSync(dataDir) && fs.readdirSync(dataDir).some((e) => !e.startsWith('.')));
|
|
1404
|
+
if (hasRecords && !force) throw new Error(`collection "${name}" still has records under ${d.storage.path}${d.storage.under ? ` and inside ${d.storage.under.collection} folders` : ''} — remove them first or pass force`);
|
|
1405
|
+
for (const c of store.descriptors.values()) {
|
|
1406
|
+
if (c.storage?.under?.collection === name) throw new Error(`collection "${c.name}" stores its records under ${name}'s folders (storage.under) — drop that declaration or relocate its records first; removing the parent would strand them`);
|
|
1407
|
+
}
|
|
1403
1408
|
const gate = writeGated(ws, store, [dest], `dreamteamer: collections rm ${name}`, () => fs.rmSync(dest), undefined, { commentsMayDecrease: true });
|
|
1404
1409
|
return { removed: name, commits: gate.commits };
|
|
1405
1410
|
}
|
|
@@ -1453,6 +1458,9 @@ export function renameCollection(ws, store, oldName, newName) {
|
|
|
1453
1458
|
}
|
|
1454
1459
|
if (store.descriptors.has(newName)) throw new Error(`collection "${newName}" already exists`);
|
|
1455
1460
|
if (d.storage.base === 'runtime') throw new Error(`"${oldName}" is a compiled source, not a data collection — it cannot be renamed`);
|
|
1461
|
+
// Its records are spread across the parent's folders, and the per-file re-suffix below walks ONE
|
|
1462
|
+
// directory. Refused rather than half-done — the fix is small and nothing has asked for it yet.
|
|
1463
|
+
if (d.storage.under) throw new Error(`"${oldName}" is stored under ${d.storage.under.collection} (storage.under) — renaming a placed collection is not supported yet. The supported order: dreamteamer relocate ${oldName} --to-root · remove storage.under from its descriptor · compile · rename · declare storage.under again · compile · dreamteamer relocate ${newName}`);
|
|
1456
1464
|
|
|
1457
1465
|
// The descriptor is renamed IN THE MODULE THAT SHIPS IT — see `descriptorSourceDir`. Two cases
|
|
1458
1466
|
// this refuses, both because doing them halfway is worse than not doing them:
|