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 +1 -1
- package/src/catalog.js +24 -5
- package/src/plugins/api.js +59 -8
- package/src/render.js +22 -13
package/package.json
CHANGED
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
|
-
|
|
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).
|
|
580
|
-
|
|
581
|
-
|
|
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
|
|
package/src/plugins/api.js
CHANGED
|
@@ -407,7 +407,28 @@ export function api(options = {}) {
|
|
|
407
407
|
: ['list']
|
|
408
408
|
const allowedOps = new Set(ep.operations ?? defaultOps)
|
|
409
409
|
|
|
410
|
-
|
|
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 (
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
|