@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.
- package/package.json +1 -1
- package/templates/.claude/skills/build-automation/SKILL.md +260 -0
- package/templates/.claude/skills/build-state-change/SKILL.md +329 -0
- package/templates/.claude/skills/build-state-view/SKILL.md +384 -0
- package/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +609 -0
- package/templates/.claude/skills/load-slice/SKILL.md +69 -14
- package/templates/realtime-agent/src/index.js +12 -17
- package/templates/root/.env.example +22 -0
- package/templates/root/Claude.md +58 -0
- package/templates/root/agent.sh +15 -0
- package/templates/root/backend-prompt.md +139 -0
- package/templates/root/flyway.conf +17 -0
- package/templates/root/package.json +52 -0
- package/templates/root/ralph.sh +47 -26
- package/templates/root/server.ts +213 -0
- package/templates/root/setup-env.sh +55 -0
- package/templates/root/src/common/assertions.ts +6 -0
- package/templates/root/src/common/db.ts +32 -0
- package/templates/root/src/common/loadPostgresEventstore.ts +39 -0
- package/templates/root/src/common/parseEndpoint.ts +51 -0
- package/templates/root/src/common/processorDlq.ts +28 -0
- package/templates/root/src/common/realtimeBroadcast.ts +19 -0
- package/templates/root/src/common/replay.ts +16 -0
- package/templates/root/src/common/routes.ts +19 -0
- package/templates/root/src/common/testHelpers.ts +54 -0
- package/templates/root/src/slices/example/routes.ts +134 -0
- package/templates/root/src/supabase/LoginHandler.ts +36 -0
- package/templates/root/src/supabase/ProtectedPageProps.ts +21 -0
- package/templates/root/src/supabase/README.md +171 -0
- package/templates/root/src/supabase/api.ts +56 -0
- package/templates/root/src/supabase/component.ts +12 -0
- package/templates/root/src/supabase/requireOrgaAdmin.ts +32 -0
- package/templates/root/src/supabase/requireUser.ts +72 -0
- package/templates/root/src/supabase/serverProps.ts +25 -0
- package/templates/root/src/supabase/staticProps.ts +10 -0
- package/templates/root/src/swagger.ts +34 -0
- package/templates/root/src/util/assertions.ts +6 -0
- package/templates/root/src/util/hash.ts +9 -0
- package/templates/root/src/util/sanitize.ts +23 -0
- package/templates/root/supabase/config.toml +295 -0
- package/templates/root/supabase/migrations/V1__schema.sql.example +12 -0
- package/templates/root/supabase/seed.sql +1 -0
- package/templates/root/tsconfig.json +32 -0
- package/templates/root/vercel.json +8 -0
- 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,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
|
+
};
|