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/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
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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 &&
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
518
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
902
|
+
undo();
|
|
616
903
|
throw e;
|
|
617
904
|
}
|
|
618
|
-
|
|
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
|
-
|
|
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
|
-
|
|
805
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1183
|
-
fs.
|
|
1184
|
-
|
|
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) {
|