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/src/store.js CHANGED
@@ -15,6 +15,8 @@ import { NO_RUNTIME, sourceHint, loadDescriptors, runtimeDir, namespaces as comp
15
15
  import { parseRef } from './namespace.js';
16
16
  import { refTargetsOf, refIsSoft } from './ref.js';
17
17
  import { relationsOf } from './relations.js';
18
+ import { placementOf, placedRecords, rootRecords, placedRoot, placementOfFile, ownerIdOf, symlinkBelow, dirTreeStamps } from './placement.js';
19
+ import { pathToRecord } from './events.js';
18
20
 
19
21
  // git calls whose failure we CATCH must not print git's own error: execFileSync forwards the
20
22
  // child's stderr to ours unless told otherwise, so a handled "not a git repository" still
@@ -33,6 +35,7 @@ export class Store {
33
35
  addFormats(this.ajv);
34
36
  this.ajv.addFormat('markdown', true);
35
37
  this._idsCache = new Map(); // collection -> { key, ids } (see ids())
38
+ this._duplicates = new Map(); // placed collection -> id -> [files], refreshed by every walk (see assertUnambiguous)
36
39
  this._head = undefined; // memoized `git rev-parse HEAD` (see gitHead())
37
40
  const descriptors = loadDescriptors(root);
38
41
  if (!descriptors) throw new Error(NO_RUNTIME);
@@ -195,11 +198,28 @@ export class Store {
195
198
  return { files, undo: rollback };
196
199
  }
197
200
 
201
+ /** The collection's OWN folder. For a collection stored under another (`storage.under`) this is
202
+ * the FALLBACK root — where a record with no owner lives — and not an enumeration of where its
203
+ * records are: that is `ids()`, and the directories git has to be asked about are `recordDirs`. */
198
204
  dir(d) {
199
205
  return path.join(d.storage.base === 'runtime' ? this.runtime : this.root, d.storage.path);
200
206
  }
201
207
 
202
- filePath(d, id, ext) {
208
+ /** The parent collection's folder, for a collection stored under one. */
209
+ parentDir(d) {
210
+ return this.dir(this.descriptor(placementOf(d).collection));
211
+ }
212
+
213
+ /** Every directory a record of `d` can sit in: its own root, plus the parent collection's root when
214
+ * it is stored under one. What `commit` scopes `git status` to and what a surface asks git about. */
215
+ recordDirs(d) {
216
+ return placementOf(d) ? [this.dir(d), this.parentDir(d)] : [this.dir(d)];
217
+ }
218
+
219
+ /** The file a record of `d` with these `fields` belongs at. For a placed collection the owner
220
+ * field decides the root — the parent's folder, or the fallback root when it is empty — so a
221
+ * caller that has no fields (a bare id) must go through the index instead (recordRoot). */
222
+ filePath(d, id, ext, fields) {
203
223
  assertSafeId(id); // never fs-join an id that can climb out of the collection
204
224
  if (d.storage.shape === 'folder') {
205
225
  if (!d.storage.entry) throw new Error(`collection "${d.name}" is folder-shape but declares no storage.entry`);
@@ -211,7 +231,25 @@ export class Store {
211
231
  if (!ext) throw new Error(`collection "${d.name}" is \`codec: file\` — its path needs the file's extension`);
212
232
  return path.join(this.dir(d), `${id}.${d.storage.suffix}.${ext}`);
213
233
  }
214
- return path.join(this.dir(d), `${id}.${d.storage.suffix}${EXT[d.storage.codec ?? 'md']}`);
234
+ return path.join(this.rootFor(d, fields), recordFileName(d, id));
235
+ }
236
+
237
+ /** The root a record with these fields belongs under: the owner's child folder, or the fallback. */
238
+ rootFor(d, fields) {
239
+ const under = placementOf(d);
240
+ if (!under || !fields) return this.dir(d);
241
+ const parentId = ownerIdOf(fields, under, (v) => parseRef(v, this.namespaces));
242
+ if (parentId) assertSafeId(parentId); // a parsed ref, but it is about to be fs-joined
243
+ return placedRoot(under, this.dir(d), this.parentDir(d), parentId);
244
+ }
245
+
246
+ /** The root an EXISTING file of `d` sits under — the inverse of rootFor, for pruning and for the
247
+ * observed-vs-declared comparison. Falls back to the collection's own root for a file found
248
+ * under neither, which cannot happen for a file the index produced. */
249
+ rootOfFile(d, file) {
250
+ const under = placementOf(d);
251
+ if (!under) return this.dir(d);
252
+ return placementOfFile(under, file, this.dir(d), this.parentDir(d))?.root ?? this.dir(d);
215
253
  }
216
254
 
217
255
  // the on-disk unit of a record: its folder for folder shapes, its file otherwise
@@ -219,8 +257,9 @@ export class Store {
219
257
  assertSafeId(id);
220
258
  if (d.storage.shape === 'folder') return path.join(this.dir(d), id);
221
259
  // Only the index knows an opaque record's extension, so the on-disk unit is looked up rather
222
- // than derived. An unknown id is the caller's error either way — `read` says so first.
223
- if ((d.storage.codec ?? 'md') === 'file') {
260
+ // than derived. An unknown id is the caller's error either way — `read` says so first. The same
261
+ // goes for a placed record: which parent folder it sits in is a fact about the file, not the id.
262
+ if ((d.storage.codec ?? 'md') === 'file' || placementOf(d)) {
224
263
  const file = this.ids(d.name).get(id);
225
264
  if (!file) throw new Error(`${d.name}/${id}: no such record`);
226
265
  return file;
@@ -267,13 +306,13 @@ export class Store {
267
306
  ids(collection) {
268
307
  const d = this.descriptor(collection);
269
308
  const dir = this.dir(d);
270
- if (!fs.existsSync(dir)) return new Map();
309
+ if (!this.recordDirs(d).some((p) => fs.existsSync(p))) return new Map();
271
310
  // memoized per collection, keyed by (HEAD sha, collection dir mtime): every tool
272
311
  // write commits (HEAD moves) and every store mutation clears its entry below;
273
312
  // direct top-level edits move the dir mtime. honest gap: a DEEP direct edit that
274
313
  // adds/removes a record without touching HEAD or the top dir mtime can serve one
275
314
  // stale read — acceptable, tool writes always commit and `check` covers hand edits.
276
- const key = this._idsKey(dir);
315
+ const key = this._idsKey(d);
277
316
  const hit = this._idsCache.get(collection);
278
317
  if (hit?.key === key) return hit.ids;
279
318
  const ids = this._walkIds(d, dir);
@@ -282,8 +321,29 @@ export class Store {
282
321
  }
283
322
 
284
323
  /** The validity key `ids()` compares against, as its own method because `add` restates it AFTER
285
- * its write — the only way an index it just extended can still be accepted by the next call. */
286
- _idsKey(dir) { return `${this.gitHead()}:${fs.statSync(dir).mtimeMs}`; }
324
+ * its write — the only way an index it just extended can still be accepted by the next call.
325
+ * For a placed collection the PARENT root's mtime is in it too: a new parent folder is a new
326
+ * place records can be, and nothing under the fallback root moves when one appears. */
327
+ _idsKey(d) {
328
+ const stamp = (p) => { try { return String(fs.statSync(p).mtimeMs); } catch { return '-'; } };
329
+ if (!placementOf(d)) return `${this.gitHead()}:${stamp(this.dir(d))}`;
330
+ // A placed collection's records sit three levels down in folders this process did not create,
331
+ // and another writer (an agent, the editor, a hand) adds or removes one without touching a
332
+ // root's own mtime — so a root-mtime key served a long-lived Store a stale index (R4). Every
333
+ // DIRECTORY in every root is stamped instead: O(directories), which is small, and a new or
334
+ // vanished folder changes the string as surely as a changed one.
335
+ const dirs = [this.dir(d), this.parentDir(d)];
336
+ return `${this.gitHead()}:${dirs.map(dirTreeStamps).join('|')}`;
337
+ }
338
+
339
+ /** Refuse a placed write whose path crosses a symlink inside the parent collection's root — a link
340
+ * there points anywhere, and "inside the parent's folder" is the whole promise (R2). */
341
+ _assertContained(d, file) {
342
+ const under = placementOf(d);
343
+ if (!under) return;
344
+ const link = symlinkBelow(this.parentDir(d), file);
345
+ if (link) throw new Error(`${path.relative(this.root, link)} is a symlink — a placed record is written only inside its parent's real folder, never through a link. nothing was written.`);
346
+ }
287
347
 
288
348
  /**
289
349
  * The id index this add invalidated, handed back with ONE MORE ENTRY rather than thrown away.
@@ -309,20 +369,25 @@ export class Store {
309
369
  // nothing was memoized before this write, or the mirror pass has already rebuilt it cold from
310
370
  // disk — either way a walk is the current answer and this has nothing to add to it.
311
371
  if (!memo || this._idsCache.has(collection)) return;
312
- const dir = this.dir(this.descriptor(collection));
372
+ const d = this.descriptor(collection);
373
+ const dir = this.dir(d);
374
+ // a placed collection's order is by ID (see _walkIds), so the insertion point is too
375
+ const byId = !!placementOf(d);
376
+ const isBefore = (k, v) => (byId ? id < k : beforeInWalk(path.relative(dir, file), path.relative(dir, v)));
313
377
  const ids = new Map();
314
378
  let placed = false;
315
379
  for (const [k, v] of memo.ids) {
316
- if (!placed && beforeInWalk(path.relative(dir, file), path.relative(dir, v))) { ids.set(id, file); placed = true; }
380
+ if (!placed && isBefore(k, v)) { ids.set(id, file); placed = true; }
317
381
  ids.set(k, v);
318
382
  }
319
383
  if (!placed) ids.set(id, file);
320
- this._idsCache.set(collection, { key: this._idsKey(dir), ids });
384
+ this._idsCache.set(collection, { key: this._idsKey(d), ids });
321
385
  }
322
386
 
323
387
  _walkIds(d, dir) {
324
388
  const ids = new Map();
325
389
  if (d.storage.shape === 'folder') {
390
+ if (!fs.existsSync(dir)) return ids;
326
391
  for (const e of fs.readdirSync(dir).sort()) {
327
392
  if (e.startsWith('.')) continue;
328
393
  const main = path.join(dir, e, d.storage.entry ?? 'SKILL.md');
@@ -330,6 +395,20 @@ export class Store {
330
395
  }
331
396
  return ids;
332
397
  }
398
+ if (placementOf(d)) {
399
+ // Across every root. FIRST claimant wins and the rest are left for `check` to report as
400
+ // duplicates — never last-one-wins, which would make `get` answer with whichever folder
401
+ // happened to sort later. Ordered by ID, not by where the file sits: a listing whose order
402
+ // changed because a record moved between two companies would be reporting placement, which
403
+ // is not what the reader asked.
404
+ const dupes = new Map();
405
+ for (const r of placedRecords(d, dir, this.parentDir(d))) {
406
+ if (!ids.has(r.id)) { ids.set(r.id, r.file); continue; }
407
+ dupes.set(r.id, [...(dupes.get(r.id) ?? [ids.get(r.id)]), r.file]);
408
+ }
409
+ this._duplicates.set(d.name, dupes);
410
+ return new Map([...ids].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
411
+ }
333
412
  for (const f of walk(dir)) {
334
413
  const id = idFromRecordPath(d, path.relative(dir, f));
335
414
  if (id !== null) ids.set(id, f);
@@ -337,6 +416,64 @@ export class Store {
337
416
  return ids;
338
417
  }
339
418
 
419
+ /** The parent record folders of a folder-shape collection that hold records of a collection
420
+ * stored under it — the reason `rm` and `rename` on a parent have to look inside the folder. */
421
+ placedChildrenIn(d, unit) {
422
+ const out = [];
423
+ if (d.storage.shape !== 'folder') return out;
424
+ for (const c of this.descriptors.values()) {
425
+ const under = placementOf(c);
426
+ if (under?.collection !== d.name) continue;
427
+ const n = [...rootRecords(c, path.join(unit, under.path))].length;
428
+ if (n) out.push({ collection: c.name, under, count: n });
429
+ }
430
+ return out;
431
+ }
432
+
433
+ /** Drop the id memo of every collection stored under `collection` — a parent folder that moved or
434
+ * went carries their files with it. */
435
+ _dropPlacedUnder(collection) {
436
+ for (const c of this.descriptors.values()) if (placementOf(c)?.collection === collection) this._idsCache.delete(c.name);
437
+ }
438
+
439
+ /**
440
+ * Move one record file to the root its fields now say it belongs in — the ONE place a placed
441
+ * record changes folders. Creates the destination's parents, prunes the emptied ones up to the
442
+ * root it left (so a company's `meetings/` folder disappears with its last meeting), and hands
443
+ * back the undo. The caller has already refused an occupied destination.
444
+ */
445
+ _moveRecord(d, from, to) {
446
+ try {
447
+ fs.mkdirSync(path.dirname(to), { recursive: true });
448
+ fs.renameSync(from, to);
449
+ } catch (e) {
450
+ this._pruneAround(d, to); // the folders a failed move created, and nothing more
451
+ throw e;
452
+ }
453
+ // The undo exists from the moment the rename has happened, and anything that fails AFTER it
454
+ // runs the undo before it propagates (R1b): a cleanup that threw used to leave the caller with
455
+ // no undo and both copies on disk. Pruning itself no longer throws (pruneEmptyDirs), so this
456
+ // is the belt to that brace.
457
+ const undo = () => {
458
+ fs.mkdirSync(path.dirname(from), { recursive: true });
459
+ fs.renameSync(to, from);
460
+ this._pruneAround(d, to);
461
+ };
462
+ try { this._pruneAround(d, from); } catch (e) { undo(); throw e; }
463
+ return undo;
464
+ }
465
+
466
+ /** Empty directories a placed record left behind: its date folders up to the root it sat in, and —
467
+ * inside a parent folder — that root itself (`northwind/meetings/`), never the parent folder and
468
+ * never the collection's own fallback root. */
469
+ _pruneAround(d, file) {
470
+ const under = placementOf(d);
471
+ const where = under ? placementOfFile(under, file, this.dir(d), this.parentDir(d)) : null;
472
+ const root = where?.root ?? this.dir(d);
473
+ pruneEmptyDirs(path.dirname(file), root);
474
+ if (where?.parentId) pruneEmptyDirs(root, path.join(this.parentDir(d), where.parentId));
475
+ }
476
+
340
477
  read(collection, id) {
341
478
  const d = this.descriptor(collection);
342
479
  const file = this.ids(collection).get(id);
@@ -357,8 +494,11 @@ export class Store {
357
494
  // restore a record to its content at `hash` — validated like any other write, one commit.
358
495
  revert(collection, id, hash) {
359
496
  const d = this.writableDescriptor(collection);
497
+ this.assertUnambiguous(collection, id);
360
498
  const { fields: currentFields, file } = this.read(collection, id);
361
- const relPath = path.relative(this.root, file);
499
+ // A placed record may have sat in ANOTHER parent's folder at `hash` — the move is what is being
500
+ // reverted — so the historical path is looked up in that commit's tree, not assumed to be today's.
501
+ const relPath = this.pathAt(d, id, hash) ?? path.relative(this.root, file);
362
502
  let previousContent;
363
503
  try {
364
504
  previousContent = execFileSync('git', ['show', `${hash}:${relPath}`], { cwd: this.root, stdio: QUIET }).toString();
@@ -375,9 +515,24 @@ export class Store {
375
515
  // for a ref it can parse. Qualifying `after` here would attach a mirror check then calls stale.
376
516
  const restored = { ...tmpFields };
377
517
  this.validate(d, tmpFields);
518
+ // Restoring an owner field restores its PLACEMENT too — the historical record lived where its
519
+ // historical owner put it, and writing it into whatever folder the record sits in today would
520
+ // leave a file `check` reports the moment it lands. Same rule as `set`: only the owner moves it.
521
+ this._assertContained(d, file);
522
+ const target = this.ownerChanged(d, currentFields, tmpFields) ? this.filePath(d, id, undefined, tmpFields) : file;
523
+ if (target !== file) this._assertContained(d, target);
524
+ if (target !== file && fs.existsSync(target)) throw new Error(`${collection}/${id}: ${path.relative(this.root, target)} already exists — nothing was reverted.`);
378
525
  return this.withWriteLock(() => {
379
526
  this._idsCache.delete(collection); // every mutation drops the memo — cleared even if the commit rolls back
380
- atomicWrite(file, previousContent);
527
+ let unmove = null;
528
+ const undo = () => { unmove?.(); atomicWrite(file, current); };
529
+ try {
530
+ atomicWrite(file, previousContent);
531
+ if (target !== file) unmove = this._moveRecord(d, file, target);
532
+ } catch (e) {
533
+ undo();
534
+ throw e;
535
+ }
381
536
  // revert is SET-shaped — it changes the owner's foreign key — so the mirrors move with it.
382
537
  // Skipping this left BOTH targets stale: the restored one never got its link back, and the
383
538
  // abandoned one kept a link the owner no longer claims. Same ordering as `add`, see there.
@@ -385,14 +540,119 @@ export class Store {
385
540
  try {
386
541
  mirrors = this.applyMirrorEdits(d, id, currentFields, restored);
387
542
  } catch (e) {
388
- atomicWrite(file, current);
543
+ undo();
389
544
  throw e;
390
545
  }
391
- this.commit([file, ...mirrors.files], `dreamteamer: ${collection} revert ${id} to ${String(hash).slice(0, 7)}`, () => {
546
+ this.commit([target, ...(unmove ? [file] : []), ...mirrors.files], `dreamteamer: ${collection} revert ${id} to ${String(hash).slice(0, 7)}`, () => {
392
547
  mirrors.undo();
393
- atomicWrite(file, current);
548
+ undo();
394
549
  }, d.storage.repo ?? '.');
395
- return { id, reverted: true, hash };
550
+ return { id, reverted: true, hash, file: target };
551
+ });
552
+ }
553
+
554
+ /**
555
+ * What `relocate` would do: every record of `collection` whose file is not where the compiled
556
+ * descriptor says it belongs, as `{ id, from, to, why }` — plus the problems that make the plan
557
+ * unsafe to apply. Read-only. Two kinds of drift are recognised:
558
+ *
559
+ * - `placement`: a placed record whose folder disagrees with its owner field — hand-moved, written
560
+ * by an engine from before `storage.under` existed, or sitting in the fallback root after the
561
+ * annotation was switched on. The field is the truth; the file moves.
562
+ * - `shape`: a FILE record (`<id>.<suffix>.md`) at the root of a collection that is now
563
+ * `shape: folder` — the state a collection is in the moment its descriptor gains a folder shape so
564
+ * something can live inside its records. The bytes move to `<id>/<entry>`; the id is unchanged.
565
+ *
566
+ * A destination that already exists is a PROBLEM, never a silent overwrite: two files claiming one
567
+ * id is exactly the ambiguity `check` reports, and resolving it is a decision, not a move.
568
+ */
569
+ relocatePlan(collection, only = null, { toRoot = false } = {}) {
570
+ const d = this.writableDescriptor(collection);
571
+ const moves = [];
572
+ const problems = [];
573
+ const rel = (p) => path.relative(this.root, p);
574
+ const wanted = only ? new Set(only) : null;
575
+ const plan = (id, from, to, why) => {
576
+ const link = placementOf(d) ? symlinkBelow(this.parentDir(d), to) : null;
577
+ if (link) problems.push(`${collection}/${id}: ${rel(to)} is behind the symlink ${rel(link)} — a placed record is never written through a link`);
578
+ else if (fs.existsSync(to)) problems.push(`${collection}/${id}: ${rel(from)} belongs at ${rel(to)}, which already exists — two files claim one id; remove one before relocating`);
579
+ else moves.push({ id, from, to, why });
580
+ };
581
+ const under = placementOf(d);
582
+ if (under && toRoot) {
583
+ // the REVERSE: every record out of its parent's folder and into the collection's own root, ids
584
+ // unchanged — what makes dropping or changing `storage.under` a safe two-step (see compile)
585
+ for (const [id, file] of this.ids(collection)) {
586
+ if (wanted && !wanted.has(id)) continue;
587
+ const to = path.join(this.dir(d), recordFileName(d, id));
588
+ if (to !== file) plan(id, file, to, 'to-root');
589
+ }
590
+ } else if (under) {
591
+ const bf = bodyField(d);
592
+ const parse = (v) => parseRef(v, this.namespaces);
593
+ for (const [id, file] of this.ids(collection)) {
594
+ if (wanted && !wanted.has(id)) continue;
595
+ const fields = parseRecord(file, d, bf);
596
+ this.qualifyBareRefs(d, fields);
597
+ // The OWNER is validated before any destination is computed (R5): a folder is never made
598
+ // for a parent that does not exist, and a value that is not a reference to the parent
599
+ // collection is a field to fix, not a place to move to. Either stops the whole plan.
600
+ const raw = fields[under.field];
601
+ const parentId = ownerIdOf(fields, under, parse);
602
+ if (raw != null && raw !== '' && !parentId) { problems.push(`${collection}/${id}: ${under.field} is "${raw}", not a reference to ${under.collection} — fix the field first`); continue; }
603
+ if (parentId && !this.ids(under.collection).has(parentId)) { problems.push(`${collection}/${id}: ${under.field} is ${raw} — no such record; fix the field first (dreamteamer check names it)`); continue; }
604
+ const to = this.filePath(d, id, undefined, fields);
605
+ if (to !== file) plan(id, file, to, 'placement');
606
+ }
607
+ }
608
+ if (d.storage.shape === 'folder' && fs.existsSync(this.dir(d))) {
609
+ // legacy file-shape records sit at the TOP of the folder — a folder id is one segment
610
+ const asFile = { ...d, storage: { ...d.storage, shape: 'file' } };
611
+ for (const name of fs.readdirSync(this.dir(d)).sort()) {
612
+ const p = path.join(this.dir(d), name);
613
+ if (name.startsWith('.') || fs.statSync(p).isDirectory()) continue;
614
+ const id = idFromRecordPath(asFile, name);
615
+ if (id === null || (wanted && !wanted.has(id))) continue;
616
+ plan(id, p, path.join(this.dir(d), id, d.storage.entry), 'shape');
617
+ }
618
+ }
619
+ return { collection, moves, problems };
620
+ }
621
+
622
+ /**
623
+ * Reconcile files to the compiled descriptor — the explicit operation that turns a `check`
624
+ * placement report into a clean tree. Ids and references are untouched: only files move. Refuses
625
+ * rather than partially applies: a planned destination that exists, or a source git still has
626
+ * pending changes for, stops the whole plan (publish first — a move on top of an unpublished edit
627
+ * is two changes under one subject, and the second hides the first). A second run after success
628
+ * plans nothing and does nothing.
629
+ */
630
+ relocate(collection, { only = null, dryRun = false, toRoot = false } = {}) {
631
+ const d = this.writableDescriptor(collection);
632
+ const planned = this.relocatePlan(collection, only, { toRoot });
633
+ // problems FIRST, moves or not: a plan with a dangling owner in it is refused whole, and a dry run
634
+ // of it reports the problem rather than the empty success it would otherwise read as
635
+ if (planned.problems.length && !dryRun) throw new Error(`relocate refused:\n ${planned.problems.join('\n ')}\nnothing was moved.`);
636
+ if (dryRun || !planned.moves.length) return { ...planned, applied: false };
637
+ const cwd = path.resolve(this.root, d.storage.repo ?? '.');
638
+ const sources = planned.moves.map((m) => path.relative(cwd, m.from));
639
+ let dirty = '';
640
+ try { dirty = execFileSync('git', ['status', '--porcelain', '--', ...sources], { cwd, stdio: QUIET }).toString().trim(); } catch { /* not a git repo — nothing to protect */ }
641
+ if (dirty) throw new Error(`relocate refused — these records have unpublished changes, and a move on top of them would hide the edit under the move:\n${dirty.split('\n').map((l) => ` ${l}`).join('\n')}\nrun \`dreamteamer commit ${collection}\` first. nothing was moved.`);
642
+ return this.withWriteLock(() => {
643
+ this._idsCache.delete(collection);
644
+ this._dropPlacedUnder(collection);
645
+ const undos = [];
646
+ const rollback = () => { for (const u of [...undos].reverse()) u(); this._idsCache.delete(collection); this._dropPlacedUnder(collection); };
647
+ try {
648
+ for (const m of planned.moves) undos.push(this._moveRecord(d, m.from, m.to));
649
+ } catch (e) {
650
+ rollback();
651
+ throw e;
652
+ }
653
+ const files = planned.moves.flatMap((m) => [m.from, m.to]);
654
+ this.commit(files, `dreamteamer: ${collection} relocate ${planned.moves.length} record(s)${toRoot ? ' to root' : ''}`, rollback, d.storage.repo ?? '.');
655
+ return { ...planned, applied: true };
396
656
  });
397
657
  }
398
658
 
@@ -514,8 +774,12 @@ export class Store {
514
774
  if (d.id?.pattern && !patternRe(d.id.pattern).test(id)) {
515
775
  throw new Error(`id "${id}" does not match pattern ${d.id.pattern} — nothing was written.`);
516
776
  }
517
- const file = this.filePath(d, id);
518
- if (fs.existsSync(file)) throw new Error(`${collection}/${id} already exists — nothing was written.`);
777
+ // The fields decide the folder for a placed collection (rootFor), and existence is asked of the
778
+ // INDEX, not of one path: the same id already sitting under another parent is the duplicate
779
+ // `check` would report, and refusing it here is what keeps the id unique across every root.
780
+ const file = this.filePath(d, id, undefined, fields);
781
+ this._assertContained(d, file);
782
+ if (fs.existsSync(file) || (placementOf(d) && this.ids(collection).has(id))) throw new Error(`${collection}/${id} already exists — nothing was written.`);
519
783
  return this.withWriteLock(() => {
520
784
  // captured before the invalidation and handed back, one entry richer, on the success path
521
785
  // below — see _indexAdd for why an add that throws it away is the expensive shape.
@@ -533,7 +797,7 @@ export class Store {
533
797
  mirrors = this.applyMirrorEdits(d, id, null, fields);
534
798
  } catch (e) {
535
799
  fs.rmSync(file, { force: true });
536
- pruneEmptyDirs(path.dirname(file), this.dir(d));
800
+ this._pruneAround(d, file);
537
801
  // ⚠ AND THE MEMO, or the removal is only half done. applyMirrorEdits reads targets through
538
802
  // `ids()`, so a relation whose target is this same collection re-cached it WITH the record
539
803
  // just written — and the memo key (HEAD + the collection's TOP directory mtime) does not
@@ -548,7 +812,7 @@ export class Store {
548
812
  this.commit([file, ...mirrors.files], `dreamteamer: ${collection} add ${id}`, () => {
549
813
  mirrors.undo();
550
814
  fs.rmSync(file, { force: true });
551
- pruneEmptyDirs(path.dirname(file), this.dir(d));
815
+ this._pruneAround(d, file);
552
816
  this._idsCache.delete(collection); // same phantom as the catch above — see the note there
553
817
  }, d.storage.repo ?? '.');
554
818
  // LAST, after the commit: the key it is re-stated under carries the sha, and `commit` moves it
@@ -594,6 +858,7 @@ export class Store {
594
858
  set(collection, id, changes) {
595
859
  const d = this.writableDescriptor(collection);
596
860
  this.refuseMirrorWrites(collection, id, Object.keys(changes));
861
+ this.assertUnambiguous(collection, id);
597
862
  if ((d.storage.codec ?? 'md') === 'file') {
598
863
  throw new Error(`${collection}/${id} is a file record — its fields are derived from the file, so there is nothing to set. Replace it with \`dreamteamer add ${collection} ${id} --from <path> --force\`.`);
599
864
  }
@@ -602,9 +867,31 @@ export class Store {
602
867
  const next = { ...fields, ...changes };
603
868
  for (const [k, v] of Object.entries(changes)) if (v === null || v === '') delete next[k];
604
869
  this.validate(d, next);
870
+ // A placed record FOLLOWS ITS OWNER FIELD: changing it moves the file to the new parent's folder
871
+ // (or back to the fallback root) in this same write, id unchanged. Only a change to THAT field
872
+ // moves anything — a record found in the wrong folder stays there when some other field is
873
+ // edited, and `check` keeps reporting the mismatch until `relocate` is asked. Moving it as a
874
+ // side effect of an unrelated edit would make a rename of a meeting relocate a file nobody
875
+ // mentioned, in a commit whose subject says otherwise.
876
+ this._assertContained(d, file); // the existing file, not only a new destination (R2b)
877
+ const target = this.ownerChanged(d, fields, next) ? this.filePath(d, id, undefined, next) : file;
878
+ if (target !== file) this._assertContained(d, target);
879
+ if (target !== file && fs.existsSync(target)) throw new Error(`${collection}/${id}: ${path.relative(this.root, target)} already exists — nothing was written.`);
605
880
  return this.withWriteLock(() => {
606
881
  this._idsCache.delete(collection);
607
- atomicWrite(file, serialize(d, next, previous));
882
+ // ONE rollback boundary for the bytes, the move and the mirrors (R1): the field write used to
883
+ // sit before the move and outside any try, so a rename refused by the filesystem left a file
884
+ // in the OLD folder saying it belonged to the NEW owner — a record `check` reports and a
885
+ // verb that threw. Whatever fails, `undo` puts the original bytes back where they were.
886
+ let unmove = null;
887
+ const undo = () => { unmove?.(); atomicWrite(file, previous); };
888
+ try {
889
+ atomicWrite(file, serialize(d, next, previous));
890
+ if (target !== file) unmove = this._moveRecord(d, file, target);
891
+ } catch (e) {
892
+ undo();
893
+ throw e;
894
+ }
608
895
  // `fields` is the record as it was ON DISK and `next` as it will be, which is exactly the
609
896
  // before/after pair a mirror edit is: an FK that moved detaches from the old target and
610
897
  // attaches to the new one, in this same write. Same ordering as `add` — see the note there.
@@ -612,17 +899,58 @@ export class Store {
612
899
  try {
613
900
  mirrors = this.applyMirrorEdits(d, id, fields, next);
614
901
  } catch (e) {
615
- atomicWrite(file, previous);
902
+ undo();
616
903
  throw e;
617
904
  }
618
- this.commit([file, ...mirrors.files], `dreamteamer: ${collection} set ${id}`, () => {
905
+ // both paths of a move, so a commit stages the deletion and the addition together
906
+ this.commit([target, ...(unmove ? [file] : []), ...mirrors.files], `dreamteamer: ${collection} set ${id}`, () => {
619
907
  mirrors.undo();
620
- atomicWrite(file, previous);
908
+ undo();
621
909
  }, d.storage.repo ?? '.');
622
- return { id, file };
910
+ return { id, file: target };
623
911
  });
624
912
  }
625
913
 
914
+ /** Where a placed record's file was at `hash`, workspace-relative — or null when the commit holds no
915
+ * file for that id (or the collection is conventional, where today's path IS the historical one).
916
+ * One `git ls-tree` over the roots the collection can occupy, mapped back through the same
917
+ * path→record rule `commit` and `changes` use. */
918
+ pathAt(d, id, hash) {
919
+ if (!placementOf(d)) return null;
920
+ const cwd = path.resolve(this.root, d.storage.repo ?? '.');
921
+ const dirs = this.recordDirs(d).map((p) => path.relative(cwd, p));
922
+ let out = '';
923
+ try { out = execFileSync('git', ['ls-tree', '-r', '--name-only', hash, '--', ...dirs], { cwd, stdio: QUIET }).toString(); } catch { return null; }
924
+ const prefix = (d.storage.repo ?? '.') === '.' ? '' : `${d.storage.repo}/`;
925
+ for (const p of out.split('\n').filter(Boolean)) {
926
+ const rec = pathToRecord(this.descriptors, prefix + p);
927
+ if (rec && rec.collection === d.name && rec.id === id) return p;
928
+ }
929
+ return null;
930
+ }
931
+
932
+ /** Refuse to WRITE to an id that two files claim. The index keeps the first claimant so reads
933
+ * still answer, but a write would land on one copy and leave the other saying something else —
934
+ * the exact ambiguity `check` reports, made worse under a verb that printed ✔. */
935
+ assertUnambiguous(collection, id) {
936
+ const d = this.descriptor(collection);
937
+ if (!placementOf(d)) return;
938
+ this.ids(collection); // ensures the duplicate memo is current
939
+ const dupes = this._duplicates.get(collection)?.get(id);
940
+ if (dupes) throw new Error(`${collection}/${id} is held by ${dupes.length} files — ${dupes.map((f) => path.relative(this.root, f)).join(' and ')}. Remove one before writing to it (dreamteamer check names them). nothing was written.`);
941
+ }
942
+
943
+ /** Did the owner field of a placed record change between two versions of it? Compared as PARSED
944
+ * owner ids, because `before` comes off disk and may spell the reference bare. */
945
+ ownerChanged(d, before, after) {
946
+ const under = placementOf(d);
947
+ if (!under) return false;
948
+ const prior = { ...before };
949
+ this.qualifyBareRefs(d, prior);
950
+ const parse = (v) => parseRef(v, this.namespaces);
951
+ return ownerIdOf(prior, under, parse) !== ownerIdOf(after, under, parse);
952
+ }
953
+
626
954
  /** Removal, against relations. `rm`'s guard is a TEXT SCAN over every record file — any file whose
627
955
  * bytes contain `<collection>/<id>` refuses the removal — and that was exactly right while every
628
956
  * inbound reference was somebody's hand-written data. It stopped being right the day the engine
@@ -646,7 +974,19 @@ export class Store {
646
974
  */
647
975
  rm(collection, id, { force = false } = {}) {
648
976
  const d = this.writableDescriptor(collection);
977
+ this.assertUnambiguous(collection, id);
649
978
  const self = `${collection}/${id}`;
979
+ const unit = this.recordRoot(d, id); // folder-shape: the whole folder goes, not just the entry file
980
+ this._assertContained(d, unit);
981
+ // A PARENT with records placed inside its folder is refused OUTRIGHT — `--force` included. The
982
+ // folder delete below would erase every one of those records, each of them somebody's own
983
+ // record with its own references, under the subject of removing one thing. There is no honest
984
+ // force here: reassign them (or clear their owner) first, and the deletion is then an ordinary one.
985
+ const held = this.placedChildrenIn(d, unit);
986
+ if (held.length) {
987
+ const lines = held.map((h) => ` ${h.count} ${h.collection} record(s) under ${h.under.path}/ — dreamteamer set ${h.collection}/<id> ${h.under.field}=<another ${collection}> (or ${h.under.field}= to clear)`);
988
+ throw new Error(`${self} holds records of other collections inside its folder:\n${lines.join('\n')}\nmove them first, then remove the ${collection}. nothing was removed.`);
989
+ }
650
990
  // the existence check, and the FKs whose mirrors are detached below. Qualified on a COPY for the
651
991
  // same reason applyMirrorEdits qualifies `before`: a hand-edited or pre-namespace record can
652
992
  // hold `standup` where the engine writes `meetings/standup`, and a raw string compare misses it.
@@ -729,7 +1069,6 @@ export class Store {
729
1069
  throw new Error(`${self} is referenced by:\n${all.map((f) => ` ${f}`).join('\n')}\nfix the references or pass --force. nothing was removed.`);
730
1070
  }
731
1071
 
732
- const unit = this.recordRoot(d, id); // folder-shape: the whole folder goes, not just the entry file
733
1072
  // snapshot BEFORE the delete, or there is nothing left to read
734
1073
  const restore = snapshot([unit]);
735
1074
  // ⚠ THE MEMO, on the way in AND on every way back out. `ids()` keys its cache on HEAD plus the
@@ -781,6 +1120,9 @@ export class Store {
781
1120
  // by another writer) left the record in place with a mirror already detached and an FK
782
1121
  // already cleared — two stale records behind a verb that threw.
783
1122
  fs.rmSync(unit, { recursive: true });
1123
+ // a placed record takes its emptied folders with it — a company's `meetings/` does not
1124
+ // outlive its last meeting; a conventional root's date folders are left as they always were
1125
+ if (placementOf(d)) this._pruneAround(d, unit);
784
1126
  } catch (e) {
785
1127
  rollback();
786
1128
  throw e; // …and now "nothing was removed" is true of everything, not just the record
@@ -795,26 +1137,34 @@ export class Store {
795
1137
 
796
1138
  rename(collection, oldId, newId) {
797
1139
  const d = this.writableDescriptor(collection);
1140
+ this.assertUnambiguous(collection, oldId);
798
1141
  this.read(collection, oldId); // existence check
799
1142
  if (oldId === newId) return { id: newId, rewrites: 0 };
800
1143
  if (d.id?.pattern && !patternRe(d.id.pattern).test(newId)) {
801
1144
  throw new Error(`id "${newId}" does not match pattern ${d.id.pattern} — nothing was renamed.`);
802
1145
  }
803
1146
  const oldUnit = this.recordRoot(d, oldId); // folder-shape: move the WHOLE folder
804
- const newUnit = this.recordRoot(d, newId);
805
- if (fs.existsSync(newUnit)) throw new Error(`${collection}/${newId} already exists — nothing was renamed.`);
1147
+ this._assertContained(d, oldUnit);
1148
+ // a placed record keeps the folder it is in: a rename changes the id, never the owner
1149
+ const newUnit = placementOf(d) ? path.join(this.rootOfFile(d, oldUnit), recordFileName(d, newId)) : this.recordRoot(d, newId);
1150
+ this._assertContained(d, newUnit);
1151
+ if (fs.existsSync(newUnit) || (placementOf(d) && this.ids(collection).has(newId))) throw new Error(`${collection}/${newId} already exists — nothing was renamed.`);
806
1152
  return this.withWriteLock(() => {
807
1153
  this._idsCache.delete(collection);
1154
+ // a folder-shape PARENT carries the records placed inside it along — their files move,
1155
+ // their ids do not, and their id memos are stale the moment the folder is
1156
+ this._dropPlacedUnder(collection);
808
1157
  fs.mkdirSync(path.dirname(newUnit), { recursive: true });
809
1158
  fs.renameSync(oldUnit, newUnit);
810
- pruneEmptyDirs(path.dirname(oldUnit), this.dir(d)); // cross-partition renames leave empty date dirs
1159
+ pruneEmptyDirs(path.dirname(oldUnit), this.rootOfFile(d, oldUnit)); // cross-partition renames leave empty date dirs
811
1160
  // rewrite inbound references (frontmatter/structured always; prose only via wikilinks). It
812
1161
  // snapshots what it writes as it writes it — see rewriteRefs for why the caller cannot.
813
1162
  const { touched, rewrites, skipped, ambiguous, restore } = this.rewriteRefs(`${collection}/${oldId}`, `${collection}/${newId}`);
814
1163
  this.commit([oldUnit, newUnit, ...touched], `dreamteamer: ${collection} rename ${oldId} → ${newId}`, () => {
815
1164
  fs.mkdirSync(path.dirname(oldUnit), { recursive: true });
816
1165
  fs.renameSync(newUnit, oldUnit);
817
- pruneEmptyDirs(path.dirname(newUnit), this.dir(d));
1166
+ pruneEmptyDirs(path.dirname(newUnit), this.rootOfFile(d, newUnit));
1167
+ this._dropPlacedUnder(collection);
818
1168
  restore();
819
1169
  }, d.storage.repo ?? '.');
820
1170
  // each entry names the PAIR it came from, because a batch has more than one — see rewriteRefsBatch
@@ -1176,13 +1526,24 @@ export function bodyField(d) {
1176
1526
  return Object.entries(d.schema.properties ?? {}).find(([, s]) => s?.['x-body'])?.[0];
1177
1527
  }
1178
1528
 
1529
+ /** `<id>.<suffix>.<ext>` — the filename of a text record, wherever its root is. */
1530
+ function recordFileName(d, id) {
1531
+ return `${id}.${d.storage.suffix}${EXT[d.storage.codec ?? 'md']}`;
1532
+ }
1533
+
1179
1534
 
1180
1535
  // remove now-empty parent dirs up to (not including) the collection root
1536
+ // ⚠ NEVER THROWS. An empty directory left behind is cosmetic; a rename that already happened being
1537
+ // reported as a failure because its leftover folder would not go is a duplicate record (R1b). The
1538
+ // loop stops at the first directory it cannot remove — a permission, a concurrent write — and says
1539
+ // nothing, because there is nothing a caller could do about it that would be better.
1181
1540
  function pruneEmptyDirs(dir, stopAt) {
1182
- while (dir !== stopAt && dir.startsWith(stopAt) && fs.existsSync(dir) && fs.readdirSync(dir).length === 0) {
1183
- fs.rmdirSync(dir);
1184
- dir = path.dirname(dir);
1185
- }
1541
+ try {
1542
+ while (dir !== stopAt && dir.startsWith(stopAt) && fs.existsSync(dir) && fs.readdirSync(dir).length === 0) {
1543
+ fs.rmdirSync(dir);
1544
+ dir = path.dirname(dir);
1545
+ }
1546
+ } catch { /* left in place */ }
1186
1547
  }
1187
1548
 
1188
1549
  export function serialize(d, fields, previousText) {