mikser-io 9.1.0 → 9.2.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.1.0",
3
+ "version": "9.2.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
@@ -567,19 +567,38 @@ async function expandAndProject(entity, expand) {
567
567
  return { ...result, meta: projectMeta(result.meta) }
568
568
  }
569
569
 
570
+ // Combine a caller's filter with an endpoint scope. A sift-shaped scope
571
+ // becomes part of the query so sift-to-sql can push it down; a function scope
572
+ // returns the filter untouched and is applied by the caller after the fetch.
573
+ export function scopedFilter(filter, scope) {
574
+ if (!scope || typeof scope === 'function') return filter
575
+ const hasFilter = filter && Object.keys(filter).length > 0
576
+ return hasFilter ? { $and: [filter, scope] } : scope
577
+ }
578
+
570
579
  export async function queryEntities({
571
580
  filter, sort, fields, skip, limit, expand, scope,
572
581
  } = {}) {
573
582
  const effectiveLimit = Math.min(100, Math.max(1, limit ?? 25))
574
583
  const effectiveSkip = Math.max(0, skip ?? 0)
575
584
 
576
- recordQuery(filter)
585
+ // A sift-shaped `scope` is merged into the filter so it reaches
586
+ // findEntities, where sift-to-sql pushes what it can into the WHERE
587
+ // clause. A function `scope` cannot be translated and stays a post-fetch
588
+ // predicate — which means the query materializes every row the caller's
589
+ // own filter matched, including the ones the endpoint would then reject.
590
+ // For an endpoint whose filter is broad (or absent) that is the entire
591
+ // catalog, per request, and `limit` does not help: it is applied after
592
+ // this. Prefer the object form.
593
+ const scopePredicate = typeof scope === 'function' ? scope : null
594
+ const effectiveFilter = scopedFilter(filter, scope)
595
+
596
+ recordQuery(effectiveFilter)
577
597
 
578
598
  // Materialize via findEntities (which handles sqlite + shim
579
- // dispatch + js fallback). scope is a JS predicate applied
580
- // post-fetch.
581
- let all = await findEntities(filter)
582
- if (scope) all = all.filter(scope)
599
+ // dispatch + js fallback).
600
+ let all = await findEntities(effectiveFilter)
601
+ if (scopePredicate) all = all.filter(scopePredicate)
583
602
 
584
603
  const total = all.length
585
604
 
@@ -407,7 +407,28 @@ export function api(options = {}) {
407
407
  : ['list']
408
408
  const allowedOps = new Set(ep.operations ?? defaultOps)
409
409
 
410
- const query = typeof ep.query === 'function' ? ep.query : null
410
+ // The endpoint's scope. A sift filter is the form to prefer —
411
+ // queryEntities merges it into the WHERE clause, so the endpoint
412
+ // never materializes rows it would only reject. A function still
413
+ // works and is applied post-fetch, which costs every row the
414
+ // caller's filter matched.
415
+ const query = (typeof ep.query === 'function' || (ep.query && typeof ep.query === 'object'))
416
+ ? ep.query
417
+ : null
418
+
419
+ // The same scope as a PREDICATE. Two paths hold one entity in
420
+ // hand and have no query to push a filter into — admitting a
421
+ // POST /render body, and the graph-subscription filter — so
422
+ // they have to test it directly. Calling `query` there works
423
+ // only while the scope is a function; the sift-object form
424
+ // this endpoint now accepts is not callable, and the failure
425
+ // is a bare `query is not a function` surfacing from whichever
426
+ // route touched it first.
427
+ const matchesScope = typeof ep.query === 'function'
428
+ ? ep.query
429
+ : query
430
+ ? sift(query)
431
+ : null
411
432
  const pageSize = ep.pageSize ?? globalPageSize
412
433
  const renderTimeout = ep.renderTimeout ?? globalRenderTimeout
413
434
 
@@ -607,7 +628,7 @@ export function api(options = {}) {
607
628
  : null
608
629
  const handle = runtime.refs.subscribeGraph({
609
630
  filter: (entity) => {
610
- if (query && !query(entity)) return false
631
+ if (matchesScope && !matchesScope(entity)) return false
611
632
  if (requestFilter && !requestFilter(entity)) return false
612
633
  return true
613
634
  },
@@ -638,7 +659,10 @@ export function api(options = {}) {
638
659
  // is a 500. Same shape used by the POST handler below.
639
660
  const status = err instanceof ExpandError ? err.status : 500
640
661
  if (status >= 500) {
641
- logger.error('Api[%s] list error (%dms): %s', name, Date.now() - t0, err.message)
662
+ logger.error(
663
+ 'Api[%s] list error (%dms): %s\n%s',
664
+ name, Date.now() - t0, err.message, err.stack || '(no stack)',
665
+ )
642
666
  } else {
643
667
  logger.debug('Api[%s] list rejected (%dms): %s', name, Date.now() - t0, err.message)
644
668
  }
@@ -796,6 +820,10 @@ export function api(options = {}) {
796
820
  })
797
821
 
798
822
  router.post('/render', allow('render'), auth, async (req, res) => {
823
+ // Hoisted so the catch can name WHICH entity failed. A
824
+ // render that throws before this is assigned is itself the
825
+ // finding — it means the body never parsed.
826
+ let renderId
799
827
  try {
800
828
  // Body shape mirrors the JS API: entity fields at top
801
829
  // level, control flags grouped under `options`. Forwarded
@@ -806,19 +834,42 @@ export function api(options = {}) {
806
834
  // options.save: false → skip the final disk write
807
835
  // (bytes still in the response)
808
836
  const { options = {}, ...entityShape } = req.body
837
+ renderId = entityShape.id
809
838
  // When the endpoint declares a scope, reject anything
810
839
  // outside it BEFORE pushing through the renderer.
811
- if (query && !query(entityShape)) {
840
+ if (matchesScope && !matchesScope(entityShape)) {
812
841
  return res.status(403).json({ error: 'Entity is outside this endpoint\'s scope' })
813
842
  }
814
843
  const { output, entity } = await render(entityShape, options)
815
844
  await sendRenderOutput(res, output, entity)
816
845
  } catch (err) {
817
- logger.error('Api[%s] render error: %s', name, err.message)
846
+ // useRenderer tags an unrenderable entity (no layout)
847
+ // with err.status = 422; everything else is a 500.
848
+ const status = err.status ?? 500
849
+ if (status >= 500) {
850
+ // The stack, not just the message — and this is the
851
+ // one route where that is not optional. A render
852
+ // reaches here through a renderer, a postprocessor
853
+ // chain and any template helper they call, so the
854
+ // message is routinely a bare TypeError from a frame
855
+ // the operator cannot name. Logging `{error: message}`
856
+ // alone leaves bisecting deployed versions as the only
857
+ // way to find out where it came from, which is exactly
858
+ // as expensive as it sounds. The id says which entity;
859
+ // `undefined` there means the body never parsed.
860
+ logger.error(
861
+ 'Api[%s] render error for %s: %s\n%s',
862
+ name, renderId ?? '(no id in body)', err.message,
863
+ err.stack || '(no stack)',
864
+ )
865
+ } else {
866
+ logger.debug(
867
+ 'Api[%s] render rejected for %s (%d): %s',
868
+ name, renderId ?? '(no id in body)', status, err.message,
869
+ )
870
+ }
818
871
  if (!res.headersSent) {
819
- // useRenderer tags an unrenderable entity (no layout)
820
- // with err.status = 422; everything else is a 500.
821
- res.status(err.status ?? 500).json({ error: err.message })
872
+ res.status(status).json({ error: err.message })
822
873
  }
823
874
  }
824
875
  })
package/src/render.js CHANGED
@@ -376,7 +376,10 @@ export function useRenderer(runtime, { defaultTimeout = 30_000 } = {}) {
376
376
  * - `catalog: true` (default) — keep the entity in the catalog after
377
377
  * the render. Pass `catalog: false` to prune the catalog row;
378
378
  * useful for on-demand renders where the metadata row would just
379
- * accumulate.
379
+ * accumulate. Requires `save: false` — the prune goes through the
380
+ * journal, and a DELETE takes the manifest's file cleanup with it,
381
+ * so it is only safe for a render that wrote nothing. With
382
+ * `save: true` the row is kept and a warning is logged.
380
383
  * - `save: true` (default) — write the rendered output to disk at
381
384
  * `<outputFolder>/<entity.destination>`. Pass `save: false` to
382
385
  * skip the final disk write; the bytes still come back via
@@ -439,18 +442,24 @@ export function useRenderer(runtime, { defaultTimeout = 30_000 } = {}) {
439
442
  })
440
443
 
441
444
  if (catalog === false) {
442
- // Explicit opt-out: prune the catalog row so it doesn't
443
- // accumulate. The rendered output file stays on disk the
444
- // bytes are the work product. We deliberately bypass the
445
- // journal/DELETE path here (which would also unlink the file
446
- // via the manifest module's cleanup) and splice the entity
447
- // out of the in-memory catalog directly. Strict equality so
448
- // ambiguous inputs (null, "false", 0) fall through to the
449
- // default of keeping the row.
450
- const entities = runtime.catalog?.data?.entities
451
- if (entities) {
452
- const idx = entities.findIndex(e => e.id === result.entity.id)
453
- if (idx >= 0) entities.splice(idx, 1)
445
+ // Prune the row through the journal, so the DELETE lands in
446
+ // sqlite at onPersist alongside the CREATE that put it there.
447
+ // Strict equality null / "false" / 0 keep the row.
448
+ //
449
+ // Only when `save` is also false. A DELETE carries the
450
+ // manifest's file cleanup with it, which unlinks the render's
451
+ // output; that is correct for an entity that produced no file
452
+ // and wrong for one that did. `catalog: false, save: true`
453
+ // therefore keeps its row, and says so rather than dropping
454
+ // the output on the floor.
455
+ if (save === false) {
456
+ await runtime.delete(result.entity)
457
+ } else {
458
+ useLogger()?.warn(
459
+ 'render: catalog:false ignored for %s — it needs save:false, ' +
460
+ 'because pruning the row also unlinks the rendered output',
461
+ result.entity.id,
462
+ )
454
463
  }
455
464
  }
456
465