mikser-io 9.26.1 → 9.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/api-reference.md +16 -1
- package/docs/diagnostics.md +36 -2
- package/favicon.ico +0 -0
- package/favicon.svg +12 -0
- package/mikser-mark.svg +10 -0
- package/package.json +1 -1
- package/src/database/index.js +70 -13
- package/src/engine.js +49 -14
- package/src/explain.js +35 -2
- package/src/manifest.js +129 -3
- package/src/plugins/api.js +5 -9
- package/src/refs.js +68 -5
- package/src/report.js +74 -0
- package/src/server.js +27 -0
package/docs/api-reference.md
CHANGED
|
@@ -495,13 +495,28 @@ The engine substrate is a single sqlite file at
|
|
|
495
495
|
that need their own persistence reach it through these two helpers —
|
|
496
496
|
never by opening a second file.
|
|
497
497
|
|
|
498
|
-
### `registerSchema(name, sqlScript)`
|
|
498
|
+
### `registerSchema(name, sqlScript, { durable } = {})`
|
|
499
499
|
|
|
500
500
|
Register a CREATE-TABLE-IF-NOT-EXISTS SQL block. All registered schemas
|
|
501
501
|
are applied during `onLoaded`, after the engine's own tables
|
|
502
502
|
(`mikser_entities`, `mikser_refs`, `mikser_snapshots`, `mikser_journal`,
|
|
503
503
|
`mikser_meta`) and before any plugin's `onLoaded` runs.
|
|
504
504
|
|
|
505
|
+
**`durable`** (default `false`) — keep these tables when the cache is
|
|
506
|
+
wiped. The engine wipes on a schema-version change (any upgrade) and on a
|
|
507
|
+
config-checksum change (any deploy that edits `mikser.config.js`), because
|
|
508
|
+
per ADR-0002 the files are the source of truth and the database is derived.
|
|
509
|
+
That holds for anything rebuildable and fails for anything that is not: an
|
|
510
|
+
OAuth client registration, a refresh token, a received form submission
|
|
511
|
+
cannot be recreated from the working folder, and losing them is silent —
|
|
512
|
+
the first sign is a user being asked to sign in again after an unrelated
|
|
513
|
+
deploy.
|
|
514
|
+
|
|
515
|
+
Mark those `durable: true` and the wipe drops every other table instead of
|
|
516
|
+
deleting the database file. Leave it off for anything you can rebuild:
|
|
517
|
+
stale derived rows surviving an upgrade is the failure the wipe exists to
|
|
518
|
+
prevent.
|
|
519
|
+
|
|
505
520
|
```js
|
|
506
521
|
registerSchema('my_plugin_data', `
|
|
507
522
|
CREATE TABLE IF NOT EXISTS my_plugin_data (
|
package/docs/diagnostics.md
CHANGED
|
@@ -19,6 +19,7 @@ engine source, the entry point is missing and belongs on this page.
|
|
|
19
19
|
| What depends on this entity? | [`runtime.refs`](#runtimerefs) |
|
|
20
20
|
| Which layout claimed this document, and why that one? | [`layouts.inspect()`](#layoutsinspect) |
|
|
21
21
|
| Why does this page's output look stale? | [`runtime.manifest`](#runtimemanifest) |
|
|
22
|
+
| Two files seem to fight over one output | [`--explain`](#--explain-entity), [`--verify`](#--verify) |
|
|
22
23
|
| Did my schema validate anything at all? | [`schemas.names()`](#schemasnames--schemaslookup) |
|
|
23
24
|
|
|
24
25
|
## Command line
|
|
@@ -56,6 +57,20 @@ rendered 2026-08-22 21:07:52 → /page-a.html [STALE: input hash moved si
|
|
|
56
57
|
A snapshot written before per-input recording says so rather than
|
|
57
58
|
guessing.
|
|
58
59
|
|
|
60
|
+
When another entity renders to the same destination, that leads the
|
|
61
|
+
verdict and the hash reasoning follows it:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
CONTESTED — /bg/index.html is also claimed by /documents/bg/index.md.
|
|
65
|
+
Whichever renders last wins and the other output is discarded; the
|
|
66
|
+
input-hash reasoning below describes this entity only.
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The full list is under `competingDestinations` in `--json` form. The
|
|
70
|
+
hash reasoning on its own is true and useless here: "would be SKIPPED,
|
|
71
|
+
input hash unchanged" is correct about the entity you asked about and
|
|
72
|
+
silent about its output being overwritten by someone else's.
|
|
73
|
+
|
|
59
74
|
A destination whose **last render attempt threw** reads as such, rather
|
|
60
75
|
than as current:
|
|
61
76
|
|
|
@@ -128,6 +143,11 @@ Five buckets, and the distinction between them is the point:
|
|
|
128
143
|
| `errors` | the render ran and **threw** — with `id`, `destination`, `error`, `layout` |
|
|
129
144
|
| `gated` | a count — the source was unchanged, so no render was ever scheduled |
|
|
130
145
|
|
|
146
|
+
Each report also carries `cycleId`, `startedAt` and `finishedAt`. Under
|
|
147
|
+
`--watch` two consecutive reports are otherwise indistinguishable, so
|
|
148
|
+
"is this my edit's cycle or the one before it" has no answer without the
|
|
149
|
+
id. A cold build is cycle 1.
|
|
150
|
+
|
|
131
151
|
A failed render appears in `errors` and **not** in `rendered`: that bucket
|
|
132
152
|
means the output moved, and a throw writes nothing. The previous good bytes
|
|
133
153
|
stay on disk, which is what makes a failed render survivable — and also
|
|
@@ -227,6 +247,14 @@ instead of building. Four categories, and the split matters:
|
|
|
227
247
|
| `Mismatched` | the file's bytes differ from the recorded `outputHash` | error |
|
|
228
248
|
| `No hash` | a snapshot with no recorded hash — nothing to compare | warning |
|
|
229
249
|
| `Orphan` | a file on disk that no snapshot claims | warning |
|
|
250
|
+
| `Collision` | two or more entities record snapshots for the same destination | warning |
|
|
251
|
+
|
|
252
|
+
`Collision` is the one that does not show up as drift. Every render
|
|
253
|
+
hashes the file *after* writing it, so when two entities write the same
|
|
254
|
+
path the loser records the winner's bytes and both snapshots agree with
|
|
255
|
+
disk — missing, mismatched and orphaned are all empty and the output is
|
|
256
|
+
still wrong. It is reported by shape rather than by comparison, which is
|
|
257
|
+
why it is here and not in the three categories above.
|
|
230
258
|
|
|
231
259
|
Exit codes make it usable as a CI gate directly: `2` if anything is
|
|
232
260
|
missing or mismatched, `1` if only warnings, `0` when clean, and `2` when
|
|
@@ -269,7 +297,11 @@ an SDK, or an agent speaking MCP actually has.
|
|
|
269
297
|
|
|
270
298
|
**MCP** — `mikser_explain`, `mikser_build_report`, `mikser_verify`,
|
|
271
299
|
alongside the existing `mikser_refs_*`, `mikser_layouts_inspect` and the
|
|
272
|
-
`mikser://logs/recent` resource.
|
|
300
|
+
`mikser://logs/recent` resource. Two more answer the questions a shell
|
|
301
|
+
would otherwise be needed for: `mikser_search` finds a string across
|
|
302
|
+
entity meta and source files in one call, and `mikser_read_output` reads
|
|
303
|
+
the bytes currently on disk for a destination — which is a different
|
|
304
|
+
question from what the catalog or the manifest says should be there.
|
|
273
305
|
|
|
274
306
|
**REST** — on the `api` plugin, gated on their own `diagnostics`
|
|
275
307
|
operation:
|
|
@@ -398,7 +430,9 @@ What was rendered and whether it needs redoing.
|
|
|
398
430
|
| `skipDecision(entity, …)` | `{ skip, reason }` — the same reason `--json` reports |
|
|
399
431
|
| `recordedHashes()` | the dep-hashes dependents last saw |
|
|
400
432
|
| `queryAffected(mutated)` | which query-dependent snapshots this mutation hits |
|
|
401
|
-
| `verify({outputFolder})` | `{ missing, mismatched, unverifiable, orphaned }` — what `--verify` reports; pure, no mutations |
|
|
433
|
+
| `verify({outputFolder})` | `{ verdict, missing, mismatched, unverifiable, orphaned, collisions }` — what `--verify` reports; pure, no mutations |
|
|
434
|
+
| `collisions()` | destinations claimed by more than one entity, with the ids claiming each |
|
|
435
|
+
| `writerOf(destination, outputHash)` | which of several claimants wrote the bytes now on disk, when the hashes can tell them apart |
|
|
402
436
|
| `size()` | snapshot count |
|
|
403
437
|
|
|
404
438
|
`snapshotsFor(id)` exists because an entity can render to several
|
package/favicon.ico
ADDED
|
Binary file
|
package/favicon.svg
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="123.751 38.751 148.498 148.498" role="img" aria-label="mikser">
|
|
2
|
+
<title>mikser</title>
|
|
3
|
+
<!-- The mark with its blend flattened, so it survives a transparent
|
|
4
|
+
backdrop. mikser-mark.svg draws three circles under
|
|
5
|
+
mix-blend-mode: multiply, which needs something to multiply against:
|
|
6
|
+
rasterised onto transparency the orange circle knocks a hole through
|
|
7
|
+
itself instead of compositing. The colours below are sampled from the
|
|
8
|
+
canonical render, so this is the same image with nothing to blend. -->
|
|
9
|
+
<circle cx="198" cy="92" r="46" fill="#0A0907"/>
|
|
10
|
+
<circle cx="222.249" cy="134" r="46" fill="#2F2E2C"/>
|
|
11
|
+
<circle cx="173.751" cy="134" r="46" fill="#FF3F00"/>
|
|
12
|
+
</svg>
|
package/mikser-mark.svg
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="123.751 38.751 148.498 148.498" role="img" aria-label="mikser">
|
|
2
|
+
<title>mikser</title>
|
|
3
|
+
<g style="isolation:isolate">
|
|
4
|
+
<g style="mix-blend-mode:multiply">
|
|
5
|
+
<circle cx="198" cy="92" r="46" fill="#0A0907"></circle>
|
|
6
|
+
<circle cx="222.249" cy="134" r="46" fill="#0A0907" opacity="0.85"></circle>
|
|
7
|
+
<circle cx="173.751" cy="134" r="46" fill="#FF3F00"></circle>
|
|
8
|
+
</g>
|
|
9
|
+
</g>
|
|
10
|
+
</svg>
|
package/package.json
CHANGED
package/src/database/index.js
CHANGED
|
@@ -103,7 +103,26 @@ let db = null
|
|
|
103
103
|
// duplicate detection. Same name twice = the later registration wins
|
|
104
104
|
// (with a warning). Convention: `<owner>` matching the table prefix
|
|
105
105
|
// (`catalog`, `manifest`, `vector`, etc.).
|
|
106
|
-
|
|
106
|
+
// Table names a schema script creates. Used to decide what a cache wipe
|
|
107
|
+
// must leave alone — the registry knows schema NAMES, and the wipe works in
|
|
108
|
+
// tables.
|
|
109
|
+
// A registered schema is either { sql, durable } or, from an external
|
|
110
|
+
// caller of createSqliteDatabase, the bare SQL string that shape replaced.
|
|
111
|
+
// Both are supported: the argument is public API and a plugin or test
|
|
112
|
+
// passing a Map of strings predates the durability flag.
|
|
113
|
+
function schemaEntry(value) {
|
|
114
|
+
return typeof value === 'string' ? { sql: value, durable: false } : value
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function tableNamesFrom(sqlScript) {
|
|
118
|
+
const names = []
|
|
119
|
+
const re = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"'\[]?([A-Za-z_][\w$]*)/gi
|
|
120
|
+
let m
|
|
121
|
+
while ((m = re.exec(sqlScript))) names.push(m[1])
|
|
122
|
+
return names
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function registerSchema(name, sqlScript, { durable = false } = {}) {
|
|
107
126
|
if (typeof name !== 'string' || !name.length) {
|
|
108
127
|
throw new Error('registerSchema: name must be a non-empty string')
|
|
109
128
|
}
|
|
@@ -117,7 +136,7 @@ export function registerSchema(name, sqlScript) {
|
|
|
117
136
|
useLogger().warn('registerSchema: "%s" already registered, overwriting', name)
|
|
118
137
|
} catch { /* logger may not exist yet at module-import time */ }
|
|
119
138
|
}
|
|
120
|
-
schemas.set(name, sqlScript)
|
|
139
|
+
schemas.set(name, { sql: sqlScript, durable })
|
|
121
140
|
|
|
122
141
|
// Lazy-apply: if the database is already open, run the script
|
|
123
142
|
// against the live handle. Idempotent CREATE statements make this
|
|
@@ -295,15 +314,50 @@ export function createSqliteDatabase({
|
|
|
295
314
|
recorded, version,
|
|
296
315
|
)
|
|
297
316
|
}
|
|
298
|
-
|
|
299
|
-
|
|
317
|
+
// What must survive. A wipe exists because the cache is
|
|
318
|
+
// DERIVED — ADR-0002, the files are the source of truth, so
|
|
319
|
+
// throwing it away costs a rebuild and nothing else. That
|
|
320
|
+
// reasoning does not reach a table holding data no file can
|
|
321
|
+
// reproduce: an OAuth client registration, a refresh token, a
|
|
322
|
+
// form submission. Deleting the database file takes those with
|
|
323
|
+
// it, and the operator's first sign that it happened is being
|
|
324
|
+
// asked to authorize again.
|
|
325
|
+
//
|
|
326
|
+
// So a schema registered `durable` is kept and everything else
|
|
327
|
+
// goes. mikser_meta stays too — its stamps are rewritten a few
|
|
328
|
+
// lines down, and dropping it would only mean recreating it.
|
|
329
|
+
const durableTables = new Set(['mikser_meta'])
|
|
330
|
+
for (const value of schemas.values()) {
|
|
331
|
+
const { sql, durable } = schemaEntry(value)
|
|
332
|
+
if (durable) for (const t of tableNamesFrom(sql)) durableTables.add(t)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (durableTables.size > 1 && dbPath !== ':memory:') {
|
|
336
|
+
// Drop table by table rather than unlinking, so the durable
|
|
337
|
+
// ones keep their rows. Foreign keys off for the duration:
|
|
338
|
+
// a cache table may reference another and drop order here is
|
|
339
|
+
// whatever sqlite_master returns.
|
|
340
|
+
const tables = handle
|
|
341
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
|
342
|
+
.all()
|
|
343
|
+
handle.exec('PRAGMA foreign_keys = OFF')
|
|
344
|
+
for (const { name: table } of tables) {
|
|
345
|
+
if (durableTables.has(table)) continue
|
|
346
|
+
handle.exec(`DROP TABLE IF EXISTS "${table}"`)
|
|
347
|
+
}
|
|
348
|
+
handle.exec('PRAGMA foreign_keys = ON')
|
|
349
|
+
logger?.debug('Cache wiped, %d durable table(s) preserved', durableTables.size - 1)
|
|
350
|
+
} else {
|
|
351
|
+
handle.close()
|
|
352
|
+
handle = null
|
|
300
353
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
354
|
+
if (dbPath !== ':memory:') {
|
|
355
|
+
// sqlite WAL leaves -wal and -shm sidecar files. Remove
|
|
356
|
+
// them along with the main file so the next open starts
|
|
357
|
+
// from a guaranteed-clean slate.
|
|
358
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
359
|
+
try { unlinkSync(dbPath + suffix) } catch { /* file may not exist */ }
|
|
360
|
+
}
|
|
307
361
|
}
|
|
308
362
|
}
|
|
309
363
|
|
|
@@ -311,8 +365,10 @@ export function createSqliteDatabase({
|
|
|
311
365
|
// which is the right shape for a config change too: what the
|
|
312
366
|
// provisioners see is an empty-state database either way.
|
|
313
367
|
upgradedFromVersion = recorded ?? 'config'
|
|
314
|
-
|
|
315
|
-
|
|
368
|
+
if (!handle) {
|
|
369
|
+
handle = new Database(dbPath)
|
|
370
|
+
setupConnection()
|
|
371
|
+
}
|
|
316
372
|
}
|
|
317
373
|
const stmtStamp = handle.prepare('INSERT OR REPLACE INTO mikser_meta (key, value) VALUES (?, ?)')
|
|
318
374
|
stmtStamp.run('schema_version', version)
|
|
@@ -354,7 +410,8 @@ export function createSqliteDatabase({
|
|
|
354
410
|
|
|
355
411
|
// Apply each subsystem's registered schema script. Idempotent
|
|
356
412
|
// CREATE statements mean replay-safe across opens.
|
|
357
|
-
for (const [name,
|
|
413
|
+
for (const [name, value] of schemas) {
|
|
414
|
+
const { sql: sqlScript } = schemaEntry(value)
|
|
358
415
|
try {
|
|
359
416
|
handle.exec(sqlScript)
|
|
360
417
|
logger?.debug('Database schema applied: %s', name)
|
package/src/engine.js
CHANGED
|
@@ -10,7 +10,7 @@ import { useJournal, updateEntry } from './journal.js'
|
|
|
10
10
|
import { globby } from 'globby'
|
|
11
11
|
import { OPERATION, TASKS } from './constants.js'
|
|
12
12
|
import { changeExtension, formatErrorContext, projectMeta, lookupKeys } from './utils.js'
|
|
13
|
-
import { reportRendered, reportSkipped, reportError, renderErrorCount, emitReport } from './report.js'
|
|
13
|
+
import { reportRendered, reportSkipped, reportError, reportWarning, renderErrorCount, emitReport, finishCycle } from './report.js'
|
|
14
14
|
import render from './render.js'
|
|
15
15
|
import postprocess, { loadPlugin as loadPostPlugin } from './postprocess.js'
|
|
16
16
|
import map from 'p-map'
|
|
@@ -253,35 +253,40 @@ export async function setup(options) {
|
|
|
253
253
|
logger.error('Verify: no manifest available — nothing to check against')
|
|
254
254
|
process.exit(2)
|
|
255
255
|
}
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
256
|
+
const { verdict, missing, mismatched, unverifiable, orphaned, collisions } =
|
|
257
|
+
await runtime.manifest.verify()
|
|
259
258
|
const total = runtime.manifest.size()
|
|
260
|
-
const errors = missing.length + mismatched.length
|
|
261
|
-
const warnings = orphaned.length + unverifiable.length
|
|
262
259
|
|
|
263
|
-
for (const e of missing)
|
|
264
|
-
for (const e of mismatched)
|
|
260
|
+
for (const e of missing) logger.error('Missing: %s (entity %s)', e.destination, e.id)
|
|
261
|
+
for (const e of mismatched) logger.error('Mismatched: %s (entity %s)%s', e.destination, e.id,
|
|
262
|
+
e.writtenBy ? ` — the bytes on disk are ${e.writtenBy}'s` : '')
|
|
265
263
|
for (const e of unverifiable) logger.warn('No hash: %s (entity %s)', e.destination, e.id)
|
|
266
|
-
for (const e of orphaned)
|
|
264
|
+
for (const e of orphaned) logger.warn('Orphan: %s', e.path)
|
|
265
|
+
// Named per destination: "two entities write here" is only
|
|
266
|
+
// actionable if you know which two.
|
|
267
|
+
for (const c of collisions) logger.warn('Collision: %s ← %s', c.destination, c.entities.join(', '))
|
|
267
268
|
|
|
268
269
|
// Level picked from the verdict, because the level IS the marker
|
|
269
270
|
// in pino-pretty's messageFormat: notice renders 🟢, warn 🟡,
|
|
270
271
|
// error 🔴. A fixed `notice` prints a green tick next to the word
|
|
271
272
|
// FAIL, which reads as success at a glance even though the exit
|
|
272
273
|
// code is right.
|
|
273
|
-
const
|
|
274
|
-
const report = errors > 0 ? logger.error : (warnings > 0 ? logger.warn : logger.notice)
|
|
274
|
+
const report = verdict === 'FAIL' ? logger.error : verdict === 'WARN' ? logger.warn : logger.notice
|
|
275
275
|
report.call(logger,
|
|
276
|
-
'Verify %s: %d snapshots, %d missing, %d mismatched, %d unverifiable, %d orphaned',
|
|
277
|
-
verdict, total, missing.length, mismatched.length, unverifiable.length, orphaned.length)
|
|
278
|
-
process.exit(
|
|
276
|
+
'Verify %s: %d snapshots, %d missing, %d mismatched, %d unverifiable, %d orphaned, %d collisions',
|
|
277
|
+
verdict, total, missing.length, mismatched.length, unverifiable.length, orphaned.length, collisions.length)
|
|
278
|
+
process.exit(verdict === 'FAIL' ? 2 : verdict === 'WARN' ? 1 : 0)
|
|
279
279
|
}
|
|
280
280
|
})
|
|
281
281
|
|
|
282
282
|
onRender(async (signal) => {
|
|
283
283
|
const logger = useLogger()
|
|
284
284
|
const renderJobs = new Set()
|
|
285
|
+
// destination → the entity ids that actually rendered to it this
|
|
286
|
+
// cycle. Recorded past the skip gate rather than alongside renderJobs
|
|
287
|
+
// so it holds renders that ran, which is what makes "one overwrote
|
|
288
|
+
// the other" a true statement rather than a guess about two claims.
|
|
289
|
+
const renderedTo = new Map()
|
|
285
290
|
|
|
286
291
|
// Collect this cycle's mutated entity ids/hrefs/entities so the
|
|
287
292
|
// manifest skip check can re-render anything whose dependencies
|
|
@@ -374,6 +379,10 @@ export async function setup(options) {
|
|
|
374
379
|
logger.debug('Manifest skip: %s → %s', entity.name || entity.id, entity.destination)
|
|
375
380
|
return
|
|
376
381
|
}
|
|
382
|
+
if (entity.destination) {
|
|
383
|
+
if (!renderedTo.has(entity.destination)) renderedTo.set(entity.destination, new Set())
|
|
384
|
+
renderedTo.get(entity.destination).add(entity.id)
|
|
385
|
+
}
|
|
377
386
|
// Reported on the way OUT, not here: `rendered` means the
|
|
378
387
|
// output moved, and a render that throws writes nothing. The
|
|
379
388
|
// decision is carried down to the success path so the reason
|
|
@@ -575,6 +584,27 @@ export async function setup(options) {
|
|
|
575
584
|
renderJobs.size && logger.info('Rendered: %d', renderJobs.size - skipped - failed)
|
|
576
585
|
skipped && logger.info('Manifest skipped: %d', skipped)
|
|
577
586
|
failed && logger.error('Render errors: %d', failed)
|
|
587
|
+
|
|
588
|
+
// Two entities writing one destination in the same cycle: one
|
|
589
|
+
// silently overwrote the other, and every other signal reads clean.
|
|
590
|
+
// Reported per cycle rather than only by --verify because this is
|
|
591
|
+
// the moment it happened, and because a build that discards half its
|
|
592
|
+
// output must not report warnings: 0.
|
|
593
|
+
//
|
|
594
|
+
// Derived from the destinations THIS cycle rendered, so an
|
|
595
|
+
// established collision the operator already knows about does not
|
|
596
|
+
// re-warn on every unrelated build; --verify is where the standing
|
|
597
|
+
// state lives.
|
|
598
|
+
for (const [destination, ids] of renderedTo) {
|
|
599
|
+
if (ids.size < 2) continue
|
|
600
|
+
const entities = [...ids].sort()
|
|
601
|
+
reportWarning('destination-collision', { destination, entities })
|
|
602
|
+
logger.warn(
|
|
603
|
+
'Destination collision: %s written by %d entities in this cycle (%s). '
|
|
604
|
+
+ 'One overwrote the other — whichever rendered last wins.',
|
|
605
|
+
destination, entities.length, entities.join(', '),
|
|
606
|
+
)
|
|
607
|
+
}
|
|
578
608
|
})
|
|
579
609
|
|
|
580
610
|
onBeforePostprocess(async (signal) => {
|
|
@@ -801,6 +831,11 @@ export async function setup(options) {
|
|
|
801
831
|
}
|
|
802
832
|
}
|
|
803
833
|
}
|
|
834
|
+
// Close the cycle: stamp it and file it in the history, so a caller
|
|
835
|
+
// that asked "tell me about cycle N" gets an answer after N ends
|
|
836
|
+
// rather than only while it is the current one.
|
|
837
|
+
finishCycle()
|
|
838
|
+
|
|
804
839
|
// A cycle with failed renders is not a completed build, and the word
|
|
805
840
|
// people read is this one.
|
|
806
841
|
const failed = renderErrorCount()
|
package/src/explain.js
CHANGED
|
@@ -85,6 +85,17 @@ function queryCount(counts, filter) {
|
|
|
85
85
|
return { matched: hit.count, ...(hit.count === 1 && hit.sample ? { sample: hit.sample } : {}) }
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// A destination two entities claim outranks anything the hashes say. The
|
|
89
|
+
// hash reasoning would be true AND useless: "would be SKIPPED, input hash
|
|
90
|
+
// unchanged" is correct about this entity and silent about the fact that
|
|
91
|
+
// its output is being overwritten by someone else's.
|
|
92
|
+
function contestedVerdict(competing) {
|
|
93
|
+
if (!competing.length) return null
|
|
94
|
+
const parts = competing.map(c => `${c.destination} is also claimed by ${c.entities.join(', ')}`)
|
|
95
|
+
return `CONTESTED — ${parts.join('; ')}. Whichever renders last wins and the other output is discarded; `
|
|
96
|
+
+ 'the input-hash reasoning below describes this entity only.'
|
|
97
|
+
}
|
|
98
|
+
|
|
88
99
|
// The verdict line names what moved when it can. That line is the one
|
|
89
100
|
// people read, so "the input hash differs" there is the answer stopping one
|
|
90
101
|
// step short of useful.
|
|
@@ -252,7 +263,8 @@ export async function explain(reference) {
|
|
|
252
263
|
}),
|
|
253
264
|
})),
|
|
254
265
|
// What a plain build would do next, stated plainly.
|
|
255
|
-
verdict:
|
|
266
|
+
verdict: contestedVerdict(competingFor(snapshots, entity.id))
|
|
267
|
+
?? (source?.error === 'file is gone'
|
|
256
268
|
? 'source file is gone — a build would DELETE this entity and unlink its output'
|
|
257
269
|
: source?.differs
|
|
258
270
|
? 'source differs from the catalog — a build would re-import it first, then re-render. '
|
|
@@ -264,11 +276,32 @@ export async function explain(reference) {
|
|
|
264
276
|
+ `(${failedSnapshots[0].error})`
|
|
265
277
|
: snapshots.some(s => s.inputHash !== currentHash)
|
|
266
278
|
? renderVerdict(snapshots, currentHash, currentParts)
|
|
267
|
-
: 'would be SKIPPED — input hash unchanged. A dependency in refClosure changing is the only other thing that would re-render it.',
|
|
279
|
+
: 'would be SKIPPED — input hash unchanged. A dependency in refClosure changing is the only other thing that would re-render it.'),
|
|
268
280
|
lookupKeys: lookupKeys(entity),
|
|
281
|
+
// Other entities rendering to the same path as this one.
|
|
282
|
+
//
|
|
283
|
+
// The failure this makes visible: an empty stub and a real page both
|
|
284
|
+
// claiming /bg/index.html. Whichever renders last wins, the other's
|
|
285
|
+
// output is discarded, and nothing else says so — not the build
|
|
286
|
+
// (green), not verify (each render hashes the file after writing, so
|
|
287
|
+
// the loser records the winner's bytes and both snapshots agree with
|
|
288
|
+
// disk), and not this report, which showed the destination and a
|
|
289
|
+
// clean "would be SKIPPED".
|
|
290
|
+
competingDestinations: competingFor(snapshots, entity.id),
|
|
269
291
|
}
|
|
270
292
|
}
|
|
271
293
|
|
|
294
|
+
// Entities other than `id` whose snapshots claim the same destinations.
|
|
295
|
+
function competingFor(snapshots, id) {
|
|
296
|
+
const mine = new Set(snapshots.map(s => s.destination).filter(Boolean))
|
|
297
|
+
if (!mine.size) return []
|
|
298
|
+
const all = runtime.manifest?.collisions?.() ?? []
|
|
299
|
+
return all
|
|
300
|
+
.filter(c => mine.has(c.destination))
|
|
301
|
+
.map(c => ({ destination: c.destination, entities: c.entities.filter(e => e !== id) }))
|
|
302
|
+
.filter(c => c.entities.length)
|
|
303
|
+
}
|
|
304
|
+
|
|
272
305
|
// Human-readable rendering. Deliberately aligned columns rather than prose:
|
|
273
306
|
// the point is to be scanned, and to be diffable between two runs.
|
|
274
307
|
export function formatExplain(report) {
|
package/src/manifest.js
CHANGED
|
@@ -284,6 +284,21 @@ async function hashOutputFile(destination) {
|
|
|
284
284
|
export function createManifest(db) {
|
|
285
285
|
if (!db) throw new Error('createManifest: db is required')
|
|
286
286
|
|
|
287
|
+
const stmtCollisions = db.prepare(`
|
|
288
|
+
SELECT destination, count(*) AS n, group_concat(id) AS ids
|
|
289
|
+
FROM mikser_snapshots
|
|
290
|
+
GROUP BY destination HAVING n > 1
|
|
291
|
+
`)
|
|
292
|
+
const stmtClaimants = db.prepare(`
|
|
293
|
+
SELECT id, outputHash FROM mikser_snapshots WHERE destination = ?
|
|
294
|
+
`)
|
|
295
|
+
const stmtSelectByDestination = db.prepare(`
|
|
296
|
+
SELECT id FROM mikser_snapshots WHERE destination = ?
|
|
297
|
+
`)
|
|
298
|
+
const stmtDeleteByDestination = db.prepare(`
|
|
299
|
+
DELETE FROM mikser_snapshots WHERE destination = ?
|
|
300
|
+
`)
|
|
301
|
+
|
|
287
302
|
const stmtRecordFailure = db.prepare(`
|
|
288
303
|
INSERT INTO mikser_failures
|
|
289
304
|
(id, destination, error, context, firstFailedAt, lastFailedAt, attempts)
|
|
@@ -610,6 +625,37 @@ export function createManifest(db) {
|
|
|
610
625
|
stmtClearFailure.run(entity.id, entity.destination)
|
|
611
626
|
},
|
|
612
627
|
|
|
628
|
+
// Destinations claimed by more than one entity.
|
|
629
|
+
//
|
|
630
|
+
// Two entities rendering to one path is always a bug — one silently
|
|
631
|
+
// overwrites the other — and it is invisible to every other check.
|
|
632
|
+
// Not to `verify`'s hash comparison in particular: each render
|
|
633
|
+
// records the hash of the file AFTER it wrote, so with concurrent
|
|
634
|
+
// renders the loser reads the winner's bytes and both snapshots
|
|
635
|
+
// agree with disk. Measured on the case this exists for: an empty
|
|
636
|
+
// stub and a real homepage both claiming /bg/index.html recorded the
|
|
637
|
+
// SAME outputHash, and verify reported OK.
|
|
638
|
+
//
|
|
639
|
+
// So it is detected structurally rather than by content: the
|
|
640
|
+
// manifest already knows both claimants because a snapshot is keyed
|
|
641
|
+
// (id, destination).
|
|
642
|
+
collisions() {
|
|
643
|
+
return stmtCollisions.all().map(row => ({
|
|
644
|
+
destination: row.destination,
|
|
645
|
+
entities: String(row.ids).split(',').filter(Boolean).sort(),
|
|
646
|
+
}))
|
|
647
|
+
},
|
|
648
|
+
|
|
649
|
+
// Which entity's recorded bytes are the ones on disk right now.
|
|
650
|
+
// Answers "who wrote this?" for a destination several entities
|
|
651
|
+
// claim, which is the question a mismatch leaves open.
|
|
652
|
+
writerOf(destination, outputHash) {
|
|
653
|
+
if (!destination || !outputHash) return null
|
|
654
|
+
const rows = stmtClaimants.all(destination)
|
|
655
|
+
const match = rows.find(r => r.outputHash === outputHash)
|
|
656
|
+
return match?.id ?? null
|
|
657
|
+
},
|
|
658
|
+
|
|
613
659
|
// Every recorded failure for an entity, across destinations.
|
|
614
660
|
failuresFor(id) {
|
|
615
661
|
return id ? stmtFailuresFor.all(id) : []
|
|
@@ -821,8 +867,18 @@ export function createManifest(db) {
|
|
|
821
867
|
}
|
|
822
868
|
try {
|
|
823
869
|
const buf = await readFile(filePath)
|
|
824
|
-
|
|
825
|
-
|
|
870
|
+
const actual = sha1(buf)
|
|
871
|
+
if (actual !== snap.outputHash) {
|
|
872
|
+
// Name who DID write the bytes that are there, when a
|
|
873
|
+
// sibling snapshot for the same destination matches
|
|
874
|
+
// them. "Mismatched" alone leaves the reader to work
|
|
875
|
+
// out whether the file was edited by hand or lost a
|
|
876
|
+
// race with another entity claiming the same path.
|
|
877
|
+
mismatched.push({
|
|
878
|
+
id: snap.id,
|
|
879
|
+
destination: snap.destination,
|
|
880
|
+
writtenBy: this.writerOf(snap.destination, actual),
|
|
881
|
+
})
|
|
826
882
|
}
|
|
827
883
|
} catch {
|
|
828
884
|
missing.push({ id: snap.id, destination: snap.destination })
|
|
@@ -839,7 +895,29 @@ export function createManifest(db) {
|
|
|
839
895
|
if (claimed.has(rel)) continue
|
|
840
896
|
orphaned.push({ path: rel })
|
|
841
897
|
}
|
|
842
|
-
|
|
898
|
+
// Reported alongside, not as a mismatch: two entities claiming one
|
|
899
|
+
// destination usually produces NO mismatch at all, because each
|
|
900
|
+
// render hashes the file after writing it and the loser reads the
|
|
901
|
+
// winner's bytes. Without this the whole situation is silent.
|
|
902
|
+
const collisions = this.collisions()
|
|
903
|
+
// One verdict, computed here, because three callers report it —
|
|
904
|
+
// the CLI's exit code, the api route and the MCP tool — and three
|
|
905
|
+
// copies of the rule would drift.
|
|
906
|
+
//
|
|
907
|
+
// A collision is a WARNING, not a failure: nothing is missing or
|
|
908
|
+
// corrupt, the bytes on disk are some entity's real render. What
|
|
909
|
+
// is wrong is that another entity's output was discarded, which
|
|
910
|
+
// the reader has to be told about but which does not mean the
|
|
911
|
+
// deploy is broken in the way a missing or altered file does.
|
|
912
|
+
// It is also pre-existing on any site that already has one, so
|
|
913
|
+
// failing the gate outright would break pipelines on upgrade for
|
|
914
|
+
// a condition that was always there.
|
|
915
|
+
const errors = missing.length + mismatched.length
|
|
916
|
+
const warnings = orphaned.length + unverifiable.length + collisions.length
|
|
917
|
+
return {
|
|
918
|
+
verdict: errors > 0 ? 'FAIL' : warnings > 0 ? 'WARN' : 'OK',
|
|
919
|
+
missing, mismatched, unverifiable, orphaned, collisions,
|
|
920
|
+
}
|
|
843
921
|
},
|
|
844
922
|
|
|
845
923
|
size() {
|
|
@@ -852,6 +930,8 @@ export function createManifest(db) {
|
|
|
852
930
|
_stmtSelectByIdOrParent: stmtSelectByIdOrParent,
|
|
853
931
|
_stmtDeleteByIdOrParent: stmtDeleteByIdOrParent,
|
|
854
932
|
_stmtSelectByParent: stmtSelectByParent,
|
|
933
|
+
_stmtSelectByDestination: stmtSelectByDestination,
|
|
934
|
+
_stmtDeleteByDestination: stmtDeleteByDestination,
|
|
855
935
|
_stmtDeleteByPK: stmtDeleteByPK,
|
|
856
936
|
_stmtUpsert: stmtUpsert,
|
|
857
937
|
}
|
|
@@ -961,8 +1041,54 @@ onFinalize(async () => {
|
|
|
961
1041
|
}
|
|
962
1042
|
}
|
|
963
1043
|
|
|
1044
|
+
// Everything whose snapshot this pass removes: deleted entities, their
|
|
1045
|
+
// paginated children, and children dropped by a pagination shrink.
|
|
1046
|
+
const goingAway = new Set(deleted)
|
|
1047
|
+
for (const { id } of childrenToDelete) goingAway.add(id)
|
|
1048
|
+
for (const parentId of deleted) {
|
|
1049
|
+
for (const row of m._stmtSelectByParent.all(parentId)) goingAway.add(row.id)
|
|
1050
|
+
}
|
|
1051
|
+
|
|
964
1052
|
// 2d. Unlink stale output files (async, parallel-friendly).
|
|
1053
|
+
//
|
|
1054
|
+
// Never unlink a destination another entity still claims. Two entities
|
|
1055
|
+
// can render to one path — an empty `index.md` beside the real
|
|
1056
|
+
// `index.yml` — and deleting one of them was taking the shared output
|
|
1057
|
+
// with it: the file vanished while the survivor's snapshot still said it
|
|
1058
|
+
// was there, the survivor's own source had not changed so nothing
|
|
1059
|
+
// re-rendered it, and --verify reported it missing.
|
|
1060
|
+
//
|
|
1061
|
+
// That made "resolve the collision by deleting the stub" delete the
|
|
1062
|
+
// homepage, which is the opposite of what the operator asked for and the
|
|
1063
|
+
// exact operation the new collision reporting invites.
|
|
965
1064
|
for (const { destination, reason } of filesToUnlink) {
|
|
1065
|
+
// "Still claimed" means by something that SURVIVES this pass. The
|
|
1066
|
+
// ids going away here are not just the deleted entities: pagination
|
|
1067
|
+
// children staged above are removed too, and counting a child's own
|
|
1068
|
+
// snapshot as a claimant would keep every shrunk page on disk
|
|
1069
|
+
// forever.
|
|
1070
|
+
const stillClaimed = m._stmtSelectByDestination.all(destination)
|
|
1071
|
+
.filter(row => row.id !== undefined && !goingAway.has(row.id))
|
|
1072
|
+
if (stillClaimed.length) {
|
|
1073
|
+
// Keep the file — deleting a live page's output is worse than any
|
|
1074
|
+
// staleness — but do NOT let the state go quiet. The bytes on
|
|
1075
|
+
// disk were written by the entity that just went away, and the
|
|
1076
|
+
// survivor's snapshot recorded that same hash (each render hashes
|
|
1077
|
+
// the file after writing, so the loser recorded the winner's
|
|
1078
|
+
// bytes). Left alone, verify would compare the survivor's
|
|
1079
|
+
// snapshot against the deleted entity's output and report OK.
|
|
1080
|
+
//
|
|
1081
|
+
// Dropping the survivor's snapshot for this destination makes it
|
|
1082
|
+
// an orphan — a file no snapshot claims, which is exactly what it
|
|
1083
|
+
// is — so verify warns instead of blessing it, and the next time
|
|
1084
|
+
// the survivor renders it is `never-rendered` rather than skipped.
|
|
1085
|
+
m._stmtDeleteByDestination.run(destination)
|
|
1086
|
+
logger.warn(
|
|
1087
|
+
'%s: %s is also written by %s — keeping the file, but its bytes came from the '
|
|
1088
|
+
+ 'deleted entity. Re-render or --force to refresh it.',
|
|
1089
|
+
reason, destination, stillClaimed.map(r => r.id).join(', '))
|
|
1090
|
+
continue
|
|
1091
|
+
}
|
|
966
1092
|
const filePath = path.join(runtime.options.outputFolder, destination)
|
|
967
1093
|
try {
|
|
968
1094
|
await unlink(filePath)
|
package/src/plugins/api.js
CHANGED
|
@@ -982,18 +982,14 @@ export function api(options = {}) {
|
|
|
982
982
|
if (!runtime.manifest?.verify) {
|
|
983
983
|
return res.status(503).json({ error: 'No manifest available — nothing to verify against' })
|
|
984
984
|
}
|
|
985
|
-
const diff = await runtime.manifest.verify()
|
|
986
|
-
const errors = diff.missing.length + diff.mismatched.length
|
|
987
|
-
const warnings = diff.orphaned.length + diff.unverifiable.length
|
|
988
985
|
// 200 either way — the check ran and this is its answer. A
|
|
989
986
|
// CI gate reads `verdict`, which mirrors the CLI's exit
|
|
990
987
|
// vocabulary, rather than inferring from a status code that
|
|
991
|
-
// would conflate "drift found" with "request failed".
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
})
|
|
988
|
+
// would conflate "drift found" with "request failed". The
|
|
989
|
+
// verdict comes from the manifest so this route cannot
|
|
990
|
+
// disagree with the CLI about what counts as a failure.
|
|
991
|
+
const diff = await runtime.manifest.verify()
|
|
992
|
+
return res.json({ snapshots: runtime.manifest.size?.() ?? null, ...diff })
|
|
997
993
|
} catch (err) {
|
|
998
994
|
logger.error('Api verify error: %s', err.message)
|
|
999
995
|
return res.status(500).json({ error: err.message })
|
package/src/refs.js
CHANGED
|
@@ -85,6 +85,16 @@ export const REFS_SCHEMA = `
|
|
|
85
85
|
`
|
|
86
86
|
registerSchema('mikser_refs', REFS_SCHEMA)
|
|
87
87
|
|
|
88
|
+
// json_tree's `fullkey` → the field path the ref index uses.
|
|
89
|
+
// `$.meta.items[0].href` → `items[0].href`, and a quoted segment
|
|
90
|
+
// (`$.meta."$author"`, which is how json_tree spells a key starting with
|
|
91
|
+
// `$`) loses its quotes so both spellings compare equal.
|
|
92
|
+
function normalizeMetaPath(fullkey) {
|
|
93
|
+
return String(fullkey)
|
|
94
|
+
.replace(/^\$\.meta\.?/, '')
|
|
95
|
+
.replace(/"([^"]*)"/g, '$1')
|
|
96
|
+
}
|
|
97
|
+
|
|
88
98
|
// Build the index handle over the provided sqlite database. Prepares
|
|
89
99
|
// the SQL statements once at construction; subsequent reads/writes
|
|
90
100
|
// reuse them.
|
|
@@ -100,6 +110,16 @@ export function createIndex(db) {
|
|
|
100
110
|
SELECT source_id, field FROM mikser_refs
|
|
101
111
|
WHERE target_ref = ? AND kind = 'ref'
|
|
102
112
|
`)
|
|
113
|
+
// Any string value anywhere under an entity's meta that equals the
|
|
114
|
+
// target — including inside arrays, which is where nav and footer link
|
|
115
|
+
// lists live. json_tree walks the stored JSON, so no schema and no
|
|
116
|
+
// write-time indexing is involved.
|
|
117
|
+
const stmtInboundMetaValue = db.prepare(`
|
|
118
|
+
SELECT DISTINCT e.id AS id, j.fullkey AS field
|
|
119
|
+
FROM mikser_entities e, json_tree(e.data, '$.meta') j
|
|
120
|
+
WHERE j.type = 'text' AND j.value = ?
|
|
121
|
+
`)
|
|
122
|
+
|
|
103
123
|
const stmtOutboundStatic = db.prepare(`
|
|
104
124
|
SELECT field, target_ref FROM mikser_refs
|
|
105
125
|
WHERE source_id = ? AND kind = 'ref'
|
|
@@ -196,9 +216,44 @@ export function createIndex(db) {
|
|
|
196
216
|
|
|
197
217
|
// -- Read API ------------------------------------------------------
|
|
198
218
|
|
|
219
|
+
// Everything that points at `ref`, by BOTH mechanisms.
|
|
220
|
+
//
|
|
221
|
+
// The index holds `$`-keyed refs only, because those are the ones the
|
|
222
|
+
// engine resolves and invalidates on. But a site links to a page far
|
|
223
|
+
// more often with a plain string — `items: [{ label, href: '/about' }]`
|
|
224
|
+
// in a nav or footer document — and asking "what breaks if I delete
|
|
225
|
+
// this" got `count: 0` while two live pages linked to it. A silent miss
|
|
226
|
+
// is worse than no answer for that question.
|
|
227
|
+
//
|
|
228
|
+
// Plain values are found at READ time with json_tree over each entity's
|
|
229
|
+
// meta rather than indexed at write time: indexing every string in every
|
|
230
|
+
// meta would put the whole catalog in the edge table to serve a
|
|
231
|
+
// diagnostic, and the engine does not invalidate on plain strings
|
|
232
|
+
// anyway — which is itself worth knowing, and is why the two kinds stay
|
|
233
|
+
// labelled rather than merged.
|
|
199
234
|
function inboundFor(ref) {
|
|
200
|
-
|
|
201
|
-
.map(r => ({ id: r.source_id, field: r.field }))
|
|
235
|
+
const refs = stmtInboundStatic.all(ref)
|
|
236
|
+
.map(r => ({ id: r.source_id, field: r.field, kind: 'ref' }))
|
|
237
|
+
const seen = new Set(refs.map(r => `${r.id}|${r.field}`))
|
|
238
|
+
const hrefs = []
|
|
239
|
+
for (const row of stmtInboundMetaValue.all(ref)) {
|
|
240
|
+
const field = normalizeMetaPath(row.field)
|
|
241
|
+
// A `$`-keyed path is the ref index's territory by definition, and
|
|
242
|
+
// json_tree finds those values too — reporting them again as
|
|
243
|
+
// `href` would double-count every ref and mislabel it. Note that
|
|
244
|
+
// json_tree QUOTES a key beginning with `$` (`$.meta."$author"`),
|
|
245
|
+
// so this has to run after unquoting or the two spellings never
|
|
246
|
+
// meet and the dedup below misses.
|
|
247
|
+
if (field.split('.').some(seg => seg.startsWith('$'))) continue
|
|
248
|
+
// `meta.href` / `meta.url` at the top level are the entity's own
|
|
249
|
+
// ADDRESS, not a link to something else. Without this, asking
|
|
250
|
+
// what points at /about lists /documents/bg/about.md — the page
|
|
251
|
+
// itself — among the things that would break if it were deleted.
|
|
252
|
+
if (field === 'href' || field === 'url') continue
|
|
253
|
+
if (seen.has(`${row.id}|${field}`)) continue
|
|
254
|
+
hrefs.push({ id: row.id, field, kind: 'href' })
|
|
255
|
+
}
|
|
256
|
+
return [...refs, ...hrefs]
|
|
202
257
|
}
|
|
203
258
|
|
|
204
259
|
function outboundFor(sourceId) {
|
|
@@ -628,9 +683,17 @@ export function createRefs(db, prebuiltIndex = null) {
|
|
|
628
683
|
}
|
|
629
684
|
if (from === to) return { from, to, updated: [], failures: [] }
|
|
630
685
|
|
|
631
|
-
|
|
686
|
+
// `$`-keyed refs only, deliberately. inboundFor also reports
|
|
687
|
+
// plain string matches now — a nav item's `href` — but writeEntity
|
|
688
|
+
// patches `$`-keyed values, and rewriting arbitrary meta strings
|
|
689
|
+
// through it is a file-mutating change nobody asked for. A plain
|
|
690
|
+
// href pointing at the old name is REPORTED as unrewritten below
|
|
691
|
+
// rather than silently rewritten or silently skipped.
|
|
692
|
+
const all = index.inboundFor(from)
|
|
693
|
+
const entries = all.filter(e => e.kind !== 'href')
|
|
694
|
+
const unrewritten = all.filter(e => e.kind === 'href')
|
|
632
695
|
if (entries.length === 0) {
|
|
633
|
-
return { from, to, updated: [], failures: [] }
|
|
696
|
+
return { from, to, updated: [], failures: [], unrewritten }
|
|
634
697
|
}
|
|
635
698
|
|
|
636
699
|
const byEntity = new Map()
|
|
@@ -658,7 +721,7 @@ export function createRefs(db, prebuiltIndex = null) {
|
|
|
658
721
|
}
|
|
659
722
|
}
|
|
660
723
|
|
|
661
|
-
return { from, to, updated, failures }
|
|
724
|
+
return { from, to, updated, failures, unrewritten }
|
|
662
725
|
},
|
|
663
726
|
}
|
|
664
727
|
}
|
package/src/report.js
CHANGED
|
@@ -37,6 +37,46 @@ function reportWanted() {
|
|
|
37
37
|
return !!(runtime.options?.json || runtime.options?.reportRequested)
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// How many finished cycles to keep. Small on purpose: this is "what did my
|
|
41
|
+
// last few edits do", not an audit log, and each entry holds one record per
|
|
42
|
+
// entity rendered in that cycle.
|
|
43
|
+
const HISTORY_LIMIT = 10
|
|
44
|
+
|
|
45
|
+
function history() {
|
|
46
|
+
runtime.state ??= {}
|
|
47
|
+
runtime.state.reportHistory ??= []
|
|
48
|
+
return runtime.state.reportHistory
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The id the NEXT cycle will carry.
|
|
52
|
+
//
|
|
53
|
+
// A caller that writes a file needs to name the cycle its write will be
|
|
54
|
+
// picked up by BEFORE that cycle exists — otherwise "did my edit land" is
|
|
55
|
+
// unanswerable except by watching the clock. Writes go through the
|
|
56
|
+
// watcher's debounce, so the cycle after the current one is the answer.
|
|
57
|
+
export function nextCycleId() {
|
|
58
|
+
return (runtime.state?.cycle?.id ?? 0) + 1
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function currentCycle() {
|
|
62
|
+
return runtime.state?.cycle ?? null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Finished cycles, newest first. `n` of them.
|
|
66
|
+
export function cycleHistory(n = 1) {
|
|
67
|
+
return history().slice(-Math.max(1, n)).reverse()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Resolves when a cycle with at least this id has finished. Used by a
|
|
71
|
+
// caller that wants its write and the resulting report in one round trip
|
|
72
|
+
// instead of polling.
|
|
73
|
+
export function whenCycleCompletes(id) {
|
|
74
|
+
const done = history().find(c => c.id >= id)
|
|
75
|
+
if (done) return Promise.resolve(done)
|
|
76
|
+
runtime.state.cycleWaiters ??= []
|
|
77
|
+
return new Promise(resolve => runtime.state.cycleWaiters.push({ id, resolve }))
|
|
78
|
+
}
|
|
79
|
+
|
|
40
80
|
// Cleared at the start of every cycle, so the report always describes the
|
|
41
81
|
// LAST one rather than everything since the process started.
|
|
42
82
|
//
|
|
@@ -49,14 +89,42 @@ function reportWanted() {
|
|
|
49
89
|
// across cycles by design — it is the retry marker's in-memory twin, and
|
|
50
90
|
// the exit code depends on this cycle's count, which resetRenderErrors
|
|
51
91
|
// handles at the same point.
|
|
92
|
+
//
|
|
93
|
+
// Reset and IDENTITY are the same event — a report that describes "the last
|
|
94
|
+
// cycle" is only meaningful if something names which cycle that was, and a
|
|
95
|
+
// caller waiting on its own write needs to compare against something.
|
|
52
96
|
export function resetReport() {
|
|
53
97
|
if (!runtime.state) return
|
|
98
|
+
const previous = runtime.state.cycle
|
|
99
|
+
if (previous && !previous.finishedAt) finishCycle()
|
|
100
|
+
runtime.state.cycle = { id: nextCycleId(), startedAt: Date.now(), finishedAt: null }
|
|
54
101
|
runtime.state.report = { rendered: [], skipped: [], unchanged: [], errors: [], warnings: [], gated: 0 }
|
|
55
102
|
runtime.state.renderErrors = []
|
|
56
103
|
}
|
|
57
104
|
|
|
105
|
+
// End of a cycle: stamp it, file it, and wake anyone waiting on it.
|
|
106
|
+
export function finishCycle() {
|
|
107
|
+
if (!runtime.state?.cycle || runtime.state.cycle.finishedAt) return
|
|
108
|
+
runtime.state.cycle.finishedAt = Date.now()
|
|
109
|
+
const record = { ...runtime.state.cycle, ...buildReport() }
|
|
110
|
+
const kept = history()
|
|
111
|
+
kept.push(record)
|
|
112
|
+
while (kept.length > HISTORY_LIMIT) kept.shift()
|
|
113
|
+
|
|
114
|
+
const waiters = runtime.state.cycleWaiters ?? []
|
|
115
|
+
runtime.state.cycleWaiters = waiters.filter(w => {
|
|
116
|
+
if (record.id < w.id) return true
|
|
117
|
+
w.resolve(record)
|
|
118
|
+
return false
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
58
122
|
function store() {
|
|
123
|
+
// The first cycle never passes through resetReport — that fires on the
|
|
124
|
+
// watcher's trigger, and a cold build has no earlier cycle to reset from.
|
|
125
|
+
// Without this the build everyone looks at first reports cycleId: null.
|
|
59
126
|
runtime.state ??= {}
|
|
127
|
+
runtime.state.cycle ??= { id: 1, startedAt: Date.now(), finishedAt: null }
|
|
60
128
|
runtime.state.report ??= { rendered: [], skipped: [], unchanged: [], errors: [], warnings: [], gated: 0 }
|
|
61
129
|
return runtime.state.report
|
|
62
130
|
}
|
|
@@ -157,7 +225,13 @@ export function renderErrorCount() {
|
|
|
157
225
|
|
|
158
226
|
export function buildReport() {
|
|
159
227
|
const report = store()
|
|
228
|
+
const cycle = runtime.state?.cycle
|
|
160
229
|
return {
|
|
230
|
+
// Which cycle this describes. Without it, two reports read the same
|
|
231
|
+
// and "is this my edit's cycle or the one before it" has no answer.
|
|
232
|
+
cycleId: cycle?.id ?? null,
|
|
233
|
+
startedAt: cycle?.startedAt ?? null,
|
|
234
|
+
finishedAt: cycle?.finishedAt ?? null,
|
|
161
235
|
rendered: report.rendered,
|
|
162
236
|
skipped: report.skipped,
|
|
163
237
|
unchanged: report.unchanged,
|
package/src/server.js
CHANGED
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
// block — same Express app, all the substrate-level concerns in one
|
|
21
21
|
// place.
|
|
22
22
|
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
import { fileURLToPath } from 'node:url'
|
|
25
|
+
|
|
23
26
|
import runtime from './runtime.js'
|
|
24
27
|
import { useLogger } from './engine.js'
|
|
25
28
|
import { onInitialized, onLoad, onLoaded } from './lifecycle.js'
|
|
@@ -174,6 +177,30 @@ export function setupServer() {
|
|
|
174
177
|
|
|
175
178
|
runtime.options.app.use(express.static(runtime.options.outputFolder))
|
|
176
179
|
|
|
180
|
+
// A favicon, so the server has a mark instead of a browser's
|
|
181
|
+
// blank default. AFTER the static mount, which is the whole
|
|
182
|
+
// design: a project that puts favicon.ico in its output folder
|
|
183
|
+
// is served its own and never reaches this line. This is the
|
|
184
|
+
// fallback for everything else.
|
|
185
|
+
//
|
|
186
|
+
// Worth having because --server is not only a preview of the
|
|
187
|
+
// site. It is the surface WebDAV, the API and MCP mount on, and
|
|
188
|
+
// those pages sit above whatever the output folder contains —
|
|
189
|
+
// on a multi-language build the output root holds no page at
|
|
190
|
+
// all, so /favicon.ico there is a 404 by construction and every
|
|
191
|
+
// project would have to solve it the same way.
|
|
192
|
+
//
|
|
193
|
+
// Flattened artwork: the mark's mix-blend-mode has nothing to
|
|
194
|
+
// multiply against on a transparent backdrop and rasterises with
|
|
195
|
+
// a hole through it, so favicon.ico is the pre-composited copy.
|
|
196
|
+
const faviconFile = path.join(
|
|
197
|
+
path.dirname(fileURLToPath(import.meta.url)), '..', 'favicon.ico')
|
|
198
|
+
runtime.options.app.get('/favicon.ico', (req, res) => {
|
|
199
|
+
res.type('image/x-icon')
|
|
200
|
+
.set('Cache-Control', 'public, max-age=3600')
|
|
201
|
+
.sendFile(faviconFile)
|
|
202
|
+
})
|
|
203
|
+
|
|
177
204
|
await new Promise(resolve => {
|
|
178
205
|
const httpServer = runtime.options.app.listen(runtime.options.port, () => {
|
|
179
206
|
// Public URL wins for operator-clickable log lines —
|