mikser-io 6.12.0 → 6.17.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": "6.12.0",
3
+ "version": "6.17.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
  "scripts": {
package/src/api.js CHANGED
@@ -87,24 +87,41 @@ export function useRenderer(runtime, { defaultTimeout = 30_000 } = {}) {
87
87
  * `runtime.process()` cycle — within that cycle, mikser's worker pool
88
88
  * renders the batch in parallel.
89
89
  *
90
- * By default the entity is **not** kept in the catalog: after the
91
- * render resolves, the catalog row is pruned so it doesn't accumulate.
92
- * The rendered output file is always kept on disk — the bytes are the
93
- * work product. Pass `catalog: true` to also keep the catalog row,
94
- * useful if you'll re-render the same entity later or query it via
95
- * `findEntities`.
90
+ * Two control flags mirror mikser's default-keep-everything behavior;
91
+ * both opt-out via strict `=== false`:
92
+ *
93
+ * - `catalog: true` (default) — keep the entity in the catalog after
94
+ * the render. Pass `catalog: false` to prune the catalog row;
95
+ * useful for on-demand renders where the metadata row would just
96
+ * accumulate.
97
+ * - `save: true` (default) — write the rendered output to disk at
98
+ * `<outputFolder>/<entity.destination>`. Pass `save: false` to
99
+ * skip the final disk write; the bytes still come back via
100
+ * `output.result` for you to pipe wherever you want (HTTP
101
+ * response, S3, …). For layouts with a postprocessor (e.g.
102
+ * `*.html-pdf.*`), the intermediate file is still written so the
103
+ * postprocessor can consume it; only the FINAL output is skipped.
104
+ *
105
+ * The rendered output's bytes are always returned in `output.result`
106
+ * regardless of either flag — `save` only affects whether they also
107
+ * end up on disk.
96
108
  *
97
109
  * @param {object} entity - any entity-shaped object
98
110
  * @param {object} [opts]
99
111
  * @param {number} [opts.timeout] - override the default timeout
100
- * @param {boolean} [opts.catalog=false] - keep the catalog row after render
112
+ * @param {boolean} [opts.catalog=true] - keep the catalog row after render
113
+ * @param {boolean} [opts.save=true] - write the rendered output to disk
101
114
  * @returns {Promise<{output, entity}>}
102
115
  */
103
- async function render(entity, { timeout = defaultTimeout, catalog = false } = {}) {
116
+ async function render(entity, { timeout = defaultTimeout, catalog = true, save = true } = {}) {
104
117
  const result = await new Promise((resolve, reject) => {
105
118
  const correlationId = randomUUID()
119
+ const stamped = { ...entity, _correlationId: correlationId }
120
+ // Only stamp _save when explicitly opting out — keeps the
121
+ // entity object clean for the common case.
122
+ if (save === false) stamped._save = false
106
123
  pending.push({
107
- entity: { ...entity, _correlationId: correlationId },
124
+ entity: stamped,
108
125
  correlationId,
109
126
  timeout,
110
127
  resolve,
@@ -114,13 +131,15 @@ export function useRenderer(runtime, { defaultTimeout = 30_000 } = {}) {
114
131
  if (!cycleRunning) setImmediate(runBatch)
115
132
  })
116
133
 
117
- if (!catalog) {
118
- // Prune the catalog row so it doesn't accumulate, but LEAVE
119
- // the rendered output file on disk — the bytes are the work
120
- // product. We deliberately bypass the journal/DELETE path
121
- // here (which would also unlink the file via engine.js's
122
- // manifest cleanup) and splice the entity out of the
123
- // in-memory catalog directly.
134
+ if (catalog === false) {
135
+ // Explicit opt-out: prune the catalog row so it doesn't
136
+ // accumulate. The rendered output file stays on disk — the
137
+ // bytes are the work product. We deliberately bypass the
138
+ // journal/DELETE path here (which would also unlink the file
139
+ // via engine.js's manifest cleanup) and splice the entity
140
+ // out of the in-memory catalog directly. Strict equality so
141
+ // ambiguous inputs (null, "false", 0) fall through to the
142
+ // default of keeping the row.
124
143
  const entities = runtime.catalog?.data?.entities
125
144
  if (entities) {
126
145
  const idx = entities.findIndex(e => e.id === result.entity.id)
@@ -148,13 +148,16 @@ export default ({
148
148
 
149
149
  router.post('/render', auth, async (req, res) => {
150
150
  try {
151
- // Pull `catalog` out as a control flag; everything else
152
- // is treated as the entity. Default is NOT to keep the
153
- // catalog row (the rendered output stays on disk either
154
- // way). Send `catalog: true` to keep the row, e.g. if
155
- // you'll re-render or query the entity later.
156
- const { catalog, ...entityShape } = req.body
157
- const { output, entity } = await render(entityShape, { catalog })
151
+ // Body shape mirrors the JS API: entity fields at top
152
+ // level, control flags grouped under `options`. Forwarded
153
+ // straight to render(entity, options). Defaults match
154
+ // mikser's lifecycle (save and keep the catalog row);
155
+ // strict opt-outs via the literal `false`:
156
+ // options.catalog: false → prune the catalog row
157
+ // options.save: false → skip the final disk write
158
+ // (bytes still in the response)
159
+ const { options = {}, ...entityShape } = req.body
160
+ const { output, entity } = await render(entityShape, options)
158
161
  await sendRenderOutput(res, output, entity)
159
162
  } catch (err) {
160
163
  logger.error('Api render error: %s', err.message)
@@ -36,9 +36,18 @@ export default ({
36
36
  }
37
37
  }
38
38
  for (let entitiesName in entitiesConfig) {
39
+ // Per-config namespacing token. When set, this entity-writer's
40
+ // files land under <dataFolder>/<token>/ instead of straight
41
+ // in <dataFolder>. Each named entity config can declare its
42
+ // own token, so they can target different namespaces (useful
43
+ // for multi-tenant exports into shared storage).
44
+ const token = entitiesConfig[entitiesName].token
45
+ const targetFolder = token
46
+ ? path.join(runtime.options.dataFolder, token)
47
+ : runtime.options.dataFolder
39
48
  const {
40
49
  query,
41
- map,
50
+ map: mapEntity = entity => entity,
42
51
  pick,
43
52
  save: saveEntity = async entity => {
44
53
  if (!entity.name) {
@@ -46,12 +55,12 @@ export default ({
46
55
  return
47
56
  }
48
57
  const dump = JSON.stringify(normalize(entity))
49
- const entityFile = path.join(runtime.options.dataFolder, `${entity.name}.${entitiesName}.json`)
58
+ const entityFile = path.join(targetFolder, `${entity.name}.${entitiesName}.json`)
50
59
  await mkdir(path.dirname(entityFile), { recursive: true })
51
60
  await writeFile(entityFile, dump, 'utf8')
52
61
  },
53
62
  delete: deleteEntity = async entity => {
54
- const entityFile = path.join(runtime.options.dataFolder, `${entity.name}.json`)
63
+ const entityFile = path.join(targetFolder, `${entity.name}.json`)
55
64
  await unlink(entityFile)
56
65
  }
57
66
  } = entitiesConfig[entitiesName]
@@ -62,12 +71,12 @@ export default ({
62
71
  case OPERATION.CREATE:
63
72
  case OPERATION.UPDATE:
64
73
  logger.debug('Data export entity %s %s: %s', entity.collection, operation, entity.id)
65
- await saveEntity(map ? await map(entity) : {
74
+ await saveEntity(({
66
75
  refId: ('/' + entity.name.replaceAll('\\', '/')).replace(/\/index$/g, '/'),
67
76
  name: entity.name,
68
77
  date: new Date(entity.time),
69
- data: _.pick(entity, pick || ['collection', 'format', 'type', 'destination', 'stamp', 'meta', 'id',])
70
- })
78
+ data: _.pick(await mapEntity(entity), pick || ['collection', 'format', 'type', 'destination', 'stamp', 'meta', 'id',])
79
+ }))
71
80
  break
72
81
  case OPERATION.DELETE:
73
82
  await deleteEntity(entity)
@@ -90,14 +99,19 @@ export default ({
90
99
  }
91
100
  }
92
101
  for (let contextName in contextConfig) {
102
+ // Per-config namespacing token — see the entities loop above.
103
+ const token = contextConfig[contextName].token
104
+ const targetFolder = token
105
+ ? path.join(runtime.options.dataFolder, token)
106
+ : runtime.options.dataFolder
93
107
  const {
94
108
  query,
95
- map,
109
+ map: mapEntityContext = (entity, context) => context,
96
110
  pick,
97
111
  save: saveConext = async (entity, context) => {
98
112
  if (context?.data) {
99
113
  const entityName = entity.name
100
- const contextFile = path.join(runtime.options.dataFolder, `${entityName}.${contextName}.json`)
114
+ const contextFile = path.join(targetFolder, `${entityName}.${contextName}.json`)
101
115
  await mkdir(path.dirname(contextFile), { recursive: true })
102
116
  await writeFile(contextFile, JSON.stringify(context), 'utf8')
103
117
  }
@@ -107,7 +121,7 @@ export default ({
107
121
  for await (let { entity, context } of useJournal('Data context', [OPERATION.RENDER])) {
108
122
  if (query(entity)) {
109
123
  logger.debug('Data export context: %s', entity.name)
110
- await saveConext(entity, map ? await map(entity, context) : _.pick(context, pick || ['data']))
124
+ await saveConext(entity, _.pick(await mapEntityContext(entity, context), pick || ['data']))
111
125
  }
112
126
  }
113
127
  }
@@ -116,23 +130,29 @@ export default ({
116
130
  onFinalize(async () => {
117
131
  const logger = useLogger()
118
132
  for (let catalogName in runtime.config.data?.catalog || {}) {
133
+ // Per-config namespacing token — see the entities loop above.
134
+ const token = runtime.config.data?.catalog[catalogName].token
135
+ const targetFolder = token
136
+ ? path.join(runtime.options.dataFolder, token)
137
+ : runtime.options.dataFolder
119
138
  const {
120
139
  query: queryEntities = entity => entity.type == 'document',
121
- map,
140
+ map: mapEntity = entity => entity,
122
141
  pick,
123
142
  save: saveEntities = async entities => {
124
- const entitiesFile = path.join(runtime.options.dataFolder, `${catalogName}.json`)
143
+ const entitiesFile = path.join(targetFolder, `${catalogName}.json`)
144
+ await mkdir(path.dirname(entitiesFile), { recursive: true })
125
145
  logger.debug('Data export catalog %s %s: %s', catalogName, entities.length, entitiesFile)
126
146
  await writeFile(entitiesFile, JSON.stringify(entities), 'utf8')
127
147
  }
128
148
  } = runtime.config.data?.catalog[catalogName]
129
149
  const entities = await findEntities(queryEntities)
130
- await saveEntities(await pMap(entities, async entity => map ? await map(entity) : {
150
+ await saveEntities(await pMap(entities, async entity => ({
131
151
  refId: ('/' + entity.name.replaceAll('\\', '/')).replace(/\/index$/g, '/'),
132
152
  name: entity.name,
133
153
  date: new Date(entity.time),
134
- data: _.pick(entity, pick || ['collection', 'format', 'type', 'destination', 'stamp', 'meta', 'id',])
135
- }))
154
+ data: _.pick(await mapEntity(entity), pick || ['collection', 'format', 'type', 'destination', 'stamp', 'meta', 'id',])
155
+ })))
136
156
  }
137
157
  })
138
158
  }
@@ -370,13 +370,32 @@ export default ({
370
370
  onComplete(async ({ entity, options, output }) => {
371
371
  const logger = useLogger()
372
372
  if (entity.layout && !options?.ignore && output.result != null) {
373
- const destinationFile = path.join(runtime.options.outputFolder, entity.destination)
374
- await mkdir(path.dirname(destinationFile), { recursive: true })
375
- try {
376
- await unlink(destinationFile)
377
- } catch { }
378
- await writeFile(destinationFile, output.result)
379
- logger.debug('Layout render finished: %s', entity.destination.replace(runtime.options.workingFolder, ''))
373
+ // `_save: false` (stamped by useRenderer when called with
374
+ // { save: false }) opts out of writing the final output to
375
+ // disk. The bytes still come back to the caller via
376
+ // output.result. Strict equality — only the literal `false`
377
+ // opts out, matching the catalog-flag pattern.
378
+ //
379
+ // The intermediate file (when a postprocessor will run next)
380
+ // must still be written so the postprocessor can consume it,
381
+ // so we only honour `_save: false` for the FINAL output —
382
+ // detected as either "no postprocessor configured" or "we're
383
+ // running on the postprocess side already" (origin set).
384
+ const isFinal = !entity.layout.postprocessor || entity.origin != null
385
+ const skipWrite = entity._save === false && isFinal
386
+
387
+ if (!skipWrite) {
388
+ const destinationFile = path.join(runtime.options.outputFolder, entity.destination)
389
+ await mkdir(path.dirname(destinationFile), { recursive: true })
390
+ try {
391
+ await unlink(destinationFile)
392
+ } catch { }
393
+ await writeFile(destinationFile, output.result)
394
+ logger.debug('Layout render finished: %s', entity.destination.replace(runtime.options.workingFolder, ''))
395
+ } else {
396
+ logger.debug('Layout render finished (save:false, bytes only): %s', entity.id)
397
+ }
398
+
380
399
  if (entity.origin && entity.origin !== entity.destination) {
381
400
  // Don't unlink the origin if it was the same path we just
382
401
  // wrote to (post plugins that produce the same extension as
@@ -24,6 +24,7 @@ export default ({
24
24
  trackProgress,
25
25
  updateProgress,
26
26
  updateEntry,
27
+ matchEntity,
27
28
  constants: { OPERATION },
28
29
  }) => {
29
30
  const collection = 'resources'
@@ -65,8 +66,7 @@ export default ({
65
66
  _.eachDeep(entity.meta, resource => {
66
67
  if (typeof resource == 'string') {
67
68
  for (let library in resourceLib) {
68
- const match = new RegExp(library)
69
- if (resource.match(match)) {
69
+ if (matchEntity(resource, library)) {
70
70
  resourceMap[entity.id].push({ library, resource, entity })
71
71
  }
72
72
  }
package/src/utils.js CHANGED
@@ -49,9 +49,9 @@ export function matchEntity(entity, match) {
49
49
  if (typeof match == 'function') return match(entity)
50
50
  else if (typeof match == 'string') {
51
51
  if (match.substring(0, 2) == '@/') {
52
- return minimatch(entity.name, match.substring(2))
52
+ return minimatch(typeof entity == 'string' ? entity : entity.name, match.substring(2))
53
53
  } else {
54
- return minimatch(entity.id, match)
54
+ return minimatch(typeof entity == 'string' ? entity : entity.id, match)
55
55
  }
56
56
  }
57
57
  else if (typeof match == 'object') return _.isMatch(entity, match)