schematic-pg 0.1.17 → 0.1.19

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/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  `app.schema` is the source of truth. From it, schematic-pg generates PostgreSQL DDL, a type-safe DB client, REST routes, Zod validators, and ACL policies.
13
13
 
14
- A schema file always has three sections, in this order: `extensions`, `enums`, `models`.
14
+ A schema file has three required sections, in this order: `extensions`, `enums`, `models`. An optional `functions` section may follow.
15
15
 
16
16
  ```ts
17
17
  extensions {
@@ -376,6 +376,52 @@ model User {
376
376
  | `level` | `ROW`, `STATEMENT` | `ROW` |
377
377
  | `execute` | Triple-quoted PL/pgSQL | — |
378
378
 
379
+ ### Functions
380
+
381
+ Optional section after `models`. Each `function` becomes a PostgreSQL `CREATE OR REPLACE FUNCTION`. Names and parameters are converted to `snake_case` (`getUserBalance` → `get_user_balance`, `userId` → `user_id`). The `execute` body is copied as-is — use those SQL names inside it, not camelCase.
382
+
383
+ ```ts
384
+ functions {
385
+ function getUserBalance(userId: UUID): INTEGER {
386
+ language: sql
387
+ volatility: STABLE
388
+ execute: """
389
+ SELECT balance FROM "user" WHERE id = user_id
390
+ """
391
+ }
392
+
393
+ function setUpdatedAt(): TRIGGER {
394
+ language: plpgsql
395
+ execute: """
396
+ NEW.updated_at = now();
397
+ RETURN NEW;
398
+ """
399
+ }
400
+ }
401
+ ```
402
+
403
+ | Argument | Values | Default |
404
+ |----------|--------|---------|
405
+ | `language` | `sql`, `plpgsql` | `sql` |
406
+ | `volatility` | `VOLATILE`, `STABLE`, `IMMUTABLE` | `VOLATILE` |
407
+ | `security` | `INVOKER`, `DEFINER` | `INVOKER` |
408
+ | `execute` | Triple-quoted SQL / PL/pgSQL | required |
409
+
410
+ Return types are PostgreSQL types (`INTEGER`, `UUID`, `JSONB`, …), `TRIGGER`, or `VOID`. Body keys may be newline-separated or comma-separated.
411
+
412
+ `language: plpgsql` wraps the body in `BEGIN` / `END` unless it already starts with `DECLARE` or `BEGIN`. Function names must be unique in the schema.
413
+
414
+ `db:diff` treats body, language, volatility, and security changes as `CREATE OR REPLACE`. Argument or return-type changes drop the old function, then create the new one.
415
+
416
+ Functions are database objects only in this release — they are not REST endpoints. Call them with `db.$queryRaw`:
417
+
418
+ ```ts
419
+ const [row] = await db.$queryRaw<{ get_user_balance: number }>(
420
+ 'SELECT get_user_balance($1)',
421
+ [userId],
422
+ );
423
+ ```
424
+
379
425
  ---
380
426
 
381
427
  ## Quick Start
@@ -456,11 +502,12 @@ Generated code imports the runtime from the `schematic-pg` package (`schematic-p
456
502
  | `AUTH_ACCESS_TOKEN_TTL` | `1h` | Access token lifetime (`15m`, `1h`, or seconds) |
457
503
  | `JWT_ROLE_CLAIM` | `role` | JWT claim mapped to `auth.role` |
458
504
  | `JWT_USER_ID_CLAIM` | `sub` | JWT claim mapped to `auth.user.id` |
459
- | `CORS_ORIGIN` | — (disabled) | Allowed browser origins. Unset disables CORS. Use `*` for any origin, or a comma-separated list (`http://localhost:5173,https://app.example.com`) |
505
+ | `CORS_ORIGIN` | — (disabled) | Allowed browser origins. Unset disables CORS. Use `*` for any origin (no cookies), or a comma-separated list (`http://localhost:5173,https://app.example.com`). Concrete origins enable credentialed CORS |
506
+ | `CORS_ALLOW_HEADERS` | — | Extra allowed request headers (comma-separated), merged with `Authorization`, `Content-Type`, `X-CSRF-Token` |
460
507
 
461
508
  Set these in `.env` before running `dev`, `start`, or `db:bootstrap`.
462
509
 
463
- Browser frontends on another origin need `CORS_ORIGIN`. The generated app reads it at runtime (no regenerate). Preflight `OPTIONS` is handled automatically; `Authorization` and `Content-Type` are allowed. See [CORS](docs/rest-api.md#cors).
510
+ Browser frontends on another origin need `CORS_ORIGIN`. The generated app reads it at runtime (no regenerate). Preflight `OPTIONS` is handled automatically; concrete origins enable cookies via `credentials: 'include'`. See [CORS](docs/rest-api.md#cors).
464
511
 
465
512
  ---
466
513
 
@@ -1,6 +1,9 @@
1
1
  import type { MiddlewareHandler } from 'hono';
2
2
  import type { AppEnv } from '../types.js';
3
3
  export declare function parseCorsOrigin(raw?: string | undefined): string | string[] | null;
4
+ /** Extra headers from `CORS_ALLOW_HEADERS` (comma-separated). Does not include defaults. */
5
+ export declare function parseCorsAllowHeaders(raw?: string | undefined): string[];
4
6
  export declare function createCorsMiddleware(options?: {
5
7
  origin?: string | string[] | null;
8
+ allowHeaders?: string[];
6
9
  }): MiddlewareHandler<AppEnv>;
@@ -1,7 +1,17 @@
1
1
  import { cors } from 'hono/cors';
2
- const CORS_ALLOW_HEADERS = ['Authorization', 'Content-Type'];
2
+ const DEFAULT_CORS_ALLOW_HEADERS = ['Authorization', 'Content-Type', 'X-CSRF-Token'];
3
3
  const CORS_ALLOW_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'];
4
4
  const CORS_MAX_AGE_SECONDS = 86400;
5
+ function parseCommaSeparatedList(raw) {
6
+ const trimmed = raw?.trim() ?? '';
7
+ if (!trimmed) {
8
+ return [];
9
+ }
10
+ return trimmed
11
+ .split(',')
12
+ .map((item) => item.trim())
13
+ .filter((item) => item.length > 0);
14
+ }
5
15
  export function parseCorsOrigin(raw = process.env.CORS_ORIGIN) {
6
16
  const trimmed = raw?.trim() ?? '';
7
17
  if (!trimmed) {
@@ -10,15 +20,36 @@ export function parseCorsOrigin(raw = process.env.CORS_ORIGIN) {
10
20
  if (trimmed === '*') {
11
21
  return '*';
12
22
  }
13
- const origins = trimmed
14
- .split(',')
15
- .map((origin) => origin.trim())
16
- .filter((origin) => origin.length > 0);
23
+ const origins = parseCommaSeparatedList(trimmed);
17
24
  if (origins.length === 0) {
18
25
  return null;
19
26
  }
20
27
  return origins.length === 1 ? origins[0] : origins;
21
28
  }
29
+ /** Extra headers from `CORS_ALLOW_HEADERS` (comma-separated). Does not include defaults. */
30
+ export function parseCorsAllowHeaders(raw = process.env.CORS_ALLOW_HEADERS) {
31
+ return parseCommaSeparatedList(raw);
32
+ }
33
+ function resolveAllowHeaders(optionsAllowHeaders) {
34
+ if (optionsAllowHeaders !== undefined) {
35
+ return optionsAllowHeaders;
36
+ }
37
+ const extras = parseCorsAllowHeaders();
38
+ if (extras.length === 0) {
39
+ return DEFAULT_CORS_ALLOW_HEADERS;
40
+ }
41
+ const seen = new Set(DEFAULT_CORS_ALLOW_HEADERS.map((header) => header.toLowerCase()));
42
+ const merged = [...DEFAULT_CORS_ALLOW_HEADERS];
43
+ for (const header of extras) {
44
+ const key = header.toLowerCase();
45
+ if (seen.has(key)) {
46
+ continue;
47
+ }
48
+ seen.add(key);
49
+ merged.push(header);
50
+ }
51
+ return merged;
52
+ }
22
53
  export function createCorsMiddleware(options = {}) {
23
54
  const origin = options.origin !== undefined ? options.origin : parseCorsOrigin();
24
55
  if (origin == null) {
@@ -26,9 +57,12 @@ export function createCorsMiddleware(options = {}) {
26
57
  await next();
27
58
  };
28
59
  }
60
+ const allowHeaders = resolveAllowHeaders(options.allowHeaders);
61
+ const credentials = origin !== '*';
29
62
  return cors({
30
63
  origin,
31
- allowHeaders: CORS_ALLOW_HEADERS,
64
+ credentials,
65
+ allowHeaders,
32
66
  allowMethods: CORS_ALLOW_METHODS,
33
67
  maxAge: CORS_MAX_AGE_SECONDS,
34
68
  });
@@ -14,7 +14,7 @@ schematic-pg is a single-file backend framework for PostgreSQL and Node.js. **`a
14
14
 
15
15
  1. **Never edit `generated/`** — it is overwritten on every `generate` / `dev` run.
16
16
  2. **Regenerate after changes** to `app.schema`, `src/routes/`, or `src/hooks/` (`schematic-pg generate` or `schematic-pg dev`).
17
- 3. **Edit `app.schema`** for models, relations, policies, indexes, and triggers.
17
+ 3. **Edit `app.schema`** for models, relations, policies, indexes, triggers, and SQL functions.
18
18
  4. **Use extension points** for app-specific logic: `src/routes/` (custom HTTP) and `src/hooks/` (lifecycle hooks).
19
19
  5. **Do not hand-write SQL** for CRUD — use the generated DB client or REST API.
20
20
 
@@ -59,7 +59,8 @@ npx schematic-pg dev
59
59
  | `JWT_SECRET` | HMAC secret for Bearer JWT auth |
60
60
  | `JWT_ROLE_CLAIM` | JWT claim for role (default `role`) |
61
61
  | `JWT_USER_ID_CLAIM` | JWT claim for user id (default `sub`) |
62
- | `CORS_ORIGIN` | Allowed browser origins (`*` or comma-separated). Unset disables CORS |
62
+ | `CORS_ORIGIN` | Allowed browser origins (`*` or comma-separated). Unset disables CORS. Concrete origins enable credentialed CORS (cookies); `*` does not |
63
+ | `CORS_ALLOW_HEADERS` | Extra allowed request headers (comma-separated), merged with Authorization, Content-Type, X-CSRF-Token |
63
64
 
64
65
  ## Schema DSL Essentials
65
66
 
@@ -99,6 +100,7 @@ models {
99
100
  - **Policies:** `@policy(role: ..., allow: [select|insert|update|delete|all], where: "...")` — `where` supports `{{auth.user.id}}`.
100
101
  - **Validation:** `@regex(...)`, `@range(min: ..., max: ...)` flow into generated Zod schemas.
101
102
  - **Indexes / triggers:** `@@index(...)`, `@@trigger { timing, event, level, execute: """...""" }`.
103
+ - **SQL functions:** optional `functions { function name(args): ReturnType { execute: """...""" } }` after `models`. Names snake_case in SQL; call with `db.$queryRaw`.
102
104
 
103
105
  ## Database Client
104
106
 
@@ -1,6 +1,6 @@
1
1
  export declare const AGENTS_TEMPLATE: string;
2
2
  export declare const APP_SCHEMA_TEMPLATE = "extensions {\n\n}\n\nenums {\n UserRole { ADMIN, USER }\n}\n\nmodels {\n model User {\n id: UUID @id @default(gen_random_uuid())\n email: VARCHAR(255) @unique @regex(pattern: \"^[\\w.-]+@[\\w.-]+\\.\\w+$\", message: \"Invalid email address\")\n name: VARCHAR(150)?\n role: UserRole @default(USER)\n passwordHash: VARCHAR(255)? @omit @unfilterable\n createdAt: TIMESTAMP @default(now())\n\n @rest(except: [create, update, delete])\n @policy(role: USER, allow: [select], where: \"id = {{auth.user.id}}\")\n @policy(role: ADMIN, allow: all)\n }\n}\n";
3
- export declare const ENV_TEMPLATE = "DATABASE_URL=postgresql://postgrest:postgrest@localhost:5432/postgrest\nJWT_SECRET=\nAUTH_PEPPER=\nAUTH_ACCESS_TOKEN_TTL=1h\nJWT_ROLE_CLAIM=role\nJWT_USER_ID_CLAIM=sub\nCORS_ORIGIN=\n";
3
+ export declare const ENV_TEMPLATE = "DATABASE_URL=postgresql://postgrest:postgrest@localhost:5432/postgrest\nJWT_SECRET=\nAUTH_PEPPER=\nAUTH_ACCESS_TOKEN_TTL=1h\nJWT_ROLE_CLAIM=role\nJWT_USER_ID_CLAIM=sub\nCORS_ORIGIN=\nCORS_ALLOW_HEADERS=\n";
4
4
  export declare const GITIGNORE_TEMPLATE = "node_modules/\ndist/\n.env\ndocker_data/\n.DS_Store\n*.log\nnpm-debug.log*\n";
5
5
  export declare const DOCKER_COMPOSE_TEMPLATE = "services:\n postgres:\n image: postgres:18.4-bookworm\n container_name: schematic-pg\n restart: unless-stopped\n ports:\n - \"5432:5432\"\n environment:\n POSTGRES_USER: postgrest\n POSTGRES_PASSWORD: postgrest\n POSTGRES_DB: postgrest\n volumes:\n - ./docker_data/postgres:/var/lib/postgresql\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U postgrest -d postgrest\"]\n interval: 5s\n timeout: 5s\n retries: 5\n";
6
6
  export declare const TSCONFIG_TEMPLATE = "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"NodeNext\",\n \"moduleResolution\": \"NodeNext\",\n \"strict\": true,\n \"esModuleInterop\": true,\n \"skipLibCheck\": true,\n \"outDir\": \"dist\",\n \"rootDir\": \".\"\n },\n \"include\": [\"generated/**/*\", \"src/**/*\"]\n}\n";
@@ -34,6 +34,7 @@ AUTH_ACCESS_TOKEN_TTL=1h
34
34
  JWT_ROLE_CLAIM=role
35
35
  JWT_USER_ID_CLAIM=sub
36
36
  CORS_ORIGIN=
37
+ CORS_ALLOW_HEADERS=
37
38
  `;
38
39
  export const GITIGNORE_TEMPLATE = `node_modules/
39
40
  dist/
@@ -9,6 +9,7 @@ export interface Schema {
9
9
  extensions: Extension[];
10
10
  enums: Enum[];
11
11
  models: Model[];
12
+ functions: SqlFunction[];
12
13
  loc: SourceLocation;
13
14
  }
14
15
  export interface Extension {
@@ -31,6 +32,23 @@ export interface Model {
31
32
  directives: Directive[];
32
33
  loc: SourceLocation;
33
34
  }
35
+ export interface SqlFunction {
36
+ kind: 'SqlFunction';
37
+ name: string;
38
+ params: FunctionParam[];
39
+ returns: TypeExpr;
40
+ language?: string;
41
+ volatility?: string;
42
+ security?: string;
43
+ execute: string;
44
+ loc: SourceLocation;
45
+ }
46
+ export interface FunctionParam {
47
+ kind: 'FunctionParam';
48
+ name: string;
49
+ type: TypeExpr;
50
+ loc: SourceLocation;
51
+ }
34
52
  export interface Field {
35
53
  kind: 'Field';
36
54
  name: string;
@@ -130,7 +130,10 @@ export class Lexer {
130
130
  scanTripleString(startLine, startCol) {
131
131
  let value = '';
132
132
  while (!this.isAtEnd()) {
133
- if (this.match('"') && this.match('"') && this.match('"')) {
133
+ if (this.peekChar() === '"' && this.peekChar(1) === '"' && this.peekChar(2) === '"') {
134
+ this.advance();
135
+ this.advance();
136
+ this.advance();
134
137
  return this.makeToken(TokenType.TRIPLE_STRING, value, startLine, startCol);
135
138
  }
136
139
  value += this.advance();
@@ -1,4 +1,4 @@
1
- import type { Attribute, Field, Model, Schema } from './ast.js';
1
+ import type { Attribute, Field, Model, Schema, SqlFunction } from './ast.js';
2
2
  import { Token } from './tokens.js';
3
3
  export declare class ParseError extends Error {
4
4
  readonly line: number;
@@ -20,6 +20,11 @@ export declare class Parser {
20
20
  private parseEnumsSection;
21
21
  private parseEnum;
22
22
  private parseModelsSection;
23
+ private parseFunctionsSection;
24
+ parseFunction(existingNames?: Set<string>): SqlFunction;
25
+ private parseFunctionParams;
26
+ private parseFunctionParam;
27
+ private parseFunctionBody;
23
28
  private parseModelBody;
24
29
  private parseTypeExpr;
25
30
  private parseFieldAttributes;
@@ -24,12 +24,14 @@ export class Parser {
24
24
  const extensions = this.parseExtensionsSection();
25
25
  const enums = this.parseEnumsSection();
26
26
  const models = this.parseModelsSection();
27
+ const functions = this.check(TokenType.FUNCTIONS) ? this.parseFunctionsSection() : [];
27
28
  this.expect(TokenType.EOF, 'end of schema');
28
29
  return {
29
30
  kind: 'Schema',
30
31
  extensions,
31
32
  enums,
32
33
  models,
34
+ functions,
33
35
  loc: this.loc(start),
34
36
  };
35
37
  }
@@ -113,6 +115,135 @@ export class Parser {
113
115
  this.expect(TokenType.RBRACE, "'}'");
114
116
  return models;
115
117
  }
118
+ parseFunctionsSection() {
119
+ this.expect(TokenType.FUNCTIONS, "'functions'");
120
+ this.expect(TokenType.LBRACE, "'{'");
121
+ const functions = [];
122
+ const names = new Set();
123
+ while (!this.check(TokenType.RBRACE)) {
124
+ functions.push(this.parseFunction(names));
125
+ }
126
+ this.expect(TokenType.RBRACE, "'}'");
127
+ return functions;
128
+ }
129
+ parseFunction(existingNames) {
130
+ const start = this.expect(TokenType.FUNCTION, "'function'");
131
+ const nameToken = this.expect(TokenType.IDENT, 'function name');
132
+ if (existingNames?.has(nameToken.value)) {
133
+ throw new ParseError(`unique function name, "${nameToken.value}" already defined`, nameToken);
134
+ }
135
+ existingNames?.add(nameToken.value);
136
+ this.expect(TokenType.LPAREN, "'('");
137
+ const params = this.parseFunctionParams();
138
+ this.expect(TokenType.RPAREN, "')'");
139
+ this.expect(TokenType.COLON, "':'");
140
+ const returns = this.parseTypeExpr();
141
+ this.expect(TokenType.LBRACE, "'{'");
142
+ const body = this.parseFunctionBody();
143
+ this.expect(TokenType.RBRACE, "'}'");
144
+ return {
145
+ kind: 'SqlFunction',
146
+ name: nameToken.value,
147
+ params,
148
+ returns,
149
+ language: body.language,
150
+ volatility: body.volatility,
151
+ security: body.security,
152
+ execute: body.execute,
153
+ loc: this.loc(start),
154
+ };
155
+ }
156
+ parseFunctionParams() {
157
+ if (this.check(TokenType.RPAREN)) {
158
+ return [];
159
+ }
160
+ const params = [];
161
+ do {
162
+ params.push(this.parseFunctionParam());
163
+ } while (this.match(TokenType.COMMA) && !this.check(TokenType.RPAREN));
164
+ this.consumeTrailingComma();
165
+ return params;
166
+ }
167
+ parseFunctionParam() {
168
+ const start = this.expect(TokenType.IDENT, 'parameter name');
169
+ this.expect(TokenType.COLON, "':'");
170
+ const type = this.parseTypeExpr();
171
+ return {
172
+ kind: 'FunctionParam',
173
+ name: start.value,
174
+ type,
175
+ loc: this.loc(start),
176
+ };
177
+ }
178
+ parseFunctionBody() {
179
+ if (this.check(TokenType.RBRACE)) {
180
+ throw new ParseError("function body key 'execute'", this.current());
181
+ }
182
+ let language;
183
+ let volatility;
184
+ let security;
185
+ let execute;
186
+ do {
187
+ const keyToken = this.expect(TokenType.IDENT, 'function body key');
188
+ this.expect(TokenType.COLON, "':'");
189
+ const valueToken = this.current();
190
+ const value = this.parseValue();
191
+ switch (keyToken.value) {
192
+ case 'language': {
193
+ if (value.kind !== 'Identifier') {
194
+ throw new ParseError("'sql' or 'plpgsql'", valueToken);
195
+ }
196
+ const languageName = value.name.toLowerCase();
197
+ if (languageName !== 'sql' && languageName !== 'plpgsql') {
198
+ throw new ParseError("'sql' or 'plpgsql'", valueToken);
199
+ }
200
+ language = languageName;
201
+ break;
202
+ }
203
+ case 'volatility': {
204
+ if (value.kind !== 'Identifier') {
205
+ throw new ParseError("'VOLATILE', 'STABLE', or 'IMMUTABLE'", valueToken);
206
+ }
207
+ const volatilityName = value.name.toUpperCase();
208
+ if (volatilityName !== 'VOLATILE' &&
209
+ volatilityName !== 'STABLE' &&
210
+ volatilityName !== 'IMMUTABLE') {
211
+ throw new ParseError("'VOLATILE', 'STABLE', or 'IMMUTABLE'", valueToken);
212
+ }
213
+ volatility = volatilityName;
214
+ break;
215
+ }
216
+ case 'security': {
217
+ if (value.kind !== 'Identifier') {
218
+ throw new ParseError("'INVOKER' or 'DEFINER'", valueToken);
219
+ }
220
+ const securityName = value.name.toUpperCase();
221
+ if (securityName !== 'INVOKER' && securityName !== 'DEFINER') {
222
+ throw new ParseError("'INVOKER' or 'DEFINER'", valueToken);
223
+ }
224
+ security = securityName;
225
+ break;
226
+ }
227
+ case 'execute': {
228
+ if (value.kind !== 'TripleStringLiteral') {
229
+ throw new ParseError('triple-quoted execute body', valueToken);
230
+ }
231
+ execute = value.value.trim();
232
+ if (execute.length === 0) {
233
+ throw new ParseError('non-empty execute body', valueToken);
234
+ }
235
+ break;
236
+ }
237
+ default:
238
+ throw new ParseError("function body key 'language', 'volatility', 'security', or 'execute'", keyToken);
239
+ }
240
+ this.match(TokenType.COMMA);
241
+ } while (!this.check(TokenType.RBRACE));
242
+ if (!execute) {
243
+ throw new ParseError("function body key 'execute'", this.current());
244
+ }
245
+ return { language, volatility, security, execute };
246
+ }
116
247
  parseModelBody(name, start) {
117
248
  const fields = [];
118
249
  const attributes = [];
@@ -3,6 +3,8 @@ export declare enum TokenType {
3
3
  ENUMS = "ENUMS",
4
4
  MODELS = "MODELS",
5
5
  MODEL = "MODEL",
6
+ FUNCTIONS = "FUNCTIONS",
7
+ FUNCTION = "FUNCTION",
6
8
  STRING = "STRING",
7
9
  TRIPLE_STRING = "TRIPLE_STRING",
8
10
  NUMBER = "NUMBER",
@@ -4,6 +4,8 @@ export var TokenType;
4
4
  TokenType["ENUMS"] = "ENUMS";
5
5
  TokenType["MODELS"] = "MODELS";
6
6
  TokenType["MODEL"] = "MODEL";
7
+ TokenType["FUNCTIONS"] = "FUNCTIONS";
8
+ TokenType["FUNCTION"] = "FUNCTION";
7
9
  TokenType["STRING"] = "STRING";
8
10
  TokenType["TRIPLE_STRING"] = "TRIPLE_STRING";
9
11
  TokenType["NUMBER"] = "NUMBER";
@@ -27,6 +29,8 @@ const KEYWORDS = {
27
29
  enums: TokenType.ENUMS,
28
30
  models: TokenType.MODELS,
29
31
  model: TokenType.MODEL,
32
+ functions: TokenType.FUNCTIONS,
33
+ function: TokenType.FUNCTION,
30
34
  true: TokenType.BOOLEAN,
31
35
  false: TokenType.BOOLEAN,
32
36
  };
@@ -0,0 +1,6 @@
1
+ import type { Schema } from '../../schema-dsl/ast.js';
2
+ import { type NormalizedFunction } from '../utils/ast-helpers.js';
3
+ export type { NormalizedFunction };
4
+ export declare function generateCreateFunction(normalized: NormalizedFunction): string;
5
+ export declare function generateDropFunction(normalized: NormalizedFunction): string;
6
+ export declare function generateFunctions(schema: Schema): string;
@@ -0,0 +1,50 @@
1
+ import { getEnumNames, normalizeFunction, } from '../utils/ast-helpers.js';
2
+ import { joinSection } from '../utils/format.js';
3
+ import { quoteIdentifier } from '../utils/snake-case.js';
4
+ export function generateCreateFunction(normalized) {
5
+ const functionName = quoteIdentifier(normalized.sqlName);
6
+ const params = normalized.params
7
+ .map((param) => `${quoteIdentifier(param.sqlName)} ${param.sqlType}`)
8
+ .join(', ');
9
+ const clauses = [
10
+ `CREATE OR REPLACE FUNCTION ${functionName}(${params})`,
11
+ `RETURNS ${normalized.returns}`,
12
+ `LANGUAGE ${normalized.language}`,
13
+ ];
14
+ if (normalized.volatility !== 'VOLATILE') {
15
+ clauses.push(normalized.volatility);
16
+ }
17
+ if (normalized.security === 'DEFINER') {
18
+ clauses.push('SECURITY DEFINER');
19
+ }
20
+ const body = formatFunctionBody(normalized.execute, normalized.language);
21
+ clauses.push(`AS $$\n${body}\n$$;`);
22
+ return clauses.join('\n');
23
+ }
24
+ export function generateDropFunction(normalized) {
25
+ const functionName = quoteIdentifier(normalized.sqlName);
26
+ const argTypes = normalized.params.map((param) => param.sqlType).join(', ');
27
+ return `DROP FUNCTION IF EXISTS ${functionName}(${argTypes});`;
28
+ }
29
+ export function generateFunctions(schema) {
30
+ const enumNames = getEnumNames(schema);
31
+ const statements = schema.functions.map((sqlFunction) => generateCreateFunction(normalizeFunction(sqlFunction, enumNames)));
32
+ return joinSection('Create functions', statements);
33
+ }
34
+ function formatFunctionBody(execute, language) {
35
+ const trimmed = execute.trim();
36
+ const indented = indentBody(trimmed);
37
+ if (language !== 'plpgsql') {
38
+ return indented;
39
+ }
40
+ if (/^(declare|begin)\b/i.test(trimmed)) {
41
+ return indented;
42
+ }
43
+ return `BEGIN\n${indented}\nEND;`;
44
+ }
45
+ function indentBody(body) {
46
+ return body
47
+ .split('\n')
48
+ .map((line) => (line.length > 0 ? ` ${line}` : line))
49
+ .join('\n');
50
+ }
@@ -9,6 +9,7 @@ export declare class MigrationPlanner {
9
9
  private diffConstraints;
10
10
  private diffIndexes;
11
11
  private diffTriggers;
12
+ private diffFunctions;
12
13
  private triggerSignatures;
13
14
  private indexSignatures;
14
15
  }
@@ -1,4 +1,4 @@
1
- import { collectForeignKeys, getDirectives, getEnumNames, getModelNames, getStoredFields, isStoredField, normalizeIndexDirective, normalizeTriggerDirective, serializeColumnType, serializeDefault, serializeForeignKey, } from './utils/ast-helpers.js';
1
+ import { collectForeignKeys, functionIdentity, functionSignature, getDirectives, getEnumNames, getModelNames, getStoredFields, isStoredField, normalizeFunction, normalizeIndexDirective, normalizeTriggerDirective, serializeColumnType, serializeDefault, serializeForeignKey, } from './utils/ast-helpers.js';
2
2
  export class MigrationPlanner {
3
3
  generateMigration(oldSchema, newSchema) {
4
4
  const migrations = [];
@@ -7,6 +7,7 @@ export class MigrationPlanner {
7
7
  migrations.push(...this.diffModels(oldSchema, newSchema));
8
8
  migrations.push(...this.diffConstraints(oldSchema, newSchema));
9
9
  migrations.push(...this.diffIndexes(oldSchema, newSchema));
10
+ migrations.push(...this.diffFunctions(oldSchema, newSchema));
10
11
  migrations.push(...this.diffTriggers(oldSchema, newSchema));
11
12
  return migrations;
12
13
  }
@@ -195,6 +196,48 @@ export class MigrationPlanner {
195
196
  }
196
197
  return migrations;
197
198
  }
199
+ diffFunctions(oldSchema, newSchema) {
200
+ const migrations = [];
201
+ const oldEnumNames = getEnumNames(oldSchema);
202
+ const newEnumNames = getEnumNames(newSchema);
203
+ const oldFunctions = new Map(oldSchema.functions.map((sqlFunction) => [
204
+ sqlFunction.name,
205
+ normalizeFunction(sqlFunction, oldEnumNames),
206
+ ]));
207
+ const newFunctions = new Map(newSchema.functions.map((sqlFunction) => [
208
+ sqlFunction.name,
209
+ normalizeFunction(sqlFunction, newEnumNames),
210
+ ]));
211
+ for (const [functionName, newFunction] of newFunctions) {
212
+ const oldFunction = oldFunctions.get(functionName);
213
+ if (!oldFunction) {
214
+ migrations.push({ kind: 'CreateFunction', functionName });
215
+ continue;
216
+ }
217
+ if (functionIdentity(oldFunction) !== functionIdentity(newFunction)) {
218
+ migrations.push({
219
+ kind: 'DropFunction',
220
+ functionName,
221
+ signature: functionSignature(oldFunction),
222
+ });
223
+ migrations.push({ kind: 'CreateFunction', functionName });
224
+ continue;
225
+ }
226
+ if (functionSignature(oldFunction) !== functionSignature(newFunction)) {
227
+ migrations.push({ kind: 'ReplaceFunction', functionName });
228
+ }
229
+ }
230
+ for (const [functionName, oldFunction] of oldFunctions) {
231
+ if (!newFunctions.has(functionName)) {
232
+ migrations.push({
233
+ kind: 'DropFunction',
234
+ functionName,
235
+ signature: functionSignature(oldFunction),
236
+ });
237
+ }
238
+ }
239
+ return migrations;
240
+ }
198
241
  triggerSignatures(model) {
199
242
  return new Set(getDirectives(model, 'trigger').map((directive) => JSON.stringify(normalizeTriggerDirective(directive))));
200
243
  }
@@ -1,10 +1,11 @@
1
1
  import { generateAddEnumValue, generateEnum } from './generators/enums.js';
2
2
  import { generateCreateExtension, generateDropExtension } from './generators/extensions.js';
3
3
  import { generateForeignKey } from './generators/foreign-keys.js';
4
+ import { generateCreateFunction, generateDropFunction, } from './generators/functions.js';
4
5
  import { generateCreateIndex, generateDropIndex, } from './generators/indexes.js';
5
6
  import { generateColumnDefinition, generateTable } from './generators/tables.js';
6
7
  import { generateCreateTrigger, generateDropTrigger, } from './generators/triggers.js';
7
- import { getDirectives, getDefaultExpression, getEnumNames, getModelNames, getStoredFields, normalizeIndexDirective, normalizeTriggerDirective, parseForeignKeySignature, } from './utils/ast-helpers.js';
8
+ import { getDirectives, getDefaultExpression, getEnumNames, getModelNames, getStoredFields, normalizeFunction, normalizeIndexDirective, normalizeTriggerDirective, parseForeignKeySignature, } from './utils/ast-helpers.js';
8
9
  import { quoteIdentifier, toSnakeCase, toTableName } from './utils/snake-case.js';
9
10
  const MIGRATION_ORDER = {
10
11
  CreateExtension: 0,
@@ -18,10 +19,13 @@ const MIGRATION_ORDER = {
18
19
  DropConstraint: 8,
19
20
  DropIndex: 9,
20
21
  CreateIndex: 10,
21
- CreateTrigger: 11,
22
- DropTrigger: 12,
23
- DropTable: 13,
24
- DropExtension: 14,
22
+ DropFunction: 11,
23
+ CreateFunction: 12,
24
+ ReplaceFunction: 13,
25
+ CreateTrigger: 14,
26
+ DropTrigger: 15,
27
+ DropTable: 16,
28
+ DropExtension: 17,
25
29
  };
26
30
  export class MigrationSqlGenerator {
27
31
  generate(migrations, newSchema) {
@@ -32,12 +36,20 @@ export class MigrationSqlGenerator {
32
36
  const modelNames = getModelNames(newSchema);
33
37
  const modelMap = new Map(newSchema.models.map((model) => [model.name, model]));
34
38
  const enumMap = new Map(newSchema.enums.map((enumDef) => [enumDef.name, enumDef]));
39
+ const functionMap = new Map(newSchema.functions.map((sqlFunction) => [sqlFunction.name, sqlFunction]));
35
40
  const ordered = [...migrations].sort((left, right) => MIGRATION_ORDER[left.kind] - MIGRATION_ORDER[right.kind]);
36
- const statements = ordered.map((migration) => this.migrationToSql(migration, { newSchema, enumNames, modelNames, modelMap, enumMap }));
41
+ const statements = ordered.map((migration) => this.migrationToSql(migration, {
42
+ newSchema,
43
+ enumNames,
44
+ modelNames,
45
+ modelMap,
46
+ enumMap,
47
+ functionMap,
48
+ }));
37
49
  return `${statements.join('\n\n')}\n`;
38
50
  }
39
51
  migrationToSql(migration, context) {
40
- const { enumNames, modelNames, modelMap, enumMap } = context;
52
+ const { enumNames, modelNames, modelMap, enumMap, functionMap } = context;
41
53
  switch (migration.kind) {
42
54
  case 'CreateExtension':
43
55
  return generateCreateExtension(migration.extensionName);
@@ -147,6 +159,18 @@ export class MigrationSqlGenerator {
147
159
  const normalized = JSON.parse(migration.signature);
148
160
  return generateDropTrigger(model, normalized);
149
161
  }
162
+ case 'CreateFunction':
163
+ case 'ReplaceFunction': {
164
+ const sqlFunction = functionMap.get(migration.functionName);
165
+ if (!sqlFunction) {
166
+ throw new Error(`Function "${migration.functionName}" not found in new schema`);
167
+ }
168
+ return generateCreateFunction(normalizeFunction(sqlFunction, enumNames));
169
+ }
170
+ case 'DropFunction': {
171
+ const normalized = JSON.parse(migration.signature);
172
+ return generateDropFunction(normalized);
173
+ }
150
174
  default: {
151
175
  const exhaustive = migration;
152
176
  throw new Error(`Unsupported migration kind: ${exhaustive.kind}`);
@@ -1,4 +1,4 @@
1
- export type Migration = CreateExtension | DropExtension | CreateTable | DropTable | AddColumn | DropColumn | AlterColumn | CreateIndex | DropIndex | CreateEnum | AddEnumValue | AddConstraint | DropConstraint | CreateTrigger | DropTrigger;
1
+ export type Migration = CreateExtension | DropExtension | CreateTable | DropTable | AddColumn | DropColumn | AlterColumn | CreateIndex | DropIndex | CreateEnum | AddEnumValue | AddConstraint | DropConstraint | CreateFunction | ReplaceFunction | DropFunction | CreateTrigger | DropTrigger;
2
2
  export interface CreateTable {
3
3
  kind: 'CreateTable';
4
4
  modelName: string;
@@ -84,3 +84,16 @@ export interface DropTrigger {
84
84
  modelName: string;
85
85
  signature: string;
86
86
  }
87
+ export interface CreateFunction {
88
+ kind: 'CreateFunction';
89
+ functionName: string;
90
+ }
91
+ export interface ReplaceFunction {
92
+ kind: 'ReplaceFunction';
93
+ functionName: string;
94
+ }
95
+ export interface DropFunction {
96
+ kind: 'DropFunction';
97
+ functionName: string;
98
+ signature: string;
99
+ }
@@ -3,6 +3,7 @@ import { generateDropTables } from './generators/drop-tables.js';
3
3
  import { generateEnums } from './generators/enums.js';
4
4
  import { generateExtensions } from './generators/extensions.js';
5
5
  import { generateForeignKeys } from './generators/foreign-keys.js';
6
+ import { generateFunctions } from './generators/functions.js';
6
7
  import { generateIndexes } from './generators/indexes.js';
7
8
  import { generateTables } from './generators/tables.js';
8
9
  import { generateTriggers } from './generators/triggers.js';
@@ -15,6 +16,7 @@ export class SqlGenerator {
15
16
  generateTables(schema),
16
17
  generateForeignKeys(schema),
17
18
  generateIndexes(schema),
19
+ generateFunctions(schema),
18
20
  generateTriggers(schema),
19
21
  ];
20
22
  return `${sections.join('\n')}\n`;
@@ -1,4 +1,4 @@
1
- import type { Attribute, AttributeArgs, Directive, Field, KeyValueArgs, Model, Schema, TypeExpr, Value } from '../../schema-dsl/ast.js';
1
+ import type { Attribute, AttributeArgs, Directive, Field, KeyValueArgs, Model, Schema, SqlFunction, TypeExpr, Value } from '../../schema-dsl/ast.js';
2
2
  export interface PrimaryKeyInfo {
3
3
  fields: string[];
4
4
  composite: boolean;
@@ -56,3 +56,21 @@ export interface TriggerNames {
56
56
  }
57
57
  export declare function normalizeTriggerDirective(directive: Directive): NormalizedTrigger;
58
58
  export declare function resolveTriggerNames(model: Model, timing: string, event: string): TriggerNames;
59
+ export interface NormalizedFunctionParam {
60
+ name: string;
61
+ sqlName: string;
62
+ sqlType: string;
63
+ }
64
+ export interface NormalizedFunction {
65
+ name: string;
66
+ sqlName: string;
67
+ params: NormalizedFunctionParam[];
68
+ returns: string;
69
+ language: string;
70
+ volatility: string;
71
+ security: string;
72
+ execute: string;
73
+ }
74
+ export declare function normalizeFunction(sqlFunction: SqlFunction, enumNames: Set<string>): NormalizedFunction;
75
+ export declare function functionIdentity(normalized: NormalizedFunction): string;
76
+ export declare function functionSignature(normalized: NormalizedFunction): string;
@@ -250,3 +250,29 @@ export function resolveTriggerNames(model, timing, event) {
250
250
  triggerName: `${baseName}_trigger`,
251
251
  };
252
252
  }
253
+ export function normalizeFunction(sqlFunction, enumNames) {
254
+ return {
255
+ name: sqlFunction.name,
256
+ sqlName: toSnakeCase(sqlFunction.name),
257
+ params: sqlFunction.params.map((param) => ({
258
+ name: param.name,
259
+ sqlName: toSnakeCase(param.name),
260
+ sqlType: serializeColumnType(param.type, enumNames),
261
+ })),
262
+ returns: serializeColumnType(sqlFunction.returns, enumNames),
263
+ language: (sqlFunction.language ?? 'sql').toLowerCase(),
264
+ volatility: (sqlFunction.volatility ?? 'VOLATILE').toUpperCase(),
265
+ security: (sqlFunction.security ?? 'INVOKER').toUpperCase(),
266
+ execute: sqlFunction.execute.trim(),
267
+ };
268
+ }
269
+ export function functionIdentity(normalized) {
270
+ return JSON.stringify({
271
+ sqlName: normalized.sqlName,
272
+ params: normalized.params.map((param) => param.sqlType),
273
+ returns: normalized.returns,
274
+ });
275
+ }
276
+ export function functionSignature(normalized) {
277
+ return JSON.stringify(normalized);
278
+ }
@@ -13,6 +13,9 @@ const PRIMITIVE_TYPES = new Set([
13
13
  'TIMESTAMP',
14
14
  ]);
15
15
  export function mapColumnType(type, enumNames) {
16
+ if (type.name === 'TRIGGER' || type.name === 'VOID') {
17
+ return type.name;
18
+ }
16
19
  const baseType = mapBaseType(type, enumNames);
17
20
  return type.array ? `${baseType}[]` : baseType;
18
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "schematic-pg",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Single-file backend framework for PostgreSQL and Node.js",
5
5
  "type": "module",
6
6
  "license": "MIT",