create-flowdular 0.6.0 → 0.6.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/agent-template/.agents/skills/module-new/SKILL.md +1 -1
- package/agent-template/.agents/skills/module-update/SKILL.md +1 -1
- package/agent-template/.agents/skills/perf-audit/SKILL.md +1 -1
- package/agent-template/.agents/skills/spec-approval/SKILL.md +6 -2
- package/agent-template/.agents/skills/spec-interview/SKILL.md +2 -2
- package/agent-template/.ai/agents/README.md +1 -1
- package/agent-template/.ai/agents/sandbox/agentic-engineer.md +1 -1
- package/agent-template/.ai/agents/sandbox/backend-engineer.md +1 -1
- package/agent-template/.ai/agents/sandbox/business-manager.md +2 -4
- package/agent-template/.ai/agents/sandbox/frontend-engineer.md +1 -1
- package/agent-template/.ai/agents/sandbox/ux-designer.md +1 -1
- package/agent-template/.ai/platform-capabilities.md +4 -4
- package/agent-template/.ai/policies/model-routing.yaml +2 -1
- package/agent-template/.ai/policies/task-budgets.yaml +1 -1
- package/agent-template/.ai/references/catalog/migrations/0001_catalog_core.up.sql +2 -2
- package/agent-template/.ai/references/catalog/migrations/0002_catalog_history.up.sql +2 -2
- package/agent-template/.ai/references/catalog/migrations/0003_catalog_history_service_actors.up.sql +2 -2
- package/agent-template/.ai/references/catalog/migrations/0004_catalog_idempotency_ledger.up.sql +2 -2
- package/agent-template/.ai/references/catalog/module.json +3 -3
- package/agent-template/.ai/references/catalog/package.json +2 -2
- package/agent-template/.ai/references/catalog/spec/module.yaml +3 -3
- package/agent-template/.ai/references/catalog/src/client/CatalogItemForm.tsrx +2 -2
- package/agent-template/.ai/references/catalog/src/services/migration.ts +8 -8
- package/agent-template/.ai/references/catalog/tests/migrations.test.ts +1 -1
- package/agent-template/.ai/skills/module-new/SKILL.md +1 -1
- package/agent-template/.ai/skills/module-update/SKILL.md +1 -1
- package/agent-template/.ai/skills/perf-audit/SKILL.md +1 -1
- package/agent-template/.ai/skills/spec-approval/SKILL.md +6 -2
- package/agent-template/.ai/skills/spec-interview/SKILL.md +2 -2
- package/agent-template/.claude/skills/module-new/SKILL.md +1 -1
- package/agent-template/.claude/skills/module-update/SKILL.md +1 -1
- package/agent-template/.claude/skills/perf-audit/SKILL.md +1 -1
- package/agent-template/.claude/skills/spec-approval/SKILL.md +6 -2
- package/agent-template/.claude/skills/spec-interview/SKILL.md +2 -2
- package/agent-template/docs/adr/0003-module-settings.md +2 -0
- package/agent-template/docs/agent-contract.md +1 -1
- package/agent-template/docs/cli-extensions.md +1 -0
- package/agent-template/docs/cli.md +16 -0
- package/agent-template/docs/configuration.md +32 -4
- package/agent-template/docs/database-adapters.md +10 -2
- package/agent-template/docs/design-system.md +6 -2
- package/agent-template/docs/getting-started.md +5 -1
- package/agent-template/docs/module-distribution.md +1 -2
- package/agent-template/docs/modules.md +5 -3
- package/agent-template/docs/sandbox.md +64 -7
- package/package.json +1 -1
- package/template/default/.env.example +5 -0
- package/template/default/infra/docker/.env.example +5 -0
- package/template/default/infra/docker/Dockerfile +5 -1
- package/template/default/infra/docker/compose.yaml +4 -0
- package/template/default/infra/kubernetes/deployment.yaml +5 -0
- package/template/default/infra/sdk-module-manifests.mjs +118 -0
- package/template/default/infra/vercel/build.mjs +9 -0
- package/template/default/modules/example/package.json +1 -1
- package/template/default/package.json +2 -3
- package/template/default/platform/octane.config.ts +53 -15
- package/template/default/platform/package.json +1 -1
- package/template/default/platform/scripts/dev.mjs +66 -21
- package/template/default/platform/src/server/lifecycle.ts +325 -0
- package/template/default/platform/src/server/setup/modules.ts +28 -32
- package/template/default/platform/src/server/setup/page.ts +54 -3
- package/template/default/platform/src/server/setup/routes.ts +1 -0
- package/template/default/platform/src/server/setup/seed.ts +51 -4
- package/agent-template/.ai/references/catalog.provenance.json +0 -65
|
@@ -44,6 +44,10 @@ import {
|
|
|
44
44
|
createReadinessEndpoint,
|
|
45
45
|
healthEndpoint,
|
|
46
46
|
} from './src/server/health.ts';
|
|
47
|
+
import {
|
|
48
|
+
createPlatformRuntimeLifecycle,
|
|
49
|
+
prepareAndActivatePlatformRuntimeLifecycle,
|
|
50
|
+
} from './src/server/lifecycle.ts';
|
|
47
51
|
import { createMetricsRoutes } from './src/server/metrics.ts';
|
|
48
52
|
import {
|
|
49
53
|
platformRuntimeRole,
|
|
@@ -97,6 +101,11 @@ function firstRunConfig(databasePreconfigured = false) {
|
|
|
97
101
|
}
|
|
98
102
|
|
|
99
103
|
async function createPlatformConfig() {
|
|
104
|
+
if (process.env.FD_INTERNAL_PLATFORM_TERMINATING === 'true') {
|
|
105
|
+
throw new Error(
|
|
106
|
+
'Platform startup was requested while the process is stopping.',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
100
109
|
loadPlatformEnvironmentFile(workspaceRoot);
|
|
101
110
|
const serverless = process.env.FD_DEPLOYMENT_TARGET === 'vercel';
|
|
102
111
|
if (!building && !platformDatabaseConfigured(process.env)) {
|
|
@@ -215,10 +224,11 @@ async function createPlatformConfig() {
|
|
|
215
224
|
catalogue, so every reader sees the declarations the modules agreed on. */
|
|
216
225
|
dataClasses.seal();
|
|
217
226
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
227
|
+
/* Each evaluation of this file is one generation, and Vite evaluates it more
|
|
228
|
+
than once per process. The generation retires when the next one is
|
|
229
|
+
prepared, when the development server stops, or on a stop signal. */
|
|
230
|
+
const lifecycle = createPlatformRuntimeLifecycle();
|
|
231
|
+
lifecycle.add(async () => {
|
|
222
232
|
await ticker?.close();
|
|
223
233
|
for (const composition of moduleCompositions) {
|
|
224
234
|
await composition.stop?.();
|
|
@@ -230,24 +240,38 @@ async function createPlatformConfig() {
|
|
|
230
240
|
/* Last, so the spans and error reports this process queued while it stopped
|
|
231
241
|
still leave with it. */
|
|
232
242
|
await observability.dispose();
|
|
233
|
-
};
|
|
243
|
+
});
|
|
234
244
|
|
|
235
245
|
/* check() proves the runtime role holds neither SUPERUSER nor BYPASSRLS before
|
|
236
246
|
any module reads a row. */
|
|
237
247
|
if (!building) {
|
|
238
248
|
try {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
249
|
+
/* The previous generation retires once this one is prepared, and this
|
|
250
|
+
one starts its workers only after the previous has drained. */
|
|
251
|
+
await prepareAndActivatePlatformRuntimeLifecycle(lifecycle, [
|
|
252
|
+
async () => {
|
|
253
|
+
await databases.check();
|
|
254
|
+
},
|
|
255
|
+
/* Platform-scoped settings are read by background work before any request
|
|
256
|
+
could prime them; a workspace is primed by the authentication middleware. */
|
|
257
|
+
() => settings.prime(PLATFORM_SETTINGS_TENANT),
|
|
258
|
+
...moduleCompositions.map(
|
|
259
|
+
(composition) => () => composition.prepare?.(),
|
|
260
|
+
),
|
|
261
|
+
]);
|
|
262
|
+
/* A stop that arrived while this generation was preparing retired only
|
|
263
|
+
the generations active then, so this one retires itself. */
|
|
264
|
+
if (process.env.FD_INTERNAL_PLATFORM_TERMINATING === 'true') {
|
|
265
|
+
throw new Error(
|
|
266
|
+
'Platform startup was requested while the process is stopping.',
|
|
267
|
+
);
|
|
268
|
+
}
|
|
245
269
|
for (const composition of moduleCompositions) composition.start?.();
|
|
246
270
|
await startModuleWorkers(moduleCompositions, runtimeRole);
|
|
247
271
|
} catch (error) {
|
|
248
272
|
/* A worker that started before the failure would keep running in a
|
|
249
273
|
process that never serves. The boot failure is the one rethrown. */
|
|
250
|
-
await
|
|
274
|
+
await lifecycle.retire().catch((cleanupError: unknown) => {
|
|
251
275
|
serverLogger().error('platform boot cleanup failed', {
|
|
252
276
|
module: 'platform',
|
|
253
277
|
err: cleanupError,
|
|
@@ -259,10 +283,23 @@ async function createPlatformConfig() {
|
|
|
259
283
|
|
|
260
284
|
// Bundling needs route declarations without background work or retained leases.
|
|
261
285
|
if (building) {
|
|
262
|
-
await
|
|
286
|
+
await lifecycle.retire();
|
|
263
287
|
} else {
|
|
264
|
-
|
|
265
|
-
|
|
288
|
+
/* scripts/dev.mjs retires every generation itself; a production server
|
|
289
|
+
has only these. */
|
|
290
|
+
const retire = () =>
|
|
291
|
+
void lifecycle.retire().catch((error: unknown) => {
|
|
292
|
+
serverLogger().error('platform shutdown failed', {
|
|
293
|
+
module: 'platform',
|
|
294
|
+
err: error,
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
process.once('SIGINT', retire);
|
|
298
|
+
process.once('SIGTERM', retire);
|
|
299
|
+
lifecycle.add(() => {
|
|
300
|
+
process.off('SIGINT', retire);
|
|
301
|
+
process.off('SIGTERM', retire);
|
|
302
|
+
});
|
|
266
303
|
}
|
|
267
304
|
|
|
268
305
|
return defineConfig({
|
|
@@ -273,6 +310,7 @@ async function createPlatformConfig() {
|
|
|
273
310
|
createCorsMiddleware({
|
|
274
311
|
allowOrigin: (origin) => authRuntime.apiOriginAllowed(origin),
|
|
275
312
|
}),
|
|
313
|
+
lifecycle.middleware,
|
|
276
314
|
...(firstRun ? [firstRun.middleware] : []),
|
|
277
315
|
authRuntime.middleware,
|
|
278
316
|
],
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
1
2
|
import { resolve } from 'node:path';
|
|
2
3
|
import { fileURLToPath } from 'node:url';
|
|
3
4
|
import { spawnSync } from 'node:child_process';
|
|
@@ -9,6 +10,12 @@ import {
|
|
|
9
10
|
createTheme,
|
|
10
11
|
printReady,
|
|
11
12
|
} from '@flowdular/sdk/dev-console';
|
|
13
|
+
import {
|
|
14
|
+
PLATFORM_SHUTDOWN_BUDGET_MS,
|
|
15
|
+
retirePlatformRuntimes,
|
|
16
|
+
stopOnSignals,
|
|
17
|
+
stopServing,
|
|
18
|
+
} from '@flowdular/sdk/dev-console/shutdown';
|
|
12
19
|
|
|
13
20
|
const appRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
14
21
|
/* `pnpm dev -- --port 4396 --host 0.0.0.0` overrides vite.config.ts, so a
|
|
@@ -27,36 +34,81 @@ const verbose =
|
|
|
27
34
|
const color = shouldUseColor();
|
|
28
35
|
const restoreConsole = installOctaneConsoleBridge(verbose, color);
|
|
29
36
|
|
|
37
|
+
/* Vite serves in middleware mode behind this server. A Vite that listens
|
|
38
|
+
itself exits the process on SIGTERM as soon as it has closed, before
|
|
39
|
+
octane.config.ts has released its databases. */
|
|
30
40
|
let server;
|
|
41
|
+
const httpServer = createHttpServer((request, response) =>
|
|
42
|
+
server.middlewares(request, response),
|
|
43
|
+
);
|
|
44
|
+
let localUrl;
|
|
31
45
|
try {
|
|
32
46
|
server = await createServer({
|
|
33
47
|
root: appRoot,
|
|
34
48
|
configFile: resolve(appRoot, 'vite.config.ts'),
|
|
35
49
|
customLogger: createOctaneLogger(verbose, color),
|
|
36
50
|
clearScreen: false,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
:
|
|
40
|
-
|
|
41
|
-
|
|
51
|
+
server: {
|
|
52
|
+
middlewareMode: true,
|
|
53
|
+
ws: { server: httpServer },
|
|
54
|
+
...(Number.isInteger(port) && port > 0 ? { port, strictPort: true } : {}),
|
|
55
|
+
...(host ? { host } : {}),
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
/* The address Vite would have bound: vite.config.ts or the flags above,
|
|
59
|
+
and localhost when neither names a host. */
|
|
60
|
+
const listenPort = server.config.server.port;
|
|
61
|
+
const configuredHost = server.config.server.host;
|
|
62
|
+
const listenHost =
|
|
63
|
+
configuredHost === true ? undefined : configuredHost || 'localhost';
|
|
64
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
65
|
+
httpServer.once('error', rejectListen);
|
|
66
|
+
httpServer.listen(listenPort, listenHost, () => {
|
|
67
|
+
httpServer.off('error', rejectListen);
|
|
68
|
+
resolveListen();
|
|
69
|
+
});
|
|
42
70
|
});
|
|
43
|
-
|
|
71
|
+
const displayHost =
|
|
72
|
+
listenHost === undefined || listenHost === '0.0.0.0' || listenHost === '::'
|
|
73
|
+
? 'localhost'
|
|
74
|
+
: listenHost;
|
|
75
|
+
localUrl = `http://${displayHost.includes(':') ? `[${displayHost}]` : displayHost}:${listenPort}/`;
|
|
44
76
|
} catch (error) {
|
|
77
|
+
if (httpServer.listening) httpServer.close();
|
|
45
78
|
restoreConsole();
|
|
46
79
|
throw error;
|
|
47
80
|
}
|
|
81
|
+
|
|
82
|
+
/* The process ends on its own once every runtime generation has released
|
|
83
|
+
what it holds, so one that was still preparing when the stop arrived
|
|
84
|
+
drains as well; the deadline bounds the wait. */
|
|
85
|
+
async function stop() {
|
|
86
|
+
process.env.FD_INTERNAL_PLATFORM_TERMINATING = 'true';
|
|
87
|
+
try {
|
|
88
|
+
await Promise.all([
|
|
89
|
+
stopServing(httpServer, server),
|
|
90
|
+
retirePlatformRuntimes(),
|
|
91
|
+
]);
|
|
92
|
+
} finally {
|
|
93
|
+
await server.close();
|
|
94
|
+
restoreConsole();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
stopOnSignals(
|
|
98
|
+
() =>
|
|
99
|
+
void stop().catch((error) => {
|
|
100
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
101
|
+
process.exitCode = 1;
|
|
102
|
+
}),
|
|
103
|
+
{ deadlineMs: PLATFORM_SHUTDOWN_BUDGET_MS },
|
|
104
|
+
);
|
|
105
|
+
|
|
48
106
|
printReady({
|
|
49
107
|
title: 'FLOWDULAR',
|
|
50
108
|
subtitle: 'development workspace',
|
|
51
109
|
theme: createTheme(color),
|
|
52
110
|
lines: [
|
|
53
|
-
[
|
|
54
|
-
'local',
|
|
55
|
-
server.resolvedUrls?.local?.[0] ??
|
|
56
|
-
server.resolvedUrls?.network?.[0] ??
|
|
57
|
-
'the address vite.config.ts sets',
|
|
58
|
-
'info',
|
|
59
|
-
],
|
|
111
|
+
['local', localUrl, 'info'],
|
|
60
112
|
['diagnostics', verbose ? 'verbose' : 'quiet · use --verbose', 'muted'],
|
|
61
113
|
],
|
|
62
114
|
});
|
|
@@ -80,8 +132,7 @@ function openBrowser(url) {
|
|
|
80
132
|
return !result.error && result.status === 0;
|
|
81
133
|
}
|
|
82
134
|
|
|
83
|
-
|
|
84
|
-
if (localUrl && !process.argv.includes('--no-open')) {
|
|
135
|
+
if (!process.argv.includes('--no-open')) {
|
|
85
136
|
const setupUrl = new URL('/setup', localUrl).href;
|
|
86
137
|
try {
|
|
87
138
|
const response = await fetch(setupUrl, {
|
|
@@ -96,9 +147,3 @@ if (localUrl && !process.argv.includes('--no-open')) {
|
|
|
96
147
|
// The server remains usable when a browser is unavailable.
|
|
97
148
|
}
|
|
98
149
|
}
|
|
99
|
-
|
|
100
|
-
/* Closing without exiting lets octane.config.ts release the database on the
|
|
101
|
-
same signal; the process ends once both have drained. */
|
|
102
|
-
const stop = () => void server.close().finally(restoreConsole);
|
|
103
|
-
process.once('SIGINT', stop);
|
|
104
|
-
process.once('SIGTERM', stop);
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import type { EventEmitter } from 'node:events';
|
|
4
|
+
import { BroadcastChannel } from 'node:worker_threads';
|
|
5
|
+
import type { Middleware } from '@octanejs/app-core';
|
|
6
|
+
import { serverLogger, trackResponseBody } from '@flowdular/sdk/server';
|
|
7
|
+
|
|
8
|
+
export const PLATFORM_LIFECYCLE_SYMBOL = Symbol.for(
|
|
9
|
+
'flowdular.platform.runtime-lifecycle',
|
|
10
|
+
);
|
|
11
|
+
export const PLATFORM_LIFECYCLE_ACTIVATE_EVENT =
|
|
12
|
+
'flowdular:platform-runtime-activate';
|
|
13
|
+
export const PLATFORM_LIFECYCLE_RETIRE_EVENT =
|
|
14
|
+
'flowdular:platform-runtime-retire';
|
|
15
|
+
const PLATFORM_LIFECYCLE_CHANNEL = 'flowdular.platform.runtime-lifecycle';
|
|
16
|
+
|
|
17
|
+
type Dispose = () => void | Promise<void>;
|
|
18
|
+
|
|
19
|
+
export interface PlatformRuntimeLifecycle {
|
|
20
|
+
readonly middleware: Middleware;
|
|
21
|
+
/** Runs as soon as retirement begins, before the requests in flight drain:
|
|
22
|
+
for a producer that holds a request open until it is told to stop. */
|
|
23
|
+
addInterrupt(interrupt: Dispose): void;
|
|
24
|
+
addQuiesce(quiesce: Dispose): void;
|
|
25
|
+
add(dispose: Dispose): void;
|
|
26
|
+
retire(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type ProcessWithLifecycle = NodeJS.Process & {
|
|
30
|
+
[PLATFORM_LIFECYCLE_SYMBOL]?: PlatformRuntimeLifecycle;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/* An event stream never finishes on its own. Its protocol has the client
|
|
34
|
+
reconnect and resume from Last-Event-ID, which reaches the next generation,
|
|
35
|
+
so retirement ends it instead of waiting for it. Any other body drains. */
|
|
36
|
+
function isEventStream(response: Response): boolean {
|
|
37
|
+
const type = response.headers.get('content-type')?.split(';', 1)[0];
|
|
38
|
+
return type?.trim().toLowerCase() === 'text/event-stream';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* EventSource stops reconnecting for good on any answer but a 200 event
|
|
42
|
+
stream, and a production server keeps listening while it retires. A stream
|
|
43
|
+
request to a retired generation gets an empty stream that ends at once, so
|
|
44
|
+
the client keeps reconnecting until the next process answers. */
|
|
45
|
+
function retiredResponse(request: Request | undefined): Response {
|
|
46
|
+
if (
|
|
47
|
+
request?.method === 'GET' &&
|
|
48
|
+
request.headers.get('accept')?.includes('text/event-stream')
|
|
49
|
+
) {
|
|
50
|
+
return new Response('retry: 1000\n\n', {
|
|
51
|
+
headers: {
|
|
52
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
53
|
+
'cache-control': 'no-store',
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return new Response(null, {
|
|
58
|
+
status: 503,
|
|
59
|
+
headers: { 'retry-after': '1' },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* Vite evaluates octane.config.ts again when one of its SSR dependencies is
|
|
64
|
+
invalidated. A generation owns every resource created by that evaluation.
|
|
65
|
+
Retirement interrupts what would hold a request open, waits for requests
|
|
66
|
+
already using the old route closures, then disposes its resources in
|
|
67
|
+
reverse composition order. */
|
|
68
|
+
export function createPlatformRuntimeLifecycle(): PlatformRuntimeLifecycle {
|
|
69
|
+
const disposers: Dispose[] = [];
|
|
70
|
+
const quiescers: Dispose[] = [];
|
|
71
|
+
const interrupts = new Set<Dispose>();
|
|
72
|
+
let interrupted: Promise<unknown[]> = Promise.resolve([]);
|
|
73
|
+
let activeRequests = 0;
|
|
74
|
+
let retired = false;
|
|
75
|
+
let finishing = false;
|
|
76
|
+
let disposal: Promise<void> | undefined;
|
|
77
|
+
let resolveDisposal: (() => void) | undefined;
|
|
78
|
+
let rejectDisposal: ((error: unknown) => void) | undefined;
|
|
79
|
+
|
|
80
|
+
const finish = () => {
|
|
81
|
+
if (!retired || activeRequests !== 0 || disposal === undefined || finishing)
|
|
82
|
+
return;
|
|
83
|
+
finishing = true;
|
|
84
|
+
const currentQuiescers = quiescers.splice(0).reverse();
|
|
85
|
+
const current = disposers.splice(0).reverse();
|
|
86
|
+
void (async () => {
|
|
87
|
+
const failures = await interrupted;
|
|
88
|
+
/* Every background producer stops before any module repository closes. */
|
|
89
|
+
for (const quiesce of currentQuiescers) {
|
|
90
|
+
try {
|
|
91
|
+
await quiesce();
|
|
92
|
+
} catch (error) {
|
|
93
|
+
failures.push(error);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const dispose of current) {
|
|
97
|
+
try {
|
|
98
|
+
await dispose();
|
|
99
|
+
} catch (error) {
|
|
100
|
+
failures.push(error);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (failures.length > 0) {
|
|
104
|
+
/* A logger reads the message, not the errors array, so each cause
|
|
105
|
+
is named there rather than left for a debugger. */
|
|
106
|
+
rejectDisposal?.(
|
|
107
|
+
new AggregateError(
|
|
108
|
+
failures,
|
|
109
|
+
'Platform runtime teardown did not release every resource: ' +
|
|
110
|
+
failures
|
|
111
|
+
.map((failure) =>
|
|
112
|
+
failure instanceof Error ? failure.message : String(failure),
|
|
113
|
+
)
|
|
114
|
+
.join('; '),
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
} else {
|
|
118
|
+
resolveDisposal?.();
|
|
119
|
+
}
|
|
120
|
+
})();
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const interrupt = async (): Promise<unknown[]> => {
|
|
124
|
+
const failures: unknown[] = [];
|
|
125
|
+
await Promise.all(
|
|
126
|
+
[...interrupts].map(async (run) => {
|
|
127
|
+
try {
|
|
128
|
+
await run();
|
|
129
|
+
} catch (error) {
|
|
130
|
+
failures.push(error);
|
|
131
|
+
}
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
return failures;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const lifecycle: PlatformRuntimeLifecycle = {
|
|
138
|
+
middleware: async (context, next) => {
|
|
139
|
+
if (retired) return retiredResponse(context.request);
|
|
140
|
+
activeRequests += 1;
|
|
141
|
+
let endStream: (() => void) | undefined;
|
|
142
|
+
const release = () => {
|
|
143
|
+
if (endStream) interrupts.delete(endStream);
|
|
144
|
+
activeRequests -= 1;
|
|
145
|
+
finish();
|
|
146
|
+
};
|
|
147
|
+
try {
|
|
148
|
+
const response = await next();
|
|
149
|
+
const signal = context.request?.signal;
|
|
150
|
+
if (!isEventStream(response)) {
|
|
151
|
+
return trackResponseBody(response, release, signal);
|
|
152
|
+
}
|
|
153
|
+
const end = new AbortController();
|
|
154
|
+
endStream = () => end.abort(new Error('The platform runtime retired.'));
|
|
155
|
+
if (retired) endStream();
|
|
156
|
+
else interrupts.add(endStream);
|
|
157
|
+
return trackResponseBody(
|
|
158
|
+
response,
|
|
159
|
+
release,
|
|
160
|
+
signal ? AbortSignal.any([signal, end.signal]) : end.signal,
|
|
161
|
+
);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
release();
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
addInterrupt(run) {
|
|
168
|
+
if (retired) {
|
|
169
|
+
void Promise.resolve()
|
|
170
|
+
.then(run)
|
|
171
|
+
.catch((error: unknown) => {
|
|
172
|
+
serverLogger().error('late platform interrupt failed', {
|
|
173
|
+
module: 'platform',
|
|
174
|
+
err: error,
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
interrupts.add(run);
|
|
180
|
+
},
|
|
181
|
+
addQuiesce(quiesce) {
|
|
182
|
+
if (retired) {
|
|
183
|
+
void Promise.resolve()
|
|
184
|
+
.then(quiesce)
|
|
185
|
+
.catch((error: unknown) => {
|
|
186
|
+
serverLogger().error('late platform quiesce failed', {
|
|
187
|
+
module: 'platform',
|
|
188
|
+
err: error,
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
quiescers.push(quiesce);
|
|
194
|
+
},
|
|
195
|
+
add(dispose) {
|
|
196
|
+
if (retired) {
|
|
197
|
+
void Promise.resolve()
|
|
198
|
+
.then(dispose)
|
|
199
|
+
.catch((error: unknown) => {
|
|
200
|
+
serverLogger().error('late platform teardown failed', {
|
|
201
|
+
module: 'platform',
|
|
202
|
+
err: error,
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
disposers.push(dispose);
|
|
208
|
+
},
|
|
209
|
+
retire() {
|
|
210
|
+
if (!disposal) {
|
|
211
|
+
disposal = new Promise<void>((resolve, reject) => {
|
|
212
|
+
resolveDisposal = resolve;
|
|
213
|
+
rejectDisposal = reject;
|
|
214
|
+
});
|
|
215
|
+
retired = true;
|
|
216
|
+
interrupted = interrupt();
|
|
217
|
+
finish();
|
|
218
|
+
}
|
|
219
|
+
return disposal;
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
return lifecycle;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* Activation happens only after the new configuration composed successfully.
|
|
226
|
+
A failed HMR evaluation therefore tears down only its partial generation and
|
|
227
|
+
leaves the previous, still-routable generation alive. */
|
|
228
|
+
export function activatePlatformRuntimeLifecycle(
|
|
229
|
+
lifecycle: PlatformRuntimeLifecycle,
|
|
230
|
+
): Promise<void> {
|
|
231
|
+
const owner = process as ProcessWithLifecycle;
|
|
232
|
+
const events = process as unknown as EventEmitter;
|
|
233
|
+
const generationId = `${process.pid}:${randomUUID()}`;
|
|
234
|
+
const channel = new BroadcastChannel(PLATFORM_LIFECYCLE_CHANNEL);
|
|
235
|
+
channel.unref();
|
|
236
|
+
const previous = owner[PLATFORM_LIFECYCLE_SYMBOL];
|
|
237
|
+
owner[PLATFORM_LIFECYCLE_SYMBOL] = lifecycle;
|
|
238
|
+
/* Octane loads its config once through the Vite config loader and again
|
|
239
|
+
through the SSR module runner. Their process wrappers do not share custom
|
|
240
|
+
properties, but both delegate EventEmitter operations to the real process.
|
|
241
|
+
The event is therefore the cross-runner ownership handoff. */
|
|
242
|
+
const onActivation = (
|
|
243
|
+
next: PlatformRuntimeLifecycle,
|
|
244
|
+
report?: (retirement: Promise<void>) => void,
|
|
245
|
+
) => {
|
|
246
|
+
if (next === lifecycle) return;
|
|
247
|
+
const retirement = lifecycle.retire();
|
|
248
|
+
report?.(retirement);
|
|
249
|
+
void retirement.catch((error: unknown) => {
|
|
250
|
+
serverLogger().error('stale platform teardown failed', {
|
|
251
|
+
module: 'platform',
|
|
252
|
+
err: error,
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
};
|
|
256
|
+
const onRetire = (report: (retirement: Promise<void>) => void) => {
|
|
257
|
+
report(lifecycle.retire());
|
|
258
|
+
};
|
|
259
|
+
channel.onmessage = (event) => {
|
|
260
|
+
if (!event.data || typeof event.data !== 'object') return;
|
|
261
|
+
const message = event.data as { type?: unknown; generationId?: unknown };
|
|
262
|
+
if (
|
|
263
|
+
message.type !== 'retire-all' &&
|
|
264
|
+
(message.type !== 'activate' || message.generationId === generationId)
|
|
265
|
+
) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
/* Closing a BroadcastChannel from inside its own callback can wait for the
|
|
269
|
+
callback to return. Start retirement in the next microtask so channel
|
|
270
|
+
teardown cannot deadlock the generation it is releasing. */
|
|
271
|
+
queueMicrotask(() => {
|
|
272
|
+
void lifecycle.retire().catch((error: unknown) => {
|
|
273
|
+
serverLogger().error('cross-runner platform teardown failed', {
|
|
274
|
+
module: 'platform',
|
|
275
|
+
err: error,
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
};
|
|
280
|
+
events.on(PLATFORM_LIFECYCLE_ACTIVATE_EVENT, onActivation);
|
|
281
|
+
events.on(PLATFORM_LIFECYCLE_RETIRE_EVENT, onRetire);
|
|
282
|
+
lifecycle.add(() => {
|
|
283
|
+
channel.close();
|
|
284
|
+
events.off(PLATFORM_LIFECYCLE_ACTIVATE_EVENT, onActivation);
|
|
285
|
+
events.off(PLATFORM_LIFECYCLE_RETIRE_EVENT, onRetire);
|
|
286
|
+
if (owner[PLATFORM_LIFECYCLE_SYMBOL] === lifecycle) {
|
|
287
|
+
delete owner[PLATFORM_LIFECYCLE_SYMBOL];
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
const retirements = new Set<Promise<void>>();
|
|
291
|
+
events.emit(
|
|
292
|
+
PLATFORM_LIFECYCLE_ACTIVATE_EVENT,
|
|
293
|
+
lifecycle,
|
|
294
|
+
(retirement: Promise<void>) => retirements.add(retirement),
|
|
295
|
+
);
|
|
296
|
+
channel.postMessage({ type: 'activate', generationId });
|
|
297
|
+
if (previous && previous !== lifecycle) {
|
|
298
|
+
const retirement = previous.retire();
|
|
299
|
+
retirements.add(retirement);
|
|
300
|
+
void retirement.catch((error: unknown) => {
|
|
301
|
+
serverLogger().error('stale platform teardown failed', {
|
|
302
|
+
module: 'platform',
|
|
303
|
+
err: error,
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return Promise.all(retirements).then(() => undefined);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/* Preparation may inspect durable state but must not create write handles,
|
|
311
|
+
workers or timers. Only a fully prepared generation may retire the one that
|
|
312
|
+
is currently serving requests. */
|
|
313
|
+
export async function prepareAndActivatePlatformRuntimeLifecycle(
|
|
314
|
+
lifecycle: PlatformRuntimeLifecycle,
|
|
315
|
+
preparations: readonly (() => void | Promise<void>)[],
|
|
316
|
+
): Promise<void> {
|
|
317
|
+
for (const prepare of preparations) await prepare();
|
|
318
|
+
await activatePlatformRuntimeLifecycle(lifecycle);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function activePlatformRuntimeLifecycle():
|
|
322
|
+
| PlatformRuntimeLifecycle
|
|
323
|
+
| undefined {
|
|
324
|
+
return (process as ProcessWithLifecycle)[PLATFORM_LIFECYCLE_SYMBOL];
|
|
325
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
3
|
import {
|
|
4
4
|
DATABASE_CAPABILITY_IDS,
|
|
5
5
|
DATABASE_DIALECT_IDS,
|
|
6
6
|
type ModuleDatabaseRequirements,
|
|
7
7
|
} from '@flowdular/sdk/database';
|
|
8
|
+
import { findModuleManifests } from '@flowdular/sdk/kernel/module-manifests';
|
|
8
9
|
import projectManifest from '../../../../flowdular.json';
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -27,9 +28,10 @@ export const PLATFORM_MODULE_DATABASE_REQUIREMENTS = Object.freeze({
|
|
|
27
28
|
export interface EnabledDatabaseModules {
|
|
28
29
|
readonly modules: readonly ModuleDatabaseRequirements[];
|
|
29
30
|
/**
|
|
30
|
-
* True when
|
|
31
|
-
* is treated as owning tenant-scoped tables. Over-approximating
|
|
32
|
-
* check strict; the review screen says the list came from this
|
|
31
|
+
* True when a module manifest could not be read, so the enabled module it
|
|
32
|
+
* belongs to is treated as owning tenant-scoped tables. Over-approximating
|
|
33
|
+
* keeps the check strict; the review screen says the list came from this
|
|
34
|
+
* fallback.
|
|
33
35
|
*/
|
|
34
36
|
readonly approximated: boolean;
|
|
35
37
|
}
|
|
@@ -75,9 +77,10 @@ function requirements(
|
|
|
75
77
|
|
|
76
78
|
/**
|
|
77
79
|
* The enabled modules that own database tables, in `flowdular.json` order.
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
80
|
+
* Manifests are found where the CLI finds them, @flowdular/sdk included. A
|
|
81
|
+
* container image ships only `platform/dist`, so the enabled list falls back
|
|
82
|
+
* to the manifest bundled at build time and an enabled module whose manifest
|
|
83
|
+
* is not beside it is assumed to own tenant-scoped tables.
|
|
81
84
|
*/
|
|
82
85
|
export function enabledDatabaseModules(
|
|
83
86
|
workspaceRoot: string,
|
|
@@ -87,37 +90,30 @@ export function enabledDatabaseModules(
|
|
|
87
90
|
(projectManifest as ProjectManifest);
|
|
88
91
|
const enabled = stringList(project.modules?.enabled);
|
|
89
92
|
if (enabled.length === 0) return { modules: [], approximated: false };
|
|
90
|
-
|
|
93
|
+
let paths: readonly string[] = [];
|
|
94
|
+
try {
|
|
95
|
+
paths = findModuleManifests(workspaceRoot, project.modules?.roots);
|
|
96
|
+
} catch {
|
|
97
|
+
/* Unreadable roots leave every module to the strict fallback below. */
|
|
98
|
+
}
|
|
91
99
|
const manifests = new Map<string, ModuleManifest>();
|
|
92
|
-
for (const
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
entries = readdirSync(directory);
|
|
97
|
-
} catch {
|
|
98
|
-
continue;
|
|
100
|
+
for (const path of paths) {
|
|
101
|
+
const manifest = readJson<ModuleManifest>(path);
|
|
102
|
+
if (typeof manifest?.id === 'string') {
|
|
103
|
+
manifests.set(manifest.id, manifest);
|
|
99
104
|
}
|
|
100
|
-
for (const entry of entries) {
|
|
101
|
-
const manifest = readJson<ModuleManifest>(
|
|
102
|
-
join(directory, entry, 'module.json'),
|
|
103
|
-
);
|
|
104
|
-
if (typeof manifest?.id === 'string') {
|
|
105
|
-
manifests.set(manifest.id, manifest);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
if (manifests.size === 0) {
|
|
110
|
-
return {
|
|
111
|
-
modules: enabled.map((moduleId) => requirements(moduleId, true)),
|
|
112
|
-
approximated: true,
|
|
113
|
-
};
|
|
114
105
|
}
|
|
115
106
|
const modules: ModuleDatabaseRequirements[] = [];
|
|
107
|
+
let approximated = false;
|
|
116
108
|
for (const moduleId of enabled) {
|
|
117
109
|
const manifest = manifests.get(moduleId);
|
|
118
|
-
if (!manifest)
|
|
110
|
+
if (!manifest) {
|
|
111
|
+
modules.push(requirements(moduleId, true));
|
|
112
|
+
approximated = true;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
119
115
|
if (!stringList(manifest.capabilities).includes('database')) continue;
|
|
120
116
|
modules.push(requirements(moduleId, manifest.tenancy === 'required'));
|
|
121
117
|
}
|
|
122
|
-
return { modules, approximated
|
|
118
|
+
return { modules, approximated };
|
|
123
119
|
}
|