spfn 0.2.0-beta.6 → 0.2.0-beta.60

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.
@@ -14,6 +14,7 @@ dist
14
14
  .env
15
15
  .env.local
16
16
  .env*.local
17
+ .env.server
17
18
 
18
19
  # Git
19
20
  .git
@@ -1,5 +1,5 @@
1
1
  # Production Dockerfile for SPFN
2
- FROM node:20-alpine
2
+ FROM node:22-alpine
3
3
 
4
4
  WORKDIR /app
5
5
 
@@ -0,0 +1,81 @@
1
+ # {{projectName}}
2
+
3
+ A full-stack app built with [SPFN](https://github.com/spfn/spfn) — a typed SPFN
4
+ backend running alongside Next.js.
5
+
6
+ ## Getting started
7
+
8
+ ```bash
9
+ # 1. Start PostgreSQL + Redis
10
+ docker compose up -d
11
+
12
+ # 2. The env files are generated for you:
13
+ # .env.local — Next.js-facing values (gitignored)
14
+ # .env.server — server secrets: DB, cache (gitignored, never loaded by Next.js)
15
+ # Review them and adjust as needed.
16
+
17
+ # 3. Start the dev server (Next.js :3790 + SPFN API :8790)
18
+ {{pmRun}} spfn:dev
19
+ ```
20
+
21
+ Then open:
22
+
23
+ - Next.js — http://localhost:3790
24
+ - API health — http://localhost:8790/health
25
+
26
+ ## Project structure
27
+
28
+ ```
29
+ src/
30
+ app/ Next.js App Router
31
+ server/ SPFN backend (entities, repositories, routes, router)
32
+ generated/ codegen output (route map) — do not edit by hand
33
+ .env.local Next.js-facing env (gitignored)
34
+ .env.server server secrets (gitignored)
35
+ .env.example committed reference — keys only, placeholder values
36
+ .spfnrc.ts codegen configuration
37
+ spfn.config.js deployment config
38
+ ```
39
+
40
+ A feature is a vertical slice: `Entity` (Drizzle table) → `Repository` →
41
+ `Route` (TypeBox validation) → `Router` → `{{pmRun}} codegen` → typed client.
42
+
43
+ ## Common commands
44
+
45
+ ```bash
46
+ {{pmRun}} spfn:dev # dev: Next.js + SPFN API (add --watch to restart on changes)
47
+ {{pmRun}} spfn:build # production build
48
+ {{pmRun}} spfn:start # run the production build
49
+ {{pm}} spfn db generate # create a migration from schema changes
50
+ {{pm}} spfn db migrate # apply pending migrations
51
+ {{pm}} spfn env check # check .env files against the schema
52
+ {{pm}} spfn secret set DB_URL # store a secret (keychain locally, SOPS for deploys)
53
+ ```
54
+
55
+ ## Environment & secrets
56
+
57
+ `.env.server` holds server-only secrets and is gitignored — Next.js never loads it.
58
+ For a managed workflow use `spfn secret`: local values go to the OS keychain (only a
59
+ `secret:keychain:` reference lands in `.env.server`), and deployed secrets are stored
60
+ in encrypted SOPS files. See the [SPFN CLI docs](https://github.com/spfn/spfn).
61
+
62
+ <!-- {{#auth}} -->
63
+ ## Authentication
64
+
65
+ This project includes `@spfn/auth`. Configure providers and session settings in your
66
+ server setup, and read `@spfn/auth`'s README for the full Entity → Repository →
67
+ Service → Route flow and the typed `authApi` client.
68
+ <!-- {{/auth}} -->
69
+
70
+ ## Deployment
71
+
72
+ ```bash
73
+ # Build + run locally
74
+ {{pmRun}} spfn:build && {{pmRun}} spfn:start
75
+
76
+ # Or with Docker
77
+ docker compose -f docker-compose.production.yml up --build -d
78
+ ```
79
+
80
+ Never commit real secrets. `.env.server` is gitignored; inject secrets in your
81
+ CI/CD pipeline or via `spfn secret` + your GitOps decryption step.
@@ -1,47 +1,80 @@
1
1
  /**
2
2
  * API Client Configuration
3
3
  *
4
- * Type-safe client for accessing server routes
5
- */
6
-
7
- import { createApi } from '@spfn/core/nextjs';
8
- import { errorRegistry } from "@spfn/core/errors";
9
-
10
- import type { AppRouter } from '@/server/router';
11
-
12
- /**
13
- * Pre-configured type-safe API client
14
- *
4
+ * Type-safe client for accessing server routes.
15
5
  * Core HTTP errors are automatically registered.
16
- * Add custom application errors via the errors field if needed.
17
6
  *
18
7
  * @example
19
8
  * ```typescript
20
9
  * import { api } from '@/lib/api-client';
21
10
  *
22
11
  * // Basic call
23
- * const user = await api.getUser
24
- * .call({ id: '123' });
12
+ * const user = await api.getUser.call({ params: { id: '123' } });
25
13
  *
26
- * // Error handling with instanceof
27
- * try {
28
- * await api.someRoute.call();
29
- * } catch (error) {
30
- * if (error instanceof NotFoundError) {
31
- * console.log('Resource not found');
32
- * } else if (error instanceof ValidationError) {
33
- * console.log('Validation failed:', error.fields);
34
- * }
35
- * }
14
+ * // With options
15
+ * const data = await api.getData
16
+ * .headers({ 'X-Custom': 'value' })
17
+ * .fetchOptions({ next: { revalidate: 60 } })
18
+ * .call({ query: { page: 1 } });
36
19
  * ```
37
- *
38
- * Works in:
39
- * - Server Components (no router import needed)
40
- * - Client Components (proxied through /api/actions)
41
- * - Server Actions
42
- * - Route Handlers
43
20
  */
21
+
22
+ import { createApi } from '@spfn/core/nextjs';
23
+ import type { AppRouter } from '@/server/router';
24
+
44
25
  export const api = createApi<AppRouter>({
45
- errorRegistry: errorRegistry,
46
- debug: true
47
- });
26
+ /**
27
+ * Base URL for RPC endpoint
28
+ * @default '/api/rpc'
29
+ */
30
+ // baseUrl: '/api/rpc',
31
+
32
+ /**
33
+ * Default headers for all requests
34
+ */
35
+ // headers: { 'X-Client': 'web' },
36
+
37
+ /**
38
+ * Request timeout in milliseconds
39
+ * @default 30000
40
+ */
41
+ // timeout: 30000,
42
+
43
+ /**
44
+ * Custom error registry for deserialization
45
+ * Core HTTP errors are automatically included.
46
+ *
47
+ * @example
48
+ * import { authErrorRegistry } from '@myapp/auth/errors';
49
+ * errorRegistry: [authErrorRegistry, MyCustomError]
50
+ */
51
+ // errorRegistry: [],
52
+
53
+ /**
54
+ * Global request interceptor
55
+ *
56
+ * @example
57
+ * onRequest: async (url, init) => {
58
+ * init.headers = { ...init.headers, 'X-Request-Id': crypto.randomUUID() };
59
+ * return init;
60
+ * }
61
+ */
62
+ // onRequest: async (url, init) => init,
63
+
64
+ /**
65
+ * Global response interceptor
66
+ *
67
+ * @example
68
+ * onResponse: async (response, body) => {
69
+ * if (response.status === 401) redirect('/login');
70
+ * return { response, body };
71
+ * }
72
+ */
73
+ // onResponse: async (response, body) => ({ response, body }),
74
+
75
+ /**
76
+ * Enable debug logging
77
+ * @default false
78
+ */
79
+ debug: process.env.NODE_ENV === 'development',
80
+ });
@@ -36,88 +36,23 @@ export const envSchema = defineEnvSchema({
36
36
  });
37
37
 
38
38
  /**
39
- * Type-safe Environment Registry
39
+ * Type-safe Environment Variables
40
40
  *
41
- * Use this registry to access environment variables throughout your application.
42
- * It provides type-safe access and automatic validation.
41
+ * Variables are lazily validated on access — if a required variable is
42
+ * missing or invalid, an error is thrown at the first access point.
43
+ * This is timing-safe with dotenv loading.
43
44
  *
44
45
  * @example
45
46
  * ```typescript
46
47
  * import env from '@/server/config/env.config';
47
48
  *
48
- * // Get required variable (throws if missing)
49
- * const dbUrl = env.require('DATABASE_URL');
50
- *
51
- * // Get optional variable (returns undefined if missing)
52
- * const redisUrl = env.get('REDIS_URL');
53
- *
54
- * // Get with default value
55
- * const port = env.get('PORT') ?? 8790;
49
+ * const dbUrl = env.DATABASE_URL; // throws if required and missing
50
+ * const port = env.PORT ?? 8790; // optional with fallback
56
51
  * ```
57
- */
58
- export const env = createEnvRegistry(envSchema);
59
-
60
- /**
61
- * Validate Environment Variables
62
52
  *
63
- * This validation runs automatically when this module is imported.
64
- * It will fail fast on startup if any required variables are missing
65
- * or if there are configuration errors.
53
+ * For explicit upfront validation (e.g. in a CLI script), call
54
+ * `createEnvRegistry(envSchema).validateAll()` to get `{ errors, warnings }`.
66
55
  */
67
- if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'test')
68
- {
69
- const validation = env.validate();
70
-
71
- // Critical errors (missing required variables)
72
- if (!validation.valid)
73
- {
74
- console.error('\n❌ Environment Validation Failed\n');
75
- console.error('The following required environment variables are missing or invalid:\n');
76
-
77
- validation.errors.forEach((error) =>
78
- {
79
- console.error(` • ${error.key}`);
80
- console.error(` ${error.message}`);
81
-
82
- if (error.suggestion)
83
- {
84
- console.error(` 💡 ${error.suggestion}`);
85
- }
86
-
87
- console.error('');
88
- });
89
-
90
- console.error('Please check your .env files and ensure all required variables are set.');
91
- console.error('See .env.example for reference.\n');
92
-
93
- process.exit(1);
94
- }
56
+ export const env = createEnvRegistry(envSchema).validate();
95
57
 
96
- // Non-critical warnings
97
- if (validation.warnings.length > 0)
98
- {
99
- console.warn('\n⚠️ Environment Warnings:\n');
100
-
101
- validation.warnings.forEach((warning) =>
102
- {
103
- console.warn(` • ${warning.key}: ${warning.message}`);
104
-
105
- if (warning.suggestion)
106
- {
107
- console.warn(` 💡 ${warning.suggestion}`);
108
- }
109
- });
110
-
111
- console.warn('');
112
- }
113
- }
114
-
115
- /**
116
- * Export the registry as default
117
- *
118
- * Import this in your application to access environment variables:
119
- * ```typescript
120
- * import env from '@/server/config/env.config';
121
- * ```
122
- */
123
- export default env;
58
+ export default env;
@@ -1 +1 @@
1
- export { examples } from './example.entity';
1
+ export { examples } from './example.entity';
@@ -9,4 +9,4 @@ export const examples = pgTable('examples', {
9
9
  });
10
10
 
11
11
  export type Example = typeof examples.$inferSelect;
12
- export type NewExample = typeof examples.$inferInsert;
12
+ export type NewExample = typeof examples.$inferInsert;
@@ -37,4 +37,4 @@ export class ExampleRepository extends BaseRepository
37
37
  {
38
38
  return await this._count(examples);
39
39
  }
40
- }
40
+ }
@@ -1,4 +1,3 @@
1
-
2
1
  /**
3
2
  * Application Router
4
3
  *
@@ -20,10 +19,10 @@ export const appRouter = defineRouter({
20
19
  getExample,
21
20
  createExample,
22
21
  updateExample,
23
- deleteExample
22
+ deleteExample,
24
23
  });
25
24
 
26
25
  /**
27
26
  * Router type for client usage
28
27
  */
29
- export type AppRouter = typeof appRouter;
28
+ export type AppRouter = typeof appRouter;
@@ -39,26 +39,16 @@ export const listExamples = route.get('/examples')
39
39
 
40
40
  /**
41
41
  * GET /examples/:id - Get single example
42
- *
43
- * This route requires authorization header for testing header validation
44
42
  */
45
43
  export const getExample = route.get('/examples/:id')
46
44
  .input({
47
- headers: Type.Object({
48
- test: Type.String({
49
- description: 'Bearer token for authentication'
50
- })
51
- }),
52
45
  params: Type.Object({
53
46
  id: Type.String(),
54
47
  }),
55
48
  })
56
49
  .handler(async (c) =>
57
50
  {
58
- const { params, headers } = await c.data();
59
-
60
- // Log authorization header for testing
61
- console.log('Authorization:', headers.test);
51
+ const { params } = await c.data();
62
52
 
63
53
  const example = await exampleRepo.findById(params.id);
64
54
 
@@ -137,4 +127,4 @@ export const deleteExample = route.delete('/examples/:id')
137
127
  success: true,
138
128
  id: params.id,
139
129
  };
140
- });
130
+ });
@@ -15,4 +15,4 @@ export const getHealth = route.get('/health')
15
15
  timestamp: Date.now(),
16
16
  uptime: process.uptime(),
17
17
  };
18
- });
18
+ });
@@ -16,8 +16,7 @@ export const getRoot = route.get('/')
16
16
  endpoints: {
17
17
  health: '/health',
18
18
  examples: '/examples',
19
- teams: '/teams',
20
19
  },
21
20
  message: 'Welcome to SPFN! Visit /examples for usage examples.',
22
21
  };
23
- });
22
+ });
@@ -2,10 +2,10 @@
2
2
  * Server Configuration
3
3
  */
4
4
  import { defineServerConfig } from '@spfn/core/server';
5
- import { appRouter } from "@/server/router";
5
+ import { appRouter } from '@/server/router';
6
6
 
7
7
  export default defineServerConfig()
8
8
  .port(8790)
9
9
  .host('0.0.0.0')
10
10
  .routes(appRouter)
11
- .build();
11
+ .build();
package/package.json CHANGED
@@ -1,16 +1,26 @@
1
1
  {
2
2
  "name": "spfn",
3
- "version": "0.2.0-beta.6",
3
+ "version": "0.2.0-beta.60",
4
4
  "description": "Superfunction CLI - Add SPFN to your Next.js project",
5
5
  "type": "module",
6
6
  "bin": {
7
- "spfn": "./bin/spfn.js"
7
+ "spfn": "bin/spfn.js"
8
8
  },
9
9
  "types": "./dist/index.d.ts",
10
10
  "files": [
11
11
  "dist",
12
12
  "bin"
13
13
  ],
14
+ "scripts": {
15
+ "build": "tsup && node scripts/copy-templates.js",
16
+ "test": "vitest run",
17
+ "prepack": "node -e \"require('fs').copyFileSync('../../LICENSE','LICENSE')\"",
18
+ "dev": "tsup --watch",
19
+ "type-check": "tsc --noEmit",
20
+ "publish:alpha": "npm publish --access public --tag alpha",
21
+ "publish:beta": "npm publish --access public --tag beta",
22
+ "publish:latest": "npm publish --access public"
23
+ },
14
24
  "keywords": [
15
25
  "spfn",
16
26
  "cli",
@@ -27,26 +37,26 @@
27
37
  "typescript",
28
38
  "type-safe"
29
39
  ],
30
- "author": "Ray Im <rayim@inflike.com>",
40
+ "author": "Ray Im <rayim@fxy.global>",
31
41
  "license": "MIT",
32
42
  "repository": {
33
43
  "type": "git",
34
- "url": "https://github.com/spfn/spfn.git",
44
+ "url": "git+https://github.com/fxylabs/spfn.git",
35
45
  "directory": "packages/cli"
36
46
  },
37
47
  "publishConfig": {
38
48
  "access": "public",
39
- "tag": "alpha",
40
- "registry": "https://registry.npmjs.org"
49
+ "tag": "beta"
41
50
  },
42
51
  "bugs": {
43
- "url": "https://github.com/spfn/spfn/issues"
52
+ "url": "https://github.com/fxylabs/spfn/issues"
44
53
  },
45
- "homepage": "https://github.com/spfn/spfn/tree/main/packages/cli#readme",
54
+ "homepage": "https://github.com/fxylabs/spfn/tree/main/packages/cli#readme",
46
55
  "engines": {
47
56
  "node": ">=18.18.0"
48
57
  },
49
58
  "peerDependencies": {
59
+ "@spfn/core": ">=0.2.0-beta.54",
50
60
  "typescript": "^5.3.0"
51
61
  },
52
62
  "peerDependenciesMeta": {
@@ -59,7 +69,8 @@
59
69
  "chokidar": "^4.0.3",
60
70
  "commander": "^11.1.0",
61
71
  "dotenv": "^17.2.3",
62
- "drizzle-orm": "^0.45.0",
72
+ "drizzle-kit": "1.0.0-rc.4",
73
+ "drizzle-orm": "1.0.0-rc.4",
63
74
  "execa": "^8.0.1",
64
75
  "fs-extra": "^11.2.0",
65
76
  "ora": "^7.0.1",
@@ -67,21 +78,21 @@
67
78
  "postgres": "^3.4.0",
68
79
  "prompts": "^2.4.2",
69
80
  "tsup": "^8.5.0",
70
- "@spfn/core": "0.2.0-beta.8"
81
+ "yaml": "^2.5.0"
82
+ },
83
+ "optionalDependencies": {
84
+ "@napi-rs/keyring": "^1.1.0"
71
85
  },
72
86
  "devDependencies": {
87
+ "@electric-sql/pglite": "^0.5.4",
88
+ "@spfn/core": "workspace:*",
73
89
  "@types/fs-extra": "^11.0.4",
74
90
  "@types/node": "^20.11.0",
75
91
  "@types/pg": "^8.10.9",
76
92
  "@types/prompts": "^2.4.9",
77
93
  "concurrently": "^9.2.1",
78
- "drizzle-kit": "^0.31.6",
79
94
  "tsx": "^4.20.6",
80
- "typescript": "^5.3.3"
81
- },
82
- "scripts": {
83
- "build": "tsup && node scripts/copy-templates.js",
84
- "dev": "tsup --watch",
85
- "type-check": "tsc --noEmit"
95
+ "typescript": "^5.3.3",
96
+ "vitest": "^4.0.6"
86
97
  }
87
- }
98
+ }
@@ -1,58 +0,0 @@
1
- /**
2
- * {{PASCAL_NAME}} Contracts
3
- *
4
- * Auto-generated by SPFN CLI
5
- */
6
-
7
- import { Type } from '@sinclair/typebox';
8
- import { createContract } from '@spfn/core/route';
9
-
10
- /**
11
- * GET /{{ENTITY_NAME}}
12
- * List {{PASCAL_NAME}} items
13
- */
14
- export const list{{PASCAL_NAME}}Contract = createContract({
15
- method: 'GET',
16
- path: '/{{ENTITY_NAME}}',
17
- query: Type.Object({
18
- limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100 })),
19
- offset: Type.Optional(Type.Number({ minimum: 0 })),
20
- }),
21
- responses: {
22
- 200: Type.Object({
23
- items: Type.Array(
24
- Type.Object({
25
- id: Type.Number(),
26
- name: Type.String(),
27
- description: Type.Union([Type.String(), Type.Null()]),
28
- createdAt: Type.String(),
29
- updatedAt: Type.String(),
30
- })
31
- ),
32
- limit: Type.Number(),
33
- offset: Type.Number(),
34
- }),
35
- },
36
- });
37
-
38
- /**
39
- * POST /{{ENTITY_NAME}}
40
- * Create new {{PASCAL_NAME}}
41
- */
42
- export const create{{PASCAL_NAME}}Contract = createContract({
43
- method: 'POST',
44
- path: '/{{ENTITY_NAME}}',
45
- body: Type.Object({
46
- name: Type.String({ minLength: 1 }),
47
- description: Type.Optional(Type.String()),
48
- }),
49
- responses: {
50
- 201: Type.Object({
51
- id: Type.Number(),
52
- name: Type.String(),
53
- description: Type.Union([Type.String(), Type.Null()]),
54
- createdAt: Type.String(),
55
- updatedAt: Type.String(),
56
- }),
57
- },
58
- });
@@ -1,28 +0,0 @@
1
- /**
2
- * {{PASCAL_NAME}} Entity
3
- *
4
- * Auto-generated by SPFN CLI
5
- */
6
-
7
- import { index, serial, text, timestamp } from 'drizzle-orm/pg-core';
8
- import { {{SCHEMA_VAR_NAME}} } from './{{SCHEMA_FILE_NAME}}';
9
-
10
- export const {{ENTITY_NAME}} = {{SCHEMA_VAR_NAME}}.table('{{TABLE_NAME}}', {
11
- // Primary Key
12
- id: serial('id').primaryKey(),
13
-
14
- // Fields
15
- name: text('name').notNull(),
16
- description: text('description'),
17
-
18
- // Timestamps
19
- createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
20
- updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
21
- }, (table) => [
22
- // Indexes
23
- index('{{SCHEMA_NAME}}_{{TABLE_NAME}}_name_idx').on(table.name),
24
- ]);
25
-
26
- // Type inference
27
- export type {{PASCAL_NAME}} = typeof {{ENTITY_NAME}}.$inferSelect;
28
- export type New{{PASCAL_NAME}} = typeof {{ENTITY_NAME}}.$inferInsert;
@@ -1,3 +0,0 @@
1
- -- Initial migration for @spfn/{{FN_NAME}}
2
- -- Create schema for {{PASCAL_NAME}} package
3
- CREATE SCHEMA IF NOT EXISTS spfn_{{FN_NAME}};
@@ -1,56 +0,0 @@
1
- /**
2
- * {{PASCAL_NAME}} Repository
3
- *
4
- * Auto-generated by SPFN CLI
5
- */
6
-
7
- import { findOne, findMany, create, update, deleteById } from '@spfn/core/db';
8
- import { eq } from 'drizzle-orm';
9
- import { {{ENTITY_NAME}} } from '@/entities';
10
- import type { {{PASCAL_NAME}}, New{{PASCAL_NAME}} } from '@/entities';
11
-
12
- export const {{REPO_NAME}} = {
13
- /**
14
- * Find {{PASCAL_NAME}} by ID
15
- */
16
- findById: async (id: number): Promise<{{PASCAL_NAME}} | null> =>
17
- {
18
- return findOne({{ENTITY_NAME}}, eq({{ENTITY_NAME}}.id, id));
19
- },
20
-
21
- /**
22
- * Find all {{PASCAL_NAME}} items
23
- */
24
- findMany: async (options?: { limit?: number; offset?: number }): Promise<{{PASCAL_NAME}}[]> =>
25
- {
26
- const { limit = 20, offset = 0 } = options || {};
27
- return findMany({{ENTITY_NAME}}, {
28
- limit,
29
- offset,
30
- });
31
- },
32
-
33
- /**
34
- * Create new {{PASCAL_NAME}}
35
- */
36
- create: async (data: New{{PASCAL_NAME}}): Promise<{{PASCAL_NAME}}> =>
37
- {
38
- return create({{ENTITY_NAME}}, data);
39
- },
40
-
41
- /**
42
- * Update {{PASCAL_NAME}} by ID
43
- */
44
- updateById: async (id: number, data: Partial<New{{PASCAL_NAME}}>): Promise<{{PASCAL_NAME}} | null> =>
45
- {
46
- return update({{ENTITY_NAME}}, eq({{ENTITY_NAME}}.id, id), data);
47
- },
48
-
49
- /**
50
- * Delete {{PASCAL_NAME}} by ID
51
- */
52
- deleteById: async (id: number): Promise<void> =>
53
- {
54
- await deleteById({{ENTITY_NAME}}, id);
55
- },
56
- };