@eventmodelers/cli 1.0.44 → 1.0.46
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 +4 -1
- package/cli.js +53 -1
- package/package.json +2 -2
- package/shared/build-kit/lib/checks/README.md +59 -0
- package/shared/build-kit/lib/ralph.js +110 -28
- package/shared/build-kit/lib/util/find-slice.cjs +59 -0
- package/shared/build-kit/ralph-claude.js +6 -2
- package/stacks/blank/templates/build-kit/lib/backend-prompt.md +106 -102
- package/stacks/blank/templates/build-kit/lib/prompt.md +102 -106
- package/stacks/kurrent/templates/.claude/skills/build-automation/SKILL.md +422 -0
- package/stacks/kurrent/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
- package/stacks/kurrent/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +65 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-change/SKILL.md +418 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +41 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-change/references/integration-test-patterns.md +64 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-view/SKILL.md +391 -0
- package/stacks/kurrent/templates/build-kit/CLAUDE.md +122 -0
- package/stacks/kurrent/templates/build-kit/lib/AGENT.md +73 -0
- package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +169 -0
- package/stacks/kurrent/templates/build-kit/lib/prompt.md +128 -0
- package/stacks/kurrent/templates/root/README.md +46 -0
- package/stacks/kurrent/templates/root/docker-compose.yml +45 -0
- package/stacks/kurrent/templates/root/mvnw +259 -0
- package/stacks/kurrent/templates/root/mvnw.cmd +149 -0
- package/stacks/kurrent/templates/root/pom.xml +152 -0
- package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
- package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/common/EventStore.java +91 -0
- package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/config/KurrentConfiguration.java +42 -0
- package/stacks/kurrent/templates/root/src/main/resources/application.properties +14 -0
- package/stacks/kurrent/templates/root/src/main/resources/static/index.html +11 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-core-rules/SKILL.md +4 -2
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-outputs/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-slicing-event-models/SKILL.md +2 -0
- package/stacks/modeling-kit/templates/.claude/skills/place-element/SKILL.md +1 -1
- package/stacks/node/templates/build-kit/CLAUDE.md +22 -0
- package/stacks/node/templates/build-kit/lib/check-commit-scope.cjs +123 -0
- package/stacks/node/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
- package/stacks/node/templates/build-kit/lib/checks/10-slice-scope.cjs +29 -0
- package/stacks/node/templates/build-kit/lib/checks/20-append-only-migrations.cjs +20 -0
- package/stacks/node/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
- package/stacks/node/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
- package/stacks/node/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
- package/stacks/node/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
- package/stacks/node/templates/root/.githooks/pre-commit +11 -0
- package/stacks/node/templates/root/README.md +67 -0
- package/stacks/node/templates/root/package.json +2 -1
- package/stacks/node/templates/root/setup-env.sh +7 -1
- package/stacks/opencqrs/templates/.claude/skills/build-automation/SKILL.md +434 -0
- package/stacks/opencqrs/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
- package/stacks/opencqrs/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +62 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/SKILL.md +413 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +46 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/rest-api-patterns.md +145 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/test-fixture-patterns.md +85 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-view/SKILL.md +354 -0
- package/stacks/opencqrs/templates/build-kit/CLAUDE.md +101 -0
- package/stacks/opencqrs/templates/build-kit/lib/AGENT.md +58 -0
- package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +169 -0
- package/stacks/opencqrs/templates/build-kit/lib/prompt.md +128 -0
- package/stacks/opencqrs/templates/root/README.md +42 -0
- package/stacks/opencqrs/templates/root/docker-compose.yml +37 -0
- package/stacks/opencqrs/templates/root/mvnw +259 -0
- package/stacks/opencqrs/templates/root/mvnw.cmd +149 -0
- package/stacks/opencqrs/templates/root/pom.xml +139 -0
- package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
- package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/config/CqrsConfiguration.java +74 -0
- package/stacks/opencqrs/templates/root/src/main/resources/application.properties +23 -0
- package/stacks/opencqrs/templates/root/src/main/resources/schema.sql +19 -0
- package/stacks/opencqrs/templates/root/src/main/resources/static/index.html +11 -0
- package/stacks/supabase/templates/build-kit/CLAUDE.md +25 -0
- package/stacks/supabase/templates/build-kit/lib/check-commit-scope.cjs +126 -0
- package/stacks/supabase/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
- package/stacks/supabase/templates/build-kit/lib/checks/10-slice-scope.cjs +34 -0
- package/stacks/supabase/templates/build-kit/lib/checks/20-append-only-migrations.cjs +21 -0
- package/stacks/supabase/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
- package/stacks/supabase/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
- package/stacks/supabase/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
- package/stacks/supabase/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
- package/stacks/supabase/templates/root/.githooks/pre-commit +11 -0
- package/stacks/supabase/templates/root/package.json +2 -1
- package/stacks/supabase/templates/root/setup-env.sh +7 -1
- package/stacks/umadb/templates/.claude/skills/build-automation/SKILL.md +313 -0
- package/stacks/umadb/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +42 -0
- package/stacks/umadb/templates/.claude/skills/build-state-change/SKILL.md +376 -0
- package/stacks/umadb/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +42 -0
- package/stacks/umadb/templates/.claude/skills/build-state-change/references/umadb-query-patterns.md +78 -0
- package/stacks/umadb/templates/.claude/skills/build-state-view/SKILL.md +338 -0
- package/stacks/umadb/templates/build-kit/CLAUDE.md +94 -0
- package/stacks/umadb/templates/build-kit/lib/AGENT.md +47 -0
- package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +169 -0
- package/stacks/umadb/templates/build-kit/lib/prompt.md +128 -0
- package/stacks/umadb/templates/root/.mvn/wrapper/maven-wrapper.properties +19 -0
- package/stacks/umadb/templates/root/README.md +48 -0
- package/stacks/umadb/templates/root/docker-compose.yml +29 -0
- package/stacks/umadb/templates/root/mvnw +259 -0
- package/stacks/umadb/templates/root/mvnw.cmd +149 -0
- package/stacks/umadb/templates/root/pom.xml +151 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/QuickstartApplication.java +12 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/config/UmaDbConfig.java +39 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/DecisionModelLoader.java +76 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventCodec.java +40 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventDispatcher.java +100 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/OptimisticConcurrencyException.java +14 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/SliceEventListener.java +23 -0
- package/stacks/umadb/templates/root/src/main/resources/application.properties +17 -0
- package/stacks/umadb/templates/root/src/test/java/io/umadb/quickstart/testsupport/InMemoryUmaDbClient.java +135 -0
- package/stacks/umadb/templates/root/src/test/resources/application.properties +8 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// supabase/migrations/V{n}__*.sql may only be ADDED, never modified or deleted —
|
|
4
|
+
// migrations are append-only; a fix belongs in a new migration, not an edit to
|
|
5
|
+
// an old one.
|
|
6
|
+
|
|
7
|
+
const MIGRATION_PATTERN = /^supabase\/migrations\/V\d+__.*\.sql$/;
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
name: 'append-only-migrations',
|
|
11
|
+
run(ctx) {
|
|
12
|
+
const violations = [];
|
|
13
|
+
for (const { status, path: p } of ctx.changes) {
|
|
14
|
+
if (!MIGRATION_PATTERN.test(p)) continue;
|
|
15
|
+
if (status !== 'A') {
|
|
16
|
+
violations.push({ path: p, reason: 'existing migrations are append-only; add a new V{n} file instead of editing this one' });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return violations;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// A slice commit that adds/changes a command handler, projection, or processor
|
|
4
|
+
// must also include a *.test.ts for that slice — catches the agent skipping the
|
|
5
|
+
// matching build skill's test step (build-state-change Step 4 / build-state-view
|
|
6
|
+
// Step 5 / build-automation's DeciderSpecification tests).
|
|
7
|
+
|
|
8
|
+
const { execSync } = require('child_process');
|
|
9
|
+
|
|
10
|
+
const IMPLEMENTATION_FILE = /^src\/slices\/([^/]+)\/([^/]+)\/(?:[A-Za-z0-9]+Command|[A-Za-z0-9]+Projection|processor(?:-[A-Za-z0-9]+)?)\.ts$/;
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
name: 'test-file-present',
|
|
14
|
+
run(ctx) {
|
|
15
|
+
const sliceDirs = new Set();
|
|
16
|
+
for (const { path: p } of ctx.changes) {
|
|
17
|
+
const m = IMPLEMENTATION_FILE.exec(p);
|
|
18
|
+
if (m) sliceDirs.add(`src/slices/${m[1]}/${m[2]}`);
|
|
19
|
+
}
|
|
20
|
+
if (sliceDirs.size === 0) return [];
|
|
21
|
+
|
|
22
|
+
let tracked = [];
|
|
23
|
+
try {
|
|
24
|
+
tracked = execSync('git ls-files -- src/slices', { cwd: ctx.repoRoot, encoding: 'utf8' })
|
|
25
|
+
.split('\n')
|
|
26
|
+
.filter(Boolean);
|
|
27
|
+
} catch {
|
|
28
|
+
// best-effort — fall through with whatever's staged
|
|
29
|
+
}
|
|
30
|
+
const known = new Set([...tracked, ...ctx.changes.map((c) => c.path)]);
|
|
31
|
+
|
|
32
|
+
const violations = [];
|
|
33
|
+
for (const dir of sliceDirs) {
|
|
34
|
+
const hasTest = [...known].some((f) => f.startsWith(`${dir}/`) && f.endsWith('.test.ts'));
|
|
35
|
+
if (!hasTest) {
|
|
36
|
+
violations.push({
|
|
37
|
+
path: dir,
|
|
38
|
+
reason: 'no *.test.ts found for this slice — command handlers/projections/processors need test coverage',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return violations;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Heuristic: flags a field name used in a slice's Command/ReadModel type literal
|
|
4
|
+
// that doesn't appear anywhere in that slice's own slice.json. Not a real
|
|
5
|
+
// TS/schema-aware check (no parser dependency) — it regex-extracts the fields
|
|
6
|
+
// declared inside `Command<'Name', { ... }, ...>` and `type XReadModel = { ... }`
|
|
7
|
+
// literals, so unusual formatting (nested object/array field types, nonstandard
|
|
8
|
+
// generics) can slip past undetected. It only ever adds violations for fields it
|
|
9
|
+
// is confident about; when slice.json can't be found/parsed for a slice, that
|
|
10
|
+
// slice's files are skipped entirely rather than guessed at.
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { findSliceJson, normalize } = require('../util/find-slice.cjs');
|
|
15
|
+
|
|
16
|
+
const COMMON_ALLOWED = new Set(
|
|
17
|
+
['id', 'userId', 'correlationId', 'causationId', 'streamName', 'type', 'data', 'metadata', 'createdAt', 'updatedAt', 'timestamp'].map(normalize),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const TYPE_LITERALS = [
|
|
21
|
+
/(?:Command|Event)<\s*'[^']+'\s*,\s*{([^}]*)}/g,
|
|
22
|
+
/type\s+[A-Za-z0-9_]+ReadModel\s*=\s*{([^}]*)}/g,
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const FIELD_LINE = /^\s*([A-Za-z_][A-Za-z0-9_]*)\??\s*:/gm;
|
|
26
|
+
|
|
27
|
+
function collectDeclaredFields(node, out) {
|
|
28
|
+
if (Array.isArray(node)) {
|
|
29
|
+
for (const item of node) collectDeclaredFields(item, out);
|
|
30
|
+
} else if (node && typeof node === 'object') {
|
|
31
|
+
if (typeof node.name === 'string' && ('type' in node || 'optional' in node)) {
|
|
32
|
+
out.add(normalize(node.name));
|
|
33
|
+
}
|
|
34
|
+
for (const key of Object.keys(node)) collectDeclaredFields(node[key], out);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = {
|
|
39
|
+
name: 'no-invented-fields',
|
|
40
|
+
run(ctx) {
|
|
41
|
+
const bySlice = new Map(); // "context/SliceName" -> { context, sliceName, files: [] }
|
|
42
|
+
|
|
43
|
+
for (const { path: p } of ctx.changes) {
|
|
44
|
+
if (!p.endsWith('.ts') || p.endsWith('.test.ts')) continue;
|
|
45
|
+
const m = /^src\/slices\/([^/]+)\/([^/]+)\//.exec(p);
|
|
46
|
+
if (!m) continue;
|
|
47
|
+
const key = `${m[1]}/${m[2]}`;
|
|
48
|
+
if (!bySlice.has(key)) bySlice.set(key, { context: m[1], sliceName: m[2], files: [] });
|
|
49
|
+
bySlice.get(key).files.push(p);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const violations = [];
|
|
53
|
+
|
|
54
|
+
for (const { context, sliceName, files } of bySlice.values()) {
|
|
55
|
+
const slice = findSliceJson(ctx.repoRoot, context, sliceName);
|
|
56
|
+
if (!slice) continue; // can't verify — don't block
|
|
57
|
+
|
|
58
|
+
const declared = new Set();
|
|
59
|
+
collectDeclaredFields(slice, declared);
|
|
60
|
+
if (declared.size === 0) continue; // slice.json shape not recognized — don't block
|
|
61
|
+
|
|
62
|
+
for (const file of files) {
|
|
63
|
+
let content;
|
|
64
|
+
try {
|
|
65
|
+
content = fs.readFileSync(path.join(ctx.repoRoot, file), 'utf8');
|
|
66
|
+
} catch {
|
|
67
|
+
continue; // deleted/unreadable — nothing to check
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const pattern of TYPE_LITERALS) {
|
|
71
|
+
pattern.lastIndex = 0;
|
|
72
|
+
let typeMatch;
|
|
73
|
+
while ((typeMatch = pattern.exec(content))) {
|
|
74
|
+
FIELD_LINE.lastIndex = 0;
|
|
75
|
+
let fieldMatch;
|
|
76
|
+
while ((fieldMatch = FIELD_LINE.exec(typeMatch[1]))) {
|
|
77
|
+
const raw = fieldMatch[1];
|
|
78
|
+
const norm = normalize(raw);
|
|
79
|
+
if (COMMON_ALLOWED.has(norm) || declared.has(norm)) continue;
|
|
80
|
+
violations.push({
|
|
81
|
+
path: file,
|
|
82
|
+
reason: `field "${raw}" is not declared anywhere in slice.json for this slice — check for an invented field`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return violations;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Heuristic: a slice's *.test.ts must have at least as many `it(...)` blocks as
|
|
4
|
+
// slice.json has `specifications[]` entries. Doesn't verify each spec is
|
|
5
|
+
// actually tested (that would need matching scenario content, not just count),
|
|
6
|
+
// just that nobody's silently short a test case. Skipped (not blocked) when
|
|
7
|
+
// slice.json can't be found or has no specifications[] array.
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { findSliceJson } = require('../util/find-slice.cjs');
|
|
12
|
+
|
|
13
|
+
const TEST_FILE = /^src\/slices\/([^/]+)\/([^/]+)\/[^/]+\.test\.ts$/;
|
|
14
|
+
const IT_BLOCK = /\bit(?:\.(?:only|skip))?\s*\(/g;
|
|
15
|
+
|
|
16
|
+
module.exports = {
|
|
17
|
+
name: 'spec-coverage',
|
|
18
|
+
run(ctx) {
|
|
19
|
+
const violations = [];
|
|
20
|
+
|
|
21
|
+
for (const { path: p } of ctx.changes) {
|
|
22
|
+
TEST_FILE.lastIndex = 0;
|
|
23
|
+
const m = TEST_FILE.exec(p);
|
|
24
|
+
if (!m) continue;
|
|
25
|
+
const [, context, sliceName] = m;
|
|
26
|
+
|
|
27
|
+
const slice = findSliceJson(ctx.repoRoot, context, sliceName);
|
|
28
|
+
if (!slice || !Array.isArray(slice.specifications) || slice.specifications.length === 0) continue;
|
|
29
|
+
|
|
30
|
+
let content;
|
|
31
|
+
try {
|
|
32
|
+
content = fs.readFileSync(path.join(ctx.repoRoot, p), 'utf8');
|
|
33
|
+
} catch {
|
|
34
|
+
continue; // deleted — nothing to check
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const specCount = slice.specifications.length;
|
|
38
|
+
const itCount = (content.match(IT_BLOCK) || []).length;
|
|
39
|
+
|
|
40
|
+
if (itCount < specCount) {
|
|
41
|
+
violations.push({
|
|
42
|
+
path: p,
|
|
43
|
+
reason: `slice.json declares ${specCount} specification(s) but this test file only has ${itCount} it(...) block(s)`,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return violations;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The project must still typecheck after the slice change. Uses the project's
|
|
4
|
+
// own `typescript` devDependency via `npx tsc --noEmit` — no new dependency,
|
|
5
|
+
// but it does mean this check needs node_modules already installed (same
|
|
6
|
+
// prerequisite `npm run build` already has).
|
|
7
|
+
|
|
8
|
+
const { execSync } = require('child_process');
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
name: 'tsc-build',
|
|
12
|
+
skipIfAlreadyFailing: true, // slow — don't bother once the commit is rejected already
|
|
13
|
+
run(ctx) {
|
|
14
|
+
try {
|
|
15
|
+
execSync('npx tsc --noEmit', { cwd: ctx.repoRoot, stdio: 'pipe' });
|
|
16
|
+
return [];
|
|
17
|
+
} catch (err) {
|
|
18
|
+
const output = String(err.stdout || err.message || '').trim().split('\n').slice(0, 20).join('\n');
|
|
19
|
+
return [{ path: '(tsc --noEmit)', reason: `TypeScript build failed:\n${output}` }];
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Installed via setup-env.sh (`git config core.hooksPath .githooks`), so this file
|
|
3
|
+
# is versioned and shared by every clone instead of living only in .git/hooks/.
|
|
4
|
+
set -e
|
|
5
|
+
|
|
6
|
+
repo_root=$(git rev-parse --show-toplevel)
|
|
7
|
+
script="$repo_root/.build-kit/lib/check-commit-scope.cjs"
|
|
8
|
+
|
|
9
|
+
if [ -f "$script" ]; then
|
|
10
|
+
node "$script"
|
|
11
|
+
fi
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
"dev": "node --env-file=.env --require ts-node/register server.ts",
|
|
8
8
|
"build": "tsc",
|
|
9
9
|
"start": "NODE_ENV=production node --env-file=.env --require ts-node/register server.ts",
|
|
10
|
-
"test": "tsx --test 'src/**/*.test.ts'"
|
|
10
|
+
"test": "tsx --test 'src/**/*.test.ts'",
|
|
11
|
+
"check:scope": "node .build-kit/lib/check-commit-scope.cjs"
|
|
11
12
|
},
|
|
12
13
|
"dependencies": {
|
|
13
14
|
"@event-driven-io/emmett": "^0.42.1-alpha.1",
|
|
@@ -50,4 +50,10 @@ FLYWAY_PASSWORD=${DB_PASSWORD}
|
|
|
50
50
|
EOF
|
|
51
51
|
|
|
52
52
|
echo ""
|
|
53
|
-
echo ".env created successfully."
|
|
53
|
+
echo ".env created successfully."
|
|
54
|
+
|
|
55
|
+
if [ -f .githooks/pre-commit ] && git rev-parse --git-dir >/dev/null 2>&1; then
|
|
56
|
+
chmod +x .githooks/pre-commit 2>/dev/null || true
|
|
57
|
+
git config core.hooksPath .githooks
|
|
58
|
+
echo "Configured git to use .githooks/ (slice commit-scope guard)."
|
|
59
|
+
fi
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: build-automation
|
|
3
|
+
authors:
|
|
4
|
+
- Martin Dilger
|
|
5
|
+
description: >
|
|
6
|
+
Implement automation slices (Event → Command) that react to events off the shared
|
|
7
|
+
EventDispatcher subscription and dispatch a command by calling its command handler directly, in
|
|
8
|
+
this project's one established pattern. Automations can be stateless (direct event-to-command
|
|
9
|
+
mapping) or carry a private read model (to look up data needed for command construction). Use
|
|
10
|
+
when implementing a new automation / event-to-command reactor from a slice.json event model in
|
|
11
|
+
this project. There is exactly one supported style — do not offer alternatives.
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# UmaDB — Automation Slice
|
|
15
|
+
|
|
16
|
+
An automation reacts to an event by dispatching a command. In Event Modeling: the **orange**
|
|
17
|
+
stripe. There are two kinds:
|
|
18
|
+
|
|
19
|
+
- **Stateless**: direct event-to-command mapping — no stored state needed. Grounded in the
|
|
20
|
+
`AutoSubscribeToDefaultCourseProcessor` slice (test in
|
|
21
|
+
`AutoSubscribeToDefaultCourseProcessorTest`) — verified, compiled and passing under `mvn test`,
|
|
22
|
+
including end-to-end against a real `umadb/umadb:0.7.5` server (`UmaDbContainerIntegrationTest`):
|
|
23
|
+
a real `CustomerRegistered` event, delivered by the real `EventDispatcher` subscription, really
|
|
24
|
+
causes a real `SubscribeToCourse` command to be handled and its event really appended.
|
|
25
|
+
- **With private read model**: needs data NOT in the trigger event itself (e.g. iterating over all
|
|
26
|
+
entities matching a category). Same shape as `build-state-change`'s Decision-with-fold pattern,
|
|
27
|
+
applied to a private in-memory model instead of a Query/replay — documented below by direct
|
|
28
|
+
analogy, not separately proven against a compiled example in this session; verify it compiles and
|
|
29
|
+
its tests pass before considering it done, the same as any other slice.
|
|
30
|
+
|
|
31
|
+
There is no separate command-bus abstraction in this project (unlike Axon's
|
|
32
|
+
`CommandDispatcher`/`CommandGateway` split) — an automation dispatches by calling the target
|
|
33
|
+
command handler's `handle(...)` method directly, since it's just another Spring bean and the call
|
|
34
|
+
is in-process either way.
|
|
35
|
+
|
|
36
|
+
## Step 0: Discover Target Project Conventions
|
|
37
|
+
|
|
38
|
+
> **Comments & description**: each element carries a `comments: string[]` array (board comments)
|
|
39
|
+
> and a `description` field — use them as implementation hints, and resolve consumed comments via
|
|
40
|
+
> `POST <BASE_URL>/api/org/<ORG_ID>/boards/<BOARD_ID>/nodes/<nodeId>/comments/<commentId>/resolve`.
|
|
41
|
+
|
|
42
|
+
Read the target project's `.build-kit/CLAUDE.md` and explore existing slices.
|
|
43
|
+
|
|
44
|
+
**Determine `{basePackage}`** — every path below is rooted at
|
|
45
|
+
`{basePackage}.slices.{context}.automation.{slicename}`. Resolve `{basePackage}` as documented in
|
|
46
|
+
`.build-kit/CLAUDE.md`.
|
|
47
|
+
|
|
48
|
+
## Step 1: Understand the Input
|
|
49
|
+
|
|
50
|
+
Extract these elements from slice.json (or whatever Event Modeling artifact is given):
|
|
51
|
+
|
|
52
|
+
| Element | What to extract |
|
|
53
|
+
|-------------------------|-------------------------------------------------------------------------|
|
|
54
|
+
| **Trigger event** | Which event triggers the automation, and which condition filters it |
|
|
55
|
+
| **Target command** | Which command to dispatch, with what properties |
|
|
56
|
+
| **Mapping logic** | How event properties map to command properties |
|
|
57
|
+
| **Read model needed?** | Does the automation need data NOT in the trigger event itself? |
|
|
58
|
+
|
|
59
|
+
If the slice details include `## Scenarios (GWTs)`, use them to derive test cases. GWT format for
|
|
60
|
+
automations: `Given (events) → Then (command | NOTHING)`. Events in Given include read-model-
|
|
61
|
+
building events first, trigger event last.
|
|
62
|
+
|
|
63
|
+
`slice.json` may also carry an optional `storylines[]` array — see the "Storyline-Derived Tests"
|
|
64
|
+
section under Step 4 for how a trigger-event beat in one of these can add a supplementary test.
|
|
65
|
+
|
|
66
|
+
**If requirements are unclear, invoke `/request-feedback` rather than guessing** — see
|
|
67
|
+
`.build-kit/CLAUDE.md`'s escalation rule.
|
|
68
|
+
|
|
69
|
+
## Step 2: Ensure Events Exist
|
|
70
|
+
|
|
71
|
+
All events the automation reacts to, and the target command it dispatches, must already exist. If
|
|
72
|
+
they don't, create the event first following `build-state-change` Step 2, and the target command's
|
|
73
|
+
whole slice following `build-state-change` in full (a command never exists without its slice).
|
|
74
|
+
|
|
75
|
+
## Step 3: Implement the Automation
|
|
76
|
+
|
|
77
|
+
### Stateless Automation
|
|
78
|
+
|
|
79
|
+
New automation slices live under `src/main/java/.../slices/{context}/automation/{slicename}/`.
|
|
80
|
+
|
|
81
|
+
```java
|
|
82
|
+
package {basePackage}.slices.{context}.automation.{slicename};
|
|
83
|
+
|
|
84
|
+
import io.umadb.client.Event;
|
|
85
|
+
import {basePackage}.eventstore.EventCodec;
|
|
86
|
+
import {basePackage}.eventstore.SliceEventListener;
|
|
87
|
+
import {basePackage}.slices.{context}.events.{TriggerEvent};
|
|
88
|
+
import {basePackage}.slices.{context}.{targetslicename}.{TargetCommand}Command;
|
|
89
|
+
import {basePackage}.slices.{context}.{targetslicename}.{TargetCommand}CommandHandler;
|
|
90
|
+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
|
91
|
+
import org.springframework.stereotype.Component;
|
|
92
|
+
|
|
93
|
+
@Component
|
|
94
|
+
@ConditionalOnProperty(prefix = "slices.{context}.automation", name = "{slicename}.enabled")
|
|
95
|
+
public class {AutomationName}Processor implements SliceEventListener {
|
|
96
|
+
|
|
97
|
+
private final {TargetCommand}CommandHandler targetCommandHandler;
|
|
98
|
+
|
|
99
|
+
public {AutomationName}Processor({TargetCommand}CommandHandler targetCommandHandler) {
|
|
100
|
+
this.targetCommandHandler = targetCommandHandler;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@Override
|
|
104
|
+
public boolean supports(String eventType) {
|
|
105
|
+
return {TriggerEvent}.TYPE.equals(eventType);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@Override
|
|
109
|
+
public void onEvent(Event event) {
|
|
110
|
+
react(EventCodec.fromEvent(event, {TriggerEvent}.class));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Called directly (no client, no dispatcher) by this class's own test - see Step 4. */
|
|
114
|
+
public void react({TriggerEvent} event) {
|
|
115
|
+
if (!shouldReact(event)) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
targetCommandHandler.handle(new {TargetCommand}Command(event.field1() /*, mapped fields */));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private boolean shouldReact({TriggerEvent} event) {
|
|
122
|
+
return true; // replace with the actual condition from slice.json, if any
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Idempotency**: `EventDispatcher` may redeliver a trigger event after an application restart (see
|
|
128
|
+
its Javadoc on quickstart-level checkpointing) — a re-dispatched command is safe as long as the
|
|
129
|
+
target command handler's own Decision already no-ops a repeat (verified: re-subscribing to the same
|
|
130
|
+
course a second time is rejected by `SubscribeToCourseDecision.alreadySubscribedToThisCourse`, not
|
|
131
|
+
by anything in the automation itself). Don't add your own idempotency guard in the automation
|
|
132
|
+
unless the target command handler genuinely can't provide one — check that first.
|
|
133
|
+
|
|
134
|
+
### Automation with Read Model
|
|
135
|
+
|
|
136
|
+
When the automation needs data not in the trigger event, add a private in-memory model — never
|
|
137
|
+
reuse another slice's read model.
|
|
138
|
+
|
|
139
|
+
```java
|
|
140
|
+
@Component
|
|
141
|
+
@ConditionalOnProperty(prefix = "slices.{context}.automation", name = "{slicename}.enabled")
|
|
142
|
+
public class {AutomationName}Processor implements SliceEventListener {
|
|
143
|
+
|
|
144
|
+
// Private read model, indexed by entity id - belongs to this automation only
|
|
145
|
+
private final Map<String, {AutomationName}Entry> store = new ConcurrentHashMap<>();
|
|
146
|
+
|
|
147
|
+
private final {TargetCommand}CommandHandler targetCommandHandler;
|
|
148
|
+
|
|
149
|
+
public {AutomationName}Processor({TargetCommand}CommandHandler targetCommandHandler) {
|
|
150
|
+
this.targetCommandHandler = targetCommandHandler;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
@Override
|
|
154
|
+
public boolean supports(String eventType) {
|
|
155
|
+
return {SetupEvent}.TYPE.equals(eventType) || {TriggerEvent}.TYPE.equals(eventType);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
@Override
|
|
159
|
+
public void onEvent(Event event) {
|
|
160
|
+
if (event.type().equals({SetupEvent}.TYPE)) {
|
|
161
|
+
onSetup(EventCodec.fromEvent(event, {SetupEvent}.class));
|
|
162
|
+
} else if (event.type().equals({TriggerEvent}.TYPE)) {
|
|
163
|
+
react(EventCodec.fromEvent(event, {TriggerEvent}.class));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Phase 1 - build the private read model from setup events
|
|
168
|
+
public void onSetup({SetupEvent} event) {
|
|
169
|
+
store.put(event.entityId(), new {AutomationName}Entry(event.entityId(), event.filterField()));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Phase 2 - trigger: dispatch a command per matching entry
|
|
173
|
+
public void react({TriggerEvent} event) {
|
|
174
|
+
store.values().stream()
|
|
175
|
+
.filter(entry -> entry.filterField().equals(event.filterValue()))
|
|
176
|
+
.forEach(entry -> targetCommandHandler.handle(new {TargetCommand}Command(entry.entityId() /*, other fields */)));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
record {AutomationName}Entry(String entityId, String filterField) {}
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Key rules:
|
|
184
|
+
|
|
185
|
+
- **Two branches in one `onEvent`**: one builds the private model (`onSetup`), one reacts
|
|
186
|
+
(`react`) — `supports` must answer true for both event types.
|
|
187
|
+
- Since `EventDispatcher` delivers events sequentially in position order to every listener on a
|
|
188
|
+
single subscription thread (see its Javadoc), there is no concurrent-mutation race between
|
|
189
|
+
`onSetup` and `react` to guard against here the way a multi-threaded processing group would need
|
|
190
|
+
to — `ConcurrentHashMap` is a safety margin, not a requirement.
|
|
191
|
+
- **Private read model belongs to this automation only** — never share it with another slice.
|
|
192
|
+
|
|
193
|
+
## Step 4: Implement Tests
|
|
194
|
+
|
|
195
|
+
**Stateless automations** — pure unit test with a mocked target command handler (Mockito):
|
|
196
|
+
|
|
197
|
+
```java
|
|
198
|
+
package {basePackage}.slices.{context}.automation.{slicename};
|
|
199
|
+
|
|
200
|
+
import {basePackage}.slices.{context}.events.{TriggerEvent};
|
|
201
|
+
import {basePackage}.slices.{context}.{targetslicename}.{TargetCommand}Command;
|
|
202
|
+
import {basePackage}.slices.{context}.{targetslicename}.{TargetCommand}CommandHandler;
|
|
203
|
+
import org.junit.jupiter.api.BeforeEach;
|
|
204
|
+
import org.junit.jupiter.api.DisplayName;
|
|
205
|
+
import org.junit.jupiter.api.Test;
|
|
206
|
+
import org.junit.jupiter.api.extension.ExtendWith;
|
|
207
|
+
import org.mockito.Mock;
|
|
208
|
+
import org.mockito.junit.jupiter.MockitoExtension;
|
|
209
|
+
|
|
210
|
+
import static org.mockito.Mockito.verify;
|
|
211
|
+
import static org.mockito.Mockito.verifyNoInteractions;
|
|
212
|
+
|
|
213
|
+
@ExtendWith(MockitoExtension.class)
|
|
214
|
+
class {AutomationName}ProcessorTest {
|
|
215
|
+
|
|
216
|
+
@Mock
|
|
217
|
+
private {TargetCommand}CommandHandler targetCommandHandler;
|
|
218
|
+
|
|
219
|
+
private {AutomationName}Processor processor;
|
|
220
|
+
|
|
221
|
+
@BeforeEach
|
|
222
|
+
void setUp() {
|
|
223
|
+
processor = new {AutomationName}Processor(targetCommandHandler);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
@Test
|
|
227
|
+
@DisplayName("given trigger event with condition met, then command dispatched")
|
|
228
|
+
void happyPath() {
|
|
229
|
+
processor.react(new {TriggerEvent}("entity-1" /*, fields that meet condition */));
|
|
230
|
+
|
|
231
|
+
verify(targetCommandHandler).handle(new {TargetCommand}Command("entity-1" /*, expected fields */));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
@Test
|
|
235
|
+
@DisplayName("given trigger event with condition not met, then no command dispatched")
|
|
236
|
+
void conditionNotMet() {
|
|
237
|
+
processor.react(new {TriggerEvent}("entity-1" /*, fields that do NOT meet condition */));
|
|
238
|
+
|
|
239
|
+
verifyNoInteractions(targetCommandHandler);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
If Mockito's inline mock maker reports a Byte Buddy / JDK-version incompatibility when mocking a
|
|
245
|
+
project class (seen on very recent JDKs, harmless), add
|
|
246
|
+
`-Dnet.bytebuddy.experimental=true` to the surefire `<argLine>` in `pom.xml` — this affects only
|
|
247
|
+
Mockito's own bytecode generation, not the code under test.
|
|
248
|
+
|
|
249
|
+
**Automations with read model** — call `onSetup`/`react` directly, same mocked-handler style, setup
|
|
250
|
+
events before trigger events:
|
|
251
|
+
|
|
252
|
+
```java
|
|
253
|
+
processor.onSetup(new {SetupEvent}("entity-1", "filter-A"));
|
|
254
|
+
processor.onSetup(new {SetupEvent}("entity-2", "filter-B"));
|
|
255
|
+
|
|
256
|
+
processor.react(new {TriggerEvent}("filter-A"));
|
|
257
|
+
|
|
258
|
+
verify(targetCommandHandler).handle(new {TargetCommand}Command("entity-1" /*, fields */));
|
|
259
|
+
verifyNoMoreInteractions(targetCommandHandler);
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Test Cases to Cover
|
|
263
|
+
|
|
264
|
+
**Stateless automations:**
|
|
265
|
+
1. Condition met → expected command dispatched
|
|
266
|
+
2. Condition not met → no command dispatched
|
|
267
|
+
|
|
268
|
+
**Automations with read model:**
|
|
269
|
+
1. Setup + trigger with matching filter → command dispatched for matching entries only
|
|
270
|
+
2. Setup + trigger with non-matching filter → no command dispatched
|
|
271
|
+
3. Temporal ordering: setup before trigger vs. setup after trigger → only entries that existed at
|
|
272
|
+
trigger time receive a command
|
|
273
|
+
|
|
274
|
+
### Mapping GWT Scenarios to Tests
|
|
275
|
+
|
|
276
|
+
| GWT Element | Test Code |
|
|
277
|
+
|---|---|
|
|
278
|
+
| Event in Given | `processor.react(new Event(...))` (or `onSetup(...)` for a setup event) |
|
|
279
|
+
| Multiple events in Given | multiple calls — setup events first, trigger last |
|
|
280
|
+
| Command in Then | `verify(targetCommandHandler).handle(eq(expectedCommand))` |
|
|
281
|
+
| NOTHING in Then | `verifyNoInteractions(targetCommandHandler)` |
|
|
282
|
+
|
|
283
|
+
### Storyline-Derived Tests (Optional)
|
|
284
|
+
|
|
285
|
+
`slice.json` may also carry a `storylines[]` array — narrated walkthroughs with an ordered
|
|
286
|
+
`elements[]` "beats" sequence (EVENT/COMMAND/READMODEL/...). This is a secondary, supplementary
|
|
287
|
+
source; `specifications[]` above stays the primary and default source of test cases. Most slices
|
|
288
|
+
have no `storylines[]` — skip silently when there's nothing relevant.
|
|
289
|
+
|
|
290
|
+
Find a beat whose `type` is `EVENT` immediately followed by a `COMMAND` beat this automation
|
|
291
|
+
dispatches. That pair is a ready-made test: `given` = the cumulative preceding `EVENT` beats
|
|
292
|
+
(setup events) through the trigger beat, `then` = `verify(targetCommandHandler).handle(eq(...))`
|
|
293
|
+
built from the command beat's fields — same shape as the "Mapping GWT Scenarios to Tests" row
|
|
294
|
+
above, just sourced from the storyline instead of `specifications[]`.
|
|
295
|
+
|
|
296
|
+
If the beat following the trigger event isn't a COMMAND this automation dispatches, don't force a
|
|
297
|
+
test — leave it undocumented rather than fabricating an assertion.
|
|
298
|
+
|
|
299
|
+
## References
|
|
300
|
+
|
|
301
|
+
- [Feature Flag Patterns](references/feature-flag-patterns.md) — `@ConditionalOnProperty`, wired the same as `build-state-change`
|
|
302
|
+
|
|
303
|
+
## Final Verification: Does the Implementation Match slice.json?
|
|
304
|
+
|
|
305
|
+
Before marking this slice as `Done`, verify the implementation against slice.json:
|
|
306
|
+
|
|
307
|
+
- [ ] The trigger event in the processor matches the trigger event in slice.json exactly
|
|
308
|
+
- [ ] The command dispatched matches the target command defined in slice.json
|
|
309
|
+
- [ ] All fields mapped from trigger event to command come from the event fields defined in slice.json — no invented mappings
|
|
310
|
+
- [ ] Every GWT scenario in `specifications[]` maps to a test case in the test class
|
|
311
|
+
- [ ] If `storylines[]` is present: every trigger-EVENT→target-COMMAND beat pair for this automation has a storyline test — or was deliberately skipped as untraceable
|
|
312
|
+
- [ ] No filtering conditions were invented — all conditions come from slice.json `description` or `comments`
|
|
313
|
+
- [ ] No field names were assumed or guessed — if a field is not in slice.json, it is not in the code
|
package/stacks/umadb/templates/.claude/skills/build-automation/references/feature-flag-patterns.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Feature Flag Patterns
|
|
2
|
+
|
|
3
|
+
This project's one supported approach: `@ConditionalOnProperty` on the command handler and REST
|
|
4
|
+
controller (**not** on the Decision class - verified against the `RegisterCustomer` and
|
|
5
|
+
`SubscribeToCourse` slices, neither of which puts the annotation on its decision-model class).
|
|
6
|
+
Read slices (`build-state-view`) are never feature-flagged - only write and automation slices are.
|
|
7
|
+
Examples use a generic `Ordering` bounded context.
|
|
8
|
+
|
|
9
|
+
## Annotation on slice components
|
|
10
|
+
|
|
11
|
+
```java
|
|
12
|
+
// Command handler
|
|
13
|
+
@ConditionalOnProperty(prefix = "slices.ordering.write", name = "placeorder.enabled")
|
|
14
|
+
@Component
|
|
15
|
+
public class PlaceOrderCommandHandler { ... }
|
|
16
|
+
|
|
17
|
+
// REST controller (if applicable)
|
|
18
|
+
@ConditionalOnProperty(prefix = "slices.ordering.write", name = "placeorder.enabled")
|
|
19
|
+
@RestController
|
|
20
|
+
public class PlaceOrderRestController { ... }
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## `application.properties` (main — enable by default)
|
|
24
|
+
|
|
25
|
+
```properties
|
|
26
|
+
slices.ordering.write.placeorder.enabled=true
|
|
27
|
+
slices.ordering.automation.notifycustomeronorder.enabled=true
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## `application.properties` (test — disable by default)
|
|
31
|
+
|
|
32
|
+
```properties
|
|
33
|
+
slices.ordering.write.placeorder.enabled=false
|
|
34
|
+
slices.ordering.automation.notifycustomeronorder.enabled=false
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
This only matters for tests that boot a Spring context (`@SpringBootTest`). The
|
|
38
|
+
`InMemoryUmaDbClient` unit-test pattern in
|
|
39
|
+
[umadb-query-patterns.md](umadb-query-patterns.md) never boots Spring, so these properties don't
|
|
40
|
+
affect it either way - a Testcontainers-based end-to-end test (see `build-state-view`'s reference)
|
|
41
|
+
overrides them back to `true` for itself via `@DynamicPropertySource`, since it's the one test that
|
|
42
|
+
does need the real beans wired.
|