@theford/runly 0.0.0-stage → 0.20.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.
Files changed (128) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +157 -2
  3. package/dist/cli/client.d.ts +80 -0
  4. package/dist/cli/client.js +294 -0
  5. package/dist/cli/client.js.map +1 -0
  6. package/dist/cli/daemon-unit.d.ts +58 -0
  7. package/dist/cli/daemon-unit.js +289 -0
  8. package/dist/cli/daemon-unit.js.map +1 -0
  9. package/dist/cli/index.d.ts +2 -0
  10. package/dist/cli/index.js +1482 -0
  11. package/dist/cli/index.js.map +1 -0
  12. package/dist/cli/install.d.ts +9 -0
  13. package/dist/cli/install.js +53 -0
  14. package/dist/cli/install.js.map +1 -0
  15. package/dist/cli/logs.d.ts +43 -0
  16. package/dist/cli/logs.js +232 -0
  17. package/dist/cli/logs.js.map +1 -0
  18. package/dist/cli/tether.d.ts +3 -0
  19. package/dist/cli/tether.js +65 -0
  20. package/dist/cli/tether.js.map +1 -0
  21. package/dist/cli/watchdog.d.ts +1 -0
  22. package/dist/cli/watchdog.js +81 -0
  23. package/dist/cli/watchdog.js.map +1 -0
  24. package/dist/core/builds.d.ts +20 -0
  25. package/dist/core/builds.js +93 -0
  26. package/dist/core/builds.js.map +1 -0
  27. package/dist/core/caller-env.d.ts +17 -0
  28. package/dist/core/caller-env.js +77 -0
  29. package/dist/core/caller-env.js.map +1 -0
  30. package/dist/core/diagnostics.d.ts +68 -0
  31. package/dist/core/diagnostics.js +34 -0
  32. package/dist/core/diagnostics.js.map +1 -0
  33. package/dist/core/env-vars.d.ts +35 -0
  34. package/dist/core/env-vars.js +32 -0
  35. package/dist/core/env-vars.js.map +1 -0
  36. package/dist/core/events.d.ts +10 -0
  37. package/dist/core/events.js +42 -0
  38. package/dist/core/events.js.map +1 -0
  39. package/dist/core/exec.d.ts +22 -0
  40. package/dist/core/exec.js +84 -0
  41. package/dist/core/exec.js.map +1 -0
  42. package/dist/core/journal.d.ts +227 -0
  43. package/dist/core/journal.js +395 -0
  44. package/dist/core/journal.js.map +1 -0
  45. package/dist/core/logs.d.ts +56 -0
  46. package/dist/core/logs.js +259 -0
  47. package/dist/core/logs.js.map +1 -0
  48. package/dist/core/manifest.d.ts +292 -0
  49. package/dist/core/manifest.js +179 -0
  50. package/dist/core/manifest.js.map +1 -0
  51. package/dist/core/namespaces.d.ts +7 -0
  52. package/dist/core/namespaces.js +59 -0
  53. package/dist/core/namespaces.js.map +1 -0
  54. package/dist/core/paths.d.ts +31 -0
  55. package/dist/core/paths.js +65 -0
  56. package/dist/core/paths.js.map +1 -0
  57. package/dist/core/policy.d.ts +94 -0
  58. package/dist/core/policy.js +122 -0
  59. package/dist/core/policy.js.map +1 -0
  60. package/dist/core/ports.d.ts +52 -0
  61. package/dist/core/ports.js +94 -0
  62. package/dist/core/ports.js.map +1 -0
  63. package/dist/core/presets.d.ts +25 -0
  64. package/dist/core/presets.js +86 -0
  65. package/dist/core/presets.js.map +1 -0
  66. package/dist/core/procscan.d.ts +111 -0
  67. package/dist/core/procscan.js +342 -0
  68. package/dist/core/procscan.js.map +1 -0
  69. package/dist/core/retention.d.ts +97 -0
  70. package/dist/core/retention.js +322 -0
  71. package/dist/core/retention.js.map +1 -0
  72. package/dist/core/sleep.d.ts +51 -0
  73. package/dist/core/sleep.js +99 -0
  74. package/dist/core/sleep.js.map +1 -0
  75. package/dist/core/tree-ledger.d.ts +39 -0
  76. package/dist/core/tree-ledger.js +92 -0
  77. package/dist/core/tree-ledger.js.map +1 -0
  78. package/dist/core/types.d.ts +21 -0
  79. package/dist/core/types.js +6 -0
  80. package/dist/core/types.js.map +1 -0
  81. package/dist/core/units.d.ts +20 -0
  82. package/dist/core/units.js +69 -0
  83. package/dist/core/units.js.map +1 -0
  84. package/dist/core/upkeep.d.ts +90 -0
  85. package/dist/core/upkeep.js +176 -0
  86. package/dist/core/upkeep.js.map +1 -0
  87. package/dist/core/util.d.ts +46 -0
  88. package/dist/core/util.js +168 -0
  89. package/dist/core/util.js.map +1 -0
  90. package/dist/core/version.d.ts +39 -0
  91. package/dist/core/version.js +144 -0
  92. package/dist/core/version.js.map +1 -0
  93. package/dist/core/worktree.d.ts +51 -0
  94. package/dist/core/worktree.js +309 -0
  95. package/dist/core/worktree.js.map +1 -0
  96. package/dist/daemon/budget.d.ts +155 -0
  97. package/dist/daemon/budget.js +288 -0
  98. package/dist/daemon/budget.js.map +1 -0
  99. package/dist/daemon/election.d.ts +9 -0
  100. package/dist/daemon/election.js +140 -0
  101. package/dist/daemon/election.js.map +1 -0
  102. package/dist/daemon/engine.d.ts +1703 -0
  103. package/dist/daemon/engine.js +5305 -0
  104. package/dist/daemon/engine.js.map +1 -0
  105. package/dist/daemon/index.d.ts +2 -0
  106. package/dist/daemon/index.js +412 -0
  107. package/dist/daemon/index.js.map +1 -0
  108. package/dist/daemon/proxy.d.ts +108 -0
  109. package/dist/daemon/proxy.js +615 -0
  110. package/dist/daemon/proxy.js.map +1 -0
  111. package/dist/daemon/supervisor.d.ts +101 -0
  112. package/dist/daemon/supervisor.js +493 -0
  113. package/dist/daemon/supervisor.js.map +1 -0
  114. package/dist/drivers/appliances.d.ts +11 -0
  115. package/dist/drivers/appliances.js +174 -0
  116. package/dist/drivers/appliances.js.map +1 -0
  117. package/dist/drivers/datastores.d.ts +144 -0
  118. package/dist/drivers/datastores.js +775 -0
  119. package/dist/drivers/datastores.js.map +1 -0
  120. package/dist/drivers/preview.d.ts +69 -0
  121. package/dist/drivers/preview.js +605 -0
  122. package/dist/drivers/preview.js.map +1 -0
  123. package/dist/drivers/types.d.ts +70 -0
  124. package/dist/drivers/types.js +13 -0
  125. package/dist/drivers/types.js.map +1 -0
  126. package/package.json +63 -4
  127. package/schema/backlot.schema.json +549 -0
  128. package/schema/runly.schema.json +556 -0
@@ -0,0 +1,775 @@
1
+ /**
2
+ * Datastore drivers (see docs/driver-spec.md).
3
+ *
4
+ * Two families:
5
+ * - sqlite: fully engine-native — the ns IS a file; template restore is a copy.
6
+ * - command (postgres/mssql/mysql/redis): ALL mechanics are repo-declared
7
+ * commands ({{ns}}/{{preset}}/{{template}} resolved by the engine). runly
8
+ * embeds no database clients — the anti-scope ("orchestrate, don't
9
+ * reimplement") applied to data.
10
+ *
11
+ * Template model: bake once per seed-content hash into a template ns, then
12
+ * restore per environment (postgres: `createdb -T`; mssql: the repo's
13
+ * backup/restore script). Templates are machine-global and immutable-keyed
14
+ * (decision 0006/0008). A content-keyed template (one with an
15
+ * `@rebake-template` key) is shared by every worktree of the same manifest
16
+ * name on this daemon (decision 0044): it lives under `<name>@shared/`
17
+ * instead of `<stack id>/`.
18
+ */
19
+ import { copyFileSync, mkdirSync, statSync, utimesSync, rmSync, existsSync, readdirSync, readFileSync, writeFileSync, constants as fsConstants, } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
+ import { connect } from 'node:net';
22
+ import { templatesRoot } from '../core/paths.js';
23
+ import { recordNamespace } from '../core/namespaces.js';
24
+ import { sha256, template, BrokerError, commandFailure } from '../core/util.js';
25
+ import { runBounded, DEFAULT_CMD_TIMEOUT_S } from '../core/exec.js';
26
+ import { defaultPreset } from '../core/manifest.js';
27
+ import { logEvent } from '../core/events.js';
28
+ /** Every preset a datastore can hold (its catalog, or the default alone). */
29
+ function presetNames(spec) {
30
+ return [...new Set([...(spec.presets ?? []), defaultPreset(spec), 'default'])];
31
+ }
32
+ const sh = async (cmd, cwd, errCtx) => {
33
+ const r = await runBounded(cmd, cwd);
34
+ if (r.timedOut) {
35
+ // A hung command is an environment problem, not the repo's code being
36
+ // wrong — and it must be reported, never waited on forever.
37
+ throw new BrokerError('env-error', `${errCtx}: command did not finish within ${DEFAULT_CMD_TIMEOUT_S}s and was killed`, 'datastore', r.output.slice(-800));
38
+ }
39
+ if (r.code !== 0)
40
+ throw commandFailure(errCtx, 'datastore', r.output);
41
+ };
42
+ /** Best-effort variant: failures are expected (clean-slate drops) and ignored. */
43
+ const shQuiet = async (cmd, cwd) => {
44
+ await runBounded(cmd, cwd);
45
+ };
46
+ const templateLocks = new Map();
47
+ function lockOf(key) {
48
+ let l = templateLocks.get(key);
49
+ if (!l) {
50
+ l = { readers: 0, writer: false, queue: [] };
51
+ templateLocks.set(key, l);
52
+ }
53
+ return l;
54
+ }
55
+ function pump(key, l) {
56
+ while (l.queue.length > 0) {
57
+ const next = l.queue[0];
58
+ if (next.write ? l.writer || l.readers > 0 : l.writer)
59
+ break;
60
+ l.queue.shift();
61
+ if (next.write)
62
+ l.writer = true;
63
+ else
64
+ l.readers++;
65
+ next.go();
66
+ if (next.write)
67
+ break;
68
+ }
69
+ if (!l.writer && l.readers === 0 && l.queue.length === 0)
70
+ templateLocks.delete(key);
71
+ }
72
+ async function withTemplateLock(key, write, fn) {
73
+ const l = lockOf(key);
74
+ await new Promise((go) => {
75
+ l.queue.push({ write, go });
76
+ pump(key, l);
77
+ });
78
+ try {
79
+ return await fn();
80
+ }
81
+ finally {
82
+ if (write)
83
+ l.writer = false;
84
+ else
85
+ l.readers--;
86
+ pump(key, l);
87
+ }
88
+ }
89
+ /** Exclusive: bake, rebake, prune or drop the stack's templates. */
90
+ export function withBakeLock(key, fn) {
91
+ return withTemplateLock(key, true, fn);
92
+ }
93
+ /** Shared: restore from one of the stack's templates; no writer runs meanwhile. */
94
+ export function withTemplateRead(key, fn) {
95
+ return withTemplateLock(key, false, fn);
96
+ }
97
+ /**
98
+ * One datastore's templates: the stack lock shared, then this datastore's lock
99
+ * — exclusive (`write`) to bake or drop one, shared to restore from one. The
100
+ * order is always stack, then datastore, and nothing waits for the stack lock
101
+ * while holding a datastore lock, so the two levels cannot deadlock.
102
+ */
103
+ export function withDatastoreLock(stackId, ds, write, fn) {
104
+ return withTemplateLock(stackId, false, () => withTemplateLock(`${stackId}/${ds}`, write, fn));
105
+ }
106
+ export function parseBakedMarker(content) {
107
+ try {
108
+ const parsed = JSON.parse(content);
109
+ if (parsed && parsed.v === 1 && typeof parsed.ns === 'string')
110
+ return parsed;
111
+ }
112
+ catch {
113
+ /* legacy: bare ns string */
114
+ }
115
+ return { v: 1, ns: content.trim(), drop: null };
116
+ }
117
+ export function hasOtherTemplateOwner(full, ns) {
118
+ if (!ns)
119
+ return true;
120
+ const roots = [dirname(dirname(full))];
121
+ try {
122
+ for (const root of roots) {
123
+ let entries;
124
+ try {
125
+ entries = readdirSync(root, { withFileTypes: true });
126
+ }
127
+ catch (err) {
128
+ if (err.code === 'ENOENT')
129
+ continue;
130
+ throw err;
131
+ }
132
+ for (const entry of entries) {
133
+ if (!entry.isDirectory())
134
+ continue;
135
+ const dir = join(root, entry.name);
136
+ for (const file of readdirSync(dir)) {
137
+ const candidate = join(dir, file);
138
+ if (!file.endsWith('.baked') || candidate === full)
139
+ continue;
140
+ const owner = parseBakedMarker(readFileSync(candidate, 'utf8'));
141
+ if (!owner.ns || owner.ns === ns)
142
+ return true;
143
+ }
144
+ }
145
+ }
146
+ }
147
+ catch {
148
+ return true;
149
+ }
150
+ return false;
151
+ }
152
+ /**
153
+ * Shared templates (decision 0044). A template whose key folds in the content
154
+ * of its `@rebake-template` trigger files is a function of (manifest name,
155
+ * datastore, preset, create command, trigger content) — never of the worktree
156
+ * path — so every worktree of one manifest name on this daemon can restore
157
+ * from the same one. They live in `<name>@shared/` next to the per-stack dirs
158
+ * (`<name>-<8 chars>`); a manifest name cannot contain `@`, so the two never
159
+ * collide.
160
+ */
161
+ export const SHARED_TEMPLATE_SUFFIX = '@shared';
162
+ export const sharedTemplateDir = (project) => `${project}${SHARED_TEMPLATE_SUFFIX}`;
163
+ export const isSharedTemplateDir = (dir) => dir.endsWith(SHARED_TEMPLATE_SUFFIX);
164
+ /** The manifest name a templates dir belongs to: `<name>@shared`, or a stack id `<name>-<8-char hash>`. */
165
+ export function templateProject(dir) {
166
+ return isSharedTemplateDir(dir) ? dir.slice(0, -SHARED_TEMPLATE_SUFFIX.length) : dir.slice(0, -9);
167
+ }
168
+ /**
169
+ * A template baked by `--pristine` for one worktree of a shared datastore
170
+ * (decision 0044): `<ds>-<preset>@<key>.own.<ext>` in the stack's own dir. It
171
+ * outranks the shared one for that worktree while it exists, and retention
172
+ * keeps it only while a row references it.
173
+ */
174
+ export const PRIVATE_TEMPLATE_TAG = '.own';
175
+ export const isPrivateTemplateFile = (file) => {
176
+ const at = file.lastIndexOf('@');
177
+ return at > 0 && file.slice(at).includes(`${PRIVATE_TEMPLATE_TAG}.`);
178
+ };
179
+ /**
180
+ * The template slots of one datastore: where a restore at `preset` reads from
181
+ * and a bake writes to. Shared datastores read a private (pristine) template
182
+ * of this worktree first, else the shared one.
183
+ */
184
+ class TemplateSlots {
185
+ name;
186
+ stackId;
187
+ scope;
188
+ sharing;
189
+ ext;
190
+ key12;
191
+ constructor(name, stackId, scope, sharing, ext, key12) {
192
+ this.name = name;
193
+ this.stackId = stackId;
194
+ this.scope = scope;
195
+ this.sharing = sharing;
196
+ this.ext = ext;
197
+ this.key12 = key12;
198
+ }
199
+ get shared() {
200
+ return this.sharing;
201
+ }
202
+ file(preset, priv = false) {
203
+ return `${this.name}-${preset}@${this.key12()}${priv ? PRIVATE_TEMPLATE_TAG : ''}${this.ext}`;
204
+ }
205
+ path(slot) {
206
+ return join(templatesRoot(), slot.dir, slot.file);
207
+ }
208
+ resolve(preset) {
209
+ if (!this.sharing)
210
+ return { dir: this.stackId, file: this.file(preset), private: false, shared: false };
211
+ const own = { dir: this.stackId, file: this.file(preset, true), private: true, shared: false };
212
+ if (this.scope.privateBake || existsSync(this.path(own)))
213
+ return own;
214
+ return { dir: sharedTemplateDir(this.scope.project), file: this.file(preset), private: false, shared: true };
215
+ }
216
+ /**
217
+ * Migration (decision 0044): before a shared template is baked, a
218
+ * per-worktree template of the same project with the same file name — the
219
+ * same datastore, preset and key, so the same inputs — is adopted instead:
220
+ * the newest one is copied (a marker: its JSON, naming the existing
221
+ * database; a sqlite file: a clone), keeping its mtime so it does not jump
222
+ * retention's queue. The original stays where it is; retention collects it
223
+ * once nothing references it (a duplicate of a shared template is never
224
+ * "current"). Returns the source dir, or null when there was nothing to adopt.
225
+ */
226
+ adoptable(slot) {
227
+ if (!slot.shared)
228
+ return null;
229
+ const project = this.scope.project;
230
+ let best = null;
231
+ let dirs = [];
232
+ try {
233
+ dirs = readdirSync(templatesRoot());
234
+ }
235
+ catch {
236
+ return null;
237
+ }
238
+ for (const dir of dirs) {
239
+ if (isSharedTemplateDir(dir) || dir.length !== project.length + 9 || !dir.startsWith(`${project}-`))
240
+ continue;
241
+ const full = join(templatesRoot(), dir, slot.file);
242
+ try {
243
+ const mtime = statSync(full).mtimeMs;
244
+ if (!best || mtime > best.mtime)
245
+ best = { dir, full, mtime };
246
+ }
247
+ catch {
248
+ /* not baked there */
249
+ }
250
+ }
251
+ return best;
252
+ }
253
+ }
254
+ /** Whether a datastore's templates are shared across worktrees (decision 0044). */
255
+ export function sharesTemplates(spec, scope) {
256
+ return scope.project !== undefined && scope.bakeKey !== undefined && spec.share_templates !== false;
257
+ }
258
+ /**
259
+ * Record which worktree a stack's templates were baked for (`.root` next to
260
+ * them, 0.19), so retention can tell a stack whose worktree is gone from one
261
+ * that merely has no environment right now — after its worktree records were
262
+ * pruned, nothing else said.
263
+ */
264
+ function recordTemplateRoot(stackId, root) {
265
+ const file = join(templatesRoot(), stackId, '.root');
266
+ try {
267
+ if (readFileSync(file, 'utf8').trim() === root)
268
+ return;
269
+ }
270
+ catch {
271
+ /* first bake of this stack */
272
+ }
273
+ mkdirSync(dirname(file), { recursive: true });
274
+ writeFileSync(file, `${root}\n`);
275
+ }
276
+ // ---------------------------------------------------------------- sqlite
277
+ class SqliteDs {
278
+ name;
279
+ spec;
280
+ stackId;
281
+ scope;
282
+ fileBased = true;
283
+ slots;
284
+ constructor(name, spec, stackId, scope = {}) {
285
+ this.name = name;
286
+ this.spec = spec;
287
+ this.stackId = stackId;
288
+ this.scope = scope;
289
+ this.slots = new TemplateSlots(name, stackId, scope, sharesTemplates(spec, scope), '.db', () => this.contentKey().slice(0, 12));
290
+ }
291
+ /** Template identity: create command + content bake key (vetbill-1i49). */
292
+ contentKey() {
293
+ const base = this.spec.create ?? '';
294
+ return sha256(this.scope.bakeKey ? `${base}\n@bake:${this.scope.bakeKey}` : base);
295
+ }
296
+ ns(h) {
297
+ // The datastore KEY becomes a filename; a key with `/` or `..` must not
298
+ // escape dataDir (the command-family sibling already sanitizes — this
299
+ // closes the same hole here). Reject rather than mangle so a bad key is loud.
300
+ if (/[/\\]|\.\./.test(this.name)) {
301
+ throw new BrokerError('work-error', `sqlite datastore key '${this.name}' must not contain '/', '\\', or '..'`, 'manifest');
302
+ }
303
+ return join(h.dataDir, `${this.name}.db`);
304
+ }
305
+ url(h) {
306
+ return this.ns(h);
307
+ }
308
+ async probe() {
309
+ /* in-process — nothing external */
310
+ }
311
+ templateRef(preset) {
312
+ if (this.spec.template !== true)
313
+ return null;
314
+ const slot = this.slots.resolve(preset);
315
+ return `${slot.dir}/${slot.file}`;
316
+ }
317
+ dropRecipe(h) {
318
+ return { path: this.ns(h) };
319
+ }
320
+ async runCreate(cwd, ns, preset) {
321
+ if (!this.spec.create)
322
+ throw new BrokerError('work-error', `datastore '${this.name}' has no create: command`, 'datastore');
323
+ await sh(template(this.spec.create, { ns, preset }), cwd, `seed failed for '${this.name}' preset '${preset}'`);
324
+ }
325
+ async ensure(h, preset, force, exists) {
326
+ mkdirSync(h.dataDir, { recursive: true });
327
+ const dbPath = this.ns(h);
328
+ if (exists && !force && existsSync(dbPath))
329
+ return;
330
+ if (this.spec.template === true) {
331
+ const slot = this.slots.resolve(preset);
332
+ const tpl = this.slots.path(slot);
333
+ mkdirSync(dirname(tpl), { recursive: true });
334
+ // Serialize bake AND restore-copy against rebake, on the template's
335
+ // dir key (the stack, or `<name>@shared` — decision 0044): rebake
336
+ // deletes templates, so the copy below must not overlap one.
337
+ for (let attempt = 0;; attempt++) {
338
+ // Double-checked: the exclusive lock only when there is a bake to do.
339
+ // One bake per key across every worktree sharing it: the others wait
340
+ // here and then restore.
341
+ if (!existsSync(tpl)) {
342
+ await withDatastoreLock(slot.dir, this.name, true, async () => {
343
+ if (existsSync(tpl))
344
+ return;
345
+ recordTemplateRoot(slot.dir, h.cwd);
346
+ const adopt = this.slots.adoptable(slot);
347
+ if (adopt) {
348
+ try {
349
+ copyFileSync(adopt.full, tpl, fsConstants.COPYFILE_FICLONE);
350
+ utimesSync(tpl, new Date(), new Date(adopt.mtime));
351
+ logEvent({ level: 'info', kind: 'template', envId: h.envId, detail: `adopted template ${adopt.dir}/${slot.file} of '${this.name}' (${preset}) as the shared one — no bake` });
352
+ return;
353
+ }
354
+ catch {
355
+ rmSync(tpl, { force: true }); // retired meanwhile: bake instead
356
+ }
357
+ }
358
+ await this.runCreate(h.cwd, tpl, preset); // bake once
359
+ });
360
+ }
361
+ // Restores share the lock: several copies at once, but never while a
362
+ // writer replaces or deletes the template.
363
+ const copied = await withDatastoreLock(slot.dir, this.name, false, async () => {
364
+ if (!existsSync(tpl))
365
+ return false; // retired between the bake and here: bake again
366
+ // The sidecars MUST go before the .db is replaced. SQLite in WAL mode
367
+ // recovers `-wal` frames onto whatever database file it finds, so a
368
+ // leftover WAL from the previous lease would be replayed over the fresh
369
+ // template — resurrecting the old lease's rows inside a supposedly reset
370
+ // store, or corrupting it outright.
371
+ dropSidecars(dbPath);
372
+ copyFileSync(tpl, dbPath, fsConstants.COPYFILE_FICLONE); // restore = CoW clone where the fs supports it
373
+ return true;
374
+ });
375
+ if (copied)
376
+ return;
377
+ if (attempt >= 2)
378
+ throw new BrokerError('env-error', `template for '${this.name}' preset '${preset}' kept disappearing before it could be restored`, 'datastore');
379
+ }
380
+ }
381
+ else {
382
+ dropSidecars(dbPath);
383
+ await this.runCreate(h.cwd, dbPath, preset);
384
+ }
385
+ }
386
+ dropCommand(_h) {
387
+ return null;
388
+ }
389
+ async drop(h) {
390
+ const db = this.ns(h);
391
+ rmSync(db, { force: true });
392
+ dropSidecars(db); // an orphaned -wal outlives its database and poisons the next one
393
+ }
394
+ templateBaked(preset) {
395
+ return this.spec.template === true && existsSync(this.slots.path(this.slots.resolve(preset)));
396
+ }
397
+ rebake(_cwd) {
398
+ // Exclusive on this datastore: it must wait out any in-flight bake or
399
+ // restore. Only THIS datastore's current templates go — every other
400
+ // datastore's, and older keys (retention collects those), stay. A shared
401
+ // datastore drops only this worktree's private templates: the shared one
402
+ // serves other worktrees, and the bake that follows is private (0044).
403
+ const priv = this.slots.shared;
404
+ return withDatastoreLock(this.stackId, this.name, true, async () => {
405
+ for (const preset of presetNames(this.spec))
406
+ rmSync(join(templatesRoot(), this.stackId, this.slots.file(preset, priv)), { force: true });
407
+ });
408
+ }
409
+ }
410
+ /**
411
+ * SQLite writes alongside the database: `<db>-wal` (journal) and `<db>-shm`
412
+ * (shared index), plus `-journal` in rollback mode. They are only meaningful
413
+ * with the exact database they were written for, so any operation that
414
+ * replaces or removes the .db must remove them in the same breath.
415
+ */
416
+ function dropSidecars(dbPath) {
417
+ for (const suffix of ['-wal', '-shm', '-journal']) {
418
+ rmSync(`${dbPath}${suffix}`, { force: true });
419
+ }
420
+ }
421
+ // ---------------------------------------------------------------- command family
422
+ class CommandDs {
423
+ name;
424
+ spec;
425
+ stackId;
426
+ scope;
427
+ fileBased = false;
428
+ slots;
429
+ constructor(name, spec, stackId, scope = {}) {
430
+ this.name = name;
431
+ this.spec = spec;
432
+ this.stackId = stackId;
433
+ this.scope = scope;
434
+ if (!spec.url) {
435
+ throw new BrokerError('work-error', `datastore '${name}' (driver ${spec.driver}) needs a url: template with {{ns}}`, 'manifest');
436
+ }
437
+ this.slots = new TemplateSlots(name, stackId, scope, sharesTemplates(spec, scope), '.baked', () => this.contentKey().slice(0, 12));
438
+ }
439
+ get capabilities() {
440
+ return { template: Boolean(this.spec.template_restore), ephemeral: this.spec.ephemeral === true };
441
+ }
442
+ ns(h) {
443
+ // The datastore NAME belongs in the namespace. Without it, two datastores
444
+ // of the same driver in one stack (say `app` and `audit` on one postgres)
445
+ // resolve to the identical database: the second one's clean-slate drop
446
+ // destroys the first's freshly seeded data, and both services are handed
447
+ // the same url.
448
+ const raw = `backlot_${h.envId}_${this.name}`.replace(/[^A-Za-z0-9_]/g, '_');
449
+ // Postgres truncates identifiers at 63 bytes, and the disambiguating name
450
+ // sits at the END — same defense as templateNs(): trim the middle, keep a
451
+ // hash of the full identity so long names never collapse onto one db.
452
+ const LIMIT = 63;
453
+ if (raw.length <= LIMIT)
454
+ return raw;
455
+ const suffix = `_${sha256(raw).slice(0, 8)}`;
456
+ return raw.slice(0, LIMIT - suffix.length) + suffix;
457
+ }
458
+ url(h) {
459
+ return template(this.spec.url, { ns: this.ns(h) });
460
+ }
461
+ async probe() {
462
+ if (!this.spec.probe)
463
+ return;
464
+ const [host, portStr] = this.spec.probe.split(':');
465
+ const port = Number(portStr);
466
+ await new Promise((resolve, reject) => {
467
+ const sock = connect({ host: host || 'localhost', port, timeout: 3000 });
468
+ sock.once('connect', () => {
469
+ sock.end();
470
+ resolve();
471
+ });
472
+ const fail = () => reject(new BrokerError('infra-error', `datastore '${this.name}' unreachable at ${this.spec.probe} — is the server running?`, 'datastore'));
473
+ sock.once('error', fail);
474
+ sock.once('timeout', fail);
475
+ });
476
+ }
477
+ /**
478
+ * Template identity: create command + content bake key (vetbill-1i49).
479
+ * Without a bake key (no @rebake-template rule) names match the historical
480
+ * scheme, so existing baked templates stay valid.
481
+ */
482
+ contentKey() {
483
+ const base = this.spec.create ?? '';
484
+ return sha256(this.scope.bakeKey ? `${base}\n@bake:${this.scope.bakeKey}` : base);
485
+ }
486
+ /**
487
+ * The database a bake into `slot` creates: `backlot_tpl_<dir>_<preset>_<hash>`,
488
+ * where `<dir>` is the stack id, or `<name>@shared` for a shared template —
489
+ * never one worktree's id (decision 0044). A private (pristine) template
490
+ * carries `own`. `fresh` appends a nonce: a bake never drops a database
491
+ * another marker still names (an adopted template keeps its old name).
492
+ */
493
+ templateNs(slot, preset, fresh = false) {
494
+ const hash = this.contentKey().slice(0, 8);
495
+ const nonce = fresh ? `_${Math.random().toString(36).slice(2, 8)}` : '';
496
+ const raw = `backlot_tpl_${slot.dir}_${slot.private ? 'own_' : ''}${preset}_${hash}${nonce}`.replace(/[^A-Za-z0-9_]/g, '_');
497
+ // Postgres truncates identifiers at 63 bytes, and the DISAMBIGUATING hash
498
+ // is at the end — so a long stack id silently cut it off and two different
499
+ // templates collapsed onto one database. Trim the stack/preset middle
500
+ // instead, and always keep the hash.
501
+ const LIMIT = 63;
502
+ if (raw.length <= LIMIT)
503
+ return raw;
504
+ const suffix = `_${hash}${nonce}`;
505
+ return raw.slice(0, LIMIT - suffix.length) + suffix;
506
+ }
507
+ templateRef(preset) {
508
+ if (!this.spec.template_restore || this.spec.ephemeral)
509
+ return null;
510
+ const slot = this.slots.resolve(preset);
511
+ return `${slot.dir}/${slot.file}`;
512
+ }
513
+ dropRecipe(h) {
514
+ // `ns` rides along so `pool doctor` counts the namespace as referenced
515
+ // from the moment the drop is recorded — before the restore creates it.
516
+ return this.spec.drop ? { cmd: template(this.spec.drop, { ns: this.ns(h) }), cwd: h.cwd, ns: this.ns(h) } : { ns: this.ns(h) };
517
+ }
518
+ async ensure(h, preset, force, exists) {
519
+ const nsE = this.ns(h);
520
+ recordNamespace(nsE, this.stackId);
521
+ if (this.spec.ephemeral) {
522
+ // Ephemeral (redis-class): no presets, no templates — reset = the drop:
523
+ // command as a flush; create (optional) runs only on first bind.
524
+ if (force && exists && this.spec.drop) {
525
+ // For an ephemeral store the drop command IS the reset. Swallowing its
526
+ // failure handed the caller an environment that reported reset-data
527
+ // hygiene while still holding the previous lease's keys.
528
+ await sh(template(this.spec.drop, { ns: nsE }), h.cwd, `flush failed for ephemeral '${this.name}' — the store was NOT reset`);
529
+ }
530
+ if (!exists && this.spec.create) {
531
+ await sh(template(this.spec.create, { ns: nsE, preset }), h.cwd, `create failed for ephemeral '${this.name}'`);
532
+ }
533
+ return;
534
+ }
535
+ if (exists && !force)
536
+ return;
537
+ if (!this.spec.create)
538
+ throw new BrokerError('work-error', `datastore '${this.name}' has no create: command`, 'datastore');
539
+ const create = this.spec.create; // narrowed copy for the closure below
540
+ const ns = this.ns(h);
541
+ if (this.spec.drop)
542
+ await shQuiet(template(this.spec.drop, { ns }), h.cwd); // clean slate, best-effort
543
+ if (this.spec.template_restore) {
544
+ // Where this restore reads from (decision 0044): this worktree's
545
+ // private template, the shared one, or the stack's own. The lock is
546
+ // keyed by that dir, so every worktree sharing a template bakes it once
547
+ // and restores from it side by side.
548
+ const slot = this.slots.resolve(preset);
549
+ const lockDir = slot.dir;
550
+ const marker = this.slots.path(slot);
551
+ mkdirSync(dirname(marker), { recursive: true });
552
+ const bake = async (why) => {
553
+ // The database this marker names now, if any: a rebake reuses it
554
+ // unless another marker names it too (an adopted template's other
555
+ // copy) — then it bakes into a fresh name and leaves that one alone.
556
+ let current;
557
+ try {
558
+ current = parseBakedMarker(readFileSync(marker, 'utf8')).ns || undefined;
559
+ }
560
+ catch {
561
+ current = undefined;
562
+ }
563
+ let tpl = current ?? this.templateNs(slot, preset);
564
+ if (hasOtherTemplateOwner(marker, tpl))
565
+ tpl = this.templateNs(slot, preset, true);
566
+ recordNamespace(tpl, slot.dir);
567
+ if (why)
568
+ logEvent({ level: 'warn', kind: 'template', envId: h.envId, detail: `rebaking template ${tpl} of '${this.name}' (${preset}): ${why}` });
569
+ recordTemplateRoot(slot.dir, h.cwd);
570
+ await shQuiet(this.spec.drop ? template(this.spec.drop, { ns: tpl }) : 'true', h.cwd);
571
+ await sh(template(create, { ns: tpl, preset }), h.cwd, `template bake failed for '${this.name}' preset '${preset}'${why ? ` (${why})` : ''}`);
572
+ const baked = {
573
+ v: 1,
574
+ ns: tpl,
575
+ drop: this.spec.drop ? template(this.spec.drop, { ns: tpl }) : null,
576
+ ds: this.name,
577
+ bakedAt: Date.now(),
578
+ nonce: Math.random().toString(36).slice(2, 10),
579
+ ...(slot.shared ? { shared: true } : {}),
580
+ ...(slot.private ? { private: true } : {}),
581
+ };
582
+ writeFileSync(marker, JSON.stringify(baked));
583
+ };
584
+ // Migration (decision 0044): a per-worktree template with the same
585
+ // file name was baked from the same inputs — adopt it instead of baking.
586
+ const adopt = () => {
587
+ const src = this.slots.adoptable(slot);
588
+ if (!src)
589
+ return false;
590
+ try {
591
+ const legacy = parseBakedMarker(readFileSync(src.full, 'utf8'));
592
+ if (!legacy.ns)
593
+ return false;
594
+ const adopted = { ...legacy, ds: legacy.ds ?? this.name, shared: true, adoptedFrom: src.dir };
595
+ writeFileSync(marker, JSON.stringify(adopted));
596
+ utimesSync(marker, new Date(), new Date(src.mtime));
597
+ logEvent({ level: 'info', kind: 'template', envId: h.envId, detail: `adopted template ${legacy.ns} (${src.dir}) of '${this.name}' (${preset}) as the shared one — no bake` });
598
+ return true;
599
+ }
600
+ catch {
601
+ return false;
602
+ }
603
+ };
604
+ // One restore under the SHARED lock: no bake, rebake, prune or failed
605
+ // sibling can drop the template while it is copied. `gone` = the marker
606
+ // was retired between the bake and the restore. A retry first drops
607
+ // what the failed attempt may have left (a half-created database makes
608
+ // RESTORE / CREATE DATABASE fail on "already exists").
609
+ let tried = false;
610
+ const restore = () => withDatastoreLock(lockDir, this.name, false, async () => {
611
+ let seen;
612
+ try {
613
+ seen = readFileSync(marker, 'utf8');
614
+ }
615
+ catch {
616
+ return { gone: true };
617
+ }
618
+ // The marker names the database: an adopted or nonce-named template
619
+ // is not where the current naming would put it.
620
+ const tpl = parseBakedMarker(seen).ns || this.templateNs(slot, preset);
621
+ if (tried && this.spec.drop)
622
+ await shQuiet(template(this.spec.drop, { ns }), h.cwd);
623
+ tried = true;
624
+ try {
625
+ await sh(template(this.spec.template_restore, { template: tpl, ns }), h.cwd, `template restore failed for '${this.name}' preset '${preset}'`);
626
+ return { ok: true };
627
+ }
628
+ catch (err) {
629
+ return { err: err, seen };
630
+ }
631
+ });
632
+ let failure = null;
633
+ for (let attempt = 0; attempt < 6; attempt++) {
634
+ // Bake under the exclusive lock, once per template key (decision
635
+ // 0039): a marker whose key matches IS the current template. Checked
636
+ // twice, so a restore of an existing template never waits for (or
637
+ // blocks) anyone with an exclusive lock.
638
+ if (!existsSync(marker)) {
639
+ await withDatastoreLock(lockDir, this.name, true, async () => {
640
+ if (!existsSync(marker) && !adopt())
641
+ await bake();
642
+ });
643
+ }
644
+ const r = await restore();
645
+ if ('ok' in r)
646
+ return;
647
+ if ('gone' in r)
648
+ continue;
649
+ const tplOf = parseBakedMarker(r.seen).ns;
650
+ const detail = `${r.err.message}${r.err instanceof BrokerError && r.err.logExcerpt ? `: ${r.err.logExcerpt.trim().split('\n').slice(-3).join(' | ').slice(-400)}` : ''}`;
651
+ if (failure === null) {
652
+ // The first failure is retried as it is: a transient error (a lock
653
+ // wait in the repo's restore script, a busy server) must not cost a
654
+ // bake. It is never silent any more.
655
+ logEvent({ level: 'warn', kind: 'template', envId: h.envId, detail: `restoring '${this.name}' (${preset}) from template ${tplOf} into ${ns} failed — retrying the restore: ${detail}` });
656
+ failure = r;
657
+ continue;
658
+ }
659
+ // Failed twice from a template that is still on record. The marker is
660
+ // LOCAL and the template database lives on the server: wipe the
661
+ // appliance (docker rm -f, volume prune) and the marker still claims a
662
+ // template that no longer exists, so every bind would fail forever.
663
+ // Rebake it — unless another restore already did (the marker changed).
664
+ const lastErr = r.err;
665
+ await withDatastoreLock(lockDir, this.name, true, async () => {
666
+ let now = null;
667
+ try {
668
+ now = readFileSync(marker, 'utf8');
669
+ }
670
+ catch {
671
+ now = null;
672
+ }
673
+ if (now !== null && now !== r.seen)
674
+ return; // rebaked by someone else meanwhile
675
+ await bake(`two restores from it failed, last: ${detail}`);
676
+ });
677
+ const again = await restore();
678
+ if ('ok' in again)
679
+ return;
680
+ if ('gone' in again)
681
+ continue;
682
+ throw new BrokerError('work-error', `template restore failed for '${this.name}' preset '${preset}', also after a rebake (before it: ${lastErr.message})`, 'datastore', again.err instanceof BrokerError ? again.err.logExcerpt : undefined);
683
+ }
684
+ throw new BrokerError('env-error', `template for '${this.name}' preset '${preset}' kept disappearing before it could be restored`, 'datastore');
685
+ }
686
+ else {
687
+ await sh(template(this.spec.create, { ns, preset }), h.cwd, `seed failed for '${this.name}' preset '${preset}'`);
688
+ }
689
+ }
690
+ dropCommand(h) {
691
+ return this.spec.drop ? template(this.spec.drop, { ns: this.ns(h) }) : null;
692
+ }
693
+ async drop(h) {
694
+ if (this.spec.drop)
695
+ await shQuiet(template(this.spec.drop, { ns: this.ns(h) }), h.cwd);
696
+ }
697
+ templateBaked(preset) {
698
+ return Boolean(this.spec.template_restore) && !this.spec.ephemeral && existsSync(this.slots.path(this.slots.resolve(preset)));
699
+ }
700
+ async rebake(cwd) {
701
+ // Drop the server-side template DBs recorded in the markers before
702
+ // deleting the markers — otherwise `backlot_tpl_*` databases leak on the
703
+ // appliance forever (vetbill-1i49).
704
+ //
705
+ // The drop command comes from the MANIFEST and is written to run in the
706
+ // repo (it may invoke a repo-local script or a relative tool). Running it
707
+ // in templatesRoot() made it fail, and shQuiet swallows failures — so the
708
+ // leak fix silently did nothing. Fall back only when no root is known.
709
+ //
710
+ // Exclusive on this datastore: waits out its in-flight bakes and restores.
711
+ // Only THIS datastore's current templates go (decision 0039); the others,
712
+ // and older keys of this one (retention collects those), stay. A shared
713
+ // datastore drops only this worktree's PRIVATE templates (decision 0044):
714
+ // the shared one serves the other worktrees, and the bake that follows
715
+ // `--pristine` is a private one.
716
+ const priv = this.slots.shared;
717
+ await withDatastoreLock(this.stackId, this.name, true, async () => {
718
+ const dir = join(templatesRoot(), this.stackId);
719
+ const key = `@${this.contentKey().slice(0, 12)}${priv ? PRIVATE_TEMPLATE_TAG : ''}.baked`;
720
+ const catalog = new Set(presetNames(this.spec));
721
+ let files = [];
722
+ try {
723
+ files = readdirSync(dir).filter((f) => f.startsWith(`${this.name}-`) && f.endsWith(key));
724
+ }
725
+ catch {
726
+ return;
727
+ }
728
+ for (const f of files) {
729
+ const file = join(dir, f);
730
+ let baked = null;
731
+ try {
732
+ baked = parseBakedMarker(readFileSync(file, 'utf8'));
733
+ }
734
+ catch {
735
+ baked = null; // unreadable: removing it is all that is left to do
736
+ }
737
+ // A marker names its datastore since 0.18; an older one is this
738
+ // datastore's when its preset is one this datastore offers.
739
+ const preset = f.slice(this.name.length + 1, -key.length);
740
+ if (baked?.ds !== undefined ? baked.ds !== this.name : !catalog.has(preset))
741
+ continue;
742
+ if (baked?.drop && !hasOtherTemplateOwner(file, baked.ns))
743
+ await shQuiet(baked.drop, cwd ?? templatesRoot());
744
+ rmSync(file, { force: true });
745
+ }
746
+ });
747
+ }
748
+ }
749
+ // ---------------------------------------------------------------- factory
750
+ /**
751
+ * `scope` is the template scope; a bare string is the bake key alone (per
752
+ * stack, as before 0.20 — unit tests and callers that only read the url).
753
+ */
754
+ export function makeDatastore(name, spec, stackId, scope) {
755
+ const sc = typeof scope === 'string' ? { bakeKey: scope } : (scope ?? {});
756
+ // The datastore KEY becomes part of a filename (sqlite database, .baked
757
+ // marker), so a key with a separator or `..` would escape the state root.
758
+ // Checked here for EVERY driver: the command family builds marker paths too,
759
+ // and only the sqlite driver used to guard this.
760
+ if (/[/\\]|(^|[/\\])\.\.($|[/\\])/.test(name)) {
761
+ throw new BrokerError('work-error', `datastore key '${name}' must not contain path separators or '..'`, 'manifest');
762
+ }
763
+ switch (spec.driver) {
764
+ case 'sqlite':
765
+ return new SqliteDs(name, spec, stackId, sc);
766
+ case 'postgres':
767
+ case 'mssql':
768
+ case 'mysql':
769
+ case 'redis':
770
+ return new CommandDs(name, spec, stackId, sc);
771
+ default:
772
+ throw new BrokerError('work-error', `unknown datastore driver '${spec.driver}'`, 'manifest');
773
+ }
774
+ }
775
+ //# sourceMappingURL=datastores.js.map