mikser-io 10.13.0 → 11.0.1

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.
@@ -495,7 +495,8 @@ Query types throughout: function, lodash match object, or `undefined` for all.
495
495
 
496
496
  `updateEntity` is a catalog operation. `writeEntitySource` writes the FILE, with
497
497
  the checks that make a whole-file rewrite safe to perform without having watched
498
- the file the whole time.
498
+ the file the whole time. `editEntitySource` changes PART of a file, by naming
499
+ the text to change rather than resending the rest of it.
499
500
 
500
501
  ### `writeEntitySource(options)`
501
502
 
@@ -581,6 +582,52 @@ exactly the one that needs telling.
581
582
 
582
583
  `siblingDestinations(folder, relativePath)` reports files differing only by
583
584
  extension, which may render to the same destination.
585
+ ### `editEntitySource(options)`
586
+
587
+ ```js
588
+ import { editEntitySource } from 'mikser-io'
589
+
590
+ const result = await editEntitySource({
591
+ id: '/documents/pricing.md',
592
+ find: 'price: 1200',
593
+ replace: 'price: 1400',
594
+ })
595
+ ```
596
+
597
+ | Option | Meaning |
598
+ | --- | --- |
599
+ | `id` or `collection` + `relativePath` | Which file. Same rules as `writeEntitySource`. |
600
+ | `find` | The exact text to replace. Must appear EXACTLY ONCE unless `all`. |
601
+ | `replace` | What to put there. Empty or omitted deletes the matched text. |
602
+ | `all` | Replace every occurrence instead of refusing an ambiguous anchor. Reports `replacements`. |
603
+ | `ifChecksum`, `dryRun`, `awaitCycle`, `changeSet`, `summary`, `principal` | As `writeEntitySource`. |
604
+
605
+ It is layered ON `writeEntitySource` rather than beside it, so containment, the
606
+ change set, the cycle id, sibling destinations and the collection write
607
+ capability are the same code path every other write goes through — a second
608
+ implementation of any of those is a second implementation that can drift.
609
+
610
+ **Why an anchor rather than a whole file.** A model rewriting a file to change
611
+ one line must re-emit every other line, and a line it drops on the way is
612
+ indistinguishable downstream from a line someone deleted on purpose.
613
+ `ifChecksum` catches a stale READ; nothing catches a lossy WRITE. An anchor
614
+ cannot lose what it does not name: the bytes outside the match are not merely
615
+ preserved, they are never rewritten. That also makes the concurrency question
616
+ sharper than a checksum — a checksum refuses when ANY part of the file moved,
617
+ an anchor refuses when the part being edited moved.
618
+
619
+ Its own refusals, on top of `writeEntitySource`'s: `no-anchor` (empty `find`),
620
+ `anchor-not-found`, `anchor-ambiguous` (carrying `occurrences`), `no-such-file`
621
+ (an edit changes something that exists; use the whole-file write to create),
622
+ and `would-not-parse` (carrying the parser's complaint). The last one is the
623
+ guarantee a whole-file write cannot make: the result is checked against the
624
+ file's registered source format — see `validateSource` — and a file that would
625
+ not parse never lands.
626
+
627
+ Between reading the file and writing it back, the checksum it read is forwarded
628
+ as the write's precondition, so a writer landing in that window is refused
629
+ rather than overwritten.
630
+
584
631
  `locateEntityFile(id)` resolves a catalog id to its `{ collection, relativePath }`,
585
632
  or `{ error }` — taken from the entity rather than by splitting the id, since the
586
633
  prefix is configurable and the extension may have been stripped.
package/docs/plugins.md CHANGED
@@ -28,7 +28,7 @@ Plugins are standard ESM imports. There's no name-based search path — whatever
28
28
  - Sibling plugins each export a named factory: `import { vector } from 'mikser-io-vector'`.
29
29
  - Project-local plugins live anywhere — drop the file, import by relative path: `import myPlugin from './plugins/my-plugin.js'`.
30
30
 
31
- Renderer and postprocessor packages **are** listed in `plugins: []` alongside lifecycle plugins. They return descriptors (`{ name, options, load?, render? }` or `{ name, options, postprocess, ... }`) instead of `(core) => void` closures; the loader stores them in `runtime.renderers` / `runtime.postprocessors` and the dispatcher picks them up by name when a layout requests them.
31
+ Renderer and postprocessor packages **are** listed in `plugins: []` alongside lifecycle plugins. They return descriptors (`{ name, options, load?, render? }` or `{ name, options, postprocess, ... }`) instead of `(core) => void` closures; the loader stores them in `runtime.renderers` / `runtime.postprocessors` and the dispatcher picks them up by name when a layout requests them. If your package is **not** named `mikser-io-render-<name>` / `mikser-io-post-<name>`, add `module: import.meta.url` to the descriptor — a worker has no registry to read and otherwise resolves the name to a package that does not exist. See [rendering.md](rendering.md#declare-module-if-your-package-is-not-named-for-your-plugin).
32
32
 
33
33
  ---
34
34
 
package/docs/rendering.md CHANGED
@@ -174,6 +174,37 @@ export async function render({ entity, options, config, context, plugins, runtim
174
174
 
175
175
  Only the renderer plugin (named `render-{options.renderer}`) is expected to export `render()`. Other plugins (loaded via `context.plugins`, `entity.meta.plugins`, `options.plugins`) typically only export `load()`.
176
176
 
177
+ ### Declare `module` if your package is not named for your plugin
178
+
179
+ On the main thread a plugin is found in the runtime registry, under the `name`
180
+ its descriptor carries. A **worker** is another thread with another runtime
181
+ singleton, so its registry is empty and it resolves by name instead: plugin
182
+ `render-preset` is looked for in a package called `mikser-io-render-preset`.
183
+
184
+ That guess is right whenever the package is named for the plugin. It is wrong
185
+ whenever one package ships a plugin under some other name — and then the
186
+ failure is asymmetric, which is what makes it expensive: the main thread works,
187
+ and only entities dispatched with `task: worker` break. A missing helper throws
188
+ `Missing helper: "..."` and the page is never written; a missing renderer is a
189
+ render fault naming the renderer.
190
+
191
+ A descriptor can say where it lives:
192
+
193
+ ```js
194
+ export function renderPreset(options = {}) {
195
+ return { name: options.name ?? 'preset', options, load, render, module: import.meta.url }
196
+ }
197
+ ```
198
+
199
+ `import.meta.url` and not the package name, because it points at the file that
200
+ exports `load` / `render` at module level — which is what the worker imports —
201
+ so nothing has to be re-exported from the package index to make it reachable.
202
+
203
+ The field is optional and only consulted on a worker. A package named
204
+ `mikser-io-render-<name>` or `mikser-io-post-<name>` needs nothing. Everything
205
+ else should declare it; `mikser-io-assets` ships two such plugins (`preset` and
206
+ `asset`) and declares both.
207
+
177
208
  ---
178
209
 
179
210
  ## Renderer Plugins
package/index.js CHANGED
@@ -3,6 +3,7 @@ export * as constants from './src/constants.js'
3
3
  export * from './src/utils/index.js'
4
4
  export * from './src/invalidation.js'
5
5
  export * from './src/auth.js'
6
+ export * from './src/principal.js'
6
7
  export * from './src/roles.js'
7
8
  export * from './src/inventory.js'
8
9
  export * from './src/report.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "10.13.0",
3
+ "version": "11.0.1",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
package/src/auth.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import crypto from 'node:crypto'
2
2
  import { isLoopback } from './utils/index.js'
3
+ import { currentPrincipal } from './principal.js'
4
+ import runtime from './runtime.js'
3
5
 
4
6
  // Authentication seam (ADR-0012).
5
7
  //
@@ -286,6 +288,66 @@ export function anyOf(...verifiers) {
286
288
  // four — the same partial answer, one layer down.
287
289
  export const CAPABILITY_WILDCARD = '*'
288
290
 
291
+ // Writing to a collection is scoped to that collection.
292
+ //
293
+ // write:<collection> may change what is in it
294
+ //
295
+ // One name, asked by every write surface, so `write:documents` means the same
296
+ // thing whether the write arrives over MCP, over the api, or from a plugin.
297
+ // Reads are not scoped here: the api bounds them with `api:list` and the drive
298
+ // with `drive:<endpoint>`, and inventing a third read rule would give three
299
+ // answers to one question.
300
+ export const writeCapabilityFor = (collection) => `write:${collection}`
301
+
302
+ // Off until an operator turns it on, and turned on by granting.
303
+ //
304
+ // Enforcing immediately would refuse every write on every deployment that
305
+ // exists, since nobody holds a capability that did not exist until now. So
306
+ // the rule is the one the rest of this file already uses for principals,
307
+ // applied to the catalogue: a site that declares no `write:` capability is
308
+ // not using collection scoping, and its writes are bounded by whatever
309
+ // bounded them before. The first `write:` grant turns it on for EVERY
310
+ // collection at once — which is surprising exactly once, and is the only
311
+ // reading that does not leave a half-enforced site.
312
+ function collectionScopingConfigured(catalogue) {
313
+ return Object.values(catalogue ?? {}).flat()
314
+ .some(capability => String(capability).startsWith('write:'))
315
+ }
316
+
317
+ // The capability missing to write to this collection, or null when the write
318
+ // may proceed. `catalogue` says whether the site uses collection scoping at
319
+ // all; `principal` defaults to whoever the surface established.
320
+ export function missingCollectionWrite(collection, {
321
+ principal = currentPrincipal(),
322
+ catalogue = runtime.options?.roles?.catalogue,
323
+ } = {}) {
324
+ if (!collection) return null
325
+ if (!collectionScopingConfigured(catalogue)) return null
326
+ return missingCapability(principal, writeCapabilityFor(collection))
327
+ }
328
+
329
+ // Which of these capabilities the principal does NOT hold — the first one, or
330
+ // null when it holds them all.
331
+ //
332
+ // The plural is the point. Almost every gate in the tree needs a SET, not one:
333
+ // writing to a drive endpoint needs `drive:<name>` to reach it and
334
+ // `drive:<name>:write` to change it, and "may ALSO write" means the base is a
335
+ // prerequisite rather than an alternative. Each surface worked that out for
336
+ // itself and they disagreed — the WebDAV mount required both, the tool over
337
+ // the same endpoint required only the second, so one grant was refused a PUT
338
+ // and allowed the identical write through the other door.
339
+ //
340
+ // Returning the MISSING capability rather than a boolean is what lets a
341
+ // refusal name the one that is actually absent, which is the difference
342
+ // between "you lack drive:documents" and "you lack drive:documents:write" when
343
+ // the operator granted exactly one of them.
344
+ export function missingCapability(principal, capabilities = []) {
345
+ for (const capability of [].concat(capabilities)) {
346
+ if (!hasCapability(principal, capability)) return capability
347
+ }
348
+ return null
349
+ }
350
+
289
351
  export function hasCapability(principal, capability) {
290
352
  if (!capability) return true
291
353
  const caps = principal?.capabilities
@@ -16,8 +16,38 @@ export function workerMessages() {
16
16
  }
17
17
  }
18
18
 
19
+ // The identifier a descriptor is known by once its closures are gone.
20
+ // Render-shaped first, exactly as before: a descriptor carrying both `load`
21
+ // and `postprocess` has always been treated as a renderer.
22
+ function pluginIdentifier(plugin) {
23
+ if (!plugin || typeof plugin !== 'object' || typeof plugin.name !== 'string') return null
24
+ if (typeof plugin.load === 'function' || typeof plugin.render === 'function') return `render-${plugin.name}`
25
+ if (typeof plugin.postprocess === 'function') return `post-${plugin.name}`
26
+ return null
27
+ }
28
+
19
29
  export function workerSafeOptions(opts) {
20
30
  const result = {}
31
+ // Where the code behind each identifier actually lives.
32
+ //
33
+ // The projection below reduces a descriptor to `render-<name>`, and the
34
+ // worker resolves that name BY CONVENTION: a package called
35
+ // `mikser-io-render-<name>`, or a file core ships. That convention is the
36
+ // whole resolution story on a worker, because the runtime registry the
37
+ // main thread reads is empty in another thread.
38
+ //
39
+ // So a renderer shipped under a package that is not named for it is
40
+ // unresolvable there — and the failure is asymmetric in the worst way:
41
+ // the main thread renders it fine from the registry, and only the
42
+ // worker-dispatched entities break. mikser-io-assets ships two such
43
+ // plugins (`preset` and `asset`), which is how 10.12.0 left every
44
+ // worker render that calls `asset()` failing on a missing helper.
45
+ //
46
+ // A descriptor that carries `module` — the `import.meta.url` of the file
47
+ // defining it — says where it is, and a string is exactly what survives
48
+ // the thread boundary. Optional: a package named by the convention needs
49
+ // nothing, so no existing plugin changes.
50
+ const pluginModules = {}
21
51
  for (const [k, v] of Object.entries(opts)) {
22
52
  // `plugins` is a mixed array of factory-return values — functions
23
53
  // (lifecycle plugins; workers don't need them) and descriptor
@@ -28,11 +58,9 @@ export function workerSafeOptions(opts) {
28
58
  if (k === 'plugins' && Array.isArray(v)) {
29
59
  result[k] = v
30
60
  .map(p => {
31
- if (p && typeof p === 'object' && typeof p.name === 'string'
32
- && (typeof p.load === 'function' || typeof p.render === 'function')) return `render-${p.name}`
33
- if (p && typeof p === 'object' && typeof p.name === 'string'
34
- && typeof p.postprocess === 'function') return `post-${p.name}`
35
- return null
61
+ const identifier = pluginIdentifier(p)
62
+ if (identifier && typeof p.module === 'string') pluginModules[identifier] = p.module
63
+ return identifier
36
64
  })
37
65
  .filter(Boolean)
38
66
  continue
@@ -42,5 +70,8 @@ export function workerSafeOptions(opts) {
42
70
  result[k] = v
43
71
  } catch { /* not cloneable — skip */ }
44
72
  }
73
+ // Only when something declared one, so an options object a test compares
74
+ // whole does not grow an empty key.
75
+ if (Object.keys(pluginModules).length) result.pluginModules = pluginModules
45
76
  return result
46
77
  }
package/src/inventory.js CHANGED
@@ -30,39 +30,29 @@ function repositoryUrl(repository) {
30
30
  .replace(/\.git$/, '')
31
31
  }
32
32
 
33
- // Which plugins are actually RUNNING, as opposed to merely installed.
33
+ // Every mikser package INSTALLED beside this one, described.
34
34
  //
35
- // The distinction is the useful half: a package in node_modules that no config
36
- // loads explains nothing about the site's behaviour, and an agent told
37
- // otherwise will look for a feature that is not switched on.
38
- function activeNames() {
39
- const active = new Set()
40
- const named = (short) => (short.startsWith('mikser-io') ? short : `mikser-io-${short}`)
41
-
42
- // Every plugin that mounts a route already names itself there — the
43
- // strongest signal available, and one that stays correct as plugins are
44
- // added, because registerRoute requires it.
45
- for (const route of runtime.routes ?? []) {
46
- if (route?.plugin) active.add(named(route.plugin))
47
- }
48
- // Renderers and postprocessors are registered by name rather than by
49
- // package, and the package name is that name in a fixed shape.
50
- for (const name of runtime.renderers?.keys() ?? []) active.add(`mikser-io-render-${name}`)
51
- for (const name of runtime.postprocessors?.keys() ?? []) active.add(`mikser-io-post-${name}`)
52
- // A lifecycle plugin that mounts nothing is recognised by the surface it
53
- // publishes on the runtime.
54
- if (runtime.options?.layouts) active.add('mikser-io-layouts')
55
- if (runtime.options?.preview) active.add('mikser-io-preview')
56
- // The engine itself is always running; saying otherwise would be odd.
57
- active.add('mikser-io')
58
- return active
59
- }
60
-
61
- // Every mikser package installed beside this one, described.
35
+ // Installed, and nothing more. This list used to carry an `active` flag,
36
+ // derived by probing whatever surfaces a plugin happened to expose — a route
37
+ // here, a CLI flag there. Two things were wrong with it and only the second
38
+ // was dangerous.
39
+ //
40
+ // The probe went stale silently: it tested `runtime.options.layouts`, which
41
+ // stopped being layouts' API object two majors ago and is now the `--layouts`
42
+ // folder flag, so layouts read as inactive on every site that did not pass a
43
+ // flag it has no reason to pass. It also tested `runtime.options.preview` for
44
+ // a package, `mikser-io-preview`, that does not exist.
45
+ //
46
+ // And the flag was three-valued in code and two-valued in its contract:
47
+ // present meant running, absent meant EITHER not running or not detectable,
48
+ // and an agent told to read it "to know what the system can do" could only
49
+ // read absence as off. On a real site that said no schema validation and no
50
+ // git sync while both were running — and git sync is the only route by which
51
+ // an agent's own edit reaches the repository.
62
52
  //
63
- // `active` is reported only where it can be established: a lifecycle plugin
64
- // that publishes nothing on the runtime cannot be detected, and saying `false`
65
- // for it would be a claim rather than an absence of one.
53
+ // What is running is now answered by the runtime recording what it loads, in
54
+ // plugins.js. This answers a different and still useful question: what is on
55
+ // disk, what version, and where to read about it.
66
56
  export function inventory({ workingFolder = runtime.options?.workingFolder } = {}) {
67
57
  const root = path.join(workingFolder ?? '.', 'node_modules')
68
58
  let names = []
@@ -72,7 +62,6 @@ export function inventory({ workingFolder = runtime.options?.workingFolder } = {
72
62
  return []
73
63
  }
74
64
 
75
- const active = activeNames()
76
65
  const plugins = []
77
66
  for (const name of names.sort()) {
78
67
  try {
@@ -82,7 +71,6 @@ export function inventory({ workingFolder = runtime.options?.workingFolder } = {
82
71
  name,
83
72
  version: manifest.version ?? null,
84
73
  ...(manifest.description ? { summary: manifest.description } : {}),
85
- ...(active.has(name) ? { active: true } : {}),
86
74
  ...(manifest.homepage ? { homepage: manifest.homepage } : {}),
87
75
  ...(repository ? { repository } : {}),
88
76
  npm: `https://www.npmjs.com/package/${name}`,
@@ -91,3 +79,25 @@ export function inventory({ workingFolder = runtime.options?.workingFolder } = {
91
79
  }
92
80
  return plugins
93
81
  }
82
+
83
+ // What this runtime LOADED — the answer to "what is running".
84
+ //
85
+ // Read from the record plugins.js keeps as it loads, not derived from
86
+ // surfaces afterwards. Every loaded plugin appears, including one that named
87
+ // nothing: `package: null` means "running, and did not say what it is", which
88
+ // is a different statement from not running and must never be collapsed into
89
+ // one. Nothing here is ever absent because it could not be detected — a plugin
90
+ // that is loaded is in this list.
91
+ export function loadedPlugins({ workingFolder = runtime.options?.workingFolder } = {}) {
92
+ const root = path.join(workingFolder ?? '.', 'node_modules')
93
+ const versionOf = (name) => {
94
+ if (!name) return null
95
+ try {
96
+ return JSON.parse(readFileSync(path.join(root, name, 'package.json'), 'utf8')).version ?? null
97
+ } catch { return null }
98
+ }
99
+ return (runtime.plugins ?? []).map(entry => ({
100
+ ...entry,
101
+ ...(entry.package ? { version: versionOf(entry.package) } : {}),
102
+ }))
103
+ }
@@ -1209,5 +1209,10 @@ export function api(options = {}) {
1209
1209
  }
1210
1210
  }
1211
1211
  })
1212
+
1213
+ // Names this package to the runtime's loaded-plugin record — see
1214
+ // plugins.js. A plugin that declares nothing still reports as loaded,
1215
+ // but as `package: null`.
1216
+ return { module: import.meta.url }
1212
1217
  }
1213
1218
  }
@@ -304,6 +304,6 @@ export function commands(options = {}) {
304
304
  reportUnfired()
305
305
  })
306
306
 
307
- return { executeCommand }
307
+ return { executeCommand, module: import.meta.url }
308
308
  }
309
309
  }
@@ -216,5 +216,10 @@ export function data(options = {}) {
216
216
  }
217
217
  }
218
218
  })
219
+
220
+ // Names this package to the runtime's loaded-plugin record — see
221
+ // plugins.js. A plugin that declares nothing still reports as loaded,
222
+ // but as `package: null`.
223
+ return { module: import.meta.url }
219
224
  }
220
225
  }
@@ -29,6 +29,6 @@ export function documents(options = {}) {
29
29
  phase: 'import',
30
30
  })
31
31
 
32
- return { collection, type }
32
+ return { collection, type, module: import.meta.url }
33
33
  }
34
34
  }
@@ -273,6 +273,7 @@ export function files(options = {}) {
273
273
  return {
274
274
  collection,
275
275
  type,
276
+ module: import.meta.url,
276
277
  }
277
278
  }
278
279
  }
@@ -20,5 +20,10 @@ export function frontMatter(options = {}) {
20
20
  }
21
21
  }
22
22
  })
23
+
24
+ // Names this package to the runtime's loaded-plugin record — see
25
+ // plugins.js. A plugin that declares nothing still reports as loaded,
26
+ // but as `package: null`.
27
+ return { module: import.meta.url }
23
28
  }
24
29
  }
@@ -16,5 +16,10 @@ export function json(options = {}) {
16
16
  }
17
17
  }
18
18
  })
19
+
20
+ // Names this package to the runtime's loaded-plugin record — see
21
+ // plugins.js. A plugin that declares nothing still reports as loaded,
22
+ // but as `package: null`.
23
+ return { module: import.meta.url }
19
24
  }
20
25
  }
@@ -22,5 +22,10 @@ export function mapper(options = {}) {
22
22
  }
23
23
  }
24
24
  })
25
+
26
+ // Names this package to the runtime's loaded-plugin record — see
27
+ // plugins.js. A plugin that declares nothing still reports as loaded,
28
+ // but as `package: null`.
29
+ return { module: import.meta.url }
25
30
  }
26
31
  }
@@ -212,6 +212,7 @@ export function observer(options = {}) {
212
212
 
213
213
  return {
214
214
  format,
215
+ module: import.meta.url,
215
216
  }
216
217
  }
217
218
  }
@@ -164,5 +164,5 @@ export function load({ runtime, options, track, logger }) {
164
164
  }
165
165
 
166
166
  export function fileHelpers(options = {}) {
167
- return { name: options.name ?? 'file', options, load }
167
+ return { name: options.name ?? 'file', options, load, module: import.meta.url }
168
168
  }
@@ -327,5 +327,6 @@ export function renderHbs(options = {}) {
327
327
  load,
328
328
  render,
329
329
  parseReferences,
330
+ module: import.meta.url,
330
331
  }
331
332
  }
@@ -41,5 +41,5 @@ export function load({ entity, runtime, options }) {
41
41
  }
42
42
 
43
43
  export function hrefUrlHelpers(options = {}) {
44
- return { name: options.name ?? 'href', options, load }
44
+ return { name: options.name ?? 'href', options, load, module: import.meta.url }
45
45
  }
@@ -24,5 +24,5 @@ export function load({ runtime, entity, state, options, track }) {
24
24
  }
25
25
 
26
26
  export function resourceUrlHelper(options = {}) {
27
- return { name: options.name ?? 'resource', options, load }
27
+ return { name: options.name ?? 'resource', options, load, module: import.meta.url }
28
28
  }
@@ -245,6 +245,7 @@ export function resources(options = {}) {
245
245
  return {
246
246
  collection,
247
247
  type,
248
+ module: import.meta.url,
248
249
  }
249
250
  }
250
251
  }
@@ -38,5 +38,10 @@ export function shares(options = {}) {
38
38
  }
39
39
  }
40
40
  })
41
+
42
+ // Names this package to the runtime's loaded-plugin record — see
43
+ // plugins.js. A plugin that declares nothing still reports as loaded,
44
+ // but as `package: null`.
45
+ return { module: import.meta.url }
41
46
  }
42
47
  }
@@ -14,5 +14,10 @@ export function validator(options = {}) {
14
14
  })
15
15
  }
16
16
  })
17
+
18
+ // Names this package to the runtime's loaded-plugin record — see
19
+ // plugins.js. A plugin that declares nothing still reports as loaded,
20
+ // but as `package: null`.
21
+ return { module: import.meta.url }
17
22
  }
18
23
  }
@@ -22,5 +22,10 @@ export function yaml(options = {}) {
22
22
  }
23
23
  }
24
24
  })
25
+
26
+ // Names this package to the runtime's loaded-plugin record — see
27
+ // plugins.js. A plugin that declares nothing still reports as loaded,
28
+ // but as `package: null`.
29
+ return { module: import.meta.url }
25
30
  }
26
31
  }
package/src/plugins.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { useLogger } from './engine/index.js'
2
2
  import { onLoad } from './lifecycle.js'
3
3
  import { resetServices } from './services.js'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { existsSync, readFileSync } from 'node:fs'
4
7
  import runtime from './runtime.js'
5
8
 
6
9
  import * as core from '../index.js'
@@ -40,6 +43,9 @@ onLoad(() => {
40
43
  // always sees a Map, never undefined.
41
44
  runtime.renderers = runtime.renderers ?? new Map()
42
45
  runtime.postprocessors = runtime.postprocessors ?? new Map()
46
+ // Rebuilt, not appended to: loadPlugins runs again on a config change, and
47
+ // a list that accumulated would report every plugin twice.
48
+ runtime.plugins = []
43
49
 
44
50
  const factoryEntries = []
45
51
  let registeredRenderers = 0
@@ -62,6 +68,8 @@ onLoad(() => {
62
68
  continue
63
69
  }
64
70
  runtime.renderers.set(name, entry)
71
+ recordPlugin({ kind: 'renderer', name,
72
+ package: packageOfModule(entry.module), module: entry.module ?? null })
65
73
  registeredRenderers++
66
74
  continue
67
75
  }
@@ -72,6 +80,8 @@ onLoad(() => {
72
80
  continue
73
81
  }
74
82
  runtime.postprocessors.set(name, entry)
83
+ recordPlugin({ kind: 'postprocessor', name,
84
+ package: packageOfModule(entry.module), module: entry.module ?? null })
75
85
  registeredPostprocessors++
76
86
  continue
77
87
  }
@@ -140,17 +150,83 @@ onLoad(() => {
140
150
  logger.error('Plugin factory threw on registration: %s', err.message)
141
151
  continue
142
152
  }
143
- const label = descriptor?.collection ?? descriptor?.type ?? `plugin-${index + 1}`
153
+ // `plugin-7` names nothing a reader can act on. A plugin that
154
+ // declared its module has already said where it is, so the file's own
155
+ // stem is a better name than its position in an array.
156
+ const declaredName = descriptor?.module
157
+ ? path.basename(String(descriptor.module).split('?')[0]).replace(/\.[cm]?js$/, '')
158
+ : null
159
+ const label = descriptor?.collection ?? descriptor?.type ?? declaredName ?? `plugin-${index + 1}`
160
+ const registered = []
144
161
  for (const name of hookNames) {
145
- for (const hook of runtime.hooks[name].slice(before.get(name))) {
162
+ const added = runtime.hooks[name].slice(before.get(name))
163
+ if (added.length) registered.push(name)
164
+ for (const hook of added) {
146
165
  // A plugin registering the same function twice keeps its first
147
166
  // label rather than being renamed by a later registration.
148
167
  if (typeof hook === 'function' && !hook.mikserPlugin) hook.mikserPlugin = label
149
168
  }
150
169
  }
170
+ // Recorded whether or not it named itself. A plugin that declares
171
+ // nothing is still loaded, and the list must say so — that is the
172
+ // whole difference between this and the probe it replaces.
173
+ recordPlugin({
174
+ kind: 'lifecycle',
175
+ label,
176
+ package: packageOfModule(descriptor?.module),
177
+ module: descriptor?.module ?? null,
178
+ ...(descriptor?.collection ? { collection: descriptor.collection } : {}),
179
+ hooks: registered,
180
+ })
151
181
  }
152
182
  })
153
183
 
184
+
185
+ // The package a module belongs to, read from the nearest package.json.
186
+ //
187
+ // Walked up from the file rather than pattern-matched on the path: a plugin
188
+ // developed in a workspace sits at `~/Projects/mikser/mikser-io-git`, and the
189
+ // same plugin installed sits at `<site>/node_modules/mikser-io-git`. Only the
190
+ // manifest is true in both, and only the manifest is true for a plugin whose
191
+ // package is not named for it.
192
+ function packageOfModule(moduleUrl) {
193
+ if (typeof moduleUrl !== 'string') return null
194
+ let dir
195
+ try {
196
+ dir = path.dirname(moduleUrl.startsWith('file:') ? fileURLToPath(moduleUrl) : moduleUrl)
197
+ } catch { return null }
198
+ for (let depth = 0; depth < 12; depth++) {
199
+ const manifest = path.join(dir, 'package.json')
200
+ if (existsSync(manifest)) {
201
+ try { return JSON.parse(readFileSync(manifest, 'utf8')).name ?? null } catch { return null }
202
+ }
203
+ const parent = path.dirname(dir)
204
+ if (parent === dir) break
205
+ dir = parent
206
+ }
207
+ return null
208
+ }
209
+
210
+ // What this runtime LOADED, recorded as it loads it.
211
+ //
212
+ // Not derived afterwards. The previous answer was assembled by inventory.js
213
+ // from whatever surfaces happened to be observable — a route here, a CLI flag
214
+ // there — which made "is this plugin running" a question about how well the
215
+ // probe was maintained rather than about the plugin. It reported layouts as
216
+ // not running on every site that did not pass `--layouts`, because the flag it
217
+ // probed had stopped being an API object two majors earlier and nothing failed
218
+ // when it went stale.
219
+ //
220
+ // A plugin names itself by returning `module: import.meta.url` from its
221
+ // factory, the same self-naming `registerRoute` and `provideService` already
222
+ // require. One that names nothing is still recorded and still reported as
223
+ // LOADED — its `package` is null, which says "this is running and did not say
224
+ // what it is", never "this is not running".
225
+ function recordPlugin(entry) {
226
+ runtime.plugins = runtime.plugins ?? []
227
+ runtime.plugins.push(entry)
228
+ }
229
+
154
230
  function kebabToCamel(s) {
155
231
  return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
156
232
  }
@@ -7,7 +7,7 @@ import _ from 'lodash'
7
7
  import { useLogger } from './engine/index.js'
8
8
  import engineRuntime from './runtime.js'
9
9
 
10
- export async function loadPlugin(pluginName, workingFolder, loggerOverride) {
10
+ export async function loadPlugin(pluginName, workingFolder, loggerOverride, pluginModules) {
11
11
  // Worker contexts don't have access to the engine's pino instance
12
12
  // via useLogger() — callers from inside the Piscina dispatch pass
13
13
  // their port-forwarded logger explicitly so plugin-load failures
@@ -28,6 +28,9 @@ export async function loadPlugin(pluginName, workingFolder, loggerOverride) {
28
28
  } catch { /* package not installed at this level — fine, try next */ }
29
29
 
30
30
  const resolveLocations = [
31
+ // See render.js: what the descriptor declared, before anything that
32
+ // guesses a package name from the plugin name.
33
+ pluginModules?.[pluginName],
31
34
  path.join(workingFolder, 'node_modules', `mikser-io-${pluginName}/index.js`),
32
35
  nodeModulesResolved,
33
36
  path.join(workingFolder, 'plugins', `${pluginName}.js`),
@@ -110,7 +113,7 @@ export default async ({ entity, options, config, context, state, logger, port })
110
113
  .filter(p => p && p.indexOf('post-') == 0))
111
114
 
112
115
  for (let pluginName of pluginsToLoad) {
113
- const plugin = await loadPlugin(pluginName, options.workingFolder, logger)
116
+ const plugin = await loadPlugin(pluginName, options.workingFolder, logger, options.pluginModules)
114
117
  if (!plugin) continue
115
118
  plugins[pluginName] = plugin
116
119
  }
@@ -0,0 +1,33 @@
1
+ // Who is acting, for the length of one request.
2
+ //
3
+ // A leaf, and an AsyncLocalStorage for the same reason changeSetContext is
4
+ // one: the surfaces that KNOW the caller (an HTTP route, an MCP session) are
5
+ // nowhere near the primitives that need to ask about them, and threading a
6
+ // principal through every intervening call is how one of them comes to be
7
+ // missed.
8
+ //
9
+ // This exists because the thing already flowing to the write primitives was
10
+ // not an identity. `writeEntitySource` takes a `principal`, and what MCP puts
11
+ // there is a display string — "dk@almero.bg (admins)" — built for the
12
+ // change-set log. Asked `hasCapability(thatString, 'write:documents')` it
13
+ // reads `undefined.capabilities`, takes the "not capability-scoped" branch,
14
+ // and returns TRUE. An authorization check against it would not merely fail
15
+ // to protect anything; it would look like protection while allowing
16
+ // everything.
17
+ //
18
+ // `null` when nothing established a context, and that means UNKNOWN rather
19
+ // than "nobody". Callers decide what unknown implies — the write gate treats
20
+ // it the way it treats a credential that carries no capabilities, which is
21
+ // how every static token and library caller already behaves.
22
+ import { AsyncLocalStorage } from 'node:async_hooks'
23
+
24
+ const principalContext = new AsyncLocalStorage()
25
+
26
+ export function withPrincipal(principal, fn) {
27
+ if (!principal || typeof principal !== 'object') return fn()
28
+ return principalContext.run({ principal }, fn)
29
+ }
30
+
31
+ export function currentPrincipal() {
32
+ return principalContext.getStore()?.principal ?? null
33
+ }
package/src/render.js CHANGED
@@ -149,6 +149,13 @@ export default async ({ entity, options, config, context, state, logger, port, t
149
149
  } catch { /* package not installed at this level — fine, try next */ }
150
150
 
151
151
  const resolveLocations = [
152
+ // What the descriptor itself said, first: it is the only entry
153
+ // that does not guess from the name. Everything below assumes a
154
+ // package called `mikser-io-<pluginName>`, which is true of every
155
+ // plugin named for its renderer and false of every one that ships
156
+ // alongside other things — mikser-io-assets ships two. See
157
+ // workerSafeOptions.
158
+ options.pluginModules?.[pluginName],
152
159
  path.join(options.workingFolder, 'node_modules', `mikser-io-${pluginName}/index.js`),
153
160
  nodeModulesResolved,
154
161
  path.join(options.workingFolder, 'plugins', `${pluginName}.js`),
@@ -322,7 +329,22 @@ export default async ({ entity, options, config, context, state, logger, port, t
322
329
  }
323
330
 
324
331
  const rendererPlugin = plugins[`render-${renderer}`]
325
- const output = await rendererPlugin?.render({ entity, options, config: rendererPlugin?.options, context, plugins, runtime, state, logger, track })
332
+ // A missing HELPER announces itself — the template asks for something that
333
+ // is not there and the engine reports a render error. A missing RENDERER
334
+ // did not: the optional call returned undefined, the entity produced no
335
+ // output, and the only trace was one log line above. That is how
336
+ // `render-preset` went unresolvable on workers for two releases while
337
+ // every build stayed green.
338
+ //
339
+ // An entity was asked for and nothing rendered it. That is a fault, and it
340
+ // reads as one now.
341
+ if (typeof rendererPlugin?.render !== 'function') {
342
+ throw new Error(`Renderer "${renderer}" is not loaded, so ${entity?.id ?? 'this entity'} `
343
+ + `cannot be rendered. Its plugin resolved to nothing: either it is missing from the `
344
+ + `config, or it is a plugin a worker cannot find by name — see the "Render plugin `
345
+ + `render-${renderer} not found" line above.`)
346
+ }
347
+ const output = await rendererPlugin.render({ entity, options, config: rendererPlugin.options, context, plugins, runtime, state, logger, track })
326
348
 
327
349
  // One shape for both dispatch modes, so the engine unpacks in one place
328
350
  // rather than branching on how the render happened. An inline render folds
@@ -8,6 +8,7 @@ import { contentType } from 'mime-types'
8
8
  import { minimatch } from 'minimatch'
9
9
  import { mkdir, open, readFile, unlink, writeFile } from 'node:fs/promises'
10
10
  import { createRequire } from 'node:module'
11
+ import { missingCollectionWrite } from '../auth.js'
11
12
 
12
13
  // Extension → mime type lookup for rendered outputs. Used anywhere an
13
14
  // entity's destination is being served over HTTP (the api plugin's
@@ -371,6 +372,24 @@ export function projectMeta(meta) {
371
372
  * remove(relativePath: string): Promise<void>,
372
373
  * }}
373
374
  */
375
+ // Refuse a write the acting principal may not make.
376
+ //
377
+ // Here rather than at each caller, and for the reason the change-set hook one
378
+ // line below is here: this is the lowest write primitive, so a plugin that
379
+ // has never heard of capabilities is still bounded by them. The alternative
380
+ // is every writer remembering, and the one that forgets is the one that
381
+ // leaks.
382
+ //
383
+ // Throws rather than returning a refusal because `write` has no refusal
384
+ // channel — it returns a uri — and a caller that ignores a falsy return would
385
+ // report success for a write that never happened.
386
+ function refuseUnlessMayWrite(collection) {
387
+ const missing = missingCollectionWrite(collection)
388
+ if (!missing) return
389
+ throw new Error(`Refused: writing to ${collection} needs ${missing}, `
390
+ + 'which this credential does not carry.')
391
+ }
392
+
374
393
  export function useCollection(runtime, name) {
375
394
  function resolveFolder() {
376
395
  const folder = runtime?.options?.[`${name}Folder`]
@@ -402,6 +421,7 @@ export function useCollection(runtime, name) {
402
421
  resolveWithin,
403
422
 
404
423
  async write(relativePath, content = '') {
424
+ refuseUnlessMayWrite(name)
405
425
  const uri = resolveWithin(relativePath)
406
426
  await mkdir(path.dirname(uri), { recursive: true })
407
427
  await writeFile(uri, content, 'utf8')
@@ -415,6 +435,7 @@ export function useCollection(runtime, name) {
415
435
  },
416
436
 
417
437
  async remove(relativePath) {
438
+ refuseUnlessMayWrite(name)
418
439
  const uri = resolveWithin(relativePath)
419
440
  await unlink(uri)
420
441
  runtime.recordChangeSetWrite?.({ uri, operation: 'delete' })
@@ -15,5 +15,5 @@ export { checksum, checksumOf, diffInputParts, inputHashOf, inputPartsOf, normal
15
15
  export { JUNK_IGNORE, isJunkPath, junkFilter, junkIgnore, registerJunk } from './junk.js'
16
16
  export { matchesLibrary } from './library.js'
17
17
  export { isLoopback, loopbackOnly } from './net.js'
18
- export { siteRelativeUrl, siteRootFor, writeEntity, writeOutput } from './output.js'
18
+ export { siteRelativeUrl, siteRootFor, writeEntity, writeOutput, registerSourceFormat, sourceFormatFor, validateSource } from './output.js'
19
19
  export { extractRefs, isRefKey, lookupKeys, matchesRef, refFilter } from './refs.js'
@@ -37,44 +37,159 @@ import { lstat, mkdir, open, readFile, stat, unlink, writeFile } from 'node:fs/p
37
37
  // Returns the absolute path that was written. The watcher will see the
38
38
  // change just like any external edit — the entity re-enters the lifecycle
39
39
  // naturally on the next cycle.
40
+ // How a source file carries its meta, and how to put it back.
41
+ //
42
+ // Front matter is one answer, not the answer. A `.yml` or `.json` entity IS
43
+ // its meta — there is no body — and treating every file as front-matter did
44
+ // not merely format it oddly, it destroyed it: with no `---` to find, the
45
+ // whole document was taken as the BODY and the patch written above it as
46
+ // fresh front matter. A one-key rename turned a price list into a two-key
47
+ // document with the real one quoted underneath as inert text, and nothing
48
+ // threw. refs.rename reaches this for every referring entity, which is the
49
+ // largest fan-out any single request has.
50
+ //
51
+ // Registered and dispatched the way provenance registers its readers: last
52
+ // registered is checked first, and the catch-all goes in first so it is
53
+ // consulted last. A format plugin adds its own without touching this file.
54
+ //
55
+ // test(entity, raw) does this handler own this source?
56
+ // write({ raw, patch }) the complete new file contents
57
+ // validate(text) null if it parses, else why not — so an edit can
58
+ // be refused before it lands rather than found at
59
+ // the next build
60
+ //
61
+ // `patch` rather than a merged meta, deliberately: a handler that can edit its
62
+ // source in place should be allowed to. The yaml one does, so comments and
63
+ // untouched values survive an edit that names one key.
64
+ const sourceFormats = []
65
+
66
+ export function registerSourceFormat(name, { test, write, validate = null }) {
67
+ if (!name || typeof test !== 'function' || typeof write !== 'function') {
68
+ throw new Error('registerSourceFormat(name, { test, write }) requires all three')
69
+ }
70
+ sourceFormats.unshift({ name, test, write, validate })
71
+ return () => {
72
+ const at = sourceFormats.findIndex(h => h.name === name)
73
+ if (at >= 0) sourceFormats.splice(at, 1)
74
+ }
75
+ }
76
+
77
+ // Does this text still parse as what the file is? `null` when it does, and
78
+ // when the format has no parser to ask — the answer to "is this valid" for a
79
+ // stylesheet is not this module's to give.
80
+ export function validateSource(entity, text) {
81
+ const handler = sourceFormatFor(entity, text)
82
+ if (typeof handler?.validate !== 'function') return null
83
+ try { return handler.validate(text) } catch (err) { return err.message }
84
+ }
85
+
86
+ export function sourceFormatFor(entity, raw = '') {
87
+ for (const handler of sourceFormats) {
88
+ try {
89
+ if (handler.test(entity, raw)) return handler
90
+ } catch { /* a handler that throws is a handler that declines */ }
91
+ }
92
+ return sourceFormats[sourceFormats.length - 1]
93
+ }
94
+
95
+ // The format an entity is in, by what the catalog recorded and, failing that,
96
+ // by its extension — a caller holding only a uri is one this must still answer
97
+ // for.
98
+ function formatOf(entity) {
99
+ if (entity?.format) return String(entity.format).toLowerCase()
100
+ return path.extname(entity?.uri ?? '').replace(/^\./, '').toLowerCase()
101
+ }
102
+
103
+ function applyPatch(target, patch) {
104
+ const next = { ...target }
105
+ for (const [key, value] of Object.entries(patch)) {
106
+ if (value === null) delete next[key]
107
+ else next[key] = value
108
+ }
109
+ return next
110
+ }
111
+
112
+ // A text document with a `---` block, or one that should grow one. Registered
113
+ // first, so it is checked last: this is the catch-all.
114
+ registerSourceFormat('front-matter', {
115
+ test: () => true,
116
+ // Only the block is parseable; the body is whatever the renderer makes of
117
+ // it. A file with no front matter has nothing here to be wrong.
118
+ validate(text) {
119
+ if (!fm.test(text)) return null
120
+ try { fm(text); return null } catch (err) { return err.message }
121
+ },
122
+ write({ raw, patch }) {
123
+ const parsed = fm.test(raw) ? fm(raw) : null
124
+ const body = parsed ? (parsed.body ?? '') : raw
125
+ const meta = applyPatch(parsed?.attributes ?? {}, patch)
126
+ // No meta left — write just the body, rather than a `---`/`---` shell
127
+ // that some tooling reads as broken front matter.
128
+ if (Object.keys(meta).length === 0) return body
129
+ return `---\n${yaml.stringify(meta)}---\n${body}`
130
+ },
131
+ })
132
+
133
+ // A YAML document is meta all the way down.
134
+ //
135
+ // Patched through the document AST rather than re-serialized, so comments and
136
+ // every value the patch does not name survive. Not byte-perfect: the emitter
137
+ // re-flows block scalars to its own width, so hand-wrapped prose moves. That
138
+ // is a formatting change, where re-serializing would have been a content one.
139
+ registerSourceFormat('yaml', {
140
+ validate(text) {
141
+ const errors = yaml.parseAllDocuments(text).flatMap(document => document.errors)
142
+ return errors.length ? errors[0].message : null
143
+ },
144
+ // On the format alone. `fm.test` is not a safe second opinion here: a
145
+ // multi-document YAML file has a `---` between its documents and reads as
146
+ // front matter to it, so consulting it sent exactly the files with the
147
+ // most to lose down the wrong path.
148
+ test: (entity) => ['yml', 'yaml'].includes(formatOf(entity)),
149
+ write({ raw, patch }) {
150
+ // Every document, not the first. parseDocument on a multi-document
151
+ // source returns one carrying errors, and stringifying it throws —
152
+ // loud, but it refuses a file it could have patched. The meta belongs
153
+ // to the first document; the rest are copied through untouched.
154
+ const documents = raw.trim() ? yaml.parseAllDocuments(raw) : [yaml.parseDocument('')]
155
+ const target = documents[0]
156
+ for (const [key, value] of Object.entries(patch)) {
157
+ if (value === null) target.deleteIn([key])
158
+ else target.setIn([key], value)
159
+ }
160
+ return documents.map(document => document.toString()).join('')
161
+ },
162
+ })
163
+
164
+ // JSON has no comments to keep and no body to preserve, so this is a plain
165
+ // round-trip — but the indent is read off the file rather than imposed, so a
166
+ // one-key patch does not reformat every line of a 4-space document.
167
+ registerSourceFormat('json', {
168
+ validate(text) {
169
+ if (!text.trim()) return null
170
+ try { JSON.parse(text); return null } catch (err) { return err.message }
171
+ },
172
+ test: (entity) => formatOf(entity) === 'json',
173
+ write({ raw, patch }) {
174
+ const current = raw.trim() ? JSON.parse(raw) : {}
175
+ const indent = raw.match(/^[ \t]+/m)?.[0] ?? ' '
176
+ return JSON.stringify(applyPatch(current, patch), null, indent) + '\n'
177
+ },
178
+ })
179
+
40
180
  export async function writeEntity(entity, patch = {}) {
41
181
  if (!entity?.uri) {
42
182
  throw new Error('writeEntity: entity.uri is required')
43
183
  }
44
184
 
45
- let currentMeta = {}
46
- let body = ''
185
+ let raw = ''
47
186
  try {
48
- const content = await readFile(entity.uri, 'utf8')
49
- if (fm.test(content)) {
50
- const parsed = fm(content)
51
- currentMeta = parsed.attributes ?? {}
52
- body = parsed.body ?? ''
53
- } else {
54
- body = content
55
- }
187
+ raw = await readFile(entity.uri, 'utf8')
56
188
  } catch (err) {
57
189
  if (err.code !== 'ENOENT') throw err
58
- // Fresh file — start from an empty meta + body.
59
- }
60
-
61
- const newMeta = { ...currentMeta }
62
- for (const [k, v] of Object.entries(patch)) {
63
- if (v === null) delete newMeta[k]
64
- else newMeta[k] = v
190
+ // Fresh file — the handler starts from empty source.
65
191
  }
66
-
67
- let newContent
68
- if (Object.keys(newMeta).length > 0) {
69
- // yaml.stringify always emits a trailing newline.
70
- const yamlStr = yaml.stringify(newMeta)
71
- newContent = `---\n${yamlStr}---\n${body}`
72
- } else {
73
- // No meta left — write just the body. Avoids `---\n---\n<body>`
74
- // shells that some tooling treats as "broken frontmatter."
75
- newContent = body
76
- }
77
-
192
+ const newContent = sourceFormatFor(entity, raw).write({ raw, patch, entity })
78
193
  await mkdir(path.dirname(entity.uri), { recursive: true })
79
194
  await writeFile(entity.uri, newContent, 'utf8')
80
195
  // The other file-writing primitive. A rename cascade rewrites every
package/src/write.js CHANGED
@@ -18,11 +18,11 @@
18
18
  // gap this closes: the safety belongs to the write, not to one transport.
19
19
 
20
20
  import path from 'node:path'
21
- import { readdir } from 'node:fs/promises'
21
+ import { readdir, readFile } from 'node:fs/promises'
22
22
 
23
23
  import runtime from './runtime.js'
24
24
  import { readEntity, findEntities } from './catalog.js'
25
- import { useCollection, checksum, readEntityContent, lookupKeys } from './utils/index.js'
25
+ import { useCollection, checksum, readEntityContent, lookupKeys, validateSource } from './utils/index.js'
26
26
  import { nextCycleId, whenCycleCompletes } from './report.js'
27
27
  import { recordChangeSetWrite, currentChangeSet } from './changeset.js'
28
28
 
@@ -431,3 +431,145 @@ function referrersOf(entity) {
431
431
  }
432
432
  return [...found.values()]
433
433
  }
434
+
435
+ // Change a source file by naming the text to change, not by resending it.
436
+ //
437
+ // The alternative — and until now the only one — is a whole-file rewrite. For
438
+ // a model that is a silent-corruption machine: to change one price in a
439
+ // 900-line price list it must re-emit all 900 lines, and a line it quietly
440
+ // drops is indistinguishable downstream from a line someone meant to delete.
441
+ // `ifChecksum` catches a stale READ; nothing catches a lossy WRITE.
442
+ //
443
+ // An anchor cannot lose what it does not name. The only bytes that change are
444
+ // the ones matched, so everything else is not merely preserved — it is never
445
+ // rewritten. That also makes the concurrency check sharper than a checksum: a
446
+ // checksum refuses when ANY part of the file moved, an anchor refuses when the
447
+ // part you are editing moved, and those are different questions.
448
+ //
449
+ // Three refusals, and all of them are the good kind — stop and look, never
450
+ // "wrote the wrong thing":
451
+ //
452
+ // anchor-not-found the text is not there; the file is not what you read
453
+ // anchor-ambiguous it is there more than once; say more, or pass `all`
454
+ // would-not-parse the result is not valid in this file's format
455
+ //
456
+ // The last is the one a whole-file write can never offer. Today a model can
457
+ // emit invalid YAML and the build finds out; here the file never lands.
458
+ export async function editEntitySource({
459
+ id,
460
+ collection,
461
+ relativePath,
462
+ find,
463
+ replace = '',
464
+ all = false,
465
+ ifChecksum,
466
+ dryRun = false,
467
+ awaitCycle = false,
468
+ changeSet,
469
+ summary,
470
+ principal,
471
+ } = {}) {
472
+ if (typeof find !== 'string' || find === '') {
473
+ return { ok: false, refused: 'no-anchor', error: '`find` must be a non-empty string.' }
474
+ }
475
+ if (id) {
476
+ const located = await locateEntityFile(id)
477
+ if (located.error) return { ok: false, refused: 'unresolvable-id', error: located.error }
478
+ if (collection && collection !== located.collection) {
479
+ return {
480
+ ok: false,
481
+ refused: 'collection-mismatch',
482
+ error: `id ${id} is in collection ${located.collection}, not ${collection}. Pass one or the other.`,
483
+ }
484
+ }
485
+ collection ??= located.collection
486
+ relativePath ??= located.relativePath
487
+ }
488
+ if (!collection || !relativePath) {
489
+ return {
490
+ ok: false,
491
+ refused: 'incomplete-target',
492
+ error: 'Pass either `id` (for an existing entity) or both `collection` and `relativePath`.',
493
+ }
494
+ }
495
+
496
+ let handle
497
+ let uri
498
+ try {
499
+ handle = useCollection(runtime, collection)
500
+ uri = handle.resolveWithin(relativePath)
501
+ } catch (err) {
502
+ return { ok: false, refused: 'invalid-target', collection, relativePath, error: err.message }
503
+ }
504
+
505
+ const before = await fileChecksum(uri)
506
+ if (before === null) {
507
+ return {
508
+ ok: false,
509
+ refused: 'no-such-file',
510
+ collection, relativePath,
511
+ error: 'There is nothing here to edit. Use the whole-file write to create a file.',
512
+ }
513
+ }
514
+ if (ifChecksum !== undefined && ifChecksum !== before) {
515
+ return {
516
+ ok: false,
517
+ refused: 'checksum-mismatch',
518
+ collection, relativePath,
519
+ expectedChecksum: ifChecksum, currentChecksum: before,
520
+ hint: 'The file changed since you read it. Re-read it and check your anchor still says what you meant.',
521
+ }
522
+ }
523
+
524
+ const current = await readFile(uri, 'utf8')
525
+ const occurrences = current.split(find).length - 1
526
+ if (occurrences === 0) {
527
+ return {
528
+ ok: false,
529
+ refused: 'anchor-not-found',
530
+ collection, relativePath, currentChecksum: before,
531
+ error: 'That text is not in the file.',
532
+ hint: 'Re-read the entity: either it changed, or the anchor carries whitespace or a line break it does not have.',
533
+ }
534
+ }
535
+ if (occurrences > 1 && !all) {
536
+ return {
537
+ ok: false,
538
+ refused: 'anchor-ambiguous',
539
+ collection, relativePath, occurrences, currentChecksum: before,
540
+ error: `That text appears ${occurrences} times.`,
541
+ hint: 'Extend `find` with surrounding lines until it is unique, or pass `all: true` to change every one.',
542
+ }
543
+ }
544
+
545
+ // One expression for both, because the refusals above have already made
546
+ // the counts exact: `all` means "however many there are", and without it
547
+ // there is exactly one. (split/join replaces each match in the ORIGINAL,
548
+ // so a replacement containing the anchor does not compound.)
549
+ const content = current.split(find).join(replace)
550
+
551
+ // Refused before it lands, which is the guarantee a whole-file write has
552
+ // no way to make. Formats with no parser say nothing and pass.
553
+ const invalid = validateSource({ uri, collection }, content)
554
+ if (invalid) {
555
+ return {
556
+ ok: false,
557
+ refused: 'would-not-parse',
558
+ collection, relativePath, currentChecksum: before,
559
+ error: `The result is not valid ${path.extname(uri).replace('.', '') || 'content'}: ${invalid}`,
560
+ hint: 'Nothing was written. Check the replacement for indentation or quoting the format needs.',
561
+ }
562
+ }
563
+
564
+ // Through the whole-file write, so containment, the change set, the cycle
565
+ // id, sibling destinations and the collection capability gate are the same
566
+ // ones every other write goes through rather than a second set that can
567
+ // drift. `before` as the checksum closes the window between the read above
568
+ // and the write below.
569
+ const written = await writeEntitySource({
570
+ collection, relativePath, content,
571
+ ifChecksum: before,
572
+ dryRun, awaitCycle, changeSet, summary, principal,
573
+ })
574
+ return written.ok ? { ...written, replacements: all ? occurrences : 1 } : written
575
+ }