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.
Files changed (64) hide show
  1. package/agent-template/.agents/skills/module-new/SKILL.md +1 -1
  2. package/agent-template/.agents/skills/module-update/SKILL.md +1 -1
  3. package/agent-template/.agents/skills/perf-audit/SKILL.md +1 -1
  4. package/agent-template/.agents/skills/spec-approval/SKILL.md +6 -2
  5. package/agent-template/.agents/skills/spec-interview/SKILL.md +2 -2
  6. package/agent-template/.ai/agents/README.md +1 -1
  7. package/agent-template/.ai/agents/sandbox/agentic-engineer.md +1 -1
  8. package/agent-template/.ai/agents/sandbox/backend-engineer.md +1 -1
  9. package/agent-template/.ai/agents/sandbox/business-manager.md +2 -4
  10. package/agent-template/.ai/agents/sandbox/frontend-engineer.md +1 -1
  11. package/agent-template/.ai/agents/sandbox/ux-designer.md +1 -1
  12. package/agent-template/.ai/platform-capabilities.md +4 -4
  13. package/agent-template/.ai/policies/model-routing.yaml +2 -1
  14. package/agent-template/.ai/policies/task-budgets.yaml +1 -1
  15. package/agent-template/.ai/references/catalog/migrations/0001_catalog_core.up.sql +2 -2
  16. package/agent-template/.ai/references/catalog/migrations/0002_catalog_history.up.sql +2 -2
  17. package/agent-template/.ai/references/catalog/migrations/0003_catalog_history_service_actors.up.sql +2 -2
  18. package/agent-template/.ai/references/catalog/migrations/0004_catalog_idempotency_ledger.up.sql +2 -2
  19. package/agent-template/.ai/references/catalog/module.json +3 -3
  20. package/agent-template/.ai/references/catalog/package.json +2 -2
  21. package/agent-template/.ai/references/catalog/spec/module.yaml +3 -3
  22. package/agent-template/.ai/references/catalog/src/client/CatalogItemForm.tsrx +2 -2
  23. package/agent-template/.ai/references/catalog/src/services/migration.ts +8 -8
  24. package/agent-template/.ai/references/catalog/tests/migrations.test.ts +1 -1
  25. package/agent-template/.ai/skills/module-new/SKILL.md +1 -1
  26. package/agent-template/.ai/skills/module-update/SKILL.md +1 -1
  27. package/agent-template/.ai/skills/perf-audit/SKILL.md +1 -1
  28. package/agent-template/.ai/skills/spec-approval/SKILL.md +6 -2
  29. package/agent-template/.ai/skills/spec-interview/SKILL.md +2 -2
  30. package/agent-template/.claude/skills/module-new/SKILL.md +1 -1
  31. package/agent-template/.claude/skills/module-update/SKILL.md +1 -1
  32. package/agent-template/.claude/skills/perf-audit/SKILL.md +1 -1
  33. package/agent-template/.claude/skills/spec-approval/SKILL.md +6 -2
  34. package/agent-template/.claude/skills/spec-interview/SKILL.md +2 -2
  35. package/agent-template/docs/adr/0003-module-settings.md +2 -0
  36. package/agent-template/docs/agent-contract.md +1 -1
  37. package/agent-template/docs/cli-extensions.md +1 -0
  38. package/agent-template/docs/cli.md +16 -0
  39. package/agent-template/docs/configuration.md +32 -4
  40. package/agent-template/docs/database-adapters.md +10 -2
  41. package/agent-template/docs/design-system.md +6 -2
  42. package/agent-template/docs/getting-started.md +5 -1
  43. package/agent-template/docs/module-distribution.md +1 -2
  44. package/agent-template/docs/modules.md +5 -3
  45. package/agent-template/docs/sandbox.md +64 -7
  46. package/package.json +1 -1
  47. package/template/default/.env.example +5 -0
  48. package/template/default/infra/docker/.env.example +5 -0
  49. package/template/default/infra/docker/Dockerfile +5 -1
  50. package/template/default/infra/docker/compose.yaml +4 -0
  51. package/template/default/infra/kubernetes/deployment.yaml +5 -0
  52. package/template/default/infra/sdk-module-manifests.mjs +118 -0
  53. package/template/default/infra/vercel/build.mjs +9 -0
  54. package/template/default/modules/example/package.json +1 -1
  55. package/template/default/package.json +2 -3
  56. package/template/default/platform/octane.config.ts +53 -15
  57. package/template/default/platform/package.json +1 -1
  58. package/template/default/platform/scripts/dev.mjs +66 -21
  59. package/template/default/platform/src/server/lifecycle.ts +325 -0
  60. package/template/default/platform/src/server/setup/modules.ts +28 -32
  61. package/template/default/platform/src/server/setup/page.ts +54 -3
  62. package/template/default/platform/src/server/setup/routes.ts +1 -0
  63. package/template/default/platform/src/server/setup/seed.ts +51 -4
  64. 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
- let stopping = false;
219
- const shutdown = async () => {
220
- if (stopping) return;
221
- stopping = true;
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
- await databases.check();
240
- /* Platform-scoped settings are read by background work before any request
241
- could prime them; a workspace is primed by the authentication middleware. */
242
- await settings.prime(PLATFORM_SETTINGS_TENANT);
243
- for (const composition of moduleCompositions)
244
- await composition.prepare?.();
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 shutdown().catch((cleanupError: unknown) => {
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 shutdown();
286
+ await lifecycle.retire();
263
287
  } else {
264
- process.once('SIGINT', () => void shutdown());
265
- process.once('SIGTERM', () => void shutdown());
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
  ],
@@ -15,7 +15,7 @@
15
15
  "@octanejs/vite-plugin": "0.2.1",
16
16
  "octane": "0.9.1",
17
17
  "pg": "8.23.0",
18
- "@flowdular/sdk": "0.6.0"
18
+ "@flowdular/sdk": "0.6.2"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@octanejs/app-core": "0.1.1",
@@ -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
- ...(Number.isInteger(port) && port > 0
38
- ? { server: { port, strictPort: true, ...(host ? { host } : {}) } }
39
- : host
40
- ? { server: { host } }
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
- await server.listen();
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
- const localUrl = server.resolvedUrls?.local?.[0];
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 { readdirSync, readFileSync } from 'node:fs';
2
- import { join, resolve } from 'node:path';
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 the module manifests could not be read, so every enabled module
31
- * is treated as owning tenant-scoped tables. Over-approximating keeps the
32
- * check strict; the review screen says the list came from this fallback.
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
- * A container image ships only `platform/dist`, so the enabled list falls back
79
- * to the manifest bundled at build time and, without the module manifests
80
- * beside it, every enabled module is assumed to own tenant-scoped tables.
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
- const roots = stringList(project.modules?.roots);
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 root of roots.length > 0 ? roots : ['modules']) {
93
- const directory = resolve(workspaceRoot, root);
94
- let entries: readonly string[];
95
- try {
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) continue;
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: false };
118
+ return { modules, approximated };
123
119
  }