create-flowdular 0.5.1 → 0.6.1

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 (105) hide show
  1. package/README.md +8 -5
  2. package/agent-template/.agents/skills/agent-tool-design/SKILL.md +1 -1
  3. package/agent-template/.agents/skills/auth-security-review/SKILL.md +1 -1
  4. package/agent-template/.agents/skills/database-adapter/SKILL.md +5 -5
  5. package/agent-template/.agents/skills/database-adapter/references/first-run-and-matrix.md +2 -2
  6. package/agent-template/.agents/skills/deploy-operate/SKILL.md +1 -1
  7. package/agent-template/.agents/skills/migration-authoring/SKILL.md +4 -4
  8. package/agent-template/.agents/skills/module-new/SKILL.md +1 -1
  9. package/agent-template/.agents/skills/spec-approval/SKILL.md +6 -2
  10. package/agent-template/.agents/skills/spec-interview/SKILL.md +2 -2
  11. package/agent-template/.agents/skills/test-hardening/SKILL.md +2 -2
  12. package/agent-template/.agents/skills/workflow-development/SKILL.md +95 -9
  13. package/agent-template/.ai/agents/sandbox/business-manager.md +2 -2
  14. package/agent-template/.ai/blueprints/add-migration/required-files.yaml +1 -1
  15. package/agent-template/.ai/blueprints/new-module/required-files.yaml +1 -1
  16. package/agent-template/.ai/platform-capabilities.md +10 -8
  17. package/agent-template/.ai/policies/capabilities.yaml +28 -12
  18. package/agent-template/.ai/skills/README.md +1 -1
  19. package/agent-template/.ai/skills/agent-tool-design/SKILL.md +1 -1
  20. package/agent-template/.ai/skills/auth-security-review/SKILL.md +1 -1
  21. package/agent-template/.ai/skills/database-adapter/SKILL.md +5 -5
  22. package/agent-template/.ai/skills/database-adapter/references/first-run-and-matrix.md +2 -2
  23. package/agent-template/.ai/skills/deploy-operate/SKILL.md +1 -1
  24. package/agent-template/.ai/skills/migration-authoring/SKILL.md +4 -4
  25. package/agent-template/.ai/skills/module-new/SKILL.md +1 -1
  26. package/agent-template/.ai/skills/spec-approval/SKILL.md +6 -2
  27. package/agent-template/.ai/skills/spec-interview/SKILL.md +2 -2
  28. package/agent-template/.ai/skills/test-hardening/SKILL.md +2 -2
  29. package/agent-template/.ai/skills/workflow-development/SKILL.md +96 -10
  30. package/agent-template/.claude/skills/agent-tool-design/SKILL.md +1 -1
  31. package/agent-template/.claude/skills/auth-security-review/SKILL.md +1 -1
  32. package/agent-template/.claude/skills/database-adapter/SKILL.md +5 -5
  33. package/agent-template/.claude/skills/database-adapter/references/first-run-and-matrix.md +2 -2
  34. package/agent-template/.claude/skills/deploy-operate/SKILL.md +1 -1
  35. package/agent-template/.claude/skills/migration-authoring/SKILL.md +4 -4
  36. package/agent-template/.claude/skills/module-new/SKILL.md +1 -1
  37. package/agent-template/.claude/skills/spec-approval/SKILL.md +6 -2
  38. package/agent-template/.claude/skills/spec-interview/SKILL.md +2 -2
  39. package/agent-template/.claude/skills/test-hardening/SKILL.md +2 -2
  40. package/agent-template/.claude/skills/workflow-development/SKILL.md +95 -9
  41. package/agent-template/docs/adr/0003-module-settings.md +2 -0
  42. package/agent-template/docs/adr/0007-module-owned-agents.md +35 -1
  43. package/agent-template/docs/agent-contract.md +3 -3
  44. package/agent-template/docs/cli-extensions.md +1 -0
  45. package/agent-template/docs/cli.md +40 -3
  46. package/agent-template/docs/configuration.md +64 -9
  47. package/agent-template/docs/database-adapters.md +30 -22
  48. package/agent-template/docs/design-system.md +7 -3
  49. package/agent-template/docs/getting-started.md +29 -32
  50. package/agent-template/docs/module-distribution.md +79 -86
  51. package/agent-template/docs/module-web-surfaces.md +9 -7
  52. package/agent-template/docs/modules.md +6 -2
  53. package/agent-template/docs/sandbox.md +23 -4
  54. package/agent-template/platform/scripts/build.mjs +7 -0
  55. package/dist/bin.js +3 -6
  56. package/package.json +1 -1
  57. package/template/default/.env.example +8 -3
  58. package/template/default/.vercelignore +8 -0
  59. package/template/default/README.md +20 -11
  60. package/template/default/_gitignore +3 -2
  61. package/template/default/infra/README.md +86 -65
  62. package/template/default/infra/docker/.env.example +71 -0
  63. package/template/default/infra/docker/Dockerfile +29 -11
  64. package/template/default/infra/docker/app-entrypoint.mjs +5 -0
  65. package/template/default/infra/docker/compose.yaml +109 -58
  66. package/template/default/infra/docker/database-urls.mjs +28 -0
  67. package/template/default/infra/docker/pitr.sh +177 -0
  68. package/template/default/infra/docker/postgres/10-roles.sh +16 -12
  69. package/template/default/infra/docker/start.mjs +402 -0
  70. package/template/default/infra/kubernetes/database-secret.example.yaml +3 -3
  71. package/template/default/infra/kubernetes/deployment.yaml +5 -0
  72. package/template/default/infra/sdk-module-manifests.mjs +118 -0
  73. package/template/default/infra/vercel/README.md +262 -0
  74. package/template/default/infra/vercel/build.mjs +223 -0
  75. package/template/default/infra/vercel/handler.mjs +100 -0
  76. package/template/default/modules/example/migrations/0001_example_core.up.sql +2 -2
  77. package/template/default/modules/example/module.json +1 -1
  78. package/template/default/modules/example/package.json +3 -3
  79. package/template/default/modules/example/spec/module.yaml +1 -1
  80. package/template/default/modules/example/src/services/migration.ts +2 -2
  81. package/template/default/modules/example/tests/module.test.ts +1 -1
  82. package/template/default/package.json +2 -3
  83. package/template/default/platform/octane.config.ts +290 -156
  84. package/template/default/platform/package.json +5 -5
  85. package/template/default/platform/scripts/build.mjs +56 -0
  86. package/template/default/platform/scripts/dev.mjs +101 -18
  87. package/template/default/platform/src/generated/modules.server.ts +1 -0
  88. package/template/default/platform/src/server/database.ts +24 -0
  89. package/template/default/platform/src/server/lifecycle.ts +325 -0
  90. package/template/default/platform/src/server/runtime-role.ts +33 -0
  91. package/template/default/platform/src/server/setup/access.ts +160 -0
  92. package/template/default/platform/src/server/setup/adapters.ts +554 -0
  93. package/template/default/platform/src/server/setup/environment.ts +154 -0
  94. package/template/default/platform/src/server/setup/gate.ts +84 -0
  95. package/template/default/platform/src/server/setup/index.ts +181 -0
  96. package/template/default/platform/src/server/setup/modules.ts +119 -0
  97. package/template/default/platform/src/server/setup/page.ts +548 -0
  98. package/template/default/platform/src/server/setup/routes.ts +788 -0
  99. package/template/default/platform/src/server/setup/sanitize.ts +111 -0
  100. package/template/default/platform/src/server/setup/seed.ts +192 -0
  101. package/template/default/platform/src/server/setup/token.ts +79 -0
  102. package/template/default/platform/src/server/worker-tick.ts +193 -0
  103. package/template/default/platform/src/server/workspace-root.ts +16 -0
  104. package/template/default/render.yaml +70 -0
  105. package/template/default/vercel.json +5 -0
@@ -0,0 +1,154 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import {
3
+ lstatSync,
4
+ readFileSync,
5
+ renameSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from 'node:fs';
9
+ import { resolve } from 'node:path';
10
+
11
+ /* Writing configuration is a real capability and a deployment may withhold it
12
+ on purpose: infra/docker/compose.yaml sets read_only: true. A refused write
13
+ is reported as a refused write, and the operator gets the exact block to
14
+ paste into their orchestrator instead of a success that did not happen. */
15
+
16
+ export type EnvironmentWriteStatus =
17
+ | 'failed'
18
+ | 'read-only'
19
+ | 'unchanged'
20
+ | 'written';
21
+
22
+ export interface EnvironmentWriteResult {
23
+ readonly status: EnvironmentWriteStatus;
24
+ readonly path: string;
25
+ /** Keys this run added to the file. */
26
+ readonly added: readonly string[];
27
+ /** Keys the file already set. An existing value is never replaced. */
28
+ readonly kept: readonly string[];
29
+ /** The block to paste when this deployment cannot persist it itself. */
30
+ readonly block: string;
31
+ }
32
+
33
+ const BARE_VALUE = /^[A-Za-z0-9_@%:/.,+~=?&[\]{}!*()$^-]+$/;
34
+ const KEY_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
35
+
36
+ function quote(value: string): string {
37
+ if (BARE_VALUE.test(value)) return value;
38
+ if (value.includes('"') || value.includes('\\')) {
39
+ throw new Error(
40
+ 'A value containing a quote or a backslash cannot be stored in a .env file.',
41
+ );
42
+ }
43
+ return `"${value}"`;
44
+ }
45
+
46
+ function existingKeys(contents: string): ReadonlySet<string> {
47
+ const keys = new Set<string>();
48
+ for (const line of contents.split('\n')) {
49
+ const match = KEY_LINE.exec(line);
50
+ if (match?.[1]) keys.add(match[1]);
51
+ }
52
+ return keys;
53
+ }
54
+
55
+ export function renderEnvironmentBlock(
56
+ values: Readonly<Record<string, string>>,
57
+ ): string {
58
+ return Object.entries(values)
59
+ .map(([key, value]) => `${key}=${quote(value)}`)
60
+ .join('\n');
61
+ }
62
+
63
+ function isReadOnly(error: unknown): boolean {
64
+ const code =
65
+ typeof error === 'object' && error !== null && 'code' in error
66
+ ? String((error as { code: unknown }).code)
67
+ : '';
68
+ return code === 'EROFS' || code === 'EACCES' || code === 'EPERM';
69
+ }
70
+
71
+ /**
72
+ * Adds the keys this deployment is missing to `<workspaceRoot>/.env`, owner
73
+ * readable only. A key the file already sets is left exactly as it is, so a
74
+ * second run can never quietly re-point a database that is already configured.
75
+ */
76
+ export function writeEnvironmentFile(
77
+ workspaceRoot: string,
78
+ values: Readonly<Record<string, string>>,
79
+ ): EnvironmentWriteResult {
80
+ const path = resolve(workspaceRoot, '.env');
81
+ let block: string;
82
+ try {
83
+ block = renderEnvironmentBlock(values);
84
+ } catch {
85
+ /* A value a .env file cannot represent unambiguously. Reporting the keys
86
+ is the whole answer; writing a file that parses back differently is
87
+ the one outcome worse than not writing at all. */
88
+ return {
89
+ status: 'failed',
90
+ path,
91
+ added: [],
92
+ kept: [],
93
+ block: Object.keys(values).join('\n'),
94
+ };
95
+ }
96
+ let contents = '';
97
+ try {
98
+ const stat = lstatSync(path);
99
+ if (!stat.isFile() || stat.isSymbolicLink()) {
100
+ return { status: 'failed', path, added: [], kept: [], block };
101
+ }
102
+ contents = readFileSync(path, 'utf8');
103
+ } catch (error) {
104
+ /* Only a missing file may be created. An unreadable file must not be
105
+ treated as empty and replaced by the atomic rename below. */
106
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
107
+ return {
108
+ status: isReadOnly(error) ? 'read-only' : 'failed',
109
+ path,
110
+ added: [],
111
+ kept: [],
112
+ block,
113
+ };
114
+ }
115
+ }
116
+ const present = existingKeys(contents);
117
+ const added: string[] = [];
118
+ const kept: string[] = [];
119
+ for (const key of Object.keys(values)) {
120
+ if (present.has(key)) kept.push(key);
121
+ else added.push(key);
122
+ }
123
+ if (added.length === 0) {
124
+ return { status: 'unchanged', path, added, kept, block };
125
+ }
126
+ const appended = added.map((key) => `${key}=${quote(values[key]!)}`);
127
+ const next = [
128
+ ...(contents.length > 0 ? [contents.replace(/\n*$/, '\n')] : []),
129
+ '# Written by the Flowdular first-run setup.\n',
130
+ `${appended.join('\n')}\n`,
131
+ ].join('');
132
+ /* A partial .env is worse than none, and a read-only mount must leave no
133
+ trace at all, so the file appears only once it is complete. */
134
+ const temporary = `${path}.${randomBytes(6).toString('hex')}.tmp`;
135
+ try {
136
+ writeFileSync(temporary, next, { encoding: 'utf8', mode: 0o600 });
137
+ renameSync(temporary, path);
138
+ } catch (error) {
139
+ try {
140
+ rmSync(temporary, { force: true });
141
+ } catch {
142
+ /* The temporary file was never created on a filesystem that refused
143
+ the write, so there is nothing to clean up. */
144
+ }
145
+ return {
146
+ status: isReadOnly(error) ? 'read-only' : 'failed',
147
+ path,
148
+ added: [],
149
+ kept,
150
+ block,
151
+ };
152
+ }
153
+ return { status: 'written', path, added, kept, block };
154
+ }
@@ -0,0 +1,84 @@
1
+ import { ServerRoute, type Context, type Middleware } from '@octanejs/app-core';
2
+
3
+ export interface FirstRunGate {
4
+ /** Runs ahead of authentication. Until a workspace exists it answers every
5
+ * request itself, except the pass-through paths. */
6
+ readonly middleware: Middleware;
7
+ /** Global middleware runs only for a matched route, so /setup needs one. */
8
+ readonly routes: readonly ServerRoute[];
9
+ }
10
+
11
+ export interface FirstRunGateOptions {
12
+ readonly applicationPath: string;
13
+ /** Served normally before the first workspace exists, such as /api/ready. */
14
+ readonly passThrough: readonly string[];
15
+ readonly workspaceExists: () => Promise<boolean>;
16
+ /** Builds the /setup handler. `claimed` opens this instance at once. */
17
+ readonly setup: (
18
+ claimed: () => void,
19
+ ) => (context: Context) => Promise<Response>;
20
+ }
21
+
22
+ function redirect(location: string): Response {
23
+ return new Response(null, {
24
+ status: 303,
25
+ headers: { location, 'cache-control': 'no-store' },
26
+ });
27
+ }
28
+
29
+ export function createFirstRunGate(options: FirstRunGateOptions): FirstRunGate {
30
+ let open = false;
31
+ let checking: Promise<boolean> | null = null;
32
+ const setup = options.setup(() => {
33
+ open = true;
34
+ });
35
+ /* Asked on every request until it first answers yes, so an instance that
36
+ did not run setup itself opens on its next request after another did.
37
+ Concurrent requests share one query; a failed one keeps the gate shut. */
38
+ const workspaceExists = (): Promise<boolean> =>
39
+ (checking ??= options.workspaceExists().then(
40
+ (exists) => {
41
+ checking = null;
42
+ if (exists) open = true;
43
+ return exists;
44
+ },
45
+ () => {
46
+ checking = null;
47
+ return false;
48
+ },
49
+ ));
50
+ const middleware: Middleware = async (context, next) => {
51
+ if (open || (await workspaceExists())) return next();
52
+ const { pathname } = context.url;
53
+ if (pathname === '/setup') return setup(context);
54
+ if (options.passThrough.includes(pathname)) return next();
55
+ const method = context.request.method;
56
+ if (
57
+ (method === 'GET' || method === 'HEAD') &&
58
+ pathname !== '/api' &&
59
+ !pathname.startsWith('/api/')
60
+ ) {
61
+ return redirect('/setup');
62
+ }
63
+ return Response.json(
64
+ {
65
+ error: {
66
+ code: 'PLATFORM_NOT_CONFIGURED',
67
+ message:
68
+ 'Complete the first-run setup at /setup before using the platform.',
69
+ },
70
+ },
71
+ { status: 503, headers: { 'cache-control': 'no-store' } },
72
+ );
73
+ };
74
+ return {
75
+ middleware,
76
+ routes: [
77
+ new ServerRoute({
78
+ path: '/setup',
79
+ methods: ['GET', 'POST'],
80
+ handler: () => redirect(options.applicationPath),
81
+ }),
82
+ ],
83
+ };
84
+ }
@@ -0,0 +1,181 @@
1
+ import process from 'node:process';
2
+ import type { ServerRoute } from '@octanejs/app-core';
3
+ import {
4
+ authRuntimeOptionsFromEnvironment,
5
+ createAuthRuntime,
6
+ } from '@flowdular/sdk/modules/auth/server';
7
+ import {
8
+ createPlatformDatabaseProvider,
9
+ databaseProviderConfigFromEnvironment,
10
+ } from '../database.ts';
11
+ import { createSetupAccess } from './access.ts';
12
+ import { createSetupAdapters } from './adapters.ts';
13
+ import { createFirstRunGate, type FirstRunGate } from './gate.ts';
14
+ import { enabledDatabaseModules } from './modules.ts';
15
+ import { createSetupHandler, createSetupRoutes } from './routes.ts';
16
+ import { issueSetupToken } from './token.ts';
17
+
18
+ export { clearSetupToken } from './token.ts';
19
+
20
+ const FIRST_RUN_SYMBOL = Symbol.for('flowdular.platform.first-run-setup');
21
+
22
+ export interface FirstRunSetup {
23
+ readonly routes: readonly ServerRoute[];
24
+ readonly token: string;
25
+ readonly tokenFile: string | null;
26
+ }
27
+
28
+ export interface FirstRunSetupOptions {
29
+ readonly environment: NodeJS.ProcessEnv;
30
+ readonly workspaceRoot: string;
31
+ readonly databasePreconfigured?: boolean;
32
+ readonly applicationPath?: string;
33
+ readonly webMountPaths?: readonly string[];
34
+ readonly log?: (message: string) => void;
35
+ }
36
+
37
+ /** A reachable, empty configured database still needs its first owner.
38
+ * Auth's normal migration path is used before the read, and an outage throws
39
+ * rather than accidentally opening setup against an existing installation.
40
+ */
41
+ export async function configuredDatabaseNeedsFirstRun(
42
+ environment: NodeJS.ProcessEnv,
43
+ workspaceRoot: string,
44
+ ): Promise<boolean> {
45
+ const databases = createPlatformDatabaseProvider(
46
+ databaseProviderConfigFromEnvironment(environment, workspaceRoot),
47
+ );
48
+ try {
49
+ await databases.check();
50
+ const auth = createAuthRuntime({
51
+ ...authRuntimeOptionsFromEnvironment(environment, workspaceRoot),
52
+ databases,
53
+ });
54
+ try {
55
+ return !(await (await auth.service()).hasAnyTenant());
56
+ } finally {
57
+ await auth.dispose();
58
+ }
59
+ } finally {
60
+ await databases.dispose();
61
+ }
62
+ }
63
+
64
+ type ProcessWithSetup = NodeJS.Process & {
65
+ [FIRST_RUN_SYMBOL]?: FirstRunSetup;
66
+ };
67
+
68
+ function origin(environment: NodeJS.ProcessEnv): string {
69
+ const configured = environment.FD_AUTH_PUBLIC_ORIGIN?.trim();
70
+ if (configured) return configured.replace(/\/+$/, '');
71
+ return `http://127.0.0.1:${environment.PORT?.trim() || '4310'}`;
72
+ }
73
+
74
+ /**
75
+ * The routes a deployment serves while it has no database. Held on the process
76
+ * so a development re-evaluation of the configuration keeps the token, the
77
+ * lockout counter, and the operator's half-finished session alive; a
78
+ * production process evaluates the configuration once, so this is a plain
79
+ * construction there.
80
+ */
81
+ export function createFirstRunSetup(
82
+ options: FirstRunSetupOptions,
83
+ ): FirstRunSetup {
84
+ const owner = process as ProcessWithSetup;
85
+ const existing = owner[FIRST_RUN_SYMBOL];
86
+ if (existing) return existing;
87
+ const production = options.environment.NODE_ENV === 'production';
88
+ const issued = issueSetupToken({
89
+ workspaceRoot: options.workspaceRoot,
90
+ origin: origin(options.environment),
91
+ });
92
+ const adapters = createSetupAdapters({
93
+ workspaceRoot: options.workspaceRoot,
94
+ production,
95
+ });
96
+ const enabled = enabledDatabaseModules(options.workspaceRoot);
97
+ const setup: FirstRunSetup = {
98
+ token: issued.token,
99
+ tokenFile: issued.file,
100
+ routes: createSetupRoutes({
101
+ environment: options.environment,
102
+ databasePreconfigured: options.databasePreconfigured ?? false,
103
+ defaultApplicationPath: options.applicationPath ?? '/app',
104
+ webMountPaths: options.webMountPaths ?? [],
105
+ workspaceRoot: options.workspaceRoot,
106
+ adapters,
107
+ access: createSetupAccess(issued.token),
108
+ modules: enabled.modules,
109
+ modulesApproximated: enabled.approximated,
110
+ tokenFile: issued.file,
111
+ secureCookies:
112
+ options.environment.FD_AUTH_SECURE_COOKIE === 'false'
113
+ ? false
114
+ : production,
115
+ }),
116
+ };
117
+ owner[FIRST_RUN_SYMBOL] = setup;
118
+ (options.log ?? console.log)(issued.banner);
119
+ return setup;
120
+ }
121
+
122
+ export interface InPlaceFirstRunOptions {
123
+ readonly environment: NodeJS.ProcessEnv;
124
+ readonly workspaceRoot: string;
125
+ readonly applicationPath: string;
126
+ readonly webMountPaths: readonly string[];
127
+ readonly passThrough: readonly string[];
128
+ readonly workspaceExists: () => Promise<boolean>;
129
+ readonly log?: (message: string) => void;
130
+ }
131
+
132
+ /**
133
+ * First run for a deployment that can neither restart itself nor write files,
134
+ * such as a Vercel Function: the composed application serves setup behind a
135
+ * gate until a workspace exists. The deploy command keeps the token and the
136
+ * deployment holds only its SHA-256, so nothing is issued or written here.
137
+ */
138
+ export function createInPlaceFirstRun(
139
+ options: InPlaceFirstRunOptions,
140
+ ): FirstRunGate {
141
+ const digest = options.environment.FD_SETUP_TOKEN_SHA256?.trim();
142
+ if (!digest) {
143
+ throw new Error(
144
+ 'The database has no workspace yet and FD_SETUP_TOKEN_SHA256 is not set. Run flowdular deploy start vercel --apply, which sets it and prints the setup token.',
145
+ );
146
+ }
147
+ const access = createSetupAccess({ sha256: digest });
148
+ const production = options.environment.NODE_ENV === 'production';
149
+ const adapters = createSetupAdapters({
150
+ workspaceRoot: options.workspaceRoot,
151
+ production,
152
+ });
153
+ const enabled = enabledDatabaseModules(options.workspaceRoot);
154
+ const gate = createFirstRunGate({
155
+ applicationPath: options.applicationPath,
156
+ passThrough: options.passThrough,
157
+ workspaceExists: options.workspaceExists,
158
+ setup: (claimed) =>
159
+ createSetupHandler({
160
+ environment: options.environment,
161
+ databasePreconfigured: true,
162
+ defaultApplicationPath: options.applicationPath,
163
+ webMountPaths: options.webMountPaths,
164
+ workspaceRoot: options.workspaceRoot,
165
+ adapters,
166
+ access,
167
+ modules: enabled.modules,
168
+ modulesApproximated: enabled.approximated,
169
+ tokenFile: null,
170
+ secureCookies:
171
+ options.environment.FD_AUTH_SECURE_COOKIE === 'false'
172
+ ? false
173
+ : production,
174
+ inPlace: { onClaimed: claimed },
175
+ }),
176
+ });
177
+ (options.log ?? console.log)(
178
+ 'No workspace exists yet. Open /setup and enter the setup token the deploy command printed.',
179
+ );
180
+ return gate;
181
+ }
@@ -0,0 +1,119 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import {
4
+ DATABASE_CAPABILITY_IDS,
5
+ DATABASE_DIALECT_IDS,
6
+ type ModuleDatabaseRequirements,
7
+ } from '@flowdular/sdk/database';
8
+ import { findModuleManifests } from '@flowdular/sdk/kernel/module-manifests';
9
+ import projectManifest from '../../../../flowdular.json';
10
+
11
+ /**
12
+ * What every database-owning module in this repository asks its lease for. The
13
+ * module manifest carries no machine-readable database block yet, so the
14
+ * platform applies the strictest set any enabled module requests rather than
15
+ * guessing a looser one per module. The precise fix is a `database` block in
16
+ * packages/contracts/schemas/module.schema.json that each module fills in.
17
+ */
18
+ export const PLATFORM_MODULE_DATABASE_REQUIREMENTS = Object.freeze({
19
+ dialectIds: Object.freeze([DATABASE_DIALECT_IDS.postgresql]),
20
+ capabilities: Object.freeze([
21
+ DATABASE_CAPABILITY_IDS.MIGRATION_LOCK,
22
+ DATABASE_CAPABILITY_IDS.SCHEMA_INTROSPECTION,
23
+ DATABASE_CAPABILITY_IDS.TRANSACTIONAL_DDL,
24
+ DATABASE_CAPABILITY_IDS.TRANSACTIONS,
25
+ ]),
26
+ });
27
+
28
+ export interface EnabledDatabaseModules {
29
+ readonly modules: readonly ModuleDatabaseRequirements[];
30
+ /**
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.
35
+ */
36
+ readonly approximated: boolean;
37
+ }
38
+
39
+ interface ModuleManifest {
40
+ readonly id?: unknown;
41
+ readonly capabilities?: unknown;
42
+ readonly tenancy?: unknown;
43
+ }
44
+
45
+ interface ProjectManifest {
46
+ readonly modules?: {
47
+ readonly roots?: unknown;
48
+ readonly enabled?: unknown;
49
+ };
50
+ }
51
+
52
+ function readJson<T>(path: string): T | null {
53
+ try {
54
+ return JSON.parse(readFileSync(path, 'utf8')) as T;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function stringList(value: unknown): readonly string[] {
61
+ return Array.isArray(value)
62
+ ? value.filter((entry): entry is string => typeof entry === 'string')
63
+ : [];
64
+ }
65
+
66
+ function requirements(
67
+ moduleId: string,
68
+ tenantOwned: boolean,
69
+ ): ModuleDatabaseRequirements {
70
+ return {
71
+ moduleId,
72
+ tenantOwned,
73
+ dialectIds: PLATFORM_MODULE_DATABASE_REQUIREMENTS.dialectIds,
74
+ capabilities: PLATFORM_MODULE_DATABASE_REQUIREMENTS.capabilities,
75
+ };
76
+ }
77
+
78
+ /**
79
+ * The enabled modules that own database tables, in `flowdular.json` order.
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.
84
+ */
85
+ export function enabledDatabaseModules(
86
+ workspaceRoot: string,
87
+ ): EnabledDatabaseModules {
88
+ const project =
89
+ readJson<ProjectManifest>(resolve(workspaceRoot, 'flowdular.json')) ??
90
+ (projectManifest as ProjectManifest);
91
+ const enabled = stringList(project.modules?.enabled);
92
+ if (enabled.length === 0) return { modules: [], approximated: false };
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
+ }
99
+ const manifests = new Map<string, ModuleManifest>();
100
+ for (const path of paths) {
101
+ const manifest = readJson<ModuleManifest>(path);
102
+ if (typeof manifest?.id === 'string') {
103
+ manifests.set(manifest.id, manifest);
104
+ }
105
+ }
106
+ const modules: ModuleDatabaseRequirements[] = [];
107
+ let approximated = false;
108
+ for (const moduleId of enabled) {
109
+ const manifest = manifests.get(moduleId);
110
+ if (!manifest) {
111
+ modules.push(requirements(moduleId, true));
112
+ approximated = true;
113
+ continue;
114
+ }
115
+ if (!stringList(manifest.capabilities).includes('database')) continue;
116
+ modules.push(requirements(moduleId, manifest.tenancy === 'required'));
117
+ }
118
+ return { modules, approximated };
119
+ }