@eventmodelers/cli 1.0.163 → 2.0.2
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 +48 -36
- package/RELEASE_NOTES.md +20 -2
- package/cli.js +774 -247
- package/lib/adapters/spec-kitty-adapter.js +3 -7
- package/lib/build-kits.js +23 -0
- package/lib/global-agent-id.js +45 -0
- package/lib/modeling-local-ai.js +4 -2
- package/lib/scaffold-model.js +71 -0
- package/lib/scaffold.js +224 -0
- package/lib/supervise.js +60 -0
- package/lib/ui-baseline.js +123 -0
- package/package.json +1 -1
- package/shared/build-kit/README.md +4 -19
- package/shared/build-kit/lib/agent-lock.js +75 -0
- package/shared/build-kit/lib/agent-name.js +27 -0
- package/shared/build-kit/lib/event-prune.js +58 -0
- package/shared/build-kit/lib/events-view.js +134 -0
- package/shared/build-kit/lib/local-ai-agent.js +14 -26
- package/shared/build-kit/lib/mcp-register.js +17 -1
- package/shared/build-kit/lib/prompt-journal.js +191 -0
- package/shared/build-kit/lib/prompt-replay.js +35 -0
- package/shared/build-kit/lib/ralph.js +218 -169
- package/shared/build-kit/lib/slice-curation.js +40 -0
- package/shared/build-kit/lib/slice-events.js +93 -0
- package/shared/build-kit/lib/slice-git.js +86 -0
- package/shared/build-kit/lib/slice-learnings.js +197 -0
- package/shared/build-kit/lib/slice-projection.js +101 -0
- package/shared/build-kit/lib/slice-run.js +181 -0
- package/shared/build-kit/lib/slice-state.js +184 -0
- package/shared/build-kit/lib/slice-tasks.js +35 -0
- package/shared/build-kit/ralph-claude.js +4 -2
- package/shared/build-kit/ralph-exec.js +4 -2
- package/shared/build-kit/ralph-local-ai.js +5 -3
- package/shared/scaffold/LICENSE-em-scaffold +21 -0
- package/shared/scaffold/supabase/generate.mjs +290 -0
- package/shared/scaffold/supabase/template/scripts/create-user.mjs.tmpl +29 -0
- package/shared/scaffold/supabase/template/scripts/dev.mjs.tmpl +145 -0
- package/shared/scaffold/supabase/template/scripts/write-env.mjs.tmpl +44 -0
- package/shared/scaffold/supabase/template/server.ts.tmpl +168 -0
- package/shared/scaffold/supabase/template/src/common/extraEndpoints.ts.tmpl +5 -0
- package/shared/scaffold/supabase/template/src/common/notImplemented.test.ts.tmpl +58 -0
- package/shared/scaffold/supabase/template/src/common/notImplemented.ts.tmpl +29 -0
- package/shared/scaffold/supabase/template/supabase/migrations/_V0__supabase_stubs.sql.tmpl +34 -0
- package/shared/scaffold/supabase/template/web/src/App.tsx.tmpl +38 -0
- package/shared/scaffold/supabase/template/web/src/app/Layout.tsx.tmpl +48 -0
- package/shared/scaffold/supabase/template/web/src/app/NotFound.tsx.tmpl +12 -0
- package/shared/scaffold/supabase/template/web/src/app/SignIn.tsx.tmpl +42 -0
- package/shared/scaffold/supabase/template/web/src/app/TimelinePage.tsx.tmpl +73 -0
- package/shared/scaffold/supabase/template/web/src/app/pages.ts.tmpl +26 -0
- package/shared/scaffold/supabase/template/web/src/app/skeleton.css.tmpl +25 -0
- package/shared/scaffold/supabase/template/web/src/lib/NotBuilt.tsx.tmpl +12 -0
- package/shared/scaffold/supabase/template/web/src/lib/api.ts.tmpl +226 -0
- package/shared/scaffold/supabase/template/web/src/main.tsx.tmpl +13 -0
- package/shared/skills/learn-eventmodelers-api/SKILL.md +17 -4
- package/shared/skills/request-feedback/SKILL.md +1 -2
- package/shared/skills/ui-contract/SKILL.md +7 -5
- package/shared/skills/update-slice-status/SKILL.md +1 -1
- package/stacks/axon/templates/build-kit/CLAUDE.md +7 -6
- package/stacks/axon/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/axon/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/axon/templates/build-kit/lib/prompt.md +32 -41
- package/stacks/blank/templates/build-kit/CLAUDE.md +7 -6
- package/stacks/blank/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/blank/templates/build-kit/lib/backend-prompt.md +20 -37
- package/stacks/blank/templates/build-kit/lib/prompt.md +33 -42
- package/stacks/classic/templates/.claude/skills/build-automation/SKILL.md +1 -1
- package/stacks/classic/templates/.claude/skills/build-state-change/SKILL.md +1 -1
- package/stacks/classic/templates/.claude/skills/build-state-view/SKILL.md +2 -2
- package/stacks/classic/templates/.claude/skills/record-evidence/SKILL.md +2 -2
- package/stacks/classic/templates/build-kit/CLAUDE.md +9 -8
- package/stacks/classic/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/classic/templates/build-kit/lib/backend-prompt.md +20 -38
- package/stacks/classic/templates/build-kit/lib/prompt.md +33 -42
- package/stacks/kurrent/templates/build-kit/CLAUDE.md +7 -7
- package/stacks/kurrent/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/kurrent/templates/build-kit/lib/prompt.md +32 -41
- package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/detect-model-drift/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-core-rules/SKILL.md +2 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/SKILL.md +7 -3
- package/stacks/modeling-kit/templates/.claude/skills/wdyt/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +3 -4
- package/stacks/modeling-kit/templates/kit/CLAUDE.md +18 -28
- package/stacks/modeling-kit/templates/kit/README.md +1 -1
- package/stacks/modeling-kit/templates/kit/lib/config.js +1 -1
- package/stacks/node/templates/build-kit/CLAUDE.md +7 -6
- package/stacks/node/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/node/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/node/templates/build-kit/lib/prompt.md +18 -41
- package/stacks/opencqrs/templates/build-kit/CLAUDE.md +7 -7
- package/stacks/opencqrs/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/opencqrs/templates/build-kit/lib/prompt.md +32 -41
- package/stacks/react/templates/.claude/skills/build-state-change/SKILL.md +1 -1
- package/stacks/react/templates/.claude/skills/build-state-view/SKILL.md +1 -1
- package/stacks/react/templates/build-kit/CLAUDE.md +7 -6
- package/stacks/react/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/react/templates/build-kit/lib/backend-prompt.md +25 -42
- package/stacks/react/templates/build-kit/lib/kit.json +1 -1
- package/stacks/react/templates/build-kit/lib/prompt.md +47 -43
- package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +15 -0
- package/stacks/supabase/templates/build-kit/CLAUDE.md +7 -6
- package/stacks/supabase/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/supabase/templates/build-kit/lib/prompt.md +18 -41
- package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +80 -8
- package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +66 -13
- package/stacks/supabase-react/templates/build-kit/CLAUDE.md +8 -7
- package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +25 -42
- package/stacks/supabase-react/templates/build-kit/lib/kit.json +1 -1
- package/stacks/supabase-react/templates/build-kit/lib/prompt.md +48 -44
- package/stacks/umadb/templates/build-kit/CLAUDE.md +7 -6
- package/stacks/umadb/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/umadb/templates/build-kit/lib/prompt.md +32 -41
- package/shared/bridge/spec-kitty/bridge-spec-kitty-specify/SKILL.md +0 -125
- package/shared/bridge/spec-kitty/bridge-spec-kitty-tasks/SKILL.md +0 -95
- package/shared/build-kit/lib/agent.sh +0 -20
- package/shared/build-kit/ralph.sh +0 -111
- package/shared/build-kit/realtime-agent.js +0 -20
- package/stacks/bridge/templates/bridge/lib/AGENT.md +0 -47
- package/stacks/bridge/templates/bridge/lib/prompt.md +0 -111
- package/stacks/bridge/templates/bridge/ralph-claude.js +0 -50
- package/stacks/bridge/templates/bridge/ralph-hook.js +0 -103
- package/stacks/bridge/templates/bridge/ralph-local-ai.js +0 -43
- package/stacks/bridge/templates/bridge/ralph-static.js +0 -90
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {join} from 'path';
|
|
2
|
+
import {spawnSync} from 'child_process';
|
|
3
|
+
import net from 'net';
|
|
4
|
+
import {getApplication, startAPI, WebApiSetup} from '@event-driven-io/emmett-expressjs';
|
|
5
|
+
import {glob} from "glob";
|
|
6
|
+
import express, {Application, Request, Response} from 'express';
|
|
7
|
+
import {jsonBigIntReplacer} from './src/util/sanitize';
|
|
8
|
+
import {requireUser} from "./src/supabase/requireUser";
|
|
9
|
+
import {notImplemented} from "./src/common/notImplemented";
|
|
10
|
+
import {closeDb} from "./src/common/db";
|
|
11
|
+
import swaggerUi from 'swagger-ui-express'
|
|
12
|
+
import {specs} from './src/swagger';
|
|
13
|
+
import cors from 'cors';
|
|
14
|
+
import {findEventstore} from "./src/common/loadPostgresEventstore";
|
|
15
|
+
import {PostgresEventStore} from "@event-driven-io/emmett-postgresql";
|
|
16
|
+
|
|
17
|
+
// Generated by `eventmodelers scaffold`. Slice work never edits this file (blocked-paths check).
|
|
18
|
+
|
|
19
|
+
// Run as TypeScript (tsx watch in dev, ts-node in start) or compiled (dist/server.js): either way the slices
|
|
20
|
+
// sit next to this file, with the matching extension.
|
|
21
|
+
const ext = __filename.endsWith('.ts') ? 'ts' : 'js';
|
|
22
|
+
|
|
23
|
+
// Dev only: apply Flyway migrations on every (re)start, so a slice's new migration reaches the running app
|
|
24
|
+
// with the same hot reload as its code.
|
|
25
|
+
function migrate() {
|
|
26
|
+
if (process.env.AUTO_MIGRATE !== 'true') return;
|
|
27
|
+
const result = spawnSync('flyway', ['-configFiles=flyway.conf', '-baselineOnMigrate=true', 'migrate'], {
|
|
28
|
+
cwd: process.cwd(), env: process.env, encoding: 'utf8',
|
|
29
|
+
});
|
|
30
|
+
if (result.status === 0) console.log('Flyway: migrations applied');
|
|
31
|
+
else console.error(`Flyway migrate failed (fix the migration and save to retry):\n${result.stdout ?? ''}${result.stderr ?? result.error ?? ''}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assertPortFree(port: number): Promise<void> {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const probe = net.createServer()
|
|
37
|
+
.once('error', () => reject(new Error(`Port ${port} is in use. Stop whatever holds it (macOS/Linux: lsof -nP -iTCP:${port} -sTCP:LISTEN; Windows: netstat -ano) or change PORT in .env and skeleton.json.`)))
|
|
38
|
+
.once('listening', () => probe.close(() => resolve()))
|
|
39
|
+
.listen(port);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function startServer() {
|
|
44
|
+
const port = parseInt(process.env.PORT || '__API_PORT__', 10);
|
|
45
|
+
await assertPortFree(port);
|
|
46
|
+
migrate();
|
|
47
|
+
|
|
48
|
+
const eventStore = await findEventstore()
|
|
49
|
+
const slicesBase = join(__dirname, 'src/slices');
|
|
50
|
+
|
|
51
|
+
const routeFiles = await glob(join(slicesBase, `**/routes{,-*}.${ext}`), {nodir: true});
|
|
52
|
+
console.log('Found route files:', routeFiles);
|
|
53
|
+
|
|
54
|
+
const processorFiles = await glob(join(slicesBase, `**/processor{,-*}.${ext}`), {nodir: true});
|
|
55
|
+
console.log('Found processor files:', processorFiles);
|
|
56
|
+
|
|
57
|
+
// Common routes (e.g. projection replay) are privileged/operational and are
|
|
58
|
+
// only mounted when explicitly enabled via env.
|
|
59
|
+
const commonRoutesEnabled = process.env.COMMON_ROUTES_ENABLED === 'true';
|
|
60
|
+
const commonRouteFiles = commonRoutesEnabled
|
|
61
|
+
? await glob(join(__dirname, `src/common/routes{,-*}.${ext}`), {nodir: true})
|
|
62
|
+
: [];
|
|
63
|
+
|
|
64
|
+
const rootApp: Application = express();
|
|
65
|
+
rootApp.set('json replacer', jsonBigIntReplacer);
|
|
66
|
+
|
|
67
|
+
const corsOrigins = process.env.CORS_ORIGINS?.split(',').map(o => o.trim()) ?? ['http://localhost:__WEB_PORT__', 'http://127.0.0.1:__WEB_PORT__'];
|
|
68
|
+
rootApp.use(cors({
|
|
69
|
+
origin: corsOrigins,
|
|
70
|
+
credentials: true,
|
|
71
|
+
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
|
72
|
+
allowedHeaders: ['Content-Type', 'Content-Encoding', 'accept-encoding', 'Authorization', 'x-user-id', 'x-causation-id', 'x-correlation-id']
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
const webApis: WebApiSetup[] = [];
|
|
76
|
+
for (const file of routeFiles.concat(commonRouteFiles)) {
|
|
77
|
+
const webApiModule: { api: () => WebApiSetup } = await import(file);
|
|
78
|
+
if (typeof webApiModule.api == 'function') {
|
|
79
|
+
webApis.push(webApiModule.api());
|
|
80
|
+
} else {
|
|
81
|
+
console.error(`Expected api function to be defined in ${file}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const startedProcessors: Array<{ stop: () => Promise<void> }> = [];
|
|
86
|
+
for (const processorFile of processorFiles) {
|
|
87
|
+
const processor: { processor: { start: (eventStore: PostgresEventStore) => Promise<void>; stop: () => Promise<void> } } = await import(processorFile);
|
|
88
|
+
if (typeof processor.processor?.start == "function") {
|
|
89
|
+
console.log(`starting processor ${processorFile}`)
|
|
90
|
+
processor.processor.start(eventStore).catch(err => console.error(`Processor ${processorFile} failed:`, err));
|
|
91
|
+
startedProcessors.push(processor.processor);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const shutdown = async (signal: string) => {
|
|
96
|
+
console.log(`${signal} received, shutting down processors...`);
|
|
97
|
+
await Promise.allSettled(startedProcessors.map(p => p.stop()));
|
|
98
|
+
await eventStore.close();
|
|
99
|
+
await closeDb();
|
|
100
|
+
process.exit(0);
|
|
101
|
+
};
|
|
102
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
103
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
104
|
+
|
|
105
|
+
const childApp: Application = getApplication({
|
|
106
|
+
apis: webApis,
|
|
107
|
+
disableJsonMiddleware: false,
|
|
108
|
+
enableDefaultExpressEtag: true,
|
|
109
|
+
});
|
|
110
|
+
childApp.set('json replacer', jsonBigIntReplacer);
|
|
111
|
+
|
|
112
|
+
// Signed-in user info
|
|
113
|
+
childApp.get('/api/user', async (req: Request, res: Response) => {
|
|
114
|
+
const result = await requireUser(req, res, false)
|
|
115
|
+
if (result.error) {
|
|
116
|
+
if (!res.headersSent) res.status(401).json({error: result.error})
|
|
117
|
+
} else {
|
|
118
|
+
res.status(200).json({user_id: result.user.id, email: result.user.email, metadata: result.user.user_metadata})
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
childApp.use('/api-docs', swaggerUi.serve);
|
|
123
|
+
childApp.get('/api-docs', swaggerUi.setup(specs, {swaggerOptions: {urls: [{url: '/swagger.json', name: 'JSON'}]}}));
|
|
124
|
+
childApp.get('/swagger.json', (_req: Request, res: Response) => {
|
|
125
|
+
res.setHeader('Content-Type', 'application/json');
|
|
126
|
+
res.send(specs);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
rootApp.use((req: Request, _res: Response, next) => {
|
|
130
|
+
console.log(`[${req.method}] ${req.path}`);
|
|
131
|
+
next();
|
|
132
|
+
});
|
|
133
|
+
rootApp.use(express.json());
|
|
134
|
+
|
|
135
|
+
rootApp.get('/health', (_req: Request, res: Response) => {
|
|
136
|
+
res.json({ok: true, routes: routeFiles.length, processors: processorFiles.length});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Closed-by-default auth gate: every route requires a valid Supabase JWT
|
|
140
|
+
// except the explicitly public paths below. New slice routes are protected
|
|
141
|
+
// even if a handler forgets to call requireUser.
|
|
142
|
+
const PUBLIC_PATHS = ['/api-docs', '/swagger.json', '/health'];
|
|
143
|
+
const isPublicPath = (p: string): boolean =>
|
|
144
|
+
PUBLIC_PATHS.some(pub => p === pub || p.startsWith(pub + '/'));
|
|
145
|
+
rootApp.use(async (req: Request, res: Response, next) => {
|
|
146
|
+
if (req.method === 'OPTIONS') return next();
|
|
147
|
+
if (isPublicPath(req.path)) return next();
|
|
148
|
+
const {error} = await requireUser(req, res);
|
|
149
|
+
if (error) return;
|
|
150
|
+
next();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
rootApp.use(childApp)
|
|
154
|
+
// After every real route: modeled endpoints that are not built yet answer 501, not 404.
|
|
155
|
+
rootApp.use(notImplemented)
|
|
156
|
+
|
|
157
|
+
startAPI(rootApp, {port});
|
|
158
|
+
console.log(`> Ready on port ${port}`);
|
|
159
|
+
|
|
160
|
+
process.on('unhandledRejection', (reason) => {
|
|
161
|
+
console.error('⛔ Unhandled Rejection:', reason);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
startServer().catch(error => {
|
|
166
|
+
console.error('Failed to start server:', error);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Endpoints the model describes that do not follow the kit's naming convention (e.g. a file download such as
|
|
2
|
+
// 'GET orderdocument/:orderId/pdf'). `:param` stands for one path segment. Kept by hand: `eventmodelers scaffold`
|
|
3
|
+
// creates this file once and never overwrites it.
|
|
4
|
+
export const EXTRA_ENDPOINTS: string[] = [
|
|
5
|
+
];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {describe, it} from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import {existsSync, readdirSync, readFileSync, statSync} from 'node:fs';
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
import type {AddressInfo} from 'node:net';
|
|
6
|
+
import express from 'express';
|
|
7
|
+
import {isModeledEndpoint, notImplemented} from './notImplemented';
|
|
8
|
+
import {MODELED_ENDPOINTS} from './modeledEndpoints';
|
|
9
|
+
|
|
10
|
+
const slicesRoot = join(__dirname, '../slices');
|
|
11
|
+
|
|
12
|
+
const routeFiles = (dir: string): string[] =>
|
|
13
|
+
!existsSync(dir) ? [] : readdirSync(dir).flatMap((name) => {
|
|
14
|
+
const full = join(dir, name);
|
|
15
|
+
if (statSync(full).isDirectory()) return routeFiles(full);
|
|
16
|
+
return /^routes(-.*)?\.ts$/.test(name) ? [full] : [];
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// A concrete request path for a modeled entry: `:param` -> x, and a command gets an id segment.
|
|
20
|
+
const samplePath = (entry: string) => {
|
|
21
|
+
const [method, path] = entry.split(' ');
|
|
22
|
+
return {method, url: `/api/${path.replace(/:[A-Za-z]+/g, 'x')}${method === 'POST' ? '/x' : ''}`};
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('notImplemented fallback', () => {
|
|
26
|
+
it('every real route is on the modeled list (guards the naming convention)', () => {
|
|
27
|
+
const missing: string[] = [];
|
|
28
|
+
for (const file of routeFiles(slicesRoot)) {
|
|
29
|
+
const source = readFileSync(file, 'utf8');
|
|
30
|
+
for (const m of source.matchAll(/router\.(get|post)\(\s*['"`]([^'"`]+)['"`]/g)) {
|
|
31
|
+
const method = m[1].toUpperCase();
|
|
32
|
+
const path = m[2].replace(/:[A-Za-z]+/g, 'x');
|
|
33
|
+
if (!isModeledEndpoint(method, path)) missing.push(`${method} ${m[2]} (${file})`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
assert.deepStrictEqual(missing, [], 'add these to src/common/extraEndpoints.ts, or rename the route to the kit convention');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('answers 501 for a modeled endpoint with no handler, lets a real route win, leaves other paths 404', async () => {
|
|
40
|
+
const app = express();
|
|
41
|
+
const [first, second] = MODELED_ENDPOINTS.map(samplePath);
|
|
42
|
+
if (first) app.all(first.url, (_req, res) => res.json({real: true})); // a "real" route registered first wins
|
|
43
|
+
app.use(notImplemented);
|
|
44
|
+
const server = app.listen(0);
|
|
45
|
+
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
46
|
+
try {
|
|
47
|
+
if (first) assert.equal((await fetch(base + first.url, {method: first.method})).status, 200);
|
|
48
|
+
if (second) {
|
|
49
|
+
const unbuilt = await fetch(base + second.url, {method: second.method});
|
|
50
|
+
assert.equal(unbuilt.status, 501);
|
|
51
|
+
assert.equal(((await unbuilt.json()) as {code: string}).code, 'not_implemented');
|
|
52
|
+
}
|
|
53
|
+
assert.equal((await fetch(`${base}/api/not-modeled-${Date.now()}`)).status, 404);
|
|
54
|
+
} finally {
|
|
55
|
+
server.close();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type {NextFunction, Request, RequestHandler, Response} from 'express';
|
|
2
|
+
import {MODELED_ENDPOINTS} from './modeledEndpoints';
|
|
3
|
+
import {EXTRA_ENDPOINTS} from './extraEndpoints';
|
|
4
|
+
|
|
5
|
+
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
6
|
+
|
|
7
|
+
// `:param` segments match any single path segment
|
|
8
|
+
const toPathRegex = (path: string) =>
|
|
9
|
+
path.split('/').map((seg) => (seg.startsWith(':') ? '[^/]+' : escape(seg))).join('/');
|
|
10
|
+
|
|
11
|
+
// POST /api/<name> or /api/<name>/<one segment>; GET /api/query/<name>-collection (or a listed GET path)
|
|
12
|
+
const patterns = [...MODELED_ENDPOINTS, ...EXTRA_ENDPOINTS].map((entry) => {
|
|
13
|
+
const [method, path] = entry.split(' ');
|
|
14
|
+
const re = method === 'POST'
|
|
15
|
+
? new RegExp(`^/api/${toPathRegex(path)}(/[^/]+)?/?$`)
|
|
16
|
+
: new RegExp(`^/api/${toPathRegex(path)}/?$`);
|
|
17
|
+
return {method, re};
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const isModeledEndpoint = (method: string, path: string): boolean =>
|
|
21
|
+
patterns.some((p) => p.method === method.toUpperCase() && p.re.test(path));
|
|
22
|
+
|
|
23
|
+
// Registered AFTER every real route: a modeled endpoint with no handler yet answers 501 Not Implemented
|
|
24
|
+
// (so "not built" is distinguishable from a wrong URL, which stays 404). Once a slice is built, its real
|
|
25
|
+
// route matches first and this never runs for it.
|
|
26
|
+
export const notImplemented: RequestHandler = (req: Request, res: Response, next: NextFunction) => {
|
|
27
|
+
if (res.headersSent || !isModeledEndpoint(req.method, req.path)) return next();
|
|
28
|
+
return res.status(501).json({ok: false, code: 'not_implemented', error: 'This feature is not built yet.'});
|
|
29
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- Test-only stubs for Supabase-provided roles and the auth schema, so migrations
|
|
2
|
+
-- that reference them run on a plain Postgres container. runFlywayMigrations()
|
|
3
|
+
-- (src/common/testHelpers.ts) applies this before Flyway; the leading underscore
|
|
4
|
+
-- keeps Flyway itself from picking it up as a versioned migration.
|
|
5
|
+
|
|
6
|
+
DO $$
|
|
7
|
+
BEGIN
|
|
8
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
|
|
9
|
+
CREATE ROLE anon NOLOGIN;
|
|
10
|
+
END IF;
|
|
11
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
|
|
12
|
+
CREATE ROLE authenticated NOLOGIN;
|
|
13
|
+
END IF;
|
|
14
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
|
|
15
|
+
CREATE ROLE service_role NOLOGIN BYPASSRLS;
|
|
16
|
+
END IF;
|
|
17
|
+
END
|
|
18
|
+
$$;
|
|
19
|
+
|
|
20
|
+
CREATE SCHEMA IF NOT EXISTS auth;
|
|
21
|
+
|
|
22
|
+
CREATE TABLE IF NOT EXISTS auth.users
|
|
23
|
+
(
|
|
24
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
25
|
+
email TEXT
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
CREATE OR REPLACE FUNCTION auth.uid() RETURNS UUID
|
|
29
|
+
LANGUAGE sql STABLE
|
|
30
|
+
AS $$ SELECT NULLIF(current_setting('request.jwt.claim.sub', true), '')::UUID $$;
|
|
31
|
+
|
|
32
|
+
CREATE OR REPLACE FUNCTION auth.role() RETURNS TEXT
|
|
33
|
+
LANGUAGE sql STABLE
|
|
34
|
+
AS $$ SELECT NULLIF(current_setting('request.jwt.claim.role', true), '')::TEXT $$;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import type { Session } from '@supabase/supabase-js';
|
|
3
|
+
import { Navigate, Route, Routes } from 'react-router-dom';
|
|
4
|
+
import { supabase } from './lib/supabase';
|
|
5
|
+
import { Layout } from './app/Layout';
|
|
6
|
+
import { SignIn } from './app/SignIn';
|
|
7
|
+
import { TimelinePage } from './app/TimelinePage';
|
|
8
|
+
import { pageRoutes } from './app/pages';
|
|
9
|
+
import { HOME_ROUTE } from './app/model';
|
|
10
|
+
import { NotFound } from './app/NotFound';
|
|
11
|
+
|
|
12
|
+
// App shell generated by `eventmodelers scaffold`. Pages are routed from src/app/model.ts and src/pages/*.tsx;
|
|
13
|
+
// slice work never edits this file.
|
|
14
|
+
export default function App() {
|
|
15
|
+
const [session, setSession] = useState<Session | null | undefined>(undefined);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
supabase.auth.getSession().then(({ data }) => setSession(data.session));
|
|
19
|
+
const { data } = supabase.auth.onAuthStateChange((_event, s) => setSession(s));
|
|
20
|
+
return () => data.subscription.unsubscribe();
|
|
21
|
+
}, []);
|
|
22
|
+
|
|
23
|
+
if (session === undefined) return null;
|
|
24
|
+
if (!session) return <SignIn />;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Routes>
|
|
28
|
+
<Route element={<Layout email={session.user.email} onSignOut={() => supabase.auth.signOut()} />}>
|
|
29
|
+
<Route path="/" element={<Navigate to={HOME_ROUTE} replace />} />
|
|
30
|
+
<Route path="/timelines/:slug" element={<TimelinePage />} />
|
|
31
|
+
{pageRoutes.map(({ route, Component }) => (
|
|
32
|
+
<Route key={route} path={route} element={<Component />} />
|
|
33
|
+
))}
|
|
34
|
+
<Route path="*" element={<NotFound />} />
|
|
35
|
+
</Route>
|
|
36
|
+
</Routes>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { matchPath, NavLink, Outlet, useLocation } from 'react-router-dom';
|
|
2
|
+
import { APP_TITLE, PAGES, TIMELINES } from './model';
|
|
3
|
+
|
|
4
|
+
type LayoutProps = {
|
|
5
|
+
email: string | undefined;
|
|
6
|
+
onSignOut: () => void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// Main container: one navigation entry per timeline in the event model.
|
|
10
|
+
export function Layout({ email, onSignOut }: LayoutProps) {
|
|
11
|
+
const { pathname } = useLocation();
|
|
12
|
+
// A timeline stays active on its overview and on any of its pages.
|
|
13
|
+
const activeTimeline = TIMELINES.find((t) =>
|
|
14
|
+
matchPath(`/timelines/${t.slug}`, pathname) ||
|
|
15
|
+
PAGES.some((p) => t.pages.includes(p.name) && matchPath(p.route, pathname)),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<>
|
|
20
|
+
<nav className="navbar is-light" aria-label="main navigation">
|
|
21
|
+
<div className="navbar-brand">
|
|
22
|
+
<NavLink to="/" className="navbar-item has-text-weight-bold">{APP_TITLE}</NavLink>
|
|
23
|
+
</div>
|
|
24
|
+
<div className="navbar-menu is-active">
|
|
25
|
+
<div className="navbar-start">
|
|
26
|
+
{TIMELINES.map((t) => (
|
|
27
|
+
<NavLink key={t.slug} to={`/timelines/${t.slug}`}
|
|
28
|
+
className={`navbar-item${t === activeTimeline ? ' is-active has-text-weight-semibold' : ''}`}>
|
|
29
|
+
{t.title}
|
|
30
|
+
</NavLink>
|
|
31
|
+
))}
|
|
32
|
+
</div>
|
|
33
|
+
<div className="navbar-end">
|
|
34
|
+
<div className="navbar-item">{email}</div>
|
|
35
|
+
<div className="navbar-item">
|
|
36
|
+
<button className="button is-light" onClick={onSignOut}>Sign out</button>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</nav>
|
|
41
|
+
<section className="section">
|
|
42
|
+
<div className="container">
|
|
43
|
+
<Outlet />
|
|
44
|
+
</div>
|
|
45
|
+
</section>
|
|
46
|
+
</>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Link } from 'react-router-dom';
|
|
2
|
+
import { HOME_ROUTE } from './model';
|
|
3
|
+
|
|
4
|
+
/** Shown for an address the app has no page for. Distinct from NotBuilt, which marks a modeled feature not built yet. */
|
|
5
|
+
export function NotFound() {
|
|
6
|
+
return (
|
|
7
|
+
<div className="notification" role="status">
|
|
8
|
+
<p className="has-text-weight-semibold">Page not found</p>
|
|
9
|
+
<p>There is no page at this address. <Link to={HOME_ROUTE}>Go to the home page</Link> or pick a timeline from the navigation.</p>
|
|
10
|
+
</div>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useState, type FormEvent } from 'react';
|
|
2
|
+
import { supabase } from '../lib/supabase';
|
|
3
|
+
import { APP_TITLE } from './model';
|
|
4
|
+
|
|
5
|
+
// Supabase email + password sign-in. Users are created with `npm run user -- <email> <password>`.
|
|
6
|
+
export function SignIn() {
|
|
7
|
+
const [email, setEmail] = useState('');
|
|
8
|
+
const [password, setPassword] = useState('');
|
|
9
|
+
const [error, setError] = useState<string | null>(null);
|
|
10
|
+
const [busy, setBusy] = useState(false);
|
|
11
|
+
|
|
12
|
+
async function onSubmit(e: FormEvent) {
|
|
13
|
+
e.preventDefault();
|
|
14
|
+
setBusy(true);
|
|
15
|
+
setError(null);
|
|
16
|
+
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
|
17
|
+
if (error) setError(error.message);
|
|
18
|
+
setBusy(false);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<section className="section">
|
|
23
|
+
<div className="container" style={{ maxWidth: 400 }}>
|
|
24
|
+
<h1 className="title">{APP_TITLE}</h1>
|
|
25
|
+
<form onSubmit={onSubmit}>
|
|
26
|
+
<div className="field">
|
|
27
|
+
<label className="label" htmlFor="email">Email</label>
|
|
28
|
+
<input id="email" className="input" type="email" autoComplete="username" required
|
|
29
|
+
value={email} onChange={(e) => setEmail(e.target.value)} />
|
|
30
|
+
</div>
|
|
31
|
+
<div className="field">
|
|
32
|
+
<label className="label" htmlFor="password">Password</label>
|
|
33
|
+
<input id="password" className="input" type="password" autoComplete="current-password" required
|
|
34
|
+
value={password} onChange={(e) => setPassword(e.target.value)} />
|
|
35
|
+
</div>
|
|
36
|
+
{error && <p className="help is-danger mb-3">{error}</p>}
|
|
37
|
+
<button className={`button is-primary${busy ? ' is-loading' : ''}`} type="submit">Sign in</button>
|
|
38
|
+
</form>
|
|
39
|
+
</div>
|
|
40
|
+
</section>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { Link, useParams } from 'react-router-dom';
|
|
3
|
+
import { getReadModel, isNotBuilt } from '../lib/api';
|
|
4
|
+
import { NotBuilt } from '../lib/NotBuilt';
|
|
5
|
+
import { NotFound } from './NotFound';
|
|
6
|
+
import { PAGES, TIMELINES, type SliceInfo } from './model';
|
|
7
|
+
|
|
8
|
+
type Probe = { state: 'loading' } | { state: 'built'; rows: number } | { state: 'not-built' } | { state: 'error'; message: string };
|
|
9
|
+
|
|
10
|
+
// Asks the backend for a read model. A 501 means its slice is not built yet and shows as a placeholder.
|
|
11
|
+
function ReadModelStatus({ slice }: { slice: SliceInfo }) {
|
|
12
|
+
const [probe, setProbe] = useState<Probe>({ state: 'loading' });
|
|
13
|
+
const path = slice.endpoint!.replace(/^GET /, '');
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
getReadModel<unknown>(path)
|
|
17
|
+
.then((data) => setProbe({ state: 'built', rows: Array.isArray(data) ? data.length : data ? 1 : 0 }))
|
|
18
|
+
.catch((err: unknown) =>
|
|
19
|
+
setProbe(isNotBuilt(err) ? { state: 'not-built' } : { state: 'error', message: err instanceof Error ? err.message : String(err) }),
|
|
20
|
+
);
|
|
21
|
+
}, [path]);
|
|
22
|
+
|
|
23
|
+
if (probe.state === 'loading') return <span className="tag">checking…</span>;
|
|
24
|
+
if (probe.state === 'built') return <span className="tag is-success">built · {probe.rows} row{probe.rows === 1 ? '' : 's'}</span>;
|
|
25
|
+
if (probe.state === 'not-built') return <NotBuilt compact />;
|
|
26
|
+
return <span className="tag is-danger" title={probe.message}>error: {probe.message}</span>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const TYPE_LABEL: Record<string, string> = { STATE_CHANGE: 'Command', STATE_VIEW: 'Read model', AUTOMATION: 'Automation' };
|
|
30
|
+
|
|
31
|
+
// Overview of one timeline: its pages and its slices, with a live 501 check for each read model.
|
|
32
|
+
export function TimelinePage() {
|
|
33
|
+
const { slug } = useParams();
|
|
34
|
+
const timeline = TIMELINES.find((t) => t.slug === slug);
|
|
35
|
+
if (!timeline) return <NotFound />;
|
|
36
|
+
|
|
37
|
+
const pages = PAGES.filter((p) => timeline.pages.includes(p.name));
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
<h1 className="title">{timeline.title}</h1>
|
|
42
|
+
|
|
43
|
+
<h2 className="title is-5">Pages</h2>
|
|
44
|
+
{pages.length === 0 ? <p className="mb-5">This timeline has no screens.</p> : (
|
|
45
|
+
<ul className="mb-5">
|
|
46
|
+
{pages.map((p) => (
|
|
47
|
+
<li key={p.name}>
|
|
48
|
+
{p.hasParams ? <span>{p.title} <span className="has-text-grey">({p.route}, opened from another page)</span></span>
|
|
49
|
+
: <Link to={p.route}>{p.title}</Link>}
|
|
50
|
+
</li>
|
|
51
|
+
))}
|
|
52
|
+
</ul>
|
|
53
|
+
)}
|
|
54
|
+
|
|
55
|
+
<h2 className="title is-5">Slices</h2>
|
|
56
|
+
{timeline.slices.length === 0 ? <p>This timeline has no slices.</p> : (
|
|
57
|
+
<table className="table is-fullwidth is-narrow">
|
|
58
|
+
<thead><tr><th>Slice</th><th>Type</th><th>Endpoint</th><th>Status</th></tr></thead>
|
|
59
|
+
<tbody>
|
|
60
|
+
{timeline.slices.map((s) => (
|
|
61
|
+
<tr key={s.title}>
|
|
62
|
+
<td>{s.title}</td>
|
|
63
|
+
<td>{TYPE_LABEL[s.type] ?? s.type}</td>
|
|
64
|
+
<td><code>{s.endpoint ?? '—'}</code></td>
|
|
65
|
+
<td>{s.type === 'STATE_VIEW' && s.endpoint ? <ReadModelStatus slice={s} /> : <span className="has-text-grey">—</span>}</td>
|
|
66
|
+
</tr>
|
|
67
|
+
))}
|
|
68
|
+
</tbody>
|
|
69
|
+
</table>
|
|
70
|
+
)}
|
|
71
|
+
</>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createElement, type ComponentType } from 'react';
|
|
2
|
+
import { PAGES } from './model';
|
|
3
|
+
import { NotBuilt } from '../lib/NotBuilt';
|
|
4
|
+
|
|
5
|
+
// Every file in src/pages is routed without editing the router: a page from the model gets its route from
|
|
6
|
+
// model.ts, and any other page file gets /pages/<file-name>. The page component is the export named like the
|
|
7
|
+
// file (web kit convention), or the default export.
|
|
8
|
+
const modules = import.meta.glob<Record<string, ComponentType>>('../pages/*.tsx', { eager: true });
|
|
9
|
+
|
|
10
|
+
const byName = new Map(
|
|
11
|
+
Object.entries(modules).map(([file, mod]) => {
|
|
12
|
+
const name = file.replace(/^.*\/(.*)\.tsx$/, '$1');
|
|
13
|
+
return [name, mod[name] ?? mod.default] as const;
|
|
14
|
+
}),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const kebab = (s: string) => s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
18
|
+
|
|
19
|
+
const missing = (title: string): ComponentType => () => createElement(NotBuilt, { detail: `The page file for "${title}" is missing.` });
|
|
20
|
+
|
|
21
|
+
export const pageRoutes: { route: string; Component: ComponentType }[] = [
|
|
22
|
+
...PAGES.map((p) => ({ route: p.route, Component: byName.get(p.name) ?? missing(p.title) })),
|
|
23
|
+
...[...byName.entries()]
|
|
24
|
+
.filter(([name, Component]) => Component && !PAGES.some((p) => p.name === name))
|
|
25
|
+
.map(([name, Component]) => ({ route: `/pages/${kebab(name)}`, Component: Component! })),
|
|
26
|
+
];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/* Minimal styles for the skeleton's own markup, used when the model's screens name no CSS framework.
|
|
2
|
+
Only the class names the app shell uses are covered. */
|
|
3
|
+
:root { font-family: system-ui, sans-serif; color: #222; background: #fafafa; }
|
|
4
|
+
body { margin: 0; }
|
|
5
|
+
.navbar { display: flex; align-items: center; gap: 1rem; padding: 0.5rem 1rem; background: #eee; }
|
|
6
|
+
.navbar-brand, .navbar-start, .navbar-end, .navbar-menu { display: flex; align-items: center; gap: 0.75rem; }
|
|
7
|
+
.navbar-menu { flex: 1; justify-content: space-between; }
|
|
8
|
+
.navbar-item { color: inherit; text-decoration: none; padding: 0.25rem 0.5rem; }
|
|
9
|
+
.navbar-item.is-active { background: #ddd; border-radius: 4px; }
|
|
10
|
+
.section { padding: 2rem 1rem; }
|
|
11
|
+
.container { max-width: 960px; margin: 0 auto; }
|
|
12
|
+
.title { font-size: 1.75rem; margin: 0 0 1rem; }
|
|
13
|
+
.title.is-5 { font-size: 1.15rem; }
|
|
14
|
+
.subtitle { color: #666; margin: 0 0 1rem; }
|
|
15
|
+
.notification { padding: 1rem; border-radius: 4px; background: #fff8e1; border: 1px solid #f0d58c; }
|
|
16
|
+
.tag { display: inline-block; padding: 0 0.5rem; border-radius: 4px; background: #eee; font-size: 0.8rem; }
|
|
17
|
+
.tag.is-success { background: #e3f6e8; } .tag.is-warning { background: #fff3cd; } .tag.is-danger { background: #fde2e1; }
|
|
18
|
+
.table { border-collapse: collapse; width: 100%; } .table th, .table td { text-align: left; padding: 0.35rem 0.5rem; border-bottom: 1px solid #ddd; }
|
|
19
|
+
.field { margin-bottom: 0.75rem; } .label { display: block; font-weight: 600; margin-bottom: 0.25rem; }
|
|
20
|
+
.input { width: 100%; padding: 0.4rem; box-sizing: border-box; }
|
|
21
|
+
.button { padding: 0.4rem 0.9rem; border: 1px solid #bbb; border-radius: 4px; background: #fff; cursor: pointer; }
|
|
22
|
+
.button.is-primary { background: #2a6df4; border-color: #2a6df4; color: #fff; }
|
|
23
|
+
.help.is-danger { color: #c0392b; }
|
|
24
|
+
.has-text-grey { color: #777; } .has-text-weight-bold { font-weight: 700; } .has-text-weight-semibold { font-weight: 600; }
|
|
25
|
+
.mb-3 { margin-bottom: 0.75rem; } .mb-5 { margin-bottom: 1.5rem; }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { NOT_BUILT_MESSAGE } from './api';
|
|
2
|
+
|
|
3
|
+
/** Placeholder for a feature whose slice is not built yet, shown instead of an error. Pair with `isNotBuilt(err)` from `./api`. */
|
|
4
|
+
export function NotBuilt({ detail, compact = false }: { detail?: string; compact?: boolean }) {
|
|
5
|
+
if (compact) return <span className="tag is-warning is-light">{NOT_BUILT_MESSAGE}</span>;
|
|
6
|
+
return (
|
|
7
|
+
<div className="notification is-warning is-light" role="status">
|
|
8
|
+
<p className="has-text-weight-semibold">{NOT_BUILT_MESSAGE}</p>
|
|
9
|
+
{detail && <p>{detail}</p>}
|
|
10
|
+
</div>
|
|
11
|
+
);
|
|
12
|
+
}
|