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 +1 -1
- package/src/catalog.js +22 -0
- package/src/manifest.js +31 -5
- package/src/plugins/api.js +31 -0
- package/src/runtime.js +17 -0
- package/src/subscriptions.js +25 -0
package/package.json
CHANGED
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
|
|
548
|
-
|
|
549
|
-
|
|
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
|
}
|
package/src/plugins/api.js
CHANGED
|
@@ -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() {
|
package/src/subscriptions.js
CHANGED
|
@@ -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
|