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.
@@ -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
+ }
@@ -28,9 +28,13 @@ export class Store {
28
28
  root: string;
29
29
  descriptors: Descriptors;
30
30
  descriptor(collection: string): Descriptor;
31
- /** the absolute folder a collection's records live in */
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
- /** id → record file */
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
- const hasRecords = fs.existsSync(dataDir) && fs.readdirSync(dataDir).some((e) => !e.startsWith('.'));
1402
- if (hasRecords && !force) throw new Error(`collection "${name}" still has records under ${d.storage.path} — remove them first or pass force`);
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: