mikser-io 9.2.0 → 9.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.2.0",
3
+ "version": "9.3.0",
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": {
@@ -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
@@ -415,6 +446,20 @@ export function api(options = {}) {
415
446
  const query = (typeof ep.query === 'function' || (ep.query && typeof ep.query === 'object'))
416
447
  ? ep.query
417
448
  : null
449
+
450
+ // The same scope as a PREDICATE. Two paths hold one entity in
451
+ // hand and have no query to push a filter into — admitting a
452
+ // POST /render body, and the graph-subscription filter — so
453
+ // they have to test it directly. Calling `query` there works
454
+ // only while the scope is a function; the sift-object form
455
+ // this endpoint now accepts is not callable, and the failure
456
+ // is a bare `query is not a function` surfacing from whichever
457
+ // route touched it first.
458
+ const matchesScope = typeof ep.query === 'function'
459
+ ? ep.query
460
+ : query
461
+ ? sift(query)
462
+ : null
418
463
  const pageSize = ep.pageSize ?? globalPageSize
419
464
  const renderTimeout = ep.renderTimeout ?? globalRenderTimeout
420
465
 
@@ -614,7 +659,7 @@ export function api(options = {}) {
614
659
  : null
615
660
  const handle = runtime.refs.subscribeGraph({
616
661
  filter: (entity) => {
617
- if (query && !query(entity)) return false
662
+ if (matchesScope && !matchesScope(entity)) return false
618
663
  if (requestFilter && !requestFilter(entity)) return false
619
664
  return true
620
665
  },
@@ -645,7 +690,10 @@ export function api(options = {}) {
645
690
  // is a 500. Same shape used by the POST handler below.
646
691
  const status = err instanceof ExpandError ? err.status : 500
647
692
  if (status >= 500) {
648
- logger.error('Api[%s] list error (%dms): %s', name, Date.now() - t0, err.message)
693
+ logger.error(
694
+ 'Api[%s] list error (%dms): %s\n%s',
695
+ name, Date.now() - t0, err.message, err.stack || '(no stack)',
696
+ )
649
697
  } else {
650
698
  logger.debug('Api[%s] list rejected (%dms): %s', name, Date.now() - t0, err.message)
651
699
  }
@@ -803,6 +851,10 @@ export function api(options = {}) {
803
851
  })
804
852
 
805
853
  router.post('/render', allow('render'), auth, async (req, res) => {
854
+ // Hoisted so the catch can name WHICH entity failed. A
855
+ // render that throws before this is assigned is itself the
856
+ // finding — it means the body never parsed.
857
+ let renderId
806
858
  try {
807
859
  // Body shape mirrors the JS API: entity fields at top
808
860
  // level, control flags grouped under `options`. Forwarded
@@ -813,19 +865,42 @@ export function api(options = {}) {
813
865
  // options.save: false → skip the final disk write
814
866
  // (bytes still in the response)
815
867
  const { options = {}, ...entityShape } = req.body
868
+ renderId = entityShape.id
816
869
  // When the endpoint declares a scope, reject anything
817
870
  // outside it BEFORE pushing through the renderer.
818
- if (query && !query(entityShape)) {
871
+ if (matchesScope && !matchesScope(entityShape)) {
819
872
  return res.status(403).json({ error: 'Entity is outside this endpoint\'s scope' })
820
873
  }
821
874
  const { output, entity } = await render(entityShape, options)
822
875
  await sendRenderOutput(res, output, entity)
823
876
  } catch (err) {
824
- logger.error('Api[%s] render error: %s', name, err.message)
877
+ // useRenderer tags an unrenderable entity (no layout)
878
+ // with err.status = 422; everything else is a 500.
879
+ const status = err.status ?? 500
880
+ if (status >= 500) {
881
+ // The stack, not just the message — and this is the
882
+ // one route where that is not optional. A render
883
+ // reaches here through a renderer, a postprocessor
884
+ // chain and any template helper they call, so the
885
+ // message is routinely a bare TypeError from a frame
886
+ // the operator cannot name. Logging `{error: message}`
887
+ // alone leaves bisecting deployed versions as the only
888
+ // way to find out where it came from, which is exactly
889
+ // as expensive as it sounds. The id says which entity;
890
+ // `undefined` there means the body never parsed.
891
+ logger.error(
892
+ 'Api[%s] render error for %s: %s\n%s',
893
+ name, renderId ?? '(no id in body)', err.message,
894
+ err.stack || '(no stack)',
895
+ )
896
+ } else {
897
+ logger.debug(
898
+ 'Api[%s] render rejected for %s (%d): %s',
899
+ name, renderId ?? '(no id in body)', status, err.message,
900
+ )
901
+ }
825
902
  if (!res.headersSent) {
826
- // useRenderer tags an unrenderable entity (no layout)
827
- // with err.status = 422; everything else is a 500.
828
- res.status(err.status ?? 500).json({ error: err.message })
903
+ res.status(status).json({ error: err.message })
829
904
  }
830
905
  }
831
906
  })
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() {