@eventmodelers/node-kit 0.0.10 → 0.0.12

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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/templates/.claude/skills/build-automation/SKILL.md +260 -0
  3. package/templates/.claude/skills/build-state-change/SKILL.md +329 -0
  4. package/templates/.claude/skills/build-state-view/SKILL.md +384 -0
  5. package/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +609 -0
  6. package/templates/.claude/skills/load-slice/SKILL.md +69 -14
  7. package/templates/realtime-agent/src/index.js +12 -17
  8. package/templates/root/.env.example +22 -0
  9. package/templates/root/Claude.md +58 -0
  10. package/templates/root/agent.sh +15 -0
  11. package/templates/root/backend-prompt.md +139 -0
  12. package/templates/root/flyway.conf +17 -0
  13. package/templates/root/package.json +52 -0
  14. package/templates/root/ralph.sh +47 -26
  15. package/templates/root/server.ts +213 -0
  16. package/templates/root/setup-env.sh +55 -0
  17. package/templates/root/src/common/assertions.ts +6 -0
  18. package/templates/root/src/common/db.ts +32 -0
  19. package/templates/root/src/common/loadPostgresEventstore.ts +39 -0
  20. package/templates/root/src/common/parseEndpoint.ts +51 -0
  21. package/templates/root/src/common/processorDlq.ts +28 -0
  22. package/templates/root/src/common/realtimeBroadcast.ts +19 -0
  23. package/templates/root/src/common/replay.ts +16 -0
  24. package/templates/root/src/common/routes.ts +19 -0
  25. package/templates/root/src/common/testHelpers.ts +54 -0
  26. package/templates/root/src/slices/example/routes.ts +134 -0
  27. package/templates/root/src/supabase/LoginHandler.ts +36 -0
  28. package/templates/root/src/supabase/ProtectedPageProps.ts +21 -0
  29. package/templates/root/src/supabase/README.md +171 -0
  30. package/templates/root/src/supabase/api.ts +56 -0
  31. package/templates/root/src/supabase/component.ts +12 -0
  32. package/templates/root/src/supabase/requireOrgaAdmin.ts +32 -0
  33. package/templates/root/src/supabase/requireUser.ts +72 -0
  34. package/templates/root/src/supabase/serverProps.ts +25 -0
  35. package/templates/root/src/supabase/staticProps.ts +10 -0
  36. package/templates/root/src/swagger.ts +34 -0
  37. package/templates/root/src/util/assertions.ts +6 -0
  38. package/templates/root/src/util/hash.ts +9 -0
  39. package/templates/root/src/util/sanitize.ts +23 -0
  40. package/templates/root/supabase/config.toml +295 -0
  41. package/templates/root/supabase/migrations/V1__schema.sql.example +12 -0
  42. package/templates/root/supabase/seed.sql +1 -0
  43. package/templates/root/tsconfig.json +32 -0
  44. package/templates/root/vercel.json +8 -0
  45. package/templates/root/model.md +0 -1
@@ -0,0 +1,213 @@
1
+ import {join} from 'path';
2
+ import {getApplication, startAPI, WebApiSetup} from '@event-driven-io/emmett-expressjs';
3
+ import {glob} from "glob";
4
+ import express, {Application, Request, Response} from 'express';
5
+ import {jsonBigIntReplacer} from './src/util/sanitize';
6
+ import {requireUser} from "./src/supabase/requireUser";
7
+ import {requireBotApiToken} from "./src/slices/change/requireApiToken";
8
+ import {mcpAuthRouter} from '@modelcontextprotocol/sdk/server/auth/router.js';
9
+ import type {SupabaseOAuthProvider} from './src/slices/mcp/SupabaseOAuthProvider';
10
+ import {isOrgLicenseActive, CallerContext} from "./src/slices/organization/OrganizationLicense/IsLicenseActive";
11
+ import {getKnexInstance, closeDb} from "./src/common/db";
12
+ import swaggerUi from 'swagger-ui-express'
13
+ import {specs} from './src/swagger';
14
+ import cors from 'cors';
15
+ import {testPageHtml} from "./src/slices/internal/testing/routes";
16
+ import {findEventstore} from "./src/common/loadPostgresEventstore";
17
+ import {PostgresEventStore} from "@event-driven-io/emmett-postgresql";
18
+
19
+ async function startServer() {
20
+
21
+ const eventStore = await findEventstore()
22
+ const slicesBase = join(__dirname, 'dist/src/slices');
23
+ const routesPattern = join(slicesBase, '**/routes{,-*}.js');
24
+
25
+ const routeFiles = await glob(routesPattern, {nodir: true});
26
+ console.log('Found route files:', routeFiles);
27
+
28
+ const processorPattern = join(slicesBase, '**/processor{,-*}.js');
29
+ const processorFiles = await glob(processorPattern, {nodir: true});
30
+ console.log('Found processor files:', processorFiles);
31
+
32
+ const commonPattern = join(__dirname, 'src/common/routes{,-*}.@(ts|js)');
33
+ const commonRouteFiles = await glob(commonPattern, {nodir: true});
34
+ console.log('Found common route files:', commonRouteFiles);
35
+
36
+
37
+ const rootApp: Application = express();
38
+ rootApp.set('json replacer', jsonBigIntReplacer);
39
+
40
+ const corsOrigins = process.env.CORS_ORIGINS?.split(',').map(o => o.trim()) ?? ['http://localhost:3000', 'http://localhost:3001'];
41
+ rootApp.use(cors({
42
+ origin: corsOrigins,
43
+ credentials: true,
44
+ methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
45
+ allowedHeaders: ['Content-Type', 'Content-Encoding', 'accept-encoding', 'Authorization','x-user-id','x-causation-id','x-correlation-id']
46
+ }));
47
+
48
+ const webApis: WebApiSetup[] = [];
49
+
50
+ for (const file of routeFiles.concat(commonRouteFiles)) {
51
+ const webApiModule: { api: () => WebApiSetup } = await import(file);
52
+ if (typeof webApiModule.api == 'function') {
53
+ var module = webApiModule.api()
54
+ webApis.push(module);
55
+ } else {
56
+ console.error(`Expected api function to be defined in ${file}`);
57
+ }
58
+ }
59
+
60
+ const startedProcessors: Array<{ stop: () => Promise<void> }> = [];
61
+
62
+ for (const processorFile of processorFiles) {
63
+ const processor: { processor: { start: (eventStore: PostgresEventStore) => Promise<void>; stop: () => Promise<void> } } = await import(processorFile);
64
+ if (typeof processor.processor.start == "function") {
65
+ console.log(`starting processor ${processorFile}`)
66
+ processor.processor.start(eventStore).catch(err => console.error(`Processor ${processorFile} failed:`, err));
67
+ startedProcessors.push(processor.processor);
68
+ }
69
+ }
70
+
71
+ const shutdown = async (signal: string) => {
72
+ console.log(`${signal} received, shutting down processors...`);
73
+ await Promise.allSettled(startedProcessors.map(p => p.stop()));
74
+ await eventStore.close();
75
+ await closeDb();
76
+ console.log('shutdown complete');
77
+ process.exit(0);
78
+ };
79
+
80
+ process.on('SIGINT', () => shutdown('SIGINT'));
81
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
82
+
83
+ // Get the main application from emmett
84
+ const childApp: Application = getApplication({
85
+ apis: webApis,
86
+ disableJsonMiddleware: false,
87
+ enableDefaultExpressEtag: true,
88
+ });
89
+ childApp.set('json replacer', jsonBigIntReplacer);
90
+
91
+ // Add your custom routes to the main application (BEFORE the catch-all)
92
+ if (process.env.TESTING === 'true') {
93
+ childApp.get('/internal/test', (req: Request, res: Response) => {
94
+ res.setHeader('Content-Type', 'text/html');
95
+ res.send(testPageHtml);
96
+ });
97
+ }
98
+
99
+ // Protected user info endpoint - requires JWT token in Authorization header
100
+ childApp.get('/api/user', async (req: Request, res: Response) => {
101
+ console.log('API user route hit'); // Debug log
102
+ try {
103
+ const result = await requireUser(req, res, false)
104
+ if (result.error) {
105
+ // Response already sent by requireUser if sendUnauthorized=true
106
+ if (!res.headersSent) {
107
+ res.status(401).json({error: result.error})
108
+ }
109
+ } else {
110
+ res.status(200).json({
111
+ user_id: result.user.id,
112
+ email: result.user.email,
113
+ metadata: result.user.user_metadata
114
+ })
115
+ }
116
+ } catch (error) {
117
+ console.error('Error in /api/user:', error);
118
+ if (!res.headersSent) {
119
+ res.status(500).json({error: 'Internal server error'});
120
+ }
121
+ }
122
+ });
123
+
124
+ // Swagger UI endpoints
125
+ childApp.use('/api-docs', swaggerUi.serve);
126
+ childApp.get('/api-docs', swaggerUi.setup(specs, {
127
+ swaggerOptions: {
128
+ urls: [
129
+ {
130
+ url: '/swagger.json',
131
+ name: 'JSON',
132
+ },
133
+ ],
134
+ },
135
+ }));
136
+
137
+ // OpenAPI spec endpoint
138
+ childApp.get('/swagger.json', (req: Request, res: Response) => {
139
+ res.setHeader('Content-Type', 'application/json');
140
+ res.send(specs);
141
+ });
142
+
143
+ const port = parseInt(process.env.PORT || '3000', 10);
144
+ console.log(`> Ready on port ${port}`);
145
+
146
+ const authenticate = async (req: Request, res: Response, next: () => void) => {
147
+ if (req.headers["x-token"]) {
148
+ const auth = await requireBotApiToken(req, res);
149
+ if (!auth) return;
150
+ req.tokenAuth = auth;
151
+ } else {
152
+ const principal = await requireUser(req, res, true);
153
+ if (principal.error) return;
154
+ req.userAuth = {id: principal.user.id, email: principal.user.email};
155
+ }
156
+ next();
157
+ };
158
+
159
+ rootApp.use('/api/org', authenticate);
160
+ rootApp.use('/api/boards', authenticate);
161
+ rootApp.use('/api/snapshots', authenticate);
162
+ rootApp.use('/api/takesnapshot', authenticate);
163
+ rootApp.use('/api/replay', authenticate);
164
+
165
+ rootApp.use('/api/org', async (req: Request, res: Response, next) => {
166
+ const boardMatch = req.path.match(/^\/([^/]+)\/boards\/([^/]+)\//);
167
+ if (!boardMatch) return next();
168
+
169
+ const [, orgId, boardId] = boardMatch;
170
+ const caller: CallerContext = req.tokenAuth
171
+ ? {kind: 'token', organizationId: req.tokenAuth.organizationId}
172
+ : {kind: 'user', userId: req.userAuth!.id};
173
+
174
+ const result = await isOrgLicenseActive(orgId, boardId, caller);
175
+ if (!result.active) return res.status(403).json({error: 'license_inactive', reason: result.reason});
176
+
177
+ next();
178
+ });
179
+
180
+ rootApp.use((req: Request, _res: Response, next) => {
181
+ console.log(`[${req.method}] ${req.path}`);
182
+ next();
183
+ });
184
+
185
+ const backendUrl = process.env.BACKEND_URL;
186
+ // Load oauthProvider from the same compiled dist module that routes.js uses,
187
+ // so both share the same in-memory pendingAuths/authCodes Maps.
188
+ const providerPath = join(__dirname, 'dist/src/slices/mcp/SupabaseOAuthProvider.js');
189
+ const {oauthProvider} = await import(providerPath) as {oauthProvider: SupabaseOAuthProvider};
190
+ rootApp.use(express.json());
191
+ rootApp.use(mcpAuthRouter({
192
+ provider: oauthProvider,
193
+ issuerUrl: new URL(backendUrl),
194
+ resourceServerUrl: new URL(`${backendUrl}/mcp`),
195
+ scopesSupported: ['mcp:tools'],
196
+ }));
197
+
198
+ rootApp.use(childApp)
199
+ // Start the main application
200
+ startAPI(rootApp, {port: port});
201
+
202
+ process.on('unhandledRejection', (reason, promise) => {
203
+ console.error('⛔ Unhandled Rejection:', reason);
204
+ if (reason instanceof Error && reason.stack) {
205
+ console.error('Stack trace:\n', reason.stack);
206
+ }
207
+ });
208
+ }
209
+
210
+ startServer().catch(error => {
211
+ console.error('Failed to start server:', error);
212
+ process.exit(1);
213
+ });
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ prompt() {
5
+ local var_name="$1"
6
+ local prompt_text="$2"
7
+ local value
8
+ read -rp "$prompt_text: " value
9
+ if [[ -z "$value" ]]; then
10
+ echo "Error: $var_name cannot be empty." >&2
11
+ exit 1
12
+ fi
13
+ echo "$value"
14
+ }
15
+
16
+ prompt_secret() {
17
+ local var_name="$1"
18
+ local prompt_text="$2"
19
+ local value
20
+ read -rsp "$prompt_text: " value
21
+ echo "" >&2
22
+ if [[ -z "$value" ]]; then
23
+ echo "Error: $var_name cannot be empty." >&2
24
+ exit 1
25
+ fi
26
+ echo "$value"
27
+ }
28
+
29
+ echo "=== Supabase .env setup ==="
30
+ echo ""
31
+
32
+ PROJECT_ID=$(prompt SUPABASE_PROJECT_ID "Supabase Project ID")
33
+ DB_PASSWORD=$(prompt_secret SUPABASE_DB_PASSWORD "Database Password")
34
+ PUBLISHABLE_KEY=$(prompt_secret SUPABASE_PUBLISHABLE_KEY "Supabase Publishable Key")
35
+ SECRET_KEY=$(prompt_secret SUPABASE_SECRET_KEY "Supabase Secret Key")
36
+ BACKEND_URL=$(prompt BACKEND_URL "Backend URL (e.g. https://api.yourapp.com)")
37
+ CORS_ORIGINS=$(prompt CORS_ORIGINS "Allowed CORS origins, comma-separated (e.g. https://app.yourapp.com)")
38
+
39
+ cat > .env <<EOF
40
+ SUPABASE_URL=https://${PROJECT_ID}.supabase.co
41
+ SUPABASE_PUBLISHABLE_KEY=${PUBLISHABLE_KEY}
42
+ SUPABASE_DB_URL=postgresql://postgres.${PROJECT_ID}:${DB_PASSWORD}@aws-1-eu-central-1.pooler.supabase.com:5432/postgres?prepareThreshold=0
43
+ SUPABASE_SECRET_KEY=${SECRET_KEY}
44
+
45
+ BACKEND_URL=${BACKEND_URL}
46
+ CORS_ORIGINS=${CORS_ORIGINS}
47
+
48
+ # Flyway configuration
49
+ FLYWAY_URL=jdbc:postgresql://aws-1-eu-west-1.pooler.supabase.com:6543/postgres?user=postgres.${PROJECT_ID}&password=${DB_PASSWORD}
50
+ FLYWAY_USER=postgres.${PROJECT_ID}
51
+ FLYWAY_PASSWORD=${DB_PASSWORD}
52
+ EOF
53
+
54
+ echo ""
55
+ echo ".env created successfully."
@@ -0,0 +1,6 @@
1
+ export function assertNotEmpty<T>(value: T): NonNullable<T> {
2
+ if (value === null || value === undefined) {
3
+ throw new Error("Expected non-empty value");
4
+ }
5
+ return value!!;
6
+ }
@@ -0,0 +1,32 @@
1
+ import knex, {Knex} from "knex";
2
+ import pg from "pg";
3
+
4
+ export const postgresUrl = process.env.SUPABASE_DB_URL ?? "missing-url"
5
+
6
+ let knexInstance: Knex | null = null;
7
+ let sharedPool: pg.Pool | null = null;
8
+
9
+ export const getKnexInstance = (): Knex => {
10
+ if (!knexInstance) {
11
+ knexInstance = knex({
12
+ client: 'pg',
13
+ connection: postgresUrl,
14
+ pool: { min: 0, max: 5 },
15
+ });
16
+ }
17
+ return knexInstance;
18
+ };
19
+
20
+ export const getSharedPool = (): pg.Pool => {
21
+ if (!sharedPool) {
22
+ sharedPool = new pg.Pool({ connectionString: postgresUrl, max: 5 });
23
+ }
24
+ return sharedPool;
25
+ };
26
+
27
+ export const closeDb = async (): Promise<void> => {
28
+ await knexInstance?.destroy();
29
+ await sharedPool?.end();
30
+ knexInstance = null;
31
+ sharedPool = null;
32
+ };
@@ -0,0 +1,39 @@
1
+ import {getPostgreSQLEventStore} from "@event-driven-io/emmett-postgresql";
2
+ import {projections} from "@event-driven-io/emmett";
3
+ import {postgresUrl, getSharedPool} from "./db";
4
+ import {CreatedOrganizationsProjection} from "../slices/organization/CreatedOrganizations/CreatedOrganizationsProjection";
5
+ import {OrganizationLicenseProjection} from "../slices/organization/OrganizationLicense/OrganizationLicenseProjection";
6
+ import {InvitesProjection} from "../slices/organization/Invites/InvitesProjection";
7
+ import {OrganizationBoardsProjection} from "../slices/organization/OrganizationBoards/OrganizationBoardsProjection";
8
+ import {ActiveTokensProjection} from "../slices/organization/ActiveTokens/ActiveTokensProjection";
9
+ import {LicenseSeatsProjection} from "../slices/organization/LicenseSeats/LicenseSeatsProjection";
10
+ import {UserOrganizationsProjection} from "../slices/organization/UserOrganizations/UserOrganizationsProjection";
11
+ import {EnabledUsersProjection} from "../slices/beta/EnabledUsers/EnabledUsersProjection";
12
+
13
+ let eventStoreInstance: ReturnType<typeof getPostgreSQLEventStore> | null = null;
14
+
15
+ export const findEventstore = async () => {
16
+ if (!eventStoreInstance) {
17
+ eventStoreInstance = getPostgreSQLEventStore(postgresUrl, {
18
+ schema: {
19
+ autoMigration: "CreateOrUpdate"
20
+ },
21
+ connectionOptions: {
22
+ pooled: true,
23
+ pool: getSharedPool(),
24
+ },
25
+ projections: projections.inline([
26
+ CreatedOrganizationsProjection,
27
+ OrganizationLicenseProjection,
28
+ InvitesProjection,
29
+ OrganizationBoardsProjection,
30
+ ActiveTokensProjection,
31
+ LicenseSeatsProjection,
32
+ UserOrganizationsProjection,
33
+ EnabledUsersProjection,
34
+ ]),
35
+ });
36
+ await eventStoreInstance.schema.migrate();
37
+ }
38
+ return eventStoreInstance;
39
+ };
@@ -0,0 +1,51 @@
1
+ /*
2
+ * Copyright (c) 2025 Nebulit GmbH
3
+ * Licensed under the MIT License.
4
+ */
5
+
6
+ const serviceURI = "http://localhost:3000"
7
+
8
+ export function parseEndpoint(endpoint: string, data?: any) {
9
+ var parsedEndpoint = endpoint?.startsWith("/") ? endpoint.substring(1) : endpoint
10
+ return serviceURI + "/" + lowercaseFirstCharacter(parsedEndpoint).replace(/{(\w+)}/g, (match, param) => {
11
+ return param && data && data[param] !== undefined ? data[param] : match;
12
+ })
13
+ }
14
+
15
+
16
+ export function parseQueryEndpoint(
17
+ endpoint: string,
18
+ queries?: Record<string, string>
19
+ ) {
20
+ const parsedEndpoint = endpoint.startsWith("/")
21
+ ? endpoint.substring(1)
22
+ : endpoint;
23
+
24
+ const basePath =
25
+ serviceURI + "/api/query/" + parsedEndpoint;
26
+
27
+ const queryString = queries
28
+ ? "?" + new URLSearchParams(filterEmptyEntries(queries)).toString()
29
+ : "";
30
+
31
+ return basePath + queryString;
32
+ }
33
+
34
+ function filterEmptyEntries(queries?: Record<string, string>): Record<string, string> {
35
+ if (!queries) return {};
36
+ return Object.fromEntries(
37
+ Object.entries(queries).filter(([key, value]) => value !== "")
38
+ );
39
+ }
40
+
41
+
42
+ function lowercaseFirstCharacter(inputString: string) {
43
+ // Check if the string is not empty
44
+ if (inputString?.length > 0) {
45
+ // Capitalize the first character and concatenate the rest of the string
46
+ return inputString.charAt(0).toLowerCase() + inputString.substring(1);
47
+ } else {
48
+ // Return an empty string if the input is empty
49
+ return "";
50
+ }
51
+ }
@@ -0,0 +1,28 @@
1
+ import {getKnexInstance} from './db';
2
+ import type {AnyRecordedMessageMetadata, RecordedMessage} from '@event-driven-io/emmett';
3
+
4
+ export const storeDlqMessage = async (
5
+ processorId: string,
6
+ message: RecordedMessage<any, AnyRecordedMessageMetadata>,
7
+ error: unknown,
8
+ ): Promise<void> => {
9
+
10
+ try {
11
+ console.log(`Processing DLQ ${JSON.stringify({ type: message.type, data: message.data, metadata: message.metadata } , (key, value) =>
12
+ typeof value === 'bigint' ? value.toString() : value
13
+ )}`)
14
+ await getKnexInstance()('processor_dlq').insert({
15
+ processor_id: processorId,
16
+ stream_id: message.metadata.streamName,
17
+ event: JSON.parse(
18
+ JSON.stringify({ type: message.type, data: message.data, metadata: message.metadata } , (key, value) =>
19
+ typeof value === 'bigint' ? value.toString() : value
20
+ )
21
+ ),
22
+ error: error instanceof Error ? error.message : String(error),
23
+ });
24
+ } catch (dlqError) {
25
+ console.error('Failed to write to processor_dlq:', dlqError);
26
+ }
27
+ };
28
+
@@ -0,0 +1,19 @@
1
+ const url = `${process.env.SUPABASE_URL}/realtime/v1/api/broadcast`;
2
+ const key = process.env.SUPABASE_SECRET_KEY!;
3
+
4
+ export async function broadcastRealtime(topic: string, event: string, payload: unknown, privateChannel = true): Promise<void> {
5
+ const res = await fetch(url, {
6
+ method: 'POST',
7
+ headers: {
8
+ 'Content-Type': 'application/json',
9
+ 'Authorization': `Bearer ${key}`,
10
+ 'apikey': key,
11
+ },
12
+ body: JSON.stringify({
13
+ messages: [{ topic, event, payload, private: privateChannel }],
14
+ }),
15
+ });
16
+ if (!res.ok) {
17
+ throw new Error(`realtime broadcast failed: ${res.status} ${await res.text()}`);
18
+ }
19
+ }
@@ -0,0 +1,16 @@
1
+ import {PostgreSQLProjectionDefinition, rebuildPostgreSQLProjections} from "@event-driven-io/emmett-postgresql";
2
+ import {postgresUrl} from "./db";
3
+ import {glob} from "glob";
4
+ import path from "path";
5
+
6
+ const slicesRoot = path.resolve(__dirname, '../slices');
7
+
8
+ export const replayProjection = async (projectionName: string): Promise<void> => {
9
+ const [filePath] = await glob(`**/${projectionName}.{ts|js}`, {cwd: slicesRoot, absolute: true});
10
+ if (!filePath) throw new Error(`Projection not found: ${projectionName}`);
11
+
12
+ const projectionImport = await import(filePath);
13
+ const projection: PostgreSQLProjectionDefinition = projectionImport[projectionName];
14
+
15
+ return rebuildPostgreSQLProjections({projection, connectionString: postgresUrl}).start();
16
+ }
@@ -0,0 +1,19 @@
1
+ import {Request, Response, Router} from 'express';
2
+ import {WebApiSetup} from "@event-driven-io/emmett-expressjs";
3
+ import {assertNotEmpty} from "../util/assertions";
4
+ import {replayProjection} from "./replay";
5
+
6
+
7
+ export const api =
8
+ (
9
+ // external dependencies
10
+ ): WebApiSetup =>
11
+ (router: Router): void => {
12
+
13
+ router.post('/api/replay/:projection', async (req: Request, res: Response) => {
14
+ const projection = assertNotEmpty(req.params.projection)
15
+ await replayProjection(projection)
16
+ res.status(200).json({"projection":projection})
17
+ });
18
+ };
19
+
@@ -0,0 +1,54 @@
1
+ import {execSync} from 'child_process';
2
+ import {readFileSync, writeFileSync, unlinkSync} from 'fs';
3
+ import {tmpdir} from 'os';
4
+ import {join} from 'path';
5
+ import knex from 'knex';
6
+
7
+ export async function runFlywayMigrations(connectionString: string): Promise<void> {
8
+ const stubsPath = join(process.cwd(), 'supabase', 'migrations', '_V0__supabase_stubs.sql');
9
+ const stubsSql = readFileSync(stubsPath, 'utf8');
10
+ const db = knex({client: 'pg', connection: connectionString});
11
+ try {
12
+ await db.raw(stubsSql);
13
+ } finally {
14
+ await db.destroy();
15
+ }
16
+
17
+ const url = new URL(connectionString);
18
+ const jdbcUrl = `jdbc:postgresql://${url.hostname}:${url.port || 5432}${url.pathname}`;
19
+ const user = url.username;
20
+ const password = url.password;
21
+
22
+ const tempConfigPath = join(tmpdir(), `flyway-test-${Date.now()}.conf`);
23
+ const migrationsPath = join(process.cwd(), 'supabase', 'migrations');
24
+
25
+ const config = `
26
+ flyway.url=${jdbcUrl}
27
+ flyway.user=${user}
28
+ flyway.password=${password}
29
+ flyway.locations=filesystem:${migrationsPath}
30
+ flyway.schemas=public
31
+ flyway.placeholderReplacement=false
32
+ flyway.validateOnMigrate=true
33
+ flyway.cleanDisabled=false
34
+ `;
35
+
36
+ try {
37
+ writeFileSync(tempConfigPath, config, 'utf8');
38
+ execSync(`flyway -configFiles=${tempConfigPath} migrate`, {
39
+ stdio: 'pipe',
40
+ encoding: 'utf8'
41
+ });
42
+ } catch (error: any) {
43
+ console.error('Flyway migration failed:', error.message);
44
+ if (error.stdout) console.error('STDOUT:', error.stdout);
45
+ if (error.stderr) console.error('STDERR:', error.stderr);
46
+ throw new Error(`Flyway migration failed: ${error.message}`);
47
+ } finally {
48
+ try {
49
+ unlinkSync(tempConfigPath);
50
+ } catch {
51
+ // ignore
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,134 @@
1
+ import {Request, Response, Router} from 'express';
2
+ import {WebApiSetup} from '@event-driven-io/emmett-expressjs';
3
+ import {getKnexInstance} from '../../common/db';
4
+ import {requireUser} from '../../supabase/requireUser';
5
+ import {assertNotEmpty} from '../../util/assertions';
6
+
7
+ export const api = (): WebApiSetup => (router: Router): void => {
8
+
9
+ /**
10
+ * @openapi
11
+ * /api/org/{orgId}/examples:
12
+ * get:
13
+ * summary: List all examples for an organization
14
+ * tags: [Example]
15
+ * security:
16
+ * - bearerAuth: []
17
+ * parameters:
18
+ * - in: path
19
+ * name: orgId
20
+ * required: true
21
+ * schema: { type: string }
22
+ * responses:
23
+ * 200:
24
+ * description: List of examples
25
+ * 401:
26
+ * description: Unauthorized
27
+ */
28
+ router.get('/api/org/:orgId/examples', async (req: Request, res: Response) => {
29
+ const auth = await requireUser(req, res);
30
+ if (auth.error) return;
31
+
32
+ const {orgId} = req.params;
33
+ const db = getKnexInstance();
34
+
35
+ const rows = await db('examples').where({organization_id: orgId}).select('*');
36
+ res.status(200).json(rows);
37
+ });
38
+
39
+ /**
40
+ * @openapi
41
+ * /api/org/{orgId}/examples:
42
+ * post:
43
+ * summary: Create a new example
44
+ * tags: [Example]
45
+ * security:
46
+ * - bearerAuth: []
47
+ * parameters:
48
+ * - in: path
49
+ * name: orgId
50
+ * required: true
51
+ * schema: { type: string }
52
+ * requestBody:
53
+ * required: true
54
+ * content:
55
+ * application/json:
56
+ * schema:
57
+ * type: object
58
+ * required: [name]
59
+ * properties:
60
+ * name:
61
+ * type: string
62
+ * responses:
63
+ * 201:
64
+ * description: Example created
65
+ * 400:
66
+ * description: name is required
67
+ * 401:
68
+ * description: Unauthorized
69
+ */
70
+ router.post('/api/org/:orgId/examples', async (req: Request, res: Response) => {
71
+ const auth = await requireUser(req, res);
72
+ if (auth.error) return;
73
+
74
+ const {orgId} = req.params;
75
+ const {name} = req.body as {name?: string};
76
+
77
+ if (!name) {
78
+ res.status(400).json({error: 'name is required'});
79
+ return;
80
+ }
81
+
82
+ const db = getKnexInstance();
83
+ const [row] = await db('examples')
84
+ .insert({organization_id: orgId, name, created_by: auth.user.id})
85
+ .returning('*');
86
+
87
+ res.status(201).json(row);
88
+ });
89
+
90
+ /**
91
+ * @openapi
92
+ * /api/org/{orgId}/examples/{id}:
93
+ * delete:
94
+ * summary: Delete an example by id
95
+ * tags: [Example]
96
+ * security:
97
+ * - bearerAuth: []
98
+ * parameters:
99
+ * - in: path
100
+ * name: orgId
101
+ * required: true
102
+ * schema: { type: string }
103
+ * - in: path
104
+ * name: id
105
+ * required: true
106
+ * schema: { type: string }
107
+ * responses:
108
+ * 200:
109
+ * description: Example deleted
110
+ * 401:
111
+ * description: Unauthorized
112
+ * 404:
113
+ * description: Not found
114
+ */
115
+ router.delete('/api/org/:orgId/examples/:id', async (req: Request, res: Response) => {
116
+ const auth = await requireUser(req, res);
117
+ if (auth.error) return;
118
+
119
+ const orgId = assertNotEmpty(req.params.orgId);
120
+ const id = assertNotEmpty(req.params.id);
121
+
122
+ const db = getKnexInstance();
123
+ const deleted = await db('examples')
124
+ .where({id, organization_id: orgId})
125
+ .delete();
126
+
127
+ if (!deleted) {
128
+ res.status(404).json({error: 'not found'});
129
+ return;
130
+ }
131
+
132
+ res.status(200).json({ok: true});
133
+ });
134
+ };