schematic-pg 0.1.12 → 0.1.14

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
@@ -182,7 +182,7 @@ models {
182
182
 
183
183
  model User {
184
184
  id: UUID @id @default(gen_random_uuid())
185
- email: VARCHAR(255) @unique
185
+ email: VARCHAR(255) @unique @regex(pattern: "^[\\w.-]+@[\\w.-]+\\.\\w+$", message: "Invalid email address")
186
186
  name: VARCHAR(150)
187
187
  role: UserRole @default(USER)
188
188
  age: SMALLINT?
@@ -449,14 +449,14 @@ npm run start # schematic-pg start
449
449
 
450
450
  ```bash
451
451
  schematic-pg db:ping [schema] # Test DATABASE_URL connection (SELECT 1)
452
- schematic-pg db:bootstrap [schema] # Apply DDL from schema + write .schema-state snapshot
452
+ schematic-pg db:bootstrap [schema] # Reset public schema, apply DDL, write .schema-state snapshot
453
453
  schematic-pg db:diff [schema] # Print pending schema changes (snapshot vs app.schema)
454
454
  schematic-pg db:diff --name add_users # Write a migration file under migrations/
455
455
  schematic-pg db:migrate [schema] # Apply pending migration files
456
456
  schematic-pg db:migrate:status [schema] # Show snapshot + migration file status
457
457
  ```
458
458
 
459
- `db:bootstrap` is the recommended first-time setup. Use `db:diff` / `db:migrate` when evolving an existing database.
459
+ `db:bootstrap` resets the `public` schema then applies full DDL — safe to re-run locally (including via `dev` watch). Use `db:diff` / `db:migrate` when evolving a database you need to keep.
460
460
 
461
461
  For a full walkthrough (mental model, local loop, and automating staging/production with GitHub Actions), see [Migrations tutorial](docs/migrations.md).
462
462
 
@@ -3,14 +3,20 @@ import { fieldHasAttribute, getModelNames, getPrimaryKey, getStoredFields, } fro
3
3
  import { getFilterableFields, getIncludableRelationFields, getOmittedFields, getSortableFieldNames, isStoredScalarField, } from './utils/api-fields.js';
4
4
  import { buildFilterFieldMeta, queryParamKey } from './utils/filter-operators.js';
5
5
  const ERROR_REF = { $ref: '#/components/schemas/Error' };
6
- const ERROR_CONTENT = {
7
- 'application/json': {
8
- schema: ERROR_REF,
9
- },
10
- };
6
+ const ERROR_EXAMPLE_VALIDATION = 'Validation failed';
7
+ const ERROR_EXAMPLE_FORBIDDEN = 'Role "USER" is not allowed to list this resource';
8
+ const ERROR_EXAMPLE_CONFLICT = 'Unique constraint violation on email';
11
9
  const OPTIONAL_BEARER_SECURITY = [{}, { bearerAuth: [] }];
12
- function errorResponse(description) {
13
- return { description, content: ERROR_CONTENT };
10
+ function errorResponse(description, exampleMessage = description) {
11
+ return {
12
+ description,
13
+ content: {
14
+ 'application/json': {
15
+ schema: ERROR_REF,
16
+ example: { error: exampleMessage },
17
+ },
18
+ },
19
+ };
14
20
  }
15
21
  function jsonContent(schema) {
16
22
  return {
@@ -32,7 +38,10 @@ export class OpenApiGenerator {
32
38
  type: 'object',
33
39
  required: ['error'],
34
40
  properties: {
35
- error: { type: 'string' },
41
+ error: {
42
+ type: 'string',
43
+ description: 'Human-readable error message',
44
+ },
36
45
  },
37
46
  },
38
47
  };
@@ -161,9 +170,9 @@ export class OpenApiGenerator {
161
170
  description: `List of ${model.name}`,
162
171
  content: jsonContent({ type: 'array', items: responseRef }),
163
172
  },
164
- '400': errorResponse('Validation error'),
173
+ '400': errorResponse('Validation error', ERROR_EXAMPLE_VALIDATION),
165
174
  '401': errorResponse('Unauthorized'),
166
- '403': errorResponse('Forbidden'),
175
+ '403': errorResponse('Forbidden', ERROR_EXAMPLE_FORBIDDEN),
167
176
  '500': errorResponse('Internal server error'),
168
177
  },
169
178
  },
@@ -181,10 +190,10 @@ export class OpenApiGenerator {
181
190
  description: `Created ${model.name}`,
182
191
  content: jsonContent(responseRef),
183
192
  },
184
- '400': errorResponse('Validation error'),
193
+ '400': errorResponse('Validation error', ERROR_EXAMPLE_VALIDATION),
185
194
  '401': errorResponse('Unauthorized'),
186
- '403': errorResponse('Forbidden'),
187
- '409': errorResponse('Conflict'),
195
+ '403': errorResponse('Forbidden', ERROR_EXAMPLE_FORBIDDEN),
196
+ '409': errorResponse('Conflict', ERROR_EXAMPLE_CONFLICT),
188
197
  '500': errorResponse('Internal server error'),
189
198
  },
190
199
  },
@@ -204,9 +213,9 @@ export class OpenApiGenerator {
204
213
  description: `${model.name} record`,
205
214
  content: jsonContent(responseRef),
206
215
  },
207
- '400': errorResponse('Validation error'),
216
+ '400': errorResponse('Validation error', ERROR_EXAMPLE_VALIDATION),
208
217
  '401': errorResponse('Unauthorized'),
209
- '403': errorResponse('Forbidden'),
218
+ '403': errorResponse('Forbidden', ERROR_EXAMPLE_FORBIDDEN),
210
219
  '404': errorResponse('Not found'),
211
220
  '500': errorResponse('Internal server error'),
212
221
  },
@@ -226,11 +235,11 @@ export class OpenApiGenerator {
226
235
  description: `Updated ${model.name}`,
227
236
  content: jsonContent(responseRef),
228
237
  },
229
- '400': errorResponse('Validation error'),
238
+ '400': errorResponse('Validation error', ERROR_EXAMPLE_VALIDATION),
230
239
  '401': errorResponse('Unauthorized'),
231
- '403': errorResponse('Forbidden'),
240
+ '403': errorResponse('Forbidden', ERROR_EXAMPLE_FORBIDDEN),
232
241
  '404': errorResponse('Not found'),
233
- '409': errorResponse('Conflict'),
242
+ '409': errorResponse('Conflict', ERROR_EXAMPLE_CONFLICT),
234
243
  '500': errorResponse('Internal server error'),
235
244
  },
236
245
  },
@@ -245,9 +254,9 @@ export class OpenApiGenerator {
245
254
  description: `Deleted ${model.name}`,
246
255
  content: jsonContent(responseRef),
247
256
  },
248
- '400': errorResponse('Validation error'),
257
+ '400': errorResponse('Validation error', ERROR_EXAMPLE_VALIDATION),
249
258
  '401': errorResponse('Unauthorized'),
250
- '403': errorResponse('Forbidden'),
259
+ '403': errorResponse('Forbidden', ERROR_EXAMPLE_FORBIDDEN),
251
260
  '404': errorResponse('Not found'),
252
261
  '500': errorResponse('Internal server error'),
253
262
  },
@@ -445,8 +454,8 @@ export class OpenApiGenerator {
445
454
  description: 'Registered user with access token',
446
455
  content: jsonContent({ $ref: '#/components/schemas/AuthTokenResponse' }),
447
456
  },
448
- '400': errorResponse('Validation error'),
449
- '409': errorResponse('Conflict'),
457
+ '400': errorResponse('Validation error', ERROR_EXAMPLE_VALIDATION),
458
+ '409': errorResponse('Conflict', ERROR_EXAMPLE_CONFLICT),
450
459
  '500': errorResponse('Internal server error'),
451
460
  },
452
461
  },
@@ -465,7 +474,7 @@ export class OpenApiGenerator {
465
474
  description: 'Access token and user',
466
475
  content: jsonContent({ $ref: '#/components/schemas/AuthTokenResponse' }),
467
476
  },
468
- '400': errorResponse('Validation error'),
477
+ '400': errorResponse('Validation error', ERROR_EXAMPLE_VALIDATION),
469
478
  '401': errorResponse('Invalid email or password'),
470
479
  '500': errorResponse('Internal server error'),
471
480
  },
@@ -264,7 +264,7 @@ Flow: `validate → assertPolicy → beforeHooks → db op → afterHooks → re
264
264
  schematic-pg generate # schema.sql + db client + API
265
265
  schematic-pg dev [--no-watch] # generate + bootstrap + server + watch
266
266
  schematic-pg start [--no-migrate] # production: migrate + run server
267
- schematic-pg db:bootstrap # first-time DDL apply
267
+ schematic-pg db:bootstrap # reset public schema + apply DDL
268
268
  schematic-pg db:diff [--name label] # print or write migration
269
269
  schematic-pg db:migrate # apply pending migrations
270
270
  schematic-pg hooks:add [--model X] # scaffold src/hooks/{Model}.ts
@@ -1,5 +1,5 @@
1
1
  export declare const AGENTS_TEMPLATE: string;
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\n name: VARCHAR(150)?\n role: UserRole @default(USER)\n passwordHash: VARCHAR(255)? @omit @unfilterable\n createdAt: TIMESTAMP @default(now())\n\n @policy(role: USER, allow: [select, update], where: \"id = {{auth.user.id}}\")\n @policy(role: ADMIN, allow: all)\n }\n}\n";
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 @policy(role: USER, allow: [select, update], where: \"id = {{auth.user.id}}\")\n @policy(role: ADMIN, allow: all)\n }\n}\n";
3
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\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";
@@ -15,7 +15,7 @@ enums {
15
15
  models {
16
16
  model User {
17
17
  id: UUID @id @default(gen_random_uuid())
18
- email: VARCHAR(255) @unique
18
+ email: VARCHAR(255) @unique @regex(pattern: "^[\\w.-]+@[\\w.-]+\\.\\w+$", message: "Invalid email address")
19
19
  name: VARCHAR(150)?
20
20
  role: UserRole @default(USER)
21
21
  passwordHash: VARCHAR(255)? @omit @unfilterable
package/dist/cli.js CHANGED
@@ -18,7 +18,7 @@ Commands:
18
18
  dev [schema] [--no-watch] Generate, bootstrap DB, start server, watch schema
19
19
  start [schema] [--no-migrate] Run production server (migrate DB, no generate/watch)
20
20
  db:ping Test database connection
21
- db:bootstrap [schema] Apply DDL and snapshot schema state
21
+ db:bootstrap [schema] Reset public schema, apply DDL, snapshot state
22
22
  db:diff [schema] Show schema diff (--name <name> to write migration)
23
23
  db:migrate [schema] Apply pending migrations
24
24
  db:migrate:status [schema] Show migration status
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { SqlGenerator } from '../sql-generator/sql-generator.js';
4
4
  import { DatabaseClient } from './client.js';
5
+ import { resetPublicSchema } from './reset-database.js';
5
6
  import { writeSnapshot } from './schema-state.js';
6
7
  export function generateBootstrapSql(schemaPath) {
7
8
  const source = readFileSync(schemaPath, 'utf8');
@@ -10,6 +11,8 @@ export function generateBootstrapSql(schemaPath) {
10
11
  export async function bootstrapDatabase(schemaPath = join(process.cwd(), 'app.schema'), client = new DatabaseClient()) {
11
12
  const sql = generateBootstrapSql(schemaPath);
12
13
  await client.withClient(async (pgClient) => {
14
+ // Bootstrap is greenfield: wipe existing objects so re-runs (e.g. `dev` watch) are idempotent.
15
+ await resetPublicSchema(pgClient);
13
16
  await pgClient.query(sql);
14
17
  });
15
18
  writeSnapshot(schemaPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "schematic-pg",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Single-file backend framework for PostgreSQL and Node.js",
5
5
  "type": "module",
6
6
  "license": "MIT",