dreamteamer 0.6.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/LICENSE +202 -0
- package/NOTICE +16 -0
- package/README.md +83 -0
- package/agents/dreamteamer.agent.md +7 -0
- package/bin/dreamteamer.js +65 -0
- package/collection-templates/docs.collection-template.yaml +14 -0
- package/collection-templates/entity.collection-template.yaml +16 -0
- package/collections/agents.collection.yaml +33 -0
- package/collections/collection-templates.collection.yaml +20 -0
- package/collections/collections.collection.yaml +78 -0
- package/collections/command-bindings.collection.yaml +46 -0
- package/collections/commands.collection.yaml +36 -0
- package/collections/repos.collection.yaml +42 -0
- package/collections/skills.collection.yaml +22 -0
- package/collections/ui-views.collection.yaml +48 -0
- package/collections/users.collection.yaml +21 -0
- package/package.json +58 -0
- package/skills/building-dreamteamer/SKILL.md +117 -0
- package/skills/building-dreamteamer/references/agents.md +44 -0
- package/skills/building-dreamteamer/references/before-you-build.md +42 -0
- package/skills/building-dreamteamer/references/collections.md +120 -0
- package/skills/building-dreamteamer/references/commands.md +69 -0
- package/skills/building-dreamteamer/references/skills.md +73 -0
- package/skills/building-dreamteamer/references/ui-components.md +78 -0
- package/skills/building-dreamteamer/references/ui-views.md +59 -0
- package/skills/using-dreamteamer/SKILL.md +100 -0
- package/skills/using-dreamteamer/references/git-events.md +64 -0
- package/skills/using-dreamteamer/references/records.md +102 -0
- package/src/check.js +193 -0
- package/src/cli.js +250 -0
- package/src/collections-cli.js +389 -0
- package/src/commit.js +117 -0
- package/src/compile.js +747 -0
- package/src/events.js +124 -0
- package/src/field-values.js +69 -0
- package/src/filter.js +107 -0
- package/src/harnesses.js +233 -0
- package/src/history.js +64 -0
- package/src/init.js +307 -0
- package/src/presentation.js +190 -0
- package/src/record-commands.js +84 -0
- package/src/records.js +73 -0
- package/src/runtime.js +96 -0
- package/src/schema-ops.js +263 -0
- package/src/semver.js +32 -0
- package/src/server.js +291 -0
- package/src/store.js +450 -0
- package/src/template.js +98 -0
- package/src/temporal.js +149 -0
- package/src/workspace.js +51 -0
- package/src/yaml.js +6 -0
package/src/store.js
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
// the validating store — every tooling write goes through here:
|
|
2
|
+
// parse → coerce → defaults → validate (HARD: rejected before disk) →
|
|
3
|
+
// atomic write → one git commit. direct file edits stay first-class and are
|
|
4
|
+
// covered by `check` after the fact.
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import Ajv from 'ajv';
|
|
9
|
+
import addFormats from 'ajv-formats';
|
|
10
|
+
import { dump } from './yaml.js';
|
|
11
|
+
import { generateId } from './template.js';
|
|
12
|
+
import { parseRecord, parseRecordText, patternRe, fmtAjvError, unknownFields, walk, EXT, assertSafeId } from './records.js';
|
|
13
|
+
import { normalizeRecord } from './temporal.js';
|
|
14
|
+
import { NO_RUNTIME, loadDescriptors, runtimeDir, sourceRoots as compiledSourceRoots } from './runtime.js';
|
|
15
|
+
export class Store {
|
|
16
|
+
constructor({ root, pkg }) {
|
|
17
|
+
this.root = root;
|
|
18
|
+
this.runtime = runtimeDir(root);
|
|
19
|
+
// Committing is POLICY, not durability — a write is on disk either way. Default OFF:
|
|
20
|
+
// `dt commit` is what publishes. `"auto-commit": true` restores the old behaviour of one
|
|
21
|
+
// commit per mutation.
|
|
22
|
+
this.autoCommit = (pkg ?? readPkg(root)).dreamteamer?.['auto-commit'] === true;
|
|
23
|
+
this.ajv = new Ajv({ allErrors: true, strict: false, useDefaults: true, coerceTypes: 'array' });
|
|
24
|
+
addFormats(this.ajv);
|
|
25
|
+
this.ajv.addFormat('markdown', true);
|
|
26
|
+
this._idsCache = new Map(); // collection -> { key, ids } (see ids())
|
|
27
|
+
const descriptors = loadDescriptors(root);
|
|
28
|
+
if (!descriptors) throw new Error(NO_RUNTIME);
|
|
29
|
+
this.descriptors = descriptors;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
descriptor(collection) {
|
|
33
|
+
const d = this.descriptors.get(collection);
|
|
34
|
+
if (!d) throw new Error(`unknown collection "${collection}" (known: ${[...this.descriptors.keys()].join(', ')})`);
|
|
35
|
+
return d;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// data/state collections are writable through the store; runtime-based (knowhow/meta)
|
|
39
|
+
// entities are edited as SOURCES + compile — refuse politely.
|
|
40
|
+
writableDescriptor(collection) {
|
|
41
|
+
const d = this.descriptor(collection);
|
|
42
|
+
if (d.storage.base === 'runtime') {
|
|
43
|
+
throw new Error(`"${collection}" records are compiled sources — edit the file under the owning module (modules/<module>/${d.storage.path}/) and run \`dreamteamer compile\``);
|
|
44
|
+
}
|
|
45
|
+
return d;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
dir(d) {
|
|
49
|
+
return path.join(d.storage.base === 'runtime' ? this.runtime : this.root, d.storage.path);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
filePath(d, id) {
|
|
53
|
+
assertSafeId(id); // never fs-join an id that can climb out of the collection
|
|
54
|
+
if (d.storage.shape === 'folder') {
|
|
55
|
+
if (!d.storage.entry) throw new Error(`collection "${d.name}" is folder-shape but declares no storage.entry`);
|
|
56
|
+
return path.join(this.dir(d), id, d.storage.entry);
|
|
57
|
+
}
|
|
58
|
+
return path.join(this.dir(d), `${id}.${d.storage.suffix}${EXT[d.storage.codec ?? 'md']}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// the on-disk unit of a record: its folder for folder shapes, its file otherwise
|
|
62
|
+
recordRoot(d, id) {
|
|
63
|
+
assertSafeId(id);
|
|
64
|
+
return d.storage.shape === 'folder' ? path.join(this.dir(d), id) : this.filePath(d, id);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// current HEAD — one cheap rev-parse per cache check vs a multi-thousand-file walk
|
|
68
|
+
gitHead() {
|
|
69
|
+
try { return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: this.root }).toString().trim(); } catch { return 'no-git'; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
ids(collection) {
|
|
73
|
+
const d = this.descriptor(collection);
|
|
74
|
+
const dir = this.dir(d);
|
|
75
|
+
if (!fs.existsSync(dir)) return new Map();
|
|
76
|
+
// memoized per collection, keyed by (HEAD sha, collection dir mtime): every tool
|
|
77
|
+
// write commits (HEAD moves) and every store mutation clears its entry below;
|
|
78
|
+
// direct top-level edits move the dir mtime. honest gap: a DEEP direct edit that
|
|
79
|
+
// adds/removes a record without touching HEAD or the top dir mtime can serve one
|
|
80
|
+
// stale read — acceptable, tool writes always commit and `check` covers hand edits.
|
|
81
|
+
const key = `${this.gitHead()}:${fs.statSync(dir).mtimeMs}`;
|
|
82
|
+
const hit = this._idsCache.get(collection);
|
|
83
|
+
if (hit?.key === key) return hit.ids;
|
|
84
|
+
const ids = this._walkIds(d, dir);
|
|
85
|
+
this._idsCache.set(collection, { key, ids });
|
|
86
|
+
return ids;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
_walkIds(d, dir) {
|
|
90
|
+
const ids = new Map();
|
|
91
|
+
if (d.storage.shape === 'folder') {
|
|
92
|
+
for (const e of fs.readdirSync(dir).sort()) {
|
|
93
|
+
if (e.startsWith('.')) continue;
|
|
94
|
+
const main = path.join(dir, e, d.storage.entry ?? 'SKILL.md');
|
|
95
|
+
if (fs.existsSync(main)) ids.set(e, main);
|
|
96
|
+
}
|
|
97
|
+
return ids;
|
|
98
|
+
}
|
|
99
|
+
const tail = `.${d.storage.suffix}${EXT[d.storage.codec ?? 'md']}`;
|
|
100
|
+
for (const f of walk(dir)) {
|
|
101
|
+
const r = path.relative(dir, f);
|
|
102
|
+
if (r.endsWith(tail)) ids.set(r.slice(0, -tail.length), f);
|
|
103
|
+
}
|
|
104
|
+
return ids;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
read(collection, id) {
|
|
108
|
+
const d = this.descriptor(collection);
|
|
109
|
+
const file = this.ids(collection).get(id);
|
|
110
|
+
if (!file) throw new Error(`${collection}/${id}: no such record`);
|
|
111
|
+
return { fields: parseRecord(file, d, bodyField(d)), file, descriptor: d };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// list-path reader: ONE directory walk for the whole collection (review finding 2:
|
|
115
|
+
// per-id read() re-walked the dir — O(N²) lists, 46s at 3k records).
|
|
116
|
+
*readAll(collection) {
|
|
117
|
+
const d = this.descriptor(collection);
|
|
118
|
+
const bf = bodyField(d);
|
|
119
|
+
for (const [id, file] of this.ids(collection)) {
|
|
120
|
+
yield { id, file, fields: parseRecord(file, d, bf) };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// restore a record to its content at `hash` — validated like any other write, one commit.
|
|
125
|
+
revert(collection, id, hash) {
|
|
126
|
+
const d = this.writableDescriptor(collection);
|
|
127
|
+
const { file } = this.read(collection, id);
|
|
128
|
+
const relPath = path.relative(this.root, file);
|
|
129
|
+
let previousContent;
|
|
130
|
+
try {
|
|
131
|
+
previousContent = execFileSync('git', ['show', `${hash}:${relPath}`], { cwd: this.root }).toString();
|
|
132
|
+
} catch {
|
|
133
|
+
throw new Error(`${collection}/${id}: no content at ${hash} for ${relPath} — nothing was reverted.`);
|
|
134
|
+
}
|
|
135
|
+
const current = fs.readFileSync(file, 'utf8');
|
|
136
|
+
if (current === previousContent) return { id, reverted: false };
|
|
137
|
+
// parse + validate the historical content before it touches disk
|
|
138
|
+
const tmpFields = parseRecordText(previousContent, d, bodyField(d));
|
|
139
|
+
this.validate(d, tmpFields);
|
|
140
|
+
return this.withWriteLock(() => {
|
|
141
|
+
this._idsCache.delete(collection); // every mutation drops the memo — cleared even if the commit rolls back
|
|
142
|
+
atomicWrite(file, previousContent);
|
|
143
|
+
this.commit([file], `dreamteamer: ${collection} revert ${id} to ${String(hash).slice(0, 7)}`, () => atomicWrite(file, current), d.storage.repo ?? '.');
|
|
144
|
+
return { id, reverted: true, hash };
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---- validation (hard) ---------------------------------------------------
|
|
149
|
+
|
|
150
|
+
validate(d, fields, { skipRefs = false } = {}) {
|
|
151
|
+
// hard at the tools includes UNKNOWN fields: a typo'd key must never land on disk
|
|
152
|
+
const unknown = unknownFields(d.schema, fields);
|
|
153
|
+
if (unknown.length) throw new Error(`unknown field(s) for this collection: ${unknown.join(', ')} — nothing was written.`);
|
|
154
|
+
// BEFORE ajv, and deliberately inside validate() rather than in each verb: this is the one
|
|
155
|
+
// choke point add/set/revert all pass through, so `--starts "2026-07-28 12:00"` from a CLI
|
|
156
|
+
// session and a `datetime-local` widget's `2026-07-28T12:00` reach disk as the same
|
|
157
|
+
// canonical, offset-carrying value. ajv's `date-time` accepts exactly one spelling; without
|
|
158
|
+
// this every human-shaped input is a validation error (see src/temporal.js).
|
|
159
|
+
normalizeRecord(d.schema, fields);
|
|
160
|
+
const validate = this.ajv.compile(d.schema); // useDefaults mutates: defaults materialize
|
|
161
|
+
if (!validate(fields)) {
|
|
162
|
+
const msgs = validate.errors.map((e) => ' ' + fmtAjvError(e, fields));
|
|
163
|
+
throw new Error(`validation failed:\n${msgs.join('\n')}\nnothing was written.`);
|
|
164
|
+
}
|
|
165
|
+
if (!skipRefs) this.checkRefs(d, fields);
|
|
166
|
+
return fields;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
checkRefs(d, fields, prefix = []) {
|
|
170
|
+
for (const [key, s] of Object.entries(d.schema.properties ?? {})) {
|
|
171
|
+
const target = s?.['x-reference'] ?? s?.items?.['x-reference'];
|
|
172
|
+
if (!target) continue;
|
|
173
|
+
const raw = fields[key];
|
|
174
|
+
if (raw == null) continue;
|
|
175
|
+
for (const value of Array.isArray(raw) ? raw : [raw]) {
|
|
176
|
+
if (typeof value !== 'string' || value.startsWith('@')) continue;
|
|
177
|
+
const slash = value.indexOf('/');
|
|
178
|
+
if (slash < 1) throw new Error(`${key}: reference "${value}" is not <collection>/<id> — nothing was written.`);
|
|
179
|
+
const coll = value.slice(0, slash);
|
|
180
|
+
const id = value.slice(slash + 1);
|
|
181
|
+
if (target !== '*' && coll !== target) throw new Error(`${key}: reference "${value}" must target collection "${target}" — nothing was written.`);
|
|
182
|
+
if (!this.descriptors.has(coll)) throw new Error(`${key}: reference "${value}" targets unknown collection "${coll}" — nothing was written.`);
|
|
183
|
+
if (!this.ids(coll).has(id)) throw new Error(`${key}: dangling reference "${value}" — no such record. nothing was written.`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---- verbs -----------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
add(collection, fields, { id: explicitId } = {}) {
|
|
191
|
+
const d = this.writableDescriptor(collection);
|
|
192
|
+
this.validate(d, fields);
|
|
193
|
+
const id = explicitId ?? generateId(d.id?.generate ?? '{{ name | slug }}', fields, [...this.ids(collection).keys()]);
|
|
194
|
+
if (d.id?.pattern && !patternRe(d.id.pattern).test(id)) {
|
|
195
|
+
throw new Error(`id "${id}" does not match pattern ${d.id.pattern} — nothing was written.`);
|
|
196
|
+
}
|
|
197
|
+
const file = this.filePath(d, id);
|
|
198
|
+
if (fs.existsSync(file)) throw new Error(`${collection}/${id} already exists — nothing was written.`);
|
|
199
|
+
return this.withWriteLock(() => {
|
|
200
|
+
this._idsCache.delete(collection);
|
|
201
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
202
|
+
atomicWrite(file, serialize(d, fields));
|
|
203
|
+
this.commit([file], `dreamteamer: ${collection} add ${id}`, () => {
|
|
204
|
+
fs.rmSync(file, { force: true });
|
|
205
|
+
pruneEmptyDirs(path.dirname(file), this.dir(d));
|
|
206
|
+
}, d.storage.repo ?? '.');
|
|
207
|
+
return { id, file };
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
set(collection, id, changes) {
|
|
212
|
+
const d = this.writableDescriptor(collection);
|
|
213
|
+
const { fields, file } = this.read(collection, id);
|
|
214
|
+
const previous = fs.readFileSync(file, 'utf8');
|
|
215
|
+
const next = { ...fields, ...changes };
|
|
216
|
+
for (const [k, v] of Object.entries(changes)) if (v === null || v === '') delete next[k];
|
|
217
|
+
this.validate(d, next);
|
|
218
|
+
return this.withWriteLock(() => {
|
|
219
|
+
this._idsCache.delete(collection);
|
|
220
|
+
atomicWrite(file, serialize(d, next));
|
|
221
|
+
this.commit([file], `dreamteamer: ${collection} set ${id}`, () => atomicWrite(file, previous), d.storage.repo ?? '.');
|
|
222
|
+
return { id, file };
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
rm(collection, id, { force = false } = {}) {
|
|
227
|
+
const d = this.writableDescriptor(collection);
|
|
228
|
+
this.read(collection, id); // existence check
|
|
229
|
+
const inbound = this.findInboundRefs(`${collection}/${id}`);
|
|
230
|
+
if (inbound.length && !force) {
|
|
231
|
+
throw new Error(`${collection}/${id} is referenced by:\n${inbound.map((f) => ` ${f}`).join('\n')}\nfix the references or pass --force. nothing was removed.`);
|
|
232
|
+
}
|
|
233
|
+
const unit = this.recordRoot(d, id); // folder-shape: the whole folder goes, not just the entry file
|
|
234
|
+
// Folder-shape records would need a recursive snapshot; the only folder-shape collection
|
|
235
|
+
// is `skills`, which is system-stored and so never reaches rm (writableDescriptor refuses
|
|
236
|
+
// first). Not built for a case that cannot occur.
|
|
237
|
+
// snapshot BEFORE the delete, or there is nothing left to read
|
|
238
|
+
const restore = snapshot([unit]);
|
|
239
|
+
return this.withWriteLock(() => {
|
|
240
|
+
this._idsCache.delete(collection);
|
|
241
|
+
fs.rmSync(unit, { recursive: true });
|
|
242
|
+
this.commit([unit], `dreamteamer: ${collection} rm ${id}`, restore, d.storage.repo ?? '.');
|
|
243
|
+
return { id, inboundIgnored: force ? inbound.length : 0 };
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
rename(collection, oldId, newId) {
|
|
248
|
+
const d = this.writableDescriptor(collection);
|
|
249
|
+
this.read(collection, oldId); // existence check
|
|
250
|
+
if (oldId === newId) return { id: newId, rewrites: 0 };
|
|
251
|
+
if (d.id?.pattern && !patternRe(d.id.pattern).test(newId)) {
|
|
252
|
+
throw new Error(`id "${newId}" does not match pattern ${d.id.pattern} — nothing was renamed.`);
|
|
253
|
+
}
|
|
254
|
+
const oldUnit = this.recordRoot(d, oldId); // folder-shape: move the WHOLE folder
|
|
255
|
+
const newUnit = this.recordRoot(d, newId);
|
|
256
|
+
if (fs.existsSync(newUnit)) throw new Error(`${collection}/${newId} already exists — nothing was renamed.`);
|
|
257
|
+
return this.withWriteLock(() => {
|
|
258
|
+
this._idsCache.delete(collection);
|
|
259
|
+
fs.mkdirSync(path.dirname(newUnit), { recursive: true });
|
|
260
|
+
fs.renameSync(oldUnit, newUnit);
|
|
261
|
+
pruneEmptyDirs(path.dirname(oldUnit), this.dir(d)); // cross-partition renames leave empty date dirs
|
|
262
|
+
// Snapshot the referencing files BEFORE rewriteRefs edits them — its `touched` list
|
|
263
|
+
// only exists after the damage is done. findInboundRefs returns paths relative to
|
|
264
|
+
// this.root; snapshot() needs absolute paths.
|
|
265
|
+
const refFiles = this.findInboundRefs(`${collection}/${oldId}`).map((f) => path.join(this.root, f));
|
|
266
|
+
const restoreTouched = snapshot(refFiles);
|
|
267
|
+
// rewrite inbound references (frontmatter/structured always; prose only via wikilinks)
|
|
268
|
+
const { touched, rewrites, skipped } = this.rewriteRefs(`${collection}/${oldId}`, `${collection}/${newId}`);
|
|
269
|
+
this.commit([oldUnit, newUnit, ...touched], `dreamteamer: ${collection} rename ${oldId} → ${newId}`, () => {
|
|
270
|
+
fs.mkdirSync(path.dirname(oldUnit), { recursive: true });
|
|
271
|
+
fs.renameSync(newUnit, oldUnit);
|
|
272
|
+
pruneEmptyDirs(path.dirname(newUnit), this.dir(d));
|
|
273
|
+
restoreTouched();
|
|
274
|
+
}, d.storage.repo ?? '.');
|
|
275
|
+
for (const s of skipped) {
|
|
276
|
+
console.warn(`⚠ ${path.relative(this.root, s.file)}: ${s.count} raw-prose occurrence(s) of ${collection}/${oldId} left untouched — only [[wikilinks]] are maintained in bodies (decision 7)`);
|
|
277
|
+
}
|
|
278
|
+
return { id: newId, rewrites, touched: touched.length, skipped: skipped.length };
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// exact-ref matching with a boundary so contacts/jane never matches contacts/jane-doe
|
|
283
|
+
refRegex(ref) {
|
|
284
|
+
return new RegExp(`${ref.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w/-])`, 'g');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
*recordFiles() {
|
|
288
|
+
for (const d of this.descriptors.values()) {
|
|
289
|
+
// for runtime-based collections, inbound-ref surgery targets SOURCES, not the runtime
|
|
290
|
+
const roots = d.storage.base === 'runtime' ? this.sourceRoots() : [this.root];
|
|
291
|
+
for (const srcRoot of roots) {
|
|
292
|
+
const dir = path.join(srcRoot, d.storage.path);
|
|
293
|
+
if (fs.existsSync(dir)) yield* walk(dir);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// every compiled module (review finding 10: this layer never learned decision 24 — rename
|
|
299
|
+
// silently skipped git_modules sources), read off the manifest rather than by re-discovering
|
|
300
|
+
// modules, which is what used to make the store import the compiler. See runtime.js.
|
|
301
|
+
sourceRoots() {
|
|
302
|
+
return compiledSourceRoots(this.root);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
findInboundRefs(ref) {
|
|
306
|
+
const re = this.refRegex(ref);
|
|
307
|
+
const hits = [];
|
|
308
|
+
for (const f of this.recordFiles()) {
|
|
309
|
+
const text = fs.readFileSync(f, 'utf8');
|
|
310
|
+
re.lastIndex = 0;
|
|
311
|
+
if (re.test(text)) hits.push(path.relative(this.root, f));
|
|
312
|
+
}
|
|
313
|
+
return hits;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// decision 7 (un-parked): structured surfaces (frontmatter, yaml/json records) rewrite
|
|
317
|
+
// unconditionally; PROSE bodies rewrite only inside [[collection/id]] / [[collection/id|label]]
|
|
318
|
+
// wikilinks — raw-text matching corrupted look-alike URLs (review finding 4). raw body
|
|
319
|
+
// occurrences are counted and reported, never touched.
|
|
320
|
+
rewriteRefs(oldRef, newRef) {
|
|
321
|
+
const touched = [];
|
|
322
|
+
const skipped = [];
|
|
323
|
+
let rewrites = 0;
|
|
324
|
+
const escaped = oldRef.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
325
|
+
const wikiRe = new RegExp(`\\[\\[${escaped}(\\|[^\\]]*)?\\]\\]`, 'g');
|
|
326
|
+
for (const f of this.recordFiles()) {
|
|
327
|
+
const text = fs.readFileSync(f, 'utf8');
|
|
328
|
+
let next;
|
|
329
|
+
let count = 0;
|
|
330
|
+
if (f.endsWith('.md')) {
|
|
331
|
+
// prose scoping applies to EVERY .md — a frontmatter-less file is all body
|
|
332
|
+
// (docs-audit catch: it used to fall through to raw replacement)
|
|
333
|
+
const fm = /^(---\r?\n[\s\S]*?\r?\n---\r?\n?)([\s\S]*)$/.exec(text);
|
|
334
|
+
const headText = fm ? fm[1] : '';
|
|
335
|
+
const bodyText = fm ? fm[2] : text;
|
|
336
|
+
const head = headText.replace(this.refRegex(oldRef), () => (count++, newRef));
|
|
337
|
+
const body = bodyText.replace(wikiRe, (_, label) => (count++, `[[${newRef}${label ?? ''}]]`));
|
|
338
|
+
next = head + body;
|
|
339
|
+
const raw = (body.match(this.refRegex(oldRef)) ?? []).length;
|
|
340
|
+
if (raw) skipped.push({ file: f, count: raw });
|
|
341
|
+
} else {
|
|
342
|
+
next = text.replace(this.refRegex(oldRef), () => (count++, newRef));
|
|
343
|
+
}
|
|
344
|
+
if (count === 0) continue;
|
|
345
|
+
rewrites += count;
|
|
346
|
+
atomicWrite(f, next);
|
|
347
|
+
touched.push(f);
|
|
348
|
+
}
|
|
349
|
+
return { touched, rewrites, skipped };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---- write serialization + rollback (review finding 3; reinstates the v2 commit
|
|
353
|
+
// queue idea in sync form). within ONE process Node's sync fs/exec already serializes;
|
|
354
|
+
// the lock guards CLI-beside-server cross-process races on .git/index.lock. a commit
|
|
355
|
+
// failure UNDOES the write, so "one mutation = one commit" fails CLOSED and
|
|
356
|
+
// "nothing was written" stays true.
|
|
357
|
+
withWriteLock(fn) {
|
|
358
|
+
const lock = path.join(this.runtime, '.write-lock');
|
|
359
|
+
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
|
360
|
+
const deadline = Date.now() + 5000;
|
|
361
|
+
for (;;) {
|
|
362
|
+
try { fs.mkdirSync(lock); break; } catch (e) {
|
|
363
|
+
if (e.code !== 'EEXIST') throw e;
|
|
364
|
+
try { if (Date.now() - fs.statSync(lock).mtimeMs > 30_000) { fs.rmdirSync(lock); continue; } } catch { /* raced the holder */ }
|
|
365
|
+
if (Date.now() > deadline) throw new Error('another dreamteamer process holds the write lock (.dreamteamer/.write-lock) — retry, or remove it if nothing is running.');
|
|
366
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50); // sync sleep, no busy spin
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
try { return fn(); } finally { try { fs.rmdirSync(lock); } catch { /* already gone */ } }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Persist, or don't — `auto-commit` decides. The files are already on disk in both cases;
|
|
373
|
+
* this only chooses whether they are published now or by a later `dt commit`.
|
|
374
|
+
* `repo` is the workspace-relative root of the git repo that owns them ('.' = workspace). */
|
|
375
|
+
commit(files, subject, undo, repo = '.') {
|
|
376
|
+
if (!this.autoCommit) return;
|
|
377
|
+
const cwd = path.resolve(this.root, repo);
|
|
378
|
+
const rel = files.map((f) => path.relative(cwd, f));
|
|
379
|
+
try {
|
|
380
|
+
execFileSync('git', ['add', '--all', '--', ...rel], { cwd });
|
|
381
|
+
execFileSync('git', ['commit', '--quiet', '-m', subject, '--', ...rel], { cwd });
|
|
382
|
+
} catch (e) {
|
|
383
|
+
try { execFileSync('git', ['reset', '--quiet', '--', ...rel], { cwd }); } catch { /* nothing staged */ }
|
|
384
|
+
if (undo) {
|
|
385
|
+
try { undo(); } catch (u) {
|
|
386
|
+
throw new Error(`git commit failed AND rollback failed (${u.message}) — inspect the working tree. original: ${e.message.split('\n')[0]}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
throw new Error(`git commit failed — the write was rolled back, nothing was changed. (${e.message.split('\n')[0]})`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function bodyField(d) {
|
|
395
|
+
return Object.entries(d.schema.properties ?? {}).find(([, s]) => s?.['x-body'])?.[0];
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
// remove now-empty parent dirs up to (not including) the collection root
|
|
400
|
+
function pruneEmptyDirs(dir, stopAt) {
|
|
401
|
+
while (dir !== stopAt && dir.startsWith(stopAt) && fs.existsSync(dir) && fs.readdirSync(dir).length === 0) {
|
|
402
|
+
fs.rmdirSync(dir);
|
|
403
|
+
dir = path.dirname(dir);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function serialize(d, fields) {
|
|
408
|
+
const codec = d.storage.codec ?? 'md';
|
|
409
|
+
if (codec === 'json') return JSON.stringify(fields, null, 2) + '\n';
|
|
410
|
+
if (codec === 'yaml') return dump(fields);
|
|
411
|
+
const bf = bodyField(d);
|
|
412
|
+
const fm = { ...fields };
|
|
413
|
+
let body = '';
|
|
414
|
+
if (bf && fm[bf] !== undefined) {
|
|
415
|
+
body = String(fm[bf]).trim();
|
|
416
|
+
delete fm[bf];
|
|
417
|
+
}
|
|
418
|
+
return `---\n${dump(fm)}---\n${body ? body + '\n' : ''}`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function atomicWrite(file, content) {
|
|
422
|
+
if (fs.existsSync(file) && fs.readFileSync(file, 'utf8') === content) return false; // true no-op
|
|
423
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
424
|
+
fs.writeFileSync(tmp, content);
|
|
425
|
+
fs.renameSync(tmp, file);
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function readPkg(root) {
|
|
430
|
+
try { return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); } catch { return {}; }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Byte snapshot of a set of files, and a restore closure. The undo mechanism schema-ops has
|
|
434
|
+
* used for source writes since it was written (schema-ops.js:20). Record writes used
|
|
435
|
+
* `git checkout HEAD -- <paths>` instead, which is only correct while HEAD is guaranteed to be
|
|
436
|
+
* the last good state — it is not, once writes stop committing, and it silently discarded
|
|
437
|
+
* uncommitted hand-edits even before that. */
|
|
438
|
+
function snapshot(units) {
|
|
439
|
+
const snaps = units.map((u) => ({
|
|
440
|
+
u,
|
|
441
|
+
prev: fs.existsSync(u) && fs.statSync(u).isFile() ? fs.readFileSync(u) : null,
|
|
442
|
+
existed: fs.existsSync(u),
|
|
443
|
+
}));
|
|
444
|
+
return () => {
|
|
445
|
+
for (const { u, prev, existed } of snaps) {
|
|
446
|
+
if (!existed) { fs.rmSync(u, { force: true, recursive: true }); continue; }
|
|
447
|
+
if (prev !== null) { fs.mkdirSync(path.dirname(u), { recursive: true }); fs.writeFileSync(u, prev); }
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
}
|
package/src/template.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// id-template evaluation per the descriptor `id.generate` contract:
|
|
2
|
+
// {{ field | filter[:arg] | ... }} — vocabulary: record fields, created/now,
|
|
3
|
+
// seq (next free sequence for the rendered prefix), filters date[:fmt],
|
|
4
|
+
// datetime, slug, pad:n, basename. dates come from CREATION time, never
|
|
5
|
+
// mutable fields.
|
|
6
|
+
const SEQ = '__DT_SEQ__';
|
|
7
|
+
|
|
8
|
+
export function generateId(tpl, fields, existingIds = []) {
|
|
9
|
+
const created = new Date();
|
|
10
|
+
let sawSeq = false;
|
|
11
|
+
let seqPad = 0;
|
|
12
|
+
|
|
13
|
+
const rendered = tpl.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_, expr) => {
|
|
14
|
+
const [head, ...filters] = expr.split('|').map((s) => s.trim());
|
|
15
|
+
let value;
|
|
16
|
+
if (head === 'created' || head === 'now') value = created;
|
|
17
|
+
else if (head === 'seq') { sawSeq = true; value = SEQ; }
|
|
18
|
+
else value = fields[head];
|
|
19
|
+
if (value === undefined || value === null || value === '') {
|
|
20
|
+
throw new Error(`id template needs "${head}" — provide it (or pass an explicit id)`);
|
|
21
|
+
}
|
|
22
|
+
for (const f of filters) {
|
|
23
|
+
const [name, arg] = f.split(':').map((s) => s.trim());
|
|
24
|
+
if (value === SEQ) {
|
|
25
|
+
if (name === 'pad') seqPad = Number(arg) || 0;
|
|
26
|
+
continue; // filters never transform the seq placeholder itself
|
|
27
|
+
}
|
|
28
|
+
value = applyFilter(name, arg, value);
|
|
29
|
+
}
|
|
30
|
+
if (value instanceof Date) value = fmtDate(value, 'YYYY-MM-DD');
|
|
31
|
+
return String(value);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (!sawSeq) return rendered;
|
|
35
|
+
|
|
36
|
+
// seq: next free number among existing ids matching the rendered prefix/suffix
|
|
37
|
+
const [prefix, suffix] = rendered.split(SEQ);
|
|
38
|
+
let max = 0;
|
|
39
|
+
for (const id of existingIds) {
|
|
40
|
+
if (!id.startsWith(prefix) || !id.endsWith(suffix)) continue;
|
|
41
|
+
const mid = id.slice(prefix.length, suffix.length ? -suffix.length : undefined);
|
|
42
|
+
if (/^\d+$/.test(mid)) max = Math.max(max, Number(mid));
|
|
43
|
+
}
|
|
44
|
+
const n = String(max + 1);
|
|
45
|
+
return prefix + (seqPad ? n.padStart(seqPad, '0') : n) + suffix;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function applyFilter(name, arg, value) {
|
|
49
|
+
switch (name) {
|
|
50
|
+
case 'date': return fmtDate(asDate(value), arg || 'YYYY-MM-DD');
|
|
51
|
+
// ids are paths: no colons (windows-hostile, ungreppable) — 2026-07-25T13-39-17
|
|
52
|
+
case 'datetime': return asDate(value).toISOString().slice(0, 19).replace(/:/g, '-');
|
|
53
|
+
case 'slug': return slugOrHash(String(value));
|
|
54
|
+
case 'pad': return String(value).padStart(Number(arg) || 0, '0');
|
|
55
|
+
case 'basename': return String(value).split('/').pop();
|
|
56
|
+
default: throw new Error(`unknown id-template filter "${name}"`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const asDate = (v) => (v instanceof Date ? v : new Date(v));
|
|
61
|
+
|
|
62
|
+
// `date:HH-mm` on a date-time field is how an id embeds a start time (data/meetings ids sort by
|
|
63
|
+
// start within a day). Tokens are replaced longest-first so `MM` (month) can't eat the `M` of a
|
|
64
|
+
// minute pattern. Everything is read in the MACHINE's zone — an offset-carrying value like
|
|
65
|
+
// `2026-07-28T12:00:00+03:00` therefore renders as 12:00 only on a +03:00 machine. That is the
|
|
66
|
+
// same exposure the old `date` field had and is why ids are generated at sync time, in the zone
|
|
67
|
+
// the meetings actually happen in, rather than re-derived later somewhere else.
|
|
68
|
+
function fmtDate(d, fmt) {
|
|
69
|
+
const pad = (n, w = 2) => String(n).padStart(w, '0');
|
|
70
|
+
const tokens = {
|
|
71
|
+
YYYY: String(d.getFullYear()),
|
|
72
|
+
MM: pad(d.getMonth() + 1),
|
|
73
|
+
DD: pad(d.getDate()),
|
|
74
|
+
HH: pad(d.getHours()),
|
|
75
|
+
mm: pad(d.getMinutes()),
|
|
76
|
+
ss: pad(d.getSeconds()),
|
|
77
|
+
};
|
|
78
|
+
return fmt.replace(/YYYY|MM|DD|HH|mm|ss/g, (t) => tokens[t]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function slug(s) {
|
|
82
|
+
return s
|
|
83
|
+
.normalize('NFKD')
|
|
84
|
+
.replace(/[̀-ͯ]/g, '')
|
|
85
|
+
.toLowerCase()
|
|
86
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
87
|
+
.replace(/^-+|-+$/g, '');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// non-latin titles (hebrew!) slug to "" — fall back to a short deterministic
|
|
91
|
+
// hash of the original value so the id stays pattern-legal and stable
|
|
92
|
+
export function slugOrHash(s) {
|
|
93
|
+
const out = slug(s);
|
|
94
|
+
if (out) return out;
|
|
95
|
+
let h = 0;
|
|
96
|
+
for (const ch of s) h = (h * 31 + ch.codePointAt(0)) >>> 0;
|
|
97
|
+
return 'x' + h.toString(36).padStart(7, '0');
|
|
98
|
+
}
|