mikser-io-layouts 1.0.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/LICENSE +21 -0
- package/README.md +171 -0
- package/index.js +346 -0
- package/lib/assembly.js +399 -0
- package/lib/destination.js +33 -0
- package/lib/inspect.js +150 -0
- package/lib/matching.js +212 -0
- package/lib/mcp.js +54 -0
- package/package.json +29 -0
- package/test/_harness.js +243 -0
- package/test/unit.test.js +716 -0
package/lib/matching.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Layout assignment (the matching half of the plugin).
|
|
2
|
+
//
|
|
3
|
+
// onProcessed fires once per cycle. For every CREATE/UPDATE entity in
|
|
4
|
+
// the journal that's NOT a layout itself, we figure out which layouts
|
|
5
|
+
// should render it and stash the result in `entity.layouts` (canonical
|
|
6
|
+
// multi-match) + `entity.layout` (back-compat alias).
|
|
7
|
+
//
|
|
8
|
+
// Three resolution paths, in priority:
|
|
9
|
+
// 1. Author declared (`meta.layout` string OR `meta.layouts` array
|
|
10
|
+
// — dual key, mutually exclusive).
|
|
11
|
+
// 2. options.match pattern hits → multi-match, every matching
|
|
12
|
+
// pattern contributes a layout.
|
|
13
|
+
// 3. options.autoLayouts → the peel-ladder fallback (first-wins,
|
|
14
|
+
// single match by design).
|
|
15
|
+
//
|
|
16
|
+
// onProcessed runs BEFORE onBeforeRender; the layout snapshot stored
|
|
17
|
+
// on entity.layouts here may go stale by render time (frontmatter
|
|
18
|
+
// mutations during onPersist or sidecar load). The assembly phase
|
|
19
|
+
// (lib/assembly.js) re-fetches each layout via findEntity to recover.
|
|
20
|
+
|
|
21
|
+
import path from 'node:path'
|
|
22
|
+
|
|
23
|
+
export function createOnProcessed({
|
|
24
|
+
runtime, useLogger, useJournal,
|
|
25
|
+
findEntity, matchEntity, collection,
|
|
26
|
+
OPERATION,
|
|
27
|
+
options,
|
|
28
|
+
}) {
|
|
29
|
+
return async function onProcessedHandler(signal) {
|
|
30
|
+
const logger = useLogger()
|
|
31
|
+
const { layouts } = runtime.state.layouts
|
|
32
|
+
|
|
33
|
+
// Resolve a layout name to the catalog entity (post-front-matter
|
|
34
|
+
// strip) rather than the state-map entry (raw file bytes). The
|
|
35
|
+
// state map is just an index — it's populated at sync time with
|
|
36
|
+
// whatever readLayoutContent returned, before front-matter has
|
|
37
|
+
// had a chance to lift YAML attributes into meta and strip them
|
|
38
|
+
// from content. Attaching the raw state-map entry as
|
|
39
|
+
// entity.layout makes the renderer emit YAML verbatim into the
|
|
40
|
+
// rendered output (visible bug surfaced via MCP-UI previews).
|
|
41
|
+
//
|
|
42
|
+
// Catalog is the single source of truth for content; state map
|
|
43
|
+
// is just the name → id lookup. Falls back to the state entry
|
|
44
|
+
// only when the catalog hasn't caught up (sync races, synthetic
|
|
45
|
+
// test setups where front-matter hasn't been wired).
|
|
46
|
+
async function resolveLayout(name) {
|
|
47
|
+
const stateEntry = layouts[name]
|
|
48
|
+
if (!stateEntry) return undefined
|
|
49
|
+
return (await findEntity({ id: stateEntry.id })) || stateEntry
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Resolve the set of layouts that should render `entity` this
|
|
53
|
+
// cycle. Returns an array — empty if none matched. Rules:
|
|
54
|
+
// 1. meta.layout (string) and meta.layouts (array) can't both
|
|
55
|
+
// be set; if both → throw, caught and logged below.
|
|
56
|
+
// 2. Author-declared selection (meta.layout / meta.layouts)
|
|
57
|
+
// wins: every named layout is resolved; unknown names get
|
|
58
|
+
// a warning but don't break the rest.
|
|
59
|
+
// 3. No author selection → multi-match across options.match
|
|
60
|
+
// patterns. Every matching pattern contributes a layout.
|
|
61
|
+
// 4. No pattern matched AND options.autoLayouts → fall back
|
|
62
|
+
// to the existing peel ladder; first found wins (the
|
|
63
|
+
// ladder is a search-by-priority by design, not a list of
|
|
64
|
+
// independent matches).
|
|
65
|
+
async function resolveLayoutsForEntity(entity) {
|
|
66
|
+
if (entity.meta?.layout && entity.meta?.layouts) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Entity ${entity.id}: both 'meta.layout' and 'meta.layouts' are set — pick one.`
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
const declared = Array.isArray(entity.meta?.layouts)
|
|
72
|
+
? entity.meta.layouts
|
|
73
|
+
: entity.meta?.layout
|
|
74
|
+
? [entity.meta.layout]
|
|
75
|
+
: null
|
|
76
|
+
|
|
77
|
+
if (declared) {
|
|
78
|
+
const resolved = []
|
|
79
|
+
for (const name of declared) {
|
|
80
|
+
const layout = await resolveLayout(name)
|
|
81
|
+
if (layout) {
|
|
82
|
+
if (!resolved.find(l => l.name === layout.name)) resolved.push(layout)
|
|
83
|
+
} else {
|
|
84
|
+
logger.warn('Layout not found for %s: %s', entity.collection, entity.id, name)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return resolved
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Multi-match over config patterns.
|
|
91
|
+
const matched = []
|
|
92
|
+
for (const pattern in options.match || []) {
|
|
93
|
+
if (matchEntity(entity, pattern)) {
|
|
94
|
+
const name = options.match[pattern]
|
|
95
|
+
const layout = await resolveLayout(name)
|
|
96
|
+
if (layout && !matched.find(l => l.name === layout.name)) {
|
|
97
|
+
matched.push(layout)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Auto-layout: only as a fallback when no pattern matched.
|
|
103
|
+
// The peel ladder is intentionally first-wins — it's a
|
|
104
|
+
// most-specific-name search, not a multi-match.
|
|
105
|
+
if (matched.length === 0 && options.autoLayouts && entity.id) {
|
|
106
|
+
const lookupBase = entity.id.replace(`/${entity.collection}/`, '')
|
|
107
|
+
const dir = path.dirname(lookupBase)
|
|
108
|
+
const base = path.basename(lookupBase)
|
|
109
|
+
const chunks = base.split('.')
|
|
110
|
+
const candidates = []
|
|
111
|
+
for (let i = chunks.length; i > 0; i--) {
|
|
112
|
+
const head = chunks.slice(0, i).join('.')
|
|
113
|
+
candidates.push(dir && dir !== '.' ? path.join(dir, head) : head)
|
|
114
|
+
}
|
|
115
|
+
const autoLayout = candidates.find(name => layouts[name])
|
|
116
|
+
if (autoLayout) {
|
|
117
|
+
const layout = await resolveLayout(autoLayout)
|
|
118
|
+
if (layout) matched.push(layout)
|
|
119
|
+
logger.debug('Auto layout matched %s -> %s for %s', entity.name, autoLayout, entity.id)
|
|
120
|
+
} else {
|
|
121
|
+
logger.trace('Auto layout no match for %s tried: %s', entity.id, candidates.join(', '))
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return matched
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for await (let { entity, operation } of useJournal('Layouts processing', [OPERATION.CREATE, OPERATION.UPDATE, OPERATION.DELETE], signal)) {
|
|
129
|
+
if (entity.collection == collection) continue
|
|
130
|
+
switch (operation) {
|
|
131
|
+
case OPERATION.CREATE:
|
|
132
|
+
case OPERATION.UPDATE:
|
|
133
|
+
try {
|
|
134
|
+
entity.layouts = await resolveLayoutsForEntity(entity)
|
|
135
|
+
} catch (err) {
|
|
136
|
+
logger.error('Layout resolution for %s: %s', entity.id, err.message)
|
|
137
|
+
entity.layouts = []
|
|
138
|
+
}
|
|
139
|
+
// Back-compat alias. Most existing downstream code
|
|
140
|
+
// reads `entity.layout`; keep it pointing at the
|
|
141
|
+
// first matched layout so it stays useful. The
|
|
142
|
+
// onBeforeRender task-build phase iterates
|
|
143
|
+
// entity.layouts and reassigns entity.layout per
|
|
144
|
+
// task to the layout being processed.
|
|
145
|
+
entity.layout = entity.layouts[0]
|
|
146
|
+
|
|
147
|
+
// Mirror the matched name(s) into meta so the
|
|
148
|
+
// catalog's `meta_layout` index can find this
|
|
149
|
+
// entity via "anything with a layout" queries.
|
|
150
|
+
// Author-declared cases already have meta.layout
|
|
151
|
+
// (or meta.layouts) set; this covers the
|
|
152
|
+
// pattern-match / auto-layout paths.
|
|
153
|
+
if (entity.layouts.length && !entity.meta?.layout && !entity.meta?.layouts) {
|
|
154
|
+
entity.meta = entity.meta || {}
|
|
155
|
+
if (entity.layouts.length === 1) {
|
|
156
|
+
entity.meta.layout = entity.layouts[0].name
|
|
157
|
+
} else {
|
|
158
|
+
entity.meta.layouts = entity.layouts.map(l => l.name)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// A render-requested entity (carries useRenderer's
|
|
163
|
+
// correlationId) that resolved to no layout will
|
|
164
|
+
// silently produce nothing — the caller just gets
|
|
165
|
+
// api.js's "did not complete". Surface the real
|
|
166
|
+
// reason here, where we authoritatively know no
|
|
167
|
+
// layout matched. Gated on correlationId so the
|
|
168
|
+
// thousands of normal layout-less content files
|
|
169
|
+
// stay quiet.
|
|
170
|
+
if (entity.layouts.length === 0 && entity.options?.correlationId) {
|
|
171
|
+
logger.warn(
|
|
172
|
+
'Render requested for %s but no layout matched — set meta.layout / meta.layouts, add a layouts.match rule, or name it to match a layout (auto-layout). Entities without a layout are not rendered.',
|
|
173
|
+
entity.id,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// meta.postprocessor (string) / meta.postprocessors
|
|
178
|
+
// (array) override is read at task-build time (in
|
|
179
|
+
// onBeforeRender) because the layout entity is
|
|
180
|
+
// re-fetched from the catalog there — any mutation
|
|
181
|
+
// we did to it here would be reverted by the
|
|
182
|
+
// refresh. We just sanity-check the dual key here
|
|
183
|
+
// so authors see the error early.
|
|
184
|
+
if (entity.meta?.postprocessor && entity.meta?.postprocessors) {
|
|
185
|
+
logger.error(
|
|
186
|
+
'Entity %s: both meta.postprocessor and meta.postprocessors set — pick one. The chain will fall back to the singular.',
|
|
187
|
+
entity.id,
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (entity.layouts.length) {
|
|
192
|
+
logger.debug('Layouts matched for %s (%d): %s',
|
|
193
|
+
entity.id, entity.layouts.length,
|
|
194
|
+
entity.layouts.map(l => l.name).join(', '))
|
|
195
|
+
} else if (entity.meta?.href) {
|
|
196
|
+
logger.trace('Layout missing for %s: %s', entity.collection, entity.id)
|
|
197
|
+
}
|
|
198
|
+
break
|
|
199
|
+
case OPERATION.DELETE:
|
|
200
|
+
// Catalog DELETE is the sole source of truth for "this
|
|
201
|
+
// entity is no longer in the sitemap" — sitemap lives
|
|
202
|
+
// in the catalog (queried via meta_href), so the
|
|
203
|
+
// DELETE alone removes it.
|
|
204
|
+
break
|
|
205
|
+
}
|
|
206
|
+
// Any layout/meta mutation above is auto-persisted by the
|
|
207
|
+
// useJournal generator when this for-body completes (the
|
|
208
|
+
// generator JSON.stringifies the entity post-yield and
|
|
209
|
+
// UPDATEs the row if it diverged from the original).
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
package/lib/mcp.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// MCP tool surface for layouts. Registered against the mcp substrate
|
|
2
|
+
// at `runtime.options.mcp` when present — same gating pattern other
|
|
3
|
+
// siblings use (vector, schemas, preview). When mcp isn't loaded, the
|
|
4
|
+
// registration is a no-op.
|
|
5
|
+
//
|
|
6
|
+
// One tool: `mikser_layouts_inspect`. Surfaces the inspect() primitive
|
|
7
|
+
// already exposed at `runtime.options.layouts.inspect`. The schema and
|
|
8
|
+
// author-facing description live here because they're MCP-flavor; the
|
|
9
|
+
// underlying data shape is owned by lib/inspect.js.
|
|
10
|
+
|
|
11
|
+
import { z } from 'zod'
|
|
12
|
+
|
|
13
|
+
export function registerMcpTools({ runtime, useLogger }) {
|
|
14
|
+
const mcp = runtime.options.mcp
|
|
15
|
+
if (!mcp) return // mcp plugin not loaded — nothing to register
|
|
16
|
+
if (!runtime.options.layouts?.inspect) return // shouldn't happen if we run after our own inspect setup, but defensive
|
|
17
|
+
|
|
18
|
+
const logger = useLogger()
|
|
19
|
+
|
|
20
|
+
mcp.simpleTool(
|
|
21
|
+
'mikser_layouts_inspect',
|
|
22
|
+
'Inspect a layout: template source, references (runtime: precise refClosure from recent renders via the manifest; static: variables / partials / iterations / helpers parsed from source by the renderer plugin), and sample entities currently targeting it. Use this to answer "what does this layout need from an entity" before drafting a preview render — saves a guess-and-render-empty cycle.',
|
|
23
|
+
{
|
|
24
|
+
id: z.string().describe('Layout id, e.g. "/layouts/reports/royalty.html-pdf.liquid". Use mikser_query_entities with { collection: "layouts" } to discover ids.'),
|
|
25
|
+
samples: z.number().int().min(0).max(10).optional().describe('How many existing entities currently using this layout to include as data-shape examples. Default 3. Only entities with explicit meta.layout match; auto-matched layouts are not surfaced.'),
|
|
26
|
+
},
|
|
27
|
+
async ({ id, samples = 3 }) => {
|
|
28
|
+
try {
|
|
29
|
+
const result = await runtime.options.layouts.inspect(id, { samples })
|
|
30
|
+
return {
|
|
31
|
+
content: [{
|
|
32
|
+
type: 'text',
|
|
33
|
+
text: JSON.stringify({
|
|
34
|
+
...result,
|
|
35
|
+
notes: [
|
|
36
|
+
'references.runtime is the precise answer from manifest snapshots — what the renderer actually touched in recent renders. Empty if the layout has never rendered.',
|
|
37
|
+
'references.static is the renderer-plugin AST walk of the template source — what the source mentions, including unexercised conditional branches. Empty when the renderer does not expose parseReferences (markdown / metatext / custom).',
|
|
38
|
+
'samples only includes entities with explicit meta.layout. Auto-matched layouts are not listed; use mikser_query_entities with a filename-pattern filter for those.',
|
|
39
|
+
],
|
|
40
|
+
}, null, 2),
|
|
41
|
+
}],
|
|
42
|
+
}
|
|
43
|
+
} catch (err) {
|
|
44
|
+
logger.error('MCP mikser_layouts_inspect error: %s', err.message)
|
|
45
|
+
return {
|
|
46
|
+
isError: true,
|
|
47
|
+
content: [{ type: 'text', text: err.message }],
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
logger.debug('MCP tool registered: mikser_layouts_inspect (layouts plugin)')
|
|
54
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mikser-io-layouts",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "SSG-flavor task-production policy for mikser-io. Multi-match layout assignment, per-layout destinations, postprocess chain attachment, layout inspection.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node --no-warnings --test --test-reporter=spec 'test/**/*.test.js'"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/almero-digital-marketing/mikser-io-layouts.git"
|
|
13
|
+
},
|
|
14
|
+
"author": "",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/almero-digital-marketing/mikser-io-layouts/issues"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/almero-digital-marketing/mikser-io-layouts#readme",
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"mikser-io": "^9.0.0"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"globby": "^14.0.0",
|
|
25
|
+
"handlebars": "^4.7.0",
|
|
26
|
+
"lodash": "^4.17.0",
|
|
27
|
+
"zod": "^4.4.3"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/test/_harness.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// A minimal in-memory harness for testing core plugins.
|
|
2
|
+
//
|
|
3
|
+
// Real plugins call lifecycle registrations (onLoaded, onProcess, onSync, ...)
|
|
4
|
+
// during their factory invocation; the engine later drives those callbacks. In
|
|
5
|
+
// tests we substitute every API surface with a recorder, then drive the
|
|
6
|
+
// callbacks manually with controlled inputs.
|
|
7
|
+
|
|
8
|
+
import _ from 'lodash'
|
|
9
|
+
import {
|
|
10
|
+
runtime as realRuntime,
|
|
11
|
+
matchEntity, normalize, changeExtension, getFormatInfo, checksum, AbortError,
|
|
12
|
+
} from 'mikser-io'
|
|
13
|
+
|
|
14
|
+
const OPERATION = {
|
|
15
|
+
CREATE: 'create',
|
|
16
|
+
UPDATE: 'update',
|
|
17
|
+
DELETE: 'delete',
|
|
18
|
+
RENDER: 'render',
|
|
19
|
+
POSTPROCESS: 'postprocess',
|
|
20
|
+
}
|
|
21
|
+
const ACTION = {
|
|
22
|
+
CREATE: 'create',
|
|
23
|
+
UPDATE: 'update',
|
|
24
|
+
DELETE: 'delete',
|
|
25
|
+
TRIGGER: 'trigger',
|
|
26
|
+
}
|
|
27
|
+
const TASKS = { INLINE: 'inline', SERIAL: 'serial', WORKER: 'worker' }
|
|
28
|
+
|
|
29
|
+
export function createHarness({
|
|
30
|
+
config = {},
|
|
31
|
+
options = {},
|
|
32
|
+
state = {},
|
|
33
|
+
entities = [],
|
|
34
|
+
journal = [],
|
|
35
|
+
} = {}) {
|
|
36
|
+
const hookNames = [
|
|
37
|
+
'load', 'loaded', 'import', 'imported',
|
|
38
|
+
'process', 'processed', 'persist', 'persisted',
|
|
39
|
+
'beforeRender', 'render', 'afterRender',
|
|
40
|
+
'beforePostprocess', 'postprocess', 'afterPostprocess',
|
|
41
|
+
'finalize', 'finalized', 'cancel', 'cancelled',
|
|
42
|
+
'complete', 'validate',
|
|
43
|
+
]
|
|
44
|
+
const hooks = Object.fromEntries(hookNames.map(n => [n, []]))
|
|
45
|
+
const sync = new Map()
|
|
46
|
+
const watchers = []
|
|
47
|
+
const renderTasks = []
|
|
48
|
+
const postprocessTasks = []
|
|
49
|
+
const progress = []
|
|
50
|
+
const logs = []
|
|
51
|
+
const mklog = (level) => (...args) => logs.push({ level, args })
|
|
52
|
+
const logger = {
|
|
53
|
+
info: mklog('info'),
|
|
54
|
+
warn: mklog('warn'),
|
|
55
|
+
error: mklog('error'),
|
|
56
|
+
debug: mklog('debug'),
|
|
57
|
+
trace: mklog('trace'),
|
|
58
|
+
notice: mklog('notice'),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let nextId = 1
|
|
62
|
+
const journalEntries = journal.map(e => ({ id: nextId++, output: null, context: {}, options: {}, ...e }))
|
|
63
|
+
|
|
64
|
+
function addEntry({ entity, operation, context = {}, options = {} }) {
|
|
65
|
+
journalEntries.push({ id: nextId++, entity, operation, context, options, output: null })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Stub `runtime.catalog` so catalog.js's module-level findEntity /
|
|
69
|
+
// findEntities / findById / queryEntities / readEntity / expand
|
|
70
|
+
// walker read from the harness's in-memory entity set. Real
|
|
71
|
+
// catalog.js's onInitialized sets up an identical shape — a
|
|
72
|
+
// Map<id, entity> with a no-op save() — but persists to disk; the
|
|
73
|
+
// stub skips persistence. Pushes/mutations to the input `entities`
|
|
74
|
+
// array land in the same Map via the constructor below.
|
|
75
|
+
//
|
|
76
|
+
// Set on the runtime SINGLETON imported from src/runtime.js — that's
|
|
77
|
+
// what catalog.js sees. Setting it on the harness-local `runtime`
|
|
78
|
+
// object below wouldn't help because plugin code imports the
|
|
79
|
+
// singleton directly. Each createHarness() call overwrites the
|
|
80
|
+
// singleton's `.catalog`, which is fine because tests don't run in
|
|
81
|
+
// parallel within a file.
|
|
82
|
+
const catalogStub = {
|
|
83
|
+
byId: new Map(entities.filter(e => e?.id).map(e => [e.id, e])),
|
|
84
|
+
version: 'test',
|
|
85
|
+
cacheInvalidated: false,
|
|
86
|
+
// No-op save — the harness never wants disk side-effects.
|
|
87
|
+
// catalog.js's onFinalized hook calls runtime.catalog.save(),
|
|
88
|
+
// so any test that drives the full lifecycle would blow up
|
|
89
|
+
// without this.
|
|
90
|
+
save: async () => {},
|
|
91
|
+
}
|
|
92
|
+
realRuntime.catalog = catalogStub
|
|
93
|
+
|
|
94
|
+
const runtime = {
|
|
95
|
+
options: { workingFolder: '/tmp/test-mikser', plugins: [], ...options },
|
|
96
|
+
config,
|
|
97
|
+
state,
|
|
98
|
+
hooks,
|
|
99
|
+
engine: { logger },
|
|
100
|
+
catalog: catalogStub,
|
|
101
|
+
// Real lifecycle.js attaches these to the runtime via side-effect
|
|
102
|
+
// on import. The harness doesn't load that module, so reproduce
|
|
103
|
+
// them here against the in-memory journal.
|
|
104
|
+
create: async (entity) => addEntry({ operation: OPERATION.CREATE, entity }),
|
|
105
|
+
update: async (entity) => addEntry({ operation: OPERATION.UPDATE, entity }),
|
|
106
|
+
delete: async ({ id, collection, type }) =>
|
|
107
|
+
addEntry({ operation: OPERATION.DELETE, entity: { id, collection, type } }),
|
|
108
|
+
}
|
|
109
|
+
function updateEntry({ id, entity, output }) {
|
|
110
|
+
const entry = journalEntries.find(e => e.id === id)
|
|
111
|
+
if (!entry) return
|
|
112
|
+
if (entity !== undefined) entry.entity = entity
|
|
113
|
+
if (output !== undefined) entry.output = output
|
|
114
|
+
}
|
|
115
|
+
async function* useJournal(name, operations, signal) {
|
|
116
|
+
progress.push({ name, total: journalEntries.length })
|
|
117
|
+
for (const entry of journalEntries) {
|
|
118
|
+
if (operations?.length && !operations.includes(entry.operation)) continue
|
|
119
|
+
if (signal?.aborted) return
|
|
120
|
+
yield entry
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const core = {
|
|
125
|
+
runtime,
|
|
126
|
+
useLogger: () => logger,
|
|
127
|
+
|
|
128
|
+
// Lifecycle registrations
|
|
129
|
+
onLoad: (cb) => hooks.load.push(cb),
|
|
130
|
+
onLoaded: (cb) => hooks.loaded.push(cb),
|
|
131
|
+
onImport: (cb) => hooks.import.push(cb),
|
|
132
|
+
onImported: (cb) => hooks.imported.push(cb),
|
|
133
|
+
onProcess: (cb) => hooks.process.push(cb),
|
|
134
|
+
onProcessed: (cb) => hooks.processed.push(cb),
|
|
135
|
+
onPersist: (cb) => hooks.persist.push(cb),
|
|
136
|
+
onPersisted: (cb) => hooks.persisted.push(cb),
|
|
137
|
+
onBeforeRender: (cb) => hooks.beforeRender.push(cb),
|
|
138
|
+
onRender: (cb) => hooks.render.push(cb),
|
|
139
|
+
onAfterRender: (cb) => hooks.afterRender.push(cb),
|
|
140
|
+
onBeforePostprocess: (cb) => hooks.beforePostprocess.push(cb),
|
|
141
|
+
onPostprocess: (cb) => hooks.postprocess.push(cb),
|
|
142
|
+
onAfterPostprocess: (cb) => hooks.afterPostprocess.push(cb),
|
|
143
|
+
onFinalize: (cb) => hooks.finalize.push(cb),
|
|
144
|
+
onFinalized: (cb) => hooks.finalized.push(cb),
|
|
145
|
+
onCancel: (cb) => hooks.cancel.push(cb),
|
|
146
|
+
onCancelled: (cb) => hooks.cancelled.push(cb),
|
|
147
|
+
onComplete: (cb) => hooks.complete.push(cb),
|
|
148
|
+
onSync: (name, cb) => sync.set(name, cb),
|
|
149
|
+
onValidate: (operations, cb) => hooks.validate.push({ operations, cb }),
|
|
150
|
+
|
|
151
|
+
// Entity helpers — write directly to the journal
|
|
152
|
+
createEntity: async (entity) => addEntry({ operation: OPERATION.CREATE, entity }),
|
|
153
|
+
updateEntity: async (entity) => addEntry({ operation: OPERATION.UPDATE, entity }),
|
|
154
|
+
deleteEntity: async ({ id, collection, type }) =>
|
|
155
|
+
addEntry({ operation: OPERATION.DELETE, entity: { id, collection, type } }),
|
|
156
|
+
addEntry,
|
|
157
|
+
addEntries: async (entries) => entries.forEach(addEntry),
|
|
158
|
+
updateEntry,
|
|
159
|
+
useJournal,
|
|
160
|
+
findEntity: async (query) => {
|
|
161
|
+
if (!query) return entities[0]
|
|
162
|
+
if (typeof query === 'function') return entities.find(query)
|
|
163
|
+
return entities.find(e => Object.entries(query).every(([k, v]) => e[k] === v))
|
|
164
|
+
},
|
|
165
|
+
// Synchronous PK lookup mirroring catalog.js's findById. Layouts'
|
|
166
|
+
// onBeforeRender hydrates dispatch ids through this — the harness
|
|
167
|
+
// serves from the same in-memory entities array.
|
|
168
|
+
findById: (id) => entities.find(e => e?.id === id) ?? catalogStub.byId.get(id),
|
|
169
|
+
findEntities: async (query) => {
|
|
170
|
+
if (!query) return [...entities]
|
|
171
|
+
if (typeof query === 'function') return entities.filter(query)
|
|
172
|
+
return entities.filter(e => Object.entries(query).every(([k, v]) => e[k] === v))
|
|
173
|
+
},
|
|
174
|
+
iterateEntities: async function* (query) {
|
|
175
|
+
// Stub — yields the same set findEntities would return,
|
|
176
|
+
// one entity at a time. Real impl in catalog.js chunks via
|
|
177
|
+
// sqlite; the harness doesn't need that fidelity because
|
|
178
|
+
// unit-test corpora are tiny.
|
|
179
|
+
const filtered = !query
|
|
180
|
+
? [...entities]
|
|
181
|
+
: (typeof query === 'function'
|
|
182
|
+
? entities.filter(query)
|
|
183
|
+
: entities.filter(e => Object.entries(query).every(([k, v]) => e[k] === v)))
|
|
184
|
+
for (const e of filtered) yield e
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
// Rendering & postprocessing — capture for assertions
|
|
188
|
+
renderEntities: async (tasks) => renderTasks.push(...tasks),
|
|
189
|
+
postprocessEntities: async (tasks) => postprocessTasks.push(...tasks),
|
|
190
|
+
renderEntity: async () => { },
|
|
191
|
+
postprocessEntity: async () => { },
|
|
192
|
+
|
|
193
|
+
// Misc
|
|
194
|
+
watch: (name, folder) => watchers.push({ name, folder }),
|
|
195
|
+
schedule: () => { },
|
|
196
|
+
trackProgress: () => { },
|
|
197
|
+
updateProgress: () => { },
|
|
198
|
+
stopProgress: () => { },
|
|
199
|
+
|
|
200
|
+
// Utilities (real implementations)
|
|
201
|
+
matchEntity,
|
|
202
|
+
normalize,
|
|
203
|
+
changeExtension,
|
|
204
|
+
getFormatInfo,
|
|
205
|
+
checksum,
|
|
206
|
+
AbortError,
|
|
207
|
+
|
|
208
|
+
constants: { OPERATION, ACTION, TASKS },
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function runHook(name, ...args) {
|
|
212
|
+
const callbacks = hooks[name] || []
|
|
213
|
+
const results = []
|
|
214
|
+
for (const cb of callbacks) {
|
|
215
|
+
results.push(await cb(...args))
|
|
216
|
+
}
|
|
217
|
+
return results
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function runSync(name, payload) {
|
|
221
|
+
const handler = sync.get(name)
|
|
222
|
+
if (!handler) return undefined
|
|
223
|
+
return handler(payload)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
core,
|
|
228
|
+
runtime,
|
|
229
|
+
logger,
|
|
230
|
+
logs,
|
|
231
|
+
sync,
|
|
232
|
+
watchers,
|
|
233
|
+
renderTasks,
|
|
234
|
+
postprocessTasks,
|
|
235
|
+
progress,
|
|
236
|
+
journal: journalEntries,
|
|
237
|
+
hooks,
|
|
238
|
+
runHook,
|
|
239
|
+
runSync,
|
|
240
|
+
addJournalEntry: addEntry,
|
|
241
|
+
constants: { OPERATION, ACTION, TASKS },
|
|
242
|
+
}
|
|
243
|
+
}
|