@eventmodelers/cli 1.0.150 → 1.0.152
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/README.md +1 -0
- package/cli.js +20 -36
- package/lib/bash-guard.js +39 -1
- package/package.json +1 -1
- package/shared/build-kit/lib/adapters/pocketbase-realtime-adapter.js +14 -7
- package/shared/build-kit/lib/adapters/realtime-adapter.js +65 -2
- package/shared/build-kit/lib/adapters/supabase-realtime-adapter.js +19 -10
- package/shared/build-kit/lib/ralph.js +30 -62
- package/shared/skills/learn-eventmodelers-api/SKILL.md +38 -1
- package/stacks/axon/templates/.claude/skills/build-automation/SKILL.md +2 -0
- package/stacks/axon/templates/.claude/skills/build-state-change/SKILL.md +2 -0
- package/stacks/axon/templates/.claude/skills/build-state-view/SKILL.md +2 -0
- package/stacks/classic/templates/.claude/skills/build-automation/SKILL.md +2 -0
- package/stacks/classic/templates/.claude/skills/build-state-change/SKILL.md +2 -0
- package/stacks/classic/templates/.claude/skills/build-state-view/SKILL.md +2 -0
- package/stacks/kurrent/templates/.claude/skills/build-automation/SKILL.md +2 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-change/SKILL.md +2 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-view/SKILL.md +2 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/SKILL.md +69 -3
- package/stacks/modeling-kit/templates/.claude/skills/place-element/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/prototype/SKILL.md +181 -0
- package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/index.template.html +490 -0
- package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/prototype.mjs +224 -0
- package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/runtime.js +434 -0
- package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/slices.example.html +201 -0
- package/stacks/modeling-kit/templates/.claude/skills/prototype/references/api-fallback.md +30 -0
- package/stacks/modeling-kit/templates/kit/CLAUDE.md +2 -0
- package/stacks/node/templates/.claude/skills/build-automation/SKILL.md +2 -0
- package/stacks/node/templates/.claude/skills/build-state-change/SKILL.md +2 -0
- package/stacks/node/templates/.claude/skills/build-state-view/SKILL.md +2 -0
- package/stacks/opencqrs/templates/.claude/skills/build-automation/SKILL.md +2 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/SKILL.md +2 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-view/SKILL.md +2 -0
- package/stacks/supabase/templates/.claude/skills/build-state-change/SKILL.md +2 -0
- package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +2 -0
- package/stacks/umadb/templates/.claude/skills/build-automation/SKILL.md +2 -0
- package/stacks/umadb/templates/.claude/skills/build-state-change/SKILL.md +2 -0
- package/stacks/umadb/templates/.claude/skills/build-state-view/SKILL.md +2 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* Slice-by-slice bookkeeping for the `prototype` skill — runs where the agent runs, never in the page.
|
|
4
|
+
*
|
|
5
|
+
* EM_TOKEN=… node prototype.mjs status --base <BASE_URL> --org <ORG_ID> --board <BOARD_ID> --context <name> --out <dir>
|
|
6
|
+
* node prototype.mjs assemble --out <dir>
|
|
7
|
+
* EM_TOKEN=… node prototype.mjs upload --base <BASE_URL> --org <ORG_ID> --board <BOARD_ID> --out <dir>
|
|
8
|
+
*
|
|
9
|
+
* status fetches the context, hashes every slice (its elements, scenarios and screens), compares the
|
|
10
|
+
* hashes with the data-em-hash of the blocks in the prototype the platform stores (else the local
|
|
11
|
+
* <out>/index.html — so a fresh session or folder still rebuilds only what changed), copies the kept blocks to
|
|
12
|
+
* <out>/.work/slices/<id>.html and writes <out>/.work/status.json. Prints which slices are new,
|
|
13
|
+
* changed, unchanged or removed. Only new and changed slices need a new block.
|
|
14
|
+
* assemble writes <out>/index.html from the template, runtime.js and every block in .work/slices that
|
|
15
|
+
* belongs to a current slice, in timeline order.
|
|
16
|
+
* upload sends <out>/index.html to the platform (POST .../prototype/<context id>), which sanitizes, gzips
|
|
17
|
+
* and stores it per effective context. Prints what the sanitizer removed — nothing, normally.
|
|
18
|
+
*
|
|
19
|
+
* The token comes from EM_TOKEN or EVENTMODELERS_TOKEN — never pass it on the command line.
|
|
20
|
+
*/
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const ASSETS = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
28
|
+
const args = {};
|
|
29
|
+
for (let i = 0; i < rest.length; i += 2) args[rest[i].replace(/^--/, '')] = rest[i + 1];
|
|
30
|
+
|
|
31
|
+
const fail = (msg) => {
|
|
32
|
+
console.error(msg);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
};
|
|
35
|
+
if (!args.out) fail('--out <dir> is required');
|
|
36
|
+
const WORK = join(args.out, '.work');
|
|
37
|
+
const BLOCKS = join(WORK, 'slices');
|
|
38
|
+
const PAGE = join(args.out, 'index.html');
|
|
39
|
+
|
|
40
|
+
// Element/scenario data that changes without the slice's meaning changing — left out of the hash.
|
|
41
|
+
const VOLATILE = new Set(['status', 'assignee', 'ticketNumber', 'comments']);
|
|
42
|
+
|
|
43
|
+
/** Stable JSON: sorted keys, volatile keys and the random id of a SPEC_ERROR step dropped. */
|
|
44
|
+
function canonical(value) {
|
|
45
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
46
|
+
if (value && typeof value === 'object') {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const k of Object.keys(value).sort()) {
|
|
49
|
+
if (VOLATILE.has(k)) continue;
|
|
50
|
+
if (k === 'id' && value.type === 'SPEC_ERROR') continue;
|
|
51
|
+
out[k] = canonical(value[k]);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const hash = (value) => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex').slice(0, 16);
|
|
59
|
+
|
|
60
|
+
/** The slice blocks in an assembled page: [{ id, title, hash, block }]. */
|
|
61
|
+
function blocksIn(html) {
|
|
62
|
+
const found = [];
|
|
63
|
+
const re = /<script\b([^>]*\bdata-em-slice="[^"]*"[^>]*)>[\s\S]*?<\/script>/g;
|
|
64
|
+
for (let m; (m = re.exec(html)); ) {
|
|
65
|
+
const attr = (name) => ((m[1].match(new RegExp(`${name}="([^"]*)"`)) || [])[1] || '');
|
|
66
|
+
found.push({ id: attr('data-em-slice'), title: attr('data-em-title'), hash: attr('data-em-hash'), block: m[0] });
|
|
67
|
+
}
|
|
68
|
+
return found;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The board API base and headers, from --base/--org/--board and the token in the environment. */
|
|
72
|
+
function boardApi(required) {
|
|
73
|
+
for (const k of ['base', 'org', 'board', ...required]) if (!args[k]) fail(`--${k} is required`);
|
|
74
|
+
const token = process.env.EM_TOKEN || process.env.EVENTMODELERS_TOKEN;
|
|
75
|
+
if (!token) fail('Set EM_TOKEN (or EVENTMODELERS_TOKEN) to the board token');
|
|
76
|
+
const api = `${args.base.replace(/\/$/, '')}/api/org/${args.org}/boards/${args.board}`;
|
|
77
|
+
return { api, headers: { 'x-token': token, 'x-user-id': 'prototype-skill', 'content-type': 'application/json' } };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The id of the MODEL_CONTEXT or timeline named `name` (contexts first) — the platform keys a prototype by it. */
|
|
81
|
+
async function contextIdOf(get, name) {
|
|
82
|
+
const lower = name.toLowerCase();
|
|
83
|
+
for (const type of ['MODEL_CONTEXT', 'CHAPTER']) {
|
|
84
|
+
const nodes = await get(`/nodes?type=${type}&projection=line`);
|
|
85
|
+
const match = (Array.isArray(nodes) ? nodes : nodes.nodes || []).find((n) => (n.title || '').toLowerCase() === lower);
|
|
86
|
+
if (match) return match.id;
|
|
87
|
+
}
|
|
88
|
+
fail(`No model context or timeline named "${name}" on this board`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function status() {
|
|
92
|
+
const { api, headers } = boardApi(['context']);
|
|
93
|
+
const get = async (path, { allow404 = false } = {}) => {
|
|
94
|
+
const res = await fetch(api + path, { headers });
|
|
95
|
+
if (allow404 && res.status === 404) return null;
|
|
96
|
+
if (!res.ok) fail(`GET ${path} → ${res.status} ${await res.text()}`);
|
|
97
|
+
return res.json();
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const ctx = encodeURIComponent(args.context);
|
|
101
|
+
const [fields, specs] = await Promise.all([
|
|
102
|
+
get(`/slicedata?contextName=${ctx}&projection=fields&format=json`),
|
|
103
|
+
get(`/slicedata?contextName=${ctx}&projection=specs&format=json`),
|
|
104
|
+
]);
|
|
105
|
+
const specsById = Object.fromEntries((specs.slices || []).map((s) => [s.id, s]));
|
|
106
|
+
|
|
107
|
+
// Screens: the slice's own node (a copy can carry its own marks), with its pages or its sketch.
|
|
108
|
+
const screenIds = [...new Set((fields.slices || []).flatMap((s) => (s.screens || []).map((x) => x.linkedId || x.id)))];
|
|
109
|
+
const screens = {};
|
|
110
|
+
if (screenIds.length) {
|
|
111
|
+
const res = await fetch(`${api}/nodes/query`, { method: 'POST', headers, body: JSON.stringify({ ids: screenIds }) });
|
|
112
|
+
if (!res.ok) fail(`POST /nodes/query → ${res.status} ${await res.text()}`);
|
|
113
|
+
for (const node of await res.json()) {
|
|
114
|
+
const meta = typeof node.meta === 'string' ? JSON.parse(node.meta) : node.meta || {};
|
|
115
|
+
const frameworks = meta.cssFrameworks || node.cssFrameworks || (node.data && node.data.cssFrameworks) || [];
|
|
116
|
+
screens[node.id] = { type: node.type || meta.type, pages: meta.pages || null, marks: meta.marks || null, cssFrameworks: frameworks };
|
|
117
|
+
}
|
|
118
|
+
await Promise.all(
|
|
119
|
+
Object.entries(screens)
|
|
120
|
+
.filter(([, s]) => !s.pages)
|
|
121
|
+
.map(async ([id, s]) => {
|
|
122
|
+
s.sketch = await get(`/images/${id}/description`, { allow404: true });
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The baseline whose blocks can be kept: the page the platform stores for this context — what everyone
|
|
128
|
+
// plays, and there in any session or folder (its sanitizer leaves the blocks byte for byte) — else a
|
|
129
|
+
// local one, from an earlier run whose upload failed. Neither: every slice is new.
|
|
130
|
+
const contextId = args['context-id'] || (await contextIdOf(get, args.context));
|
|
131
|
+
const storedRes = await fetch(`${api}/prototype/${encodeURIComponent(contextId)}`, { headers });
|
|
132
|
+
if (!storedRes.ok && storedRes.status !== 404) fail(`GET /prototype/${contextId} → ${storedRes.status} ${await storedRes.text()}`);
|
|
133
|
+
const stored = storedRes.ok ? await storedRes.text() : null;
|
|
134
|
+
const baseline = stored ? 'platform' : existsSync(PAGE) ? 'local' : 'none';
|
|
135
|
+
const existing = baseline === 'none' ? [] : blocksIn(stored ?? readFileSync(PAGE, 'utf8'));
|
|
136
|
+
const byId = Object.fromEntries(existing.map((b) => [b.id, b]));
|
|
137
|
+
rmSync(BLOCKS, { recursive: true, force: true });
|
|
138
|
+
mkdirSync(BLOCKS, { recursive: true });
|
|
139
|
+
|
|
140
|
+
const frameworkVotes = {};
|
|
141
|
+
const slices = (fields.slices || []).map((slice, order) => {
|
|
142
|
+
const own = (slice.screens || []).map((x) => screens[x.linkedId || x.id] || null);
|
|
143
|
+
for (const s of own) if (s && s.pages) frameworkVotes[(s.cssFrameworks || [])[0] || 'bulma'] = (frameworkVotes[(s.cssFrameworks || [])[0] || 'bulma'] || 0) + 1;
|
|
144
|
+
const h = hash({ slice, specs: specsById[slice.id] || null, screens: own });
|
|
145
|
+
const before = byId[slice.id];
|
|
146
|
+
if (before && before.hash === h) writeFileSync(join(BLOCKS, `${slice.id}.html`), before.block + '\n');
|
|
147
|
+
return {
|
|
148
|
+
id: slice.id,
|
|
149
|
+
title: slice.title,
|
|
150
|
+
sliceType: slice.sliceType,
|
|
151
|
+
chapter: slice.chapter,
|
|
152
|
+
order: order + 1,
|
|
153
|
+
hash: h,
|
|
154
|
+
state: !before ? 'new' : before.hash === h ? 'unchanged' : 'changed',
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
const current = new Set(slices.map((s) => s.id));
|
|
158
|
+
const removed = existing.filter((b) => !current.has(b.id)).map(({ id, title }) => ({ id, title }));
|
|
159
|
+
const cssFramework = Object.entries(frameworkVotes).sort((a, b) => b[1] - a[1])[0]?.[0] || 'bulma';
|
|
160
|
+
|
|
161
|
+
const report = { context: args.context, contextId, baseline, cssFramework, mixedFrameworks: Object.keys(frameworkVotes).length > 1, slices, removed };
|
|
162
|
+
writeFileSync(join(WORK, 'status.json'), JSON.stringify(report, null, 2));
|
|
163
|
+
for (const s of slices) console.log(`${s.state.padEnd(9)} ${s.hash} ${s.id} ${s.title}`);
|
|
164
|
+
for (const r of removed) console.log(`removed ${''.padEnd(16)} ${r.id} ${r.title}`);
|
|
165
|
+
const todo = slices.filter((s) => s.state !== 'unchanged').length;
|
|
166
|
+
console.log(`\n${todo} of ${slices.length} slices to build, ${removed.length} removed (${{ platform: 'compared with the stored prototype', local: 'compared with the local page', none: 'no prototype yet' }[baseline]}) — see ${join(WORK, 'status.json')}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const CSS = {
|
|
170
|
+
bulma: '<link rel="stylesheet" href="/vendor/bulma-0.9.4.css" />',
|
|
171
|
+
bootstrap: '<link rel="stylesheet" href="/vendor/bootstrap-5.3.3.css" />',
|
|
172
|
+
tailwind: '<script src="/vendor/tailwind-play-3.4.16.js"></script>',
|
|
173
|
+
none: '',
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
function assemble() {
|
|
177
|
+
const report = JSON.parse(readFileSync(join(WORK, 'status.json'), 'utf8'));
|
|
178
|
+
const problems = [];
|
|
179
|
+
const blocks = report.slices.map((s) => {
|
|
180
|
+
const file = join(BLOCKS, `${s.id}.html`);
|
|
181
|
+
if (!existsSync(file)) {
|
|
182
|
+
problems.push(`missing block for ${s.state} slice "${s.title}" (${file})`);
|
|
183
|
+
return '';
|
|
184
|
+
}
|
|
185
|
+
const block = readFileSync(file, 'utf8').trim();
|
|
186
|
+
const [found] = blocksIn(block);
|
|
187
|
+
if (!found || found.id !== s.id) problems.push(`${file}: not a slice block for ${s.id}`);
|
|
188
|
+
else if (found.hash !== s.hash) problems.push(`${file}: data-em-hash is ${found.hash}, the slice's hash is ${s.hash}`);
|
|
189
|
+
if ((block.match(/<\/script/gi) || []).length !== 1) problems.push(`${file}: a closing script tag appears inside the block`);
|
|
190
|
+
return block;
|
|
191
|
+
});
|
|
192
|
+
const stray = readdirSync(BLOCKS).filter((f) => !report.slices.some((s) => `${s.id}.html` === f));
|
|
193
|
+
if (problems.length) fail('Not assembled:\n ' + problems.join('\n '));
|
|
194
|
+
|
|
195
|
+
const escape = (s) => s.replace(/[&"<>]/g, (c) => ({ '&': '&', '"': '"', '<': '<', '>': '>' })[c]);
|
|
196
|
+
const html = readFileSync(join(ASSETS, 'index.template.html'), 'utf8')
|
|
197
|
+
.replaceAll('{{CONTEXT}}', escape(report.context))
|
|
198
|
+
.replace('<!-- @@CSS_FRAMEWORK@@', `${CSS[report.cssFramework] ?? CSS.bulma}\n <!-- @@CSS_FRAMEWORK@@`)
|
|
199
|
+
.replace('/* @@RUNTIME@@ */', () => readFileSync(join(ASSETS, 'runtime.js'), 'utf8'))
|
|
200
|
+
.replace(/( ={20,} -->\n)(\n<!-- Load:)/, (_, end, load) => `${end}\n${blocks.join('\n\n')}\n${load}`);
|
|
201
|
+
writeFileSync(PAGE, html);
|
|
202
|
+
console.log(`Wrote ${PAGE} — ${blocks.length} slice blocks${stray.length ? `, ignored ${stray.length} stray file(s): ${stray.join(', ')}` : ''}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function upload() {
|
|
206
|
+
const { api, headers } = boardApi([]);
|
|
207
|
+
const report = JSON.parse(readFileSync(join(WORK, 'status.json'), 'utf8'));
|
|
208
|
+
if (!existsSync(PAGE)) fail(`${PAGE} does not exist — run assemble first`);
|
|
209
|
+
const res = await fetch(`${api}/prototype/${encodeURIComponent(report.contextId)}`, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: { ...headers, 'content-type': 'text/html; charset=utf-8' },
|
|
212
|
+
body: readFileSync(PAGE),
|
|
213
|
+
});
|
|
214
|
+
const text = await res.text();
|
|
215
|
+
if (!res.ok) fail(`Upload failed → ${res.status} ${text}`);
|
|
216
|
+
const stored = JSON.parse(text);
|
|
217
|
+
console.log(`Stored ${stored.contextName} at ${stored.path} (${stored.bytes} bytes, ${stored.gzippedBytes} gzipped)`);
|
|
218
|
+
if (stored.removed.length) console.log(`The platform removed ${stored.removed.length} thing(s) from the page:\n ${stored.removed.join('\n ')}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (command === 'status') await status();
|
|
222
|
+
else if (command === 'assemble') assemble();
|
|
223
|
+
else if (command === 'upload') await upload();
|
|
224
|
+
else fail('usage: node prototype.mjs status|assemble|upload --out <dir> [...]');
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Event-sourced prototype runtime — pasted verbatim into every prototype by the `prototype` skill.
|
|
3
|
+
* Never edit the copy: everything model-specific lives in the page's slice blocks.
|
|
4
|
+
*
|
|
5
|
+
* Browser only, no build step: defines globalThis.EMRuntime. Each slice block registers its part of
|
|
6
|
+
* the model with EMRuntime.defineSlice(...); EMRuntime.domain is the union of all of them.
|
|
7
|
+
*/
|
|
8
|
+
globalThis.EMRuntime = (function () {
|
|
9
|
+
class DomainError extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'DomainError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Reject a command with a business-rule message (the scenario's errorDescription). */
|
|
17
|
+
function reject(message) {
|
|
18
|
+
throw new DomainError(message);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const uuid = () =>
|
|
22
|
+
typeof crypto !== 'undefined' && crypto.randomUUID
|
|
23
|
+
? crypto.randomUUID()
|
|
24
|
+
: 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
25
|
+
const r = (Math.random() * 16) | 0;
|
|
26
|
+
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------------------------
|
|
30
|
+
// Projection — fold the log through a read model's evolve, then optionally query the state.
|
|
31
|
+
|
|
32
|
+
function project(readModel, events, params, ctx) {
|
|
33
|
+
let state = typeof readModel.initial === 'function' ? readModel.initial() : structuredCopy(readModel.initial);
|
|
34
|
+
for (const e of events) state = readModel.evolve(state, e, ctx) ?? state;
|
|
35
|
+
return readModel.query ? readModel.query(state, params || {}, ctx) : state;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function structuredCopy(v) {
|
|
39
|
+
return v === undefined ? undefined : JSON.parse(JSON.stringify(v));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** localStorage, or null where reading it throws (a sandboxed iframe) — events then live in memory. */
|
|
43
|
+
function browserStorage() {
|
|
44
|
+
try {
|
|
45
|
+
return typeof localStorage !== 'undefined' ? localStorage : null;
|
|
46
|
+
} catch (_) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------------------------
|
|
52
|
+
// Runtime — the in-browser event store, command bus and automation runner.
|
|
53
|
+
|
|
54
|
+
function createRuntime(Domain, opts) {
|
|
55
|
+
const options = Object.assign({ automationDelay: 700, autoRun: false, storage: null }, opts);
|
|
56
|
+
const storage = options.storage || browserStorage();
|
|
57
|
+
const KEY = 'em-prototype:' + Domain.context;
|
|
58
|
+
|
|
59
|
+
let persisted = load();
|
|
60
|
+
let events = persisted.events;
|
|
61
|
+
let clockOffset = persisted.clockOffset;
|
|
62
|
+
let autoRun = persisted.autoRun ?? options.autoRun;
|
|
63
|
+
const automationLog = [];
|
|
64
|
+
const outbox = [];
|
|
65
|
+
const listeners = new Set();
|
|
66
|
+
let timer = null;
|
|
67
|
+
// Todo items whose command succeeded ('done'), and those still listed afterwards ('stuck').
|
|
68
|
+
// A stuck item means the todo list never removes what its command records — re-issuing it
|
|
69
|
+
// would append the same events every run, forever.
|
|
70
|
+
const handled = new Map();
|
|
71
|
+
// Time travel: when set, read models only see events up to this position and nothing can be
|
|
72
|
+
// recorded — the screens show the system as it was then.
|
|
73
|
+
let cursor = null;
|
|
74
|
+
const visible = () => (cursor == null ? events : events.slice(0, cursor));
|
|
75
|
+
|
|
76
|
+
function load() {
|
|
77
|
+
try {
|
|
78
|
+
const raw = storage && storage.getItem(KEY);
|
|
79
|
+
if (raw) return JSON.parse(raw);
|
|
80
|
+
} catch (_) {}
|
|
81
|
+
return { events: [], clockOffset: 0 };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function save() {
|
|
85
|
+
try {
|
|
86
|
+
storage && storage.setItem(KEY, JSON.stringify({ events, clockOffset, autoRun }));
|
|
87
|
+
} catch (_) {}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function notify() {
|
|
91
|
+
save();
|
|
92
|
+
listeners.forEach((fn) => fn());
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const now = () => new Date(Date.now() + clockOffset);
|
|
96
|
+
const ctx = () => ({ now: now(), uuid });
|
|
97
|
+
|
|
98
|
+
function append(newEvents, metadata) {
|
|
99
|
+
const stored = newEvents.map((e) => ({
|
|
100
|
+
position: events.length + 1,
|
|
101
|
+
id: uuid(),
|
|
102
|
+
type: e.type,
|
|
103
|
+
data: e.data || {},
|
|
104
|
+
metadata: Object.assign({ at: now().toISOString() }, metadata),
|
|
105
|
+
}));
|
|
106
|
+
// Positions must stay gapless even when several events are appended at once.
|
|
107
|
+
stored.forEach((e, i) => (e.position = events.length + 1 + i));
|
|
108
|
+
events = events.concat(stored);
|
|
109
|
+
notify();
|
|
110
|
+
scheduleAutomations();
|
|
111
|
+
return stored;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Execute a command. Never throws for a business rule — returns { ok:false, error }. */
|
|
115
|
+
function execute(type, data, metadata) {
|
|
116
|
+
if (cursor != null) return { ok: false, error: `Viewing the log as of event #${cursor} — return to now to record anything` };
|
|
117
|
+
const command = Domain.commands[type];
|
|
118
|
+
if (!command) return { ok: false, error: `Command "${type}" is not part of this model` };
|
|
119
|
+
try {
|
|
120
|
+
const produced = command.decide(events, data || {}, ctx()) || [];
|
|
121
|
+
const list = Array.isArray(produced) ? produced : [produced];
|
|
122
|
+
const stored = append(list, Object.assign({ command: type }, metadata));
|
|
123
|
+
return { ok: true, events: stored };
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (err instanceof DomainError || (err && err.name === 'DomainError')) {
|
|
126
|
+
return { ok: false, error: err.message };
|
|
127
|
+
}
|
|
128
|
+
console.error(err);
|
|
129
|
+
return { ok: false, error: 'Prototype error: ' + (err && err.message) };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function query(name, params) {
|
|
134
|
+
const rm = Domain.readModels[name];
|
|
135
|
+
if (!rm) throw new Error(`Read model "${name}" is not part of this model`);
|
|
136
|
+
return project(rm, visible(), params, ctx());
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** An event that originates outside this context (another context, an external system). */
|
|
140
|
+
function simulate(type, data) {
|
|
141
|
+
return append([{ type, data }], { simulated: true });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- automations -----------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
function scheduleAutomations() {
|
|
147
|
+
if (!autoRun || timer || cursor != null) return;
|
|
148
|
+
timer = setTimeout(() => {
|
|
149
|
+
timer = null;
|
|
150
|
+
runAutomations('auto');
|
|
151
|
+
}, options.automationDelay);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Work every automation's todo list once. `trigger` = 'auto' (after events), 'manual' (button)
|
|
156
|
+
* or 'clock' (time advanced). An automation with `trigger: 'schedule'` only runs on 'manual'/'clock'.
|
|
157
|
+
* A todo whose command is rejected is logged and not retried until the log changes.
|
|
158
|
+
* `only` restricts the run to one automation (its Run button).
|
|
159
|
+
*/
|
|
160
|
+
function runAutomations(trigger, only) {
|
|
161
|
+
const ran = [];
|
|
162
|
+
if (cursor != null) return ran;
|
|
163
|
+
const attempted = new Set();
|
|
164
|
+
for (let round = 0; round < 50; round++) {
|
|
165
|
+
let progressed = false;
|
|
166
|
+
for (const [name, a] of Object.entries(Domain.automations || {})) {
|
|
167
|
+
if (only && name !== only) continue;
|
|
168
|
+
if (a.trigger === 'schedule' && trigger === 'auto') continue;
|
|
169
|
+
const todos = todosOf(name);
|
|
170
|
+
for (const item of todos) {
|
|
171
|
+
const key = name + ':' + JSON.stringify(item) + ':' + events.length;
|
|
172
|
+
if (attempted.has(key)) continue;
|
|
173
|
+
attempted.add(key);
|
|
174
|
+
const plan = a.process(item, ctx());
|
|
175
|
+
if (!plan) continue;
|
|
176
|
+
const itemKey = name + ':' + JSON.stringify(item);
|
|
177
|
+
if (handled.has(itemKey)) {
|
|
178
|
+
if (handled.get(itemKey) === 'done') {
|
|
179
|
+
handled.set(itemKey, 'stuck');
|
|
180
|
+
const entry = {
|
|
181
|
+
at: now().toISOString(),
|
|
182
|
+
automation: name,
|
|
183
|
+
command: plan.command && plan.command.type,
|
|
184
|
+
ok: false,
|
|
185
|
+
error: `still on "${a.readModel}" after its command succeeded — the todo list never removes it, so it is not issued again`,
|
|
186
|
+
};
|
|
187
|
+
automationLog.unshift(entry);
|
|
188
|
+
ran.push(entry);
|
|
189
|
+
}
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (plan.effect) {
|
|
193
|
+
outbox.unshift({ at: now().toISOString(), automation: name, effect: plan.effect });
|
|
194
|
+
}
|
|
195
|
+
if (!plan.command) continue;
|
|
196
|
+
const result = execute(plan.command.type, plan.command.data, { automation: name });
|
|
197
|
+
const entry = {
|
|
198
|
+
at: now().toISOString(),
|
|
199
|
+
automation: name,
|
|
200
|
+
command: plan.command.type,
|
|
201
|
+
ok: result.ok,
|
|
202
|
+
error: result.error,
|
|
203
|
+
};
|
|
204
|
+
automationLog.unshift(entry);
|
|
205
|
+
ran.push(entry);
|
|
206
|
+
if (result.ok) {
|
|
207
|
+
handled.set(itemKey, 'done');
|
|
208
|
+
progressed = true;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (!progressed) break;
|
|
213
|
+
}
|
|
214
|
+
if (ran.length || trigger !== 'auto') notify();
|
|
215
|
+
return ran;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The items an automation would act on right now — the open entries of its todo list. */
|
|
219
|
+
function todosOf(name) {
|
|
220
|
+
const a = Domain.automations[name];
|
|
221
|
+
return (a && a.todo(query(a.readModel), ctx())) || [];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function advanceClock(ms) {
|
|
225
|
+
clockOffset += ms;
|
|
226
|
+
notify();
|
|
227
|
+
// Items that fall due show up in the automations tab; they only run by themselves when
|
|
228
|
+
// automatic runs are switched on.
|
|
229
|
+
return autoRun ? runAutomations('clock') : [];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function setAutoRun(on) {
|
|
233
|
+
autoRun = !!on;
|
|
234
|
+
notify();
|
|
235
|
+
if (autoRun) scheduleAutomations();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function viewAsOf(position) {
|
|
239
|
+
cursor = position == null || position >= events.length ? null : Math.max(0, position);
|
|
240
|
+
notify();
|
|
241
|
+
if (cursor == null) scheduleAutomations();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function reset() {
|
|
245
|
+
cursor = null;
|
|
246
|
+
events = [];
|
|
247
|
+
clockOffset = 0;
|
|
248
|
+
automationLog.length = 0;
|
|
249
|
+
outbox.length = 0;
|
|
250
|
+
handled.clear();
|
|
251
|
+
notify();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function importLog(log) {
|
|
255
|
+
cursor = null;
|
|
256
|
+
events = (log || []).map((e, i) => Object.assign({}, e, { position: i + 1 }));
|
|
257
|
+
handled.clear();
|
|
258
|
+
notify();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
Domain,
|
|
263
|
+
persistent: !!storage,
|
|
264
|
+
execute,
|
|
265
|
+
query,
|
|
266
|
+
simulate,
|
|
267
|
+
runAutomations,
|
|
268
|
+
todosOf,
|
|
269
|
+
advanceClock,
|
|
270
|
+
setAutoRun,
|
|
271
|
+
reset,
|
|
272
|
+
importLog,
|
|
273
|
+
viewAsOf,
|
|
274
|
+
now,
|
|
275
|
+
uuid,
|
|
276
|
+
get events() {
|
|
277
|
+
return events;
|
|
278
|
+
},
|
|
279
|
+
get cursor() {
|
|
280
|
+
return cursor;
|
|
281
|
+
},
|
|
282
|
+
get autoRun() {
|
|
283
|
+
return autoRun;
|
|
284
|
+
},
|
|
285
|
+
get automationLog() {
|
|
286
|
+
return automationLog;
|
|
287
|
+
},
|
|
288
|
+
get outbox() {
|
|
289
|
+
return outbox;
|
|
290
|
+
},
|
|
291
|
+
subscribe(fn) {
|
|
292
|
+
listeners.add(fn);
|
|
293
|
+
return () => listeners.delete(fn);
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------------------------
|
|
299
|
+
// Spec runner — the board's GWT scenarios, executed against the domain. Pure — the page runs it on load.
|
|
300
|
+
|
|
301
|
+
const SPEC_NOW = new Date('2026-01-01T09:00:00.000Z');
|
|
302
|
+
|
|
303
|
+
function toEvents(given) {
|
|
304
|
+
return (given || []).map((e, i) => ({
|
|
305
|
+
position: i + 1,
|
|
306
|
+
id: 'given-' + (i + 1),
|
|
307
|
+
type: e.type,
|
|
308
|
+
data: e.data || {},
|
|
309
|
+
metadata: { at: (e.at || SPEC_NOW.toISOString()) },
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Expected values only constrain what they name; ids/timestamps the domain generates are left out of specs. */
|
|
314
|
+
function matches(expected, actual) {
|
|
315
|
+
if (expected === undefined) return true;
|
|
316
|
+
if (expected === null) return actual === null || actual === undefined;
|
|
317
|
+
if (Array.isArray(expected)) {
|
|
318
|
+
return (
|
|
319
|
+
Array.isArray(actual) &&
|
|
320
|
+
expected.length === actual.length &&
|
|
321
|
+
expected.every((e, i) => matches(e, actual[i]))
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
if (typeof expected === 'object') {
|
|
325
|
+
return (
|
|
326
|
+
actual != null &&
|
|
327
|
+
typeof actual === 'object' &&
|
|
328
|
+
Object.keys(expected).every((k) => matches(expected[k], actual[k]))
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
return String(expected) === String(actual);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function runSpec(Domain, spec) {
|
|
335
|
+
const ctx = { now: new Date((spec.now && Date.parse(spec.now)) || SPEC_NOW), uuid };
|
|
336
|
+
const history = toEvents(spec.given);
|
|
337
|
+
try {
|
|
338
|
+
if (spec.kind === 'command') {
|
|
339
|
+
const command = Domain.commands[spec.when.type];
|
|
340
|
+
if (!command) return { pass: false, detail: `unknown command ${spec.when.type}` };
|
|
341
|
+
let produced;
|
|
342
|
+
try {
|
|
343
|
+
produced = command.decide(history, spec.when.data || {}, ctx) || [];
|
|
344
|
+
produced = Array.isArray(produced) ? produced : [produced];
|
|
345
|
+
} catch (err) {
|
|
346
|
+
if (err && err.name === 'DomainError') {
|
|
347
|
+
if (spec.error !== undefined) return { pass: true, detail: `rejected: ${err.message}` };
|
|
348
|
+
return { pass: false, detail: `unexpectedly rejected: ${err.message}` };
|
|
349
|
+
}
|
|
350
|
+
throw err;
|
|
351
|
+
}
|
|
352
|
+
if (spec.error !== undefined) {
|
|
353
|
+
return { pass: false, detail: `expected rejection "${spec.error}", got ${produced.map((e) => e.type).join(', ') || 'no events'}` };
|
|
354
|
+
}
|
|
355
|
+
const ok = matches(
|
|
356
|
+
spec.then.map((e) => ({ type: e.type, data: e.data || {} })),
|
|
357
|
+
produced.map((e) => ({ type: e.type, data: e.data || {} })),
|
|
358
|
+
);
|
|
359
|
+
return {
|
|
360
|
+
pass: ok,
|
|
361
|
+
detail: ok ? 'ok' : `expected ${JSON.stringify(spec.then)}, got ${JSON.stringify(produced)}`,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
if (spec.kind === 'readmodel') {
|
|
365
|
+
const rm = Domain.readModels[spec.readModel];
|
|
366
|
+
if (!rm) return { pass: false, detail: `unknown read model ${spec.readModel}` };
|
|
367
|
+
const actual = project(rm, history, spec.params, ctx);
|
|
368
|
+
const ok = matches(spec.expect, actual);
|
|
369
|
+
return { pass: ok, detail: ok ? 'ok' : `expected ${JSON.stringify(spec.expect)}, got ${JSON.stringify(actual)}` };
|
|
370
|
+
}
|
|
371
|
+
if (spec.kind === 'automation') {
|
|
372
|
+
const a = Domain.automations[spec.automation];
|
|
373
|
+
if (!a) return { pass: false, detail: `unknown automation ${spec.automation}` };
|
|
374
|
+
const todos = a.todo(project(Domain.readModels[a.readModel], history, {}, ctx), ctx) || [];
|
|
375
|
+
const commands = todos.map((t) => a.process(t, ctx)).filter((p) => p && p.command).map((p) => p.command);
|
|
376
|
+
const ok = matches(spec.expectCommands, commands);
|
|
377
|
+
return { pass: ok, detail: ok ? 'ok' : `expected ${JSON.stringify(spec.expectCommands)}, got ${JSON.stringify(commands)}` };
|
|
378
|
+
}
|
|
379
|
+
return { pass: false, detail: `unknown spec kind ${spec.kind}` };
|
|
380
|
+
} catch (err) {
|
|
381
|
+
return { pass: false, detail: 'crashed: ' + (err && err.stack ? err.stack.split('\n')[0] : err) };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function runSpecs(Domain) {
|
|
386
|
+
return (Domain.specs || []).map((spec) => Object.assign({ spec }, runSpec(Domain, spec)));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ---------------------------------------------------------------------------------------------
|
|
390
|
+
// Slice registry — one defineSlice call per slice block; the domain is assembled from them.
|
|
391
|
+
|
|
392
|
+
const domain = {
|
|
393
|
+
context: '',
|
|
394
|
+
slices: [],
|
|
395
|
+
commands: {},
|
|
396
|
+
readModels: {},
|
|
397
|
+
automations: {},
|
|
398
|
+
externalEvents: [],
|
|
399
|
+
specs: [],
|
|
400
|
+
screens: [], // { title, actor, order, slice, part } — one per slice part, grouped into tabs by title
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Register one slice: { id, title, order, commands?, readModels?, automations?, externalEvents?,
|
|
405
|
+
* specs?, screens?: { '<Screen Title>': { actor?, part: Component } } }. Throws on an element
|
|
406
|
+
* already defined by another slice — every command, read model and automation belongs to one slice.
|
|
407
|
+
*/
|
|
408
|
+
function defineSlice(slice) {
|
|
409
|
+
const owned = (kind, entries) => {
|
|
410
|
+
for (const [name, def] of Object.entries(entries || {})) {
|
|
411
|
+
const existing = domain[kind][name];
|
|
412
|
+
if (existing && existing.slice !== slice.title) {
|
|
413
|
+
throw new Error(`${name} is defined by slice "${existing.slice}" and again by "${slice.title}"`);
|
|
414
|
+
}
|
|
415
|
+
domain[kind][name] = Object.assign({}, def, { slice: slice.title });
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
owned('commands', slice.commands);
|
|
419
|
+
owned('readModels', slice.readModels);
|
|
420
|
+
owned('automations', slice.automations);
|
|
421
|
+
for (const x of slice.externalEvents || []) {
|
|
422
|
+
if (!domain.externalEvents.some((e) => e.type === x.type)) domain.externalEvents.push(x);
|
|
423
|
+
}
|
|
424
|
+
for (const spec of slice.specs || []) domain.specs.push(Object.assign({ slice: slice.title }, spec));
|
|
425
|
+
for (const [title, def] of Object.entries(slice.screens || {})) {
|
|
426
|
+
domain.screens.push({ title, actor: def.actor, order: slice.order ?? 0, slice: slice.title, part: def.part });
|
|
427
|
+
}
|
|
428
|
+
domain.slices.push({ id: slice.id, title: slice.title, order: slice.order ?? 0 });
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const api = { DomainError, reject, createRuntime, project, runSpecs, matches, uuid, defineSlice, domain };
|
|
432
|
+
|
|
433
|
+
return api;
|
|
434
|
+
})();
|