Skip to content

Commit cde0f59

Browse files
committed
add flows/ directory structure with namespace imports for ControlPlane.serve() (#441)
# Reorganize Flow Directory Structure and Improve Import Patterns This PR introduces a new, more organized directory structure for pgflow projects by moving flow definitions from `supabase/functions/_flows/` to a dedicated top-level `supabase/flows/` directory. This change improves code organization and simplifies imports. Key changes: - Created a new `supabase/flows/` directory with an `index.ts` barrel file that re-exports all flows - Updated the Control Plane to use namespace imports (`import * as flows`) instead of individual imports - Modified `ControlPlane.serve()` to accept either an array of flows or a namespace object - Updated documentation and examples to use named exports instead of default exports - Removed the `supabase` CLI dependency from package.json (not needed) - Added comprehensive tests for the new namespace import pattern This new structure makes flows first-class citizens in the project, improves discoverability, and follows a more intuitive organization pattern. The barrel file pattern (`index.ts`) makes it easy to add new flows without modifying the Control Plane.
1 parent 85ef323 commit cde0f59

File tree

23 files changed

+509
-123
lines changed

23 files changed

+509
-123
lines changed

ARCHITECTURE_GUIDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ npx pgflow compile path/to/flow.ts
221221
```typescript
222222
import { createFlowWorker } from '@pgflow/edge-worker';
223223
import { createClient } from '@supabase/supabase-js';
224-
import MyFlow from './_flows/my_flow.ts';
224+
import { MyFlow } from '../../flows/my_flow.ts';
225225

226226
// Create Supabase client
227227
const supabase = createClient(

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@
4444
"netlify-cli": "^22.1.3",
4545
"nx": "21.2.1",
4646
"prettier": "^2.6.2",
47-
"supabase": "^2.34.3",
4847
"tslib": "^2.3.0",
4948
"typescript": "5.8.3",
5049
"typescript-eslint": "8.34.1",

pkgs/cli/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ The installer will:
6565
Convert a TypeScript flow definition into a SQL migration:
6666

6767
```bash
68-
npx pgflow@latest compile supabase/functions/_flows/my_flow.ts
68+
npx pgflow@latest compile my_flow
6969
```
7070

7171
Options:

pkgs/cli/__tests__/commands/install/create-edge-function.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,11 @@ describe('createEdgeFunction', () => {
4141
expect(fs.existsSync(indexPath)).toBe(true);
4242
expect(fs.existsSync(denoJsonPath)).toBe(true);
4343

44-
// Verify index.ts content (inline flow registration, no flows.ts)
44+
// Verify index.ts content (namespace import from flows directory)
4545
const indexContent = fs.readFileSync(indexPath, 'utf8');
4646
expect(indexContent).toContain("import { ControlPlane } from '@pgflow/edge-worker'");
47-
expect(indexContent).toContain('ControlPlane.serve([');
48-
expect(indexContent).toContain('// Import your flows here');
47+
expect(indexContent).toContain("import * as flows from '../../flows/index.ts'");
48+
expect(indexContent).toContain('ControlPlane.serve(flows)');
4949

5050
// Verify deno.json content
5151
const denoJsonContent = fs.readFileSync(denoJsonPath, 'utf8');
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import fs from 'fs';
3+
import path from 'path';
4+
import os from 'os';
5+
import { createFlowsDirectory } from '../../../src/commands/install/create-flows-directory';
6+
7+
describe('createFlowsDirectory', () => {
8+
let tempDir: string;
9+
let supabasePath: string;
10+
let flowsDir: string;
11+
12+
beforeEach(() => {
13+
// Create a temporary directory for testing
14+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgflow-test-'));
15+
supabasePath = path.join(tempDir, 'supabase');
16+
flowsDir = path.join(supabasePath, 'flows');
17+
});
18+
19+
afterEach(() => {
20+
// Clean up the temporary directory
21+
fs.rmSync(tempDir, { recursive: true, force: true });
22+
});
23+
24+
it('should create both files when none exist', async () => {
25+
const result = await createFlowsDirectory({
26+
supabasePath,
27+
autoConfirm: true,
28+
});
29+
30+
// Should return true because files were created
31+
expect(result).toBe(true);
32+
33+
// Verify directory was created
34+
expect(fs.existsSync(flowsDir)).toBe(true);
35+
36+
// Verify all files exist
37+
const indexPath = path.join(flowsDir, 'index.ts');
38+
const exampleFlowPath = path.join(flowsDir, 'example_flow.ts');
39+
40+
expect(fs.existsSync(indexPath)).toBe(true);
41+
expect(fs.existsSync(exampleFlowPath)).toBe(true);
42+
});
43+
44+
it('should create index.ts with barrel export pattern', async () => {
45+
await createFlowsDirectory({
46+
supabasePath,
47+
autoConfirm: true,
48+
});
49+
50+
const indexPath = path.join(flowsDir, 'index.ts');
51+
const indexContent = fs.readFileSync(indexPath, 'utf8');
52+
53+
// Should have export for ExampleFlow
54+
expect(indexContent).toContain("export { ExampleFlow } from './example_flow.ts'");
55+
// Should have documenting comment
56+
expect(indexContent).toContain('Re-export all flows');
57+
});
58+
59+
it('should create example_flow.ts with named export', async () => {
60+
await createFlowsDirectory({
61+
supabasePath,
62+
autoConfirm: true,
63+
});
64+
65+
const exampleFlowPath = path.join(flowsDir, 'example_flow.ts');
66+
const exampleFlowContent = fs.readFileSync(exampleFlowPath, 'utf8');
67+
68+
// Should use named export (not default)
69+
expect(exampleFlowContent).toContain('export const ExampleFlow');
70+
// Should import Flow from @pgflow/dsl
71+
expect(exampleFlowContent).toContain("import { Flow } from '@pgflow/dsl'");
72+
// Should have correct slug
73+
expect(exampleFlowContent).toContain("slug: 'example_flow'");
74+
// Should have input type
75+
expect(exampleFlowContent).toContain('type Input');
76+
// Should have at least one step
77+
expect(exampleFlowContent).toContain('.step(');
78+
});
79+
80+
it('should not create files when they already exist', async () => {
81+
// Pre-create the directory and files
82+
fs.mkdirSync(flowsDir, { recursive: true });
83+
84+
const indexPath = path.join(flowsDir, 'index.ts');
85+
const exampleFlowPath = path.join(flowsDir, 'example_flow.ts');
86+
87+
fs.writeFileSync(indexPath, '// existing content');
88+
fs.writeFileSync(exampleFlowPath, '// existing content');
89+
90+
const result = await createFlowsDirectory({
91+
supabasePath,
92+
autoConfirm: true,
93+
});
94+
95+
// Should return false because no changes were needed
96+
expect(result).toBe(false);
97+
98+
// Verify files still exist with original content
99+
expect(fs.readFileSync(indexPath, 'utf8')).toBe('// existing content');
100+
expect(fs.readFileSync(exampleFlowPath, 'utf8')).toBe('// existing content');
101+
});
102+
103+
it('should create only missing files when some already exist', async () => {
104+
// Pre-create the directory and one file
105+
fs.mkdirSync(flowsDir, { recursive: true });
106+
107+
const indexPath = path.join(flowsDir, 'index.ts');
108+
const exampleFlowPath = path.join(flowsDir, 'example_flow.ts');
109+
110+
// Only create index.ts
111+
fs.writeFileSync(indexPath, '// existing content');
112+
113+
const result = await createFlowsDirectory({
114+
supabasePath,
115+
autoConfirm: true,
116+
});
117+
118+
// Should return true because example_flow.ts was created
119+
expect(result).toBe(true);
120+
121+
// Verify index.ts was not modified
122+
expect(fs.readFileSync(indexPath, 'utf8')).toBe('// existing content');
123+
124+
// Verify example_flow.ts was created
125+
expect(fs.existsSync(exampleFlowPath)).toBe(true);
126+
127+
const exampleContent = fs.readFileSync(exampleFlowPath, 'utf8');
128+
expect(exampleContent).toContain('export const ExampleFlow');
129+
});
130+
131+
it('should create parent directories if they do not exist', async () => {
132+
// Don't create anything - let the function create it all
133+
expect(fs.existsSync(supabasePath)).toBe(false);
134+
135+
const result = await createFlowsDirectory({
136+
supabasePath,
137+
autoConfirm: true,
138+
});
139+
140+
expect(result).toBe(true);
141+
142+
// Verify all parent directories were created
143+
expect(fs.existsSync(supabasePath)).toBe(true);
144+
expect(fs.existsSync(flowsDir)).toBe(true);
145+
146+
// Verify files exist
147+
expect(fs.existsSync(path.join(flowsDir, 'index.ts'))).toBe(true);
148+
expect(fs.existsSync(path.join(flowsDir, 'example_flow.ts'))).toBe(true);
149+
});
150+
});

pkgs/cli/src/commands/install/create-edge-function.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,9 @@ import chalk from 'chalk';
55
import { getVersion } from '../../utils/get-version.js';
66

77
const INDEX_TS_TEMPLATE = `import { ControlPlane } from '@pgflow/edge-worker';
8-
// Import your flows here:
9-
// import { MyFlow } from '../_flows/my_flow.ts';
8+
import * as flows from '../../flows/index.ts';
109
11-
ControlPlane.serve([
12-
// Add your flows here:
13-
// MyFlow,
14-
]);
10+
ControlPlane.serve(flows);
1511
`;
1612

1713
const DENO_JSON_TEMPLATE = (version: string) => `{
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import { log, confirm } from '@clack/prompts';
4+
import chalk from 'chalk';
5+
6+
const INDEX_TS_TEMPLATE = `// Re-export all flows from this directory
7+
// Example: export { MyFlow } from './my_flow.ts';
8+
9+
export { ExampleFlow } from './example_flow.ts';
10+
`;
11+
12+
const EXAMPLE_FLOW_TEMPLATE = `import { Flow } from '@pgflow/dsl';
13+
14+
type Input = { name: string };
15+
16+
export const ExampleFlow = new Flow<Input>({ slug: 'example_flow' })
17+
.step({ slug: 'greet' }, (input) => \`Hello, \${input.run.name}!\`);
18+
`;
19+
20+
export async function createFlowsDirectory({
21+
supabasePath,
22+
autoConfirm = false,
23+
}: {
24+
supabasePath: string;
25+
autoConfirm?: boolean;
26+
}): Promise<boolean> {
27+
const flowsDir = path.join(supabasePath, 'flows');
28+
29+
const indexPath = path.join(flowsDir, 'index.ts');
30+
const exampleFlowPath = path.join(flowsDir, 'example_flow.ts');
31+
32+
// Relative paths for display
33+
const relativeFlowsDir = 'supabase/flows';
34+
const relativeIndexPath = `${relativeFlowsDir}/index.ts`;
35+
const relativeExampleFlowPath = `${relativeFlowsDir}/example_flow.ts`;
36+
37+
// Check what needs to be created
38+
const filesToCreate: Array<{ path: string; relativePath: string }> = [];
39+
40+
if (!fs.existsSync(indexPath)) {
41+
filesToCreate.push({ path: indexPath, relativePath: relativeIndexPath });
42+
}
43+
44+
if (!fs.existsSync(exampleFlowPath)) {
45+
filesToCreate.push({ path: exampleFlowPath, relativePath: relativeExampleFlowPath });
46+
}
47+
48+
// If all files exist, return success
49+
if (filesToCreate.length === 0) {
50+
log.success('Flows directory already up to date');
51+
return false;
52+
}
53+
54+
// Show preview and ask for confirmation only when not auto-confirming
55+
if (!autoConfirm) {
56+
const summaryMsg = [
57+
`Create ${chalk.cyan('flows/')} ${chalk.dim('(flow definitions directory)')}:`,
58+
'',
59+
...filesToCreate.map((file) => ` ${chalk.bold(path.basename(file.relativePath))}`),
60+
].join('\n');
61+
62+
log.info(summaryMsg);
63+
64+
const confirmResult = await confirm({
65+
message: `Create flows/?`,
66+
});
67+
68+
if (confirmResult !== true) {
69+
log.warn('Flows directory installation skipped');
70+
return false;
71+
}
72+
}
73+
74+
// Create the directory if it doesn't exist
75+
if (!fs.existsSync(flowsDir)) {
76+
fs.mkdirSync(flowsDir, { recursive: true });
77+
}
78+
79+
// Create files
80+
if (filesToCreate.some((f) => f.path === indexPath)) {
81+
fs.writeFileSync(indexPath, INDEX_TS_TEMPLATE);
82+
}
83+
84+
if (filesToCreate.some((f) => f.path === exampleFlowPath)) {
85+
fs.writeFileSync(exampleFlowPath, EXAMPLE_FLOW_TEMPLATE);
86+
}
87+
88+
log.success('Flows directory created');
89+
90+
return true;
91+
}

pkgs/cli/src/commands/install/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { copyMigrations } from './copy-migrations.js';
55
import { updateConfigToml } from './update-config-toml.js';
66
import { updateEnvFile } from './update-env-file.js';
77
import { createEdgeFunction } from './create-edge-function.js';
8+
import { createFlowsDirectory } from './create-flows-directory.js';
89
import { supabasePathPrompt } from './supabase-path-prompt.js';
910

1011
export default (program: Command) => {
@@ -34,6 +35,7 @@ export default (program: Command) => {
3435
'',
3536
` • Update ${chalk.cyan('supabase/config.toml')} ${chalk.dim('(enable pooler, per_worker runtime)')}`,
3637
` • Add pgflow migrations to ${chalk.cyan('supabase/migrations/')}`,
38+
` • Create ${chalk.cyan('supabase/flows/')} ${chalk.dim('(flow definitions with example)')}`,
3739
` • Create Control Plane in ${chalk.cyan('supabase/functions/pgflow/')}`,
3840
` • Configure ${chalk.cyan('supabase/functions/.env')}`,
3941
'',
@@ -73,6 +75,11 @@ export default (program: Command) => {
7375
autoConfirm: true,
7476
});
7577

78+
const flowsDirectory = await createFlowsDirectory({
79+
supabasePath,
80+
autoConfirm: true,
81+
});
82+
7683
const edgeFunction = await createEdgeFunction({
7784
supabasePath,
7885
autoConfirm: true,
@@ -86,7 +93,7 @@ export default (program: Command) => {
8693
// Step 4: Show completion message
8794
const outroMessages: string[] = [];
8895

89-
if (migrations || configUpdate || edgeFunction || envFile) {
96+
if (migrations || configUpdate || flowsDirectory || edgeFunction || envFile) {
9097
outroMessages.push(chalk.green.bold('✓ Installation complete!'));
9198
} else {
9299
outroMessages.push(

pkgs/cli/supabase/flows/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Re-export all flows from this directory
2+
export { TestFlowE2E } from './test_flow_e2e.ts';

pkgs/cli/supabase/functions/_flows/test_flow_e2e.ts renamed to pkgs/cli/supabase/flows/test_flow_e2e.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,3 @@ export const TestFlowE2E = new Flow<{ value: string }>({
77
}).step({ slug: 'step1' }, async (input) => ({
88
result: `processed: ${input.run.value}`,
99
}));
10-
11-
export default TestFlowE2E;

0 commit comments

Comments
 (0)