mikser-io 9.2.1 → 9.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.2.1",
3
+ "version": "9.3.1",
4
4
  "description": "<p align=\"center\"> <img src=\"mikser-lockup-stacked.svg\" alt=\"mikser\" width=\"198\" /> </p>",
5
5
  "main": "index.js",
6
6
  "exports": {
package/src/catalog.js CHANGED
@@ -319,6 +319,28 @@ onPersist(async () => {
319
319
  })
320
320
 
321
321
  onFinalize(async () => {
322
+ // Second drain, and the one that closes the cycle.
323
+ //
324
+ // onPersist runs BEFORE render, so the drain above only ever sees what
325
+ // the load and process phases journalled. Everything a render or a
326
+ // postprocess journals lands after it — and onFinalized's clearJournal()
327
+ // then throws those entries away unread. The manifest never had this
328
+ // problem because it drains at onFinalize; the catalog simply had a
329
+ // shorter view of the same journal.
330
+ //
331
+ // The visible symptom was pruning that silently did nothing: a render
332
+ // asking for `catalog: false` journals its DELETE once the render
333
+ // resolves, which is past persist, so the row stayed. gpoint's cms
334
+ // accumulated 1,134 scratch entities carrying 86 MB of render payload,
335
+ // took its public endpoint from 70ms to 8-15s, and blanked the site.
336
+ //
337
+ // Draining again here rather than moving the persist drain: the phases
338
+ // before render still need their mutations committed at persist (the
339
+ // render reads the catalog), and journal consumers are named and
340
+ // independent, so a second pass only ever picks up what the first could
341
+ // not have seen.
342
+ await applyJournalMutations()
343
+
322
344
  // Checkpoint the WAL so the main file size stays representative
323
345
  // and external tools (mikser --verify on a separate run, debug
324
346
  // scripts) see committed state. PASSIVE never blocks readers or
package/src/manifest.js CHANGED
@@ -61,8 +61,6 @@ import { filterKey } from './track.js'
61
61
  import { findById } from './catalog.js'
62
62
  import { useDatabase, registerSchema } from './database/index.js'
63
63
 
64
- export { inputHashOf } from './utils.js'
65
-
66
64
  // Schema registration. Applied at db.open(). PRIMARY KEY (id,
67
65
  // destination) — leading id column means `WHERE id = ?` queries use the
68
66
  // PK index, no separate id index needed. Parent index is for pagination
@@ -542,11 +540,27 @@ onFinalize(async () => {
542
540
  const m = sharedManifest
543
541
 
544
542
  // 2a. Stage file unlinks for deleted entities + their children.
543
+ const deleted = new Set(deletedIds)
545
544
  const filesToUnlink = []
545
+ const staged = new Set()
546
+ const stageUnlink = (destination) => {
547
+ if (!destination || staged.has(destination)) return
548
+ staged.add(destination)
549
+ filesToUnlink.push({ destination, reason: 'Entity deleted' })
550
+ }
546
551
  for (const id of deletedIds) {
547
- const rows = m._stmtSelectByIdOrParent.all(id, id)
548
- for (const row of rows) {
549
- filesToUnlink.push({ destination: row.destination, reason: 'Entity deleted' })
552
+ for (const row of m._stmtSelectByIdOrParent.all(id, id)) {
553
+ stageUnlink(row.destination)
554
+ }
555
+ }
556
+ // Recorded snapshots only describe PRIOR cycles. An entity rendered and
557
+ // deleted within the same cycle has no row to look its destination up in —
558
+ // on a first build there is no row at all — so the file it just wrote would
559
+ // survive both the entity and its snapshot. Take the destination from this
560
+ // cycle's render entries instead.
561
+ for (const { entity } of renderedEntries) {
562
+ if (deleted.has(entity.id) || (entity.parent && deleted.has(entity.parent))) {
563
+ stageUnlink(entity.destination)
550
564
  }
551
565
  }
552
566
 
@@ -581,8 +595,20 @@ onFinalize(async () => {
581
595
  }
582
596
 
583
597
  // 2e. Hash the rendered output files (async).
598
+ //
599
+ // An id can appear in BOTH drains within one cycle — rendered, then
600
+ // deleted. A render that opts out of the catalog journals its DELETE the
601
+ // moment it resolves, so the RENDER and the DELETE sit in the same
602
+ // journal. 3a below drops the snapshot and 3c would insert it straight
603
+ // back, undoing the delete inside its own transaction. The DELETE wins:
604
+ // the entity is gone, so nothing may go on describing it. Skipping here
605
+ // rather than in 3c also saves hashing an output that was just unlinked.
606
+ //
607
+ // `parent` is checked too, mirroring _stmtDeleteByIdOrParent — a deleted
608
+ // parent takes its paginated children's snapshots with it.
584
609
  const recordedSnapshots = []
585
610
  for (const { entity, deps } of renderedEntries) {
611
+ if (deleted.has(entity.id) || (entity.parent && deleted.has(entity.parent))) continue
586
612
  const outputHash = await hashOutputFile(entity.destination)
587
613
  recordedSnapshots.push(buildSnapshot(entity, deps, outputHash))
588
614
  }
@@ -394,6 +394,37 @@ export function api(options = {}) {
394
394
  // api.endpoints.admin { token: '...', operations: ['list','update','delete','render'] }
395
395
  for (const [name, ep] of Object.entries(endpoints)) {
396
396
  const router = express.Router()
397
+
398
+ // Nothing is served until the first build cycle has finished.
399
+ //
400
+ // The server binds at the end of the loaded phase — before
401
+ // process() has emitted an entity — so without this the endpoint
402
+ // spends the whole first build answering against an empty catalog.
403
+ // Renders fail outright (the layouts registry fills during
404
+ // process()), and reads are worse than that: a list returns
405
+ // whichever subset exists at that instant, which is a wrong answer
406
+ // wearing a 200. A consumer seeding its routes from that gets zero
407
+ // routes and renders blank pages.
408
+ //
409
+ // 503, not 4xx, and Retry-After: this is the one honest status
410
+ // here. It says the request was fine and the server was not, which
411
+ // is exactly the case, and it is the status every HTTP client
412
+ // already knows to retry. A 422 or a 500 invites the caller to
413
+ // treat a transient build as a permanent defect and give up.
414
+ //
415
+ // The window is normally sub-second and easy to miss. It stretches
416
+ // whenever the cache has to be rebuilt from scratch — most notably
417
+ // after an engine upgrade, where the schema stamp no longer matches
418
+ // and the catalog is wiped before the rebuild.
419
+ router.use((req, res, next) => {
420
+ if (runtime.ready) return next()
421
+ res.set('Retry-After', '1')
422
+ res.status(503).json({
423
+ error: 'Mikser is still building — the catalog is not ready yet',
424
+ ready: false,
425
+ })
426
+ })
427
+
397
428
  router.use(express.json({ limit: ep.bodyLimit ?? globalBodyLimit }))
398
429
 
399
430
  // Operations default to the safer shape when no token is set
package/src/runtime.js CHANGED
@@ -12,6 +12,22 @@ const runtime = {
12
12
  journal: [],
13
13
  validators: [],
14
14
  started: false,
15
+ // Whether the FIRST build cycle has finished — i.e. whether the catalog
16
+ // reflects the sources yet. Not the same question as `started`, which is
17
+ // true from the moment the loaded phase ends, before process() has emitted
18
+ // a single entity.
19
+ //
20
+ // Transports need this. The server binds at the end of the loaded phase, by
21
+ // design, so that every plugin has registered its routes first — which also
22
+ // means requests are accepted while the catalog is still empty. A render
23
+ // arriving then cannot resolve its layout (the layouts registry is filled
24
+ // during process()), and a list returns whatever subset happens to exist,
25
+ // which is worse: it is wrong without being an error.
26
+ //
27
+ // Set once and never cleared. Later cycles rebuild against a catalog that
28
+ // is already populated, so they are serveable; flapping this on every watch
29
+ // rebuild would take the endpoint down for every keystroke.
30
+ ready: false,
15
31
  // Name of the lifecycle phase currently executing — null between
16
32
  // phases. Set inside start() / process() / render() etc. before
17
33
  // each callHooks(), cleared on completion. Read by mikser-io-mcp's
@@ -85,6 +101,7 @@ const runtime = {
85
101
 
86
102
  this.started = true
87
103
  await this.process()
104
+ this.ready = true
88
105
  },
89
106
 
90
107
  async process() {
@@ -35,6 +35,7 @@ import { onFinalize } from './lifecycle.js'
35
35
  import { useJournal } from './journal.js'
36
36
  import { OPERATION } from './constants.js'
37
37
  import { assertExpand, queryEntities } from './catalog.js'
38
+ import sift from 'sift'
38
39
 
39
40
  // Per-module subscription registry. The dispatcher walks this Set
40
41
  // every onFinalize. A Set (not a Map) because subscriptions are keyed
@@ -50,10 +51,34 @@ const opNames = {
50
51
  [OPERATION.DELETE]: 'delete',
51
52
  }
52
53
 
54
+ // A scope may be a predicate OR a sift object, and this is the ONE place that
55
+ // difference is resolved.
56
+ //
57
+ // An endpoint declares its scope once and it then reaches several consumers:
58
+ // queryEntities merges a sift object into the WHERE clause, while a dispatch
59
+ // holding a single entity can only test it. Every consumer that reached for
60
+ // `scope(entity)` therefore worked with the function form and threw
61
+ // `TypeError: scope is not a function` with the object one — which took a
62
+ // production render endpoint down and, because it needed a live subscription to
63
+ // fire at all, reproduced in nothing smaller than production.
64
+ //
65
+ // Compiling here rather than at each call site is the point: a call site that
66
+ // has to remember is a call site that will forget, and the next consumer added
67
+ // inherits the fix instead of the bug.
68
+ function toPredicate(scope) {
69
+ if (scope == null) return null
70
+ if (typeof scope === 'function') return scope
71
+ if (typeof scope === 'object') return sift(scope)
72
+ throw new Error('subscribe: scope must be a function or a sift filter object')
73
+ }
74
+
53
75
  export function subscribe({ filter, scope, expand, onChange, signal } = {}) {
54
76
  if (typeof onChange !== 'function') {
55
77
  throw new Error('subscribe: onChange must be a function')
56
78
  }
79
+ // Normalised before it is stored, so both the journal-walk dispatch and the
80
+ // graph dispatch below see a predicate and neither has to care.
81
+ scope = toPredicate(scope)
57
82
 
58
83
  // Reject bad expand at registration. A misconfigured subscriber
59
84
  // can otherwise open a session that's expensive to re-dispatch on