schematic-pg 0.1.4 → 0.1.6

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
@@ -201,21 +201,35 @@ npx schematic-pg init my-app
201
201
  cd my-app
202
202
  ```
203
203
 
204
- Edit `app.schema`, then generate code and start the API:
204
+ Edit `app.schema`, then start the full dev loop:
205
205
 
206
206
  ```bash
207
- # Start PostgreSQL (PostGIS-enabled, matches .env defaults)
208
- docker compose up -d
207
+ make dev
208
+ # starts PostgreSQL, generates code, bootstraps the DB, runs the dev server,
209
+ # and watches app.schema for changes (regenerate + bootstrap + restart)
210
+ # → http://localhost:3000
211
+ ```
209
212
 
210
- # Generate schema.sql + generated/ (db client, routes, policies, Zod schemas)
211
- npx schematic-pg generate
213
+ Or run each step individually:
212
214
 
213
- # Apply DDL to the database and snapshot schema state
214
- npx schematic-pg db:bootstrap
215
+ ```bash
216
+ # Start PostgreSQL (PostGIS-enabled, matches .env defaults)
217
+ docker compose up -d --wait
215
218
 
216
- # Regenerate client + API and start the server
219
+ # Generate, bootstrap, start server, and watch app.schema (default)
217
220
  npx schematic-pg dev
218
221
  # → http://localhost:3000
222
+
223
+ # One-shot dev server without schema watching:
224
+ npx schematic-pg dev --no-watch
225
+ ```
226
+
227
+ Manual split when you need finer control:
228
+
229
+ ```bash
230
+ npx schematic-pg generate
231
+ npx schematic-pg db:bootstrap
232
+ npx schematic-pg dev --no-watch
219
233
  ```
220
234
 
221
235
  The `init` command creates everything you need to get running:
@@ -225,6 +239,7 @@ The `init` command creates everything you need to get running:
225
239
  | `app.schema` | Starter schema (one `User` model) — edit this |
226
240
  | `.env` | `DATABASE_URL`, JWT settings |
227
241
  | `docker-compose.yml` | Local PostGIS PostgreSQL on `:5432` |
242
+ | `Makefile` | `make dev` — docker compose (with health wait) + `schematic-pg dev` |
228
243
  | `tsconfig.json` | TypeScript config for `generated/` and `src/routes/` |
229
244
  | `package.json` | `schematic-pg` + runtime deps (`hono`, `pg`, `zod`, …) |
230
245
  | `src/routes/health.ts` | Example custom route mounted at `/health` |
@@ -252,7 +267,7 @@ Generated code imports the runtime from the `schematic-pg` package (`schematic-p
252
267
  | `JWT_ROLE_CLAIM` | `role` | JWT claim mapped to `auth.role` |
253
268
  | `JWT_USER_ID_CLAIM` | `sub` | JWT claim mapped to `auth.user.id` |
254
269
 
255
- Set these in `.env` before running `db:bootstrap` or `dev`.
270
+ Set these in `.env` before running `dev` or `db:bootstrap`.
256
271
 
257
272
  ---
258
273
 
@@ -280,12 +295,22 @@ Run `generate:client` before `generate:api` when using the split commands — ro
280
295
  ### Development server
281
296
 
282
297
  ```bash
283
- schematic-pg dev [schema] # generate:client + generate:api, then start generated/app.ts
298
+ schematic-pg dev [schema] [--no-watch]
284
299
  ```
285
300
 
301
+ `dev` runs the full local loop:
302
+
303
+ 1. `generate` — writes `schema.sql` and `generated/*`
304
+ 2. `db:bootstrap` — waits for Postgres, applies DDL, snapshots schema state
305
+ 3. Starts `generated/app.ts`
306
+ 4. Watches `app.schema` (default) — on change, re-runs generate, bootstrap, and server restart
307
+
308
+ Pass `--no-watch` for a one-shot run without file watching.
309
+
286
310
  Equivalent npm scripts in a project created by `init`:
287
311
 
288
312
  ```bash
313
+ make dev # docker compose up -d --wait + schematic-pg dev
289
314
  npm run dev # schematic-pg dev
290
315
  npm run generate # schematic-pg generate
291
316
  ```
@@ -11,4 +11,5 @@ export declare function getFilterFieldKind(field: Field, schema: Schema): Filter
11
11
  export declare function getFilterOperators(kind: FilterFieldKind): FilterOperator[];
12
12
  export declare function buildFilterFieldMeta(field: Field, schema: Schema): FilterFieldMeta;
13
13
  export declare function queryParamKey(fieldName: string, operator: FilterOperator): string;
14
+ export declare function toQueryBooleanZodType(): string;
14
15
  export declare function toFilterZodType(type: TypeExpr, field: Field, schema: Schema, operator: FilterOperator, coerce?: boolean): string;
@@ -54,6 +54,12 @@ export function queryParamKey(fieldName, operator) {
54
54
  }
55
55
  return `${fieldName}_${operator}`;
56
56
  }
57
+ export function toQueryBooleanZodType() {
58
+ return `z.preprocess(
59
+ (value) => (typeof value === 'string' ? value.toLowerCase() : value),
60
+ z.union([z.boolean(), z.enum(['true', 'false'])]).transform((value) => value === true || value === 'true'),
61
+ )`;
62
+ }
57
63
  export function toFilterZodType(type, field, schema, operator, coerce = false) {
58
64
  const prefix = coerce ? 'z.coerce.' : 'z.';
59
65
  const enumType = schema.enums.find((enumDef) => enumDef.name === type.name);
@@ -77,7 +83,7 @@ export function toFilterZodType(type, field, schema, operator, coerce = false) {
77
83
  case 'SMALLINT':
78
84
  return `${prefix}number().int()`;
79
85
  case 'BOOLEAN':
80
- return `${prefix}boolean()`;
86
+ return toQueryBooleanZodType();
81
87
  case 'TIMESTAMP':
82
88
  return 'z.coerce.date()';
83
89
  case 'DECIMAL':
@@ -86,8 +86,7 @@ export class ZodSchemaGenerator {
86
86
  const lines = [];
87
87
  for (const operator of meta.operators) {
88
88
  const key = queryParamKey(field.name, operator);
89
- const useCoerce = operator !== 'equals' || field.type.name !== 'BOOLEAN';
90
- const zodType = toFilterZodType(field.type, field, this.schema, operator, useCoerce);
89
+ const zodType = toFilterZodType(field.type, field, this.schema, operator, true);
91
90
  lines.push(`${key}: ${zodType}.optional(),`);
92
91
  }
93
92
  return lines;
package/dist/cli/db.js CHANGED
@@ -5,6 +5,7 @@ import { applyPendingMigrations } from '../db/migrate.js';
5
5
  import { createMigration, getAppliedMigrationFilenames, listMigrationFiles } from '../db/migrations.js';
6
6
  import { snapshotExists } from '../db/schema-state.js';
7
7
  import { resolveSchemaPath } from './paths.js';
8
+ import { waitForDatabase } from './wait-for-database.js';
8
9
  function parseDiffArgs(args) {
9
10
  let schemaPath = resolveSchemaPath();
10
11
  let name;
@@ -50,6 +51,7 @@ export async function runDbBootstrap(schemaPath) {
50
51
  const resolvedSchemaPath = resolveSchemaPath(schemaPath);
51
52
  const client = new DatabaseClient();
52
53
  try {
54
+ await waitForDatabase({ client });
53
55
  await bootstrapDatabase(resolvedSchemaPath, client);
54
56
  process.stdout.write(`Database bootstrapped from ${resolvedSchemaPath}\n`);
55
57
  }
package/dist/cli/dev.d.ts CHANGED
@@ -1 +1 @@
1
- export declare function runDev(schemaPath?: string): Promise<void>;
1
+ export declare function runDev(args?: string[]): Promise<void>;
package/dist/cli/dev.js CHANGED
@@ -1,10 +1,150 @@
1
- import { execFileSync } from 'node:child_process';
1
+ import { spawn } from 'node:child_process';
2
+ import { watch } from 'node:fs';
2
3
  import path from 'node:path';
3
- import { generateApi, generateClient } from './generate.js';
4
- import { DEFAULT_OUTPUT_DIR } from './paths.js';
5
- export async function runDev(schemaPath) {
6
- await generateClient(schemaPath);
7
- await generateApi(schemaPath);
4
+ import { runDbBootstrap } from './db.js';
5
+ import { generateAll } from './generate.js';
6
+ import { DEFAULT_OUTPUT_DIR, resolveSchemaPath } from './paths.js';
7
+ const WATCH_DEBOUNCE_MS = 300;
8
+ const SERVER_STOP_TIMEOUT_MS = 5000;
9
+ function parseDevArgs(args) {
10
+ let schemaPath = resolveSchemaPath();
11
+ let watchSchema = true;
12
+ for (const arg of args) {
13
+ if (arg === '--no-watch') {
14
+ watchSchema = false;
15
+ continue;
16
+ }
17
+ if (!arg.startsWith('--')) {
18
+ schemaPath = resolveSchemaPath(arg);
19
+ }
20
+ }
21
+ return { schemaPath, watchSchema };
22
+ }
23
+ function createDebouncer(fn, ms) {
24
+ let timer;
25
+ return () => {
26
+ clearTimeout(timer);
27
+ timer = setTimeout(() => {
28
+ void fn();
29
+ }, ms);
30
+ };
31
+ }
32
+ function startServer(appPath) {
33
+ return spawn(process.execPath, ['--import', 'tsx', appPath], {
34
+ stdio: 'inherit',
35
+ cwd: process.cwd(),
36
+ });
37
+ }
38
+ function stopServer(serverProcess) {
39
+ if (!serverProcess || serverProcess.exitCode !== null || serverProcess.killed) {
40
+ return Promise.resolve();
41
+ }
42
+ return new Promise((resolve) => {
43
+ serverProcess.once('exit', () => resolve());
44
+ if (process.platform === 'win32') {
45
+ serverProcess.kill();
46
+ }
47
+ else {
48
+ serverProcess.kill('SIGTERM');
49
+ }
50
+ setTimeout(() => {
51
+ if (serverProcess.exitCode === null && !serverProcess.killed) {
52
+ serverProcess.kill('SIGKILL');
53
+ }
54
+ }, SERVER_STOP_TIMEOUT_MS);
55
+ });
56
+ }
57
+ function waitForServerExit(serverProcess) {
58
+ return new Promise((resolve) => {
59
+ serverProcess.once('exit', () => resolve());
60
+ });
61
+ }
62
+ export async function runDev(args = []) {
63
+ const { schemaPath, watchSchema } = parseDevArgs(args);
8
64
  const appPath = path.resolve(DEFAULT_OUTPUT_DIR, 'app.ts');
9
- execFileSync(process.execPath, ['--import', 'tsx', appPath], { stdio: 'inherit', cwd: process.cwd() });
65
+ let serverProcess = null;
66
+ let syncInProgress = false;
67
+ let restarting = false;
68
+ let shuttingDown = false;
69
+ async function syncAndServe() {
70
+ if (syncInProgress) {
71
+ return;
72
+ }
73
+ syncInProgress = true;
74
+ try {
75
+ await generateAll(schemaPath);
76
+ await runDbBootstrap(schemaPath);
77
+ await stopServer(serverProcess);
78
+ serverProcess = startServer(appPath);
79
+ serverProcess.on('exit', (code, signal) => {
80
+ if (restarting || shuttingDown) {
81
+ return;
82
+ }
83
+ if (!watchSchema) {
84
+ if (code !== 0 && code !== null) {
85
+ process.exitCode = code;
86
+ }
87
+ return;
88
+ }
89
+ if (code !== 0 && code !== null) {
90
+ process.stderr.write(`Dev server exited with code ${code}\n`);
91
+ process.exitCode = code;
92
+ shuttingDown = true;
93
+ }
94
+ else if (signal) {
95
+ process.stderr.write(`Dev server terminated by signal ${signal}\n`);
96
+ }
97
+ });
98
+ }
99
+ finally {
100
+ syncInProgress = false;
101
+ }
102
+ }
103
+ async function reload() {
104
+ if (shuttingDown || syncInProgress) {
105
+ return;
106
+ }
107
+ process.stderr.write('\nSchema changed — regenerating, bootstrapping, and restarting...\n');
108
+ restarting = true;
109
+ try {
110
+ await syncAndServe();
111
+ }
112
+ finally {
113
+ restarting = false;
114
+ }
115
+ }
116
+ const scheduleReload = createDebouncer(reload, WATCH_DEBOUNCE_MS);
117
+ async function shutdown() {
118
+ if (shuttingDown) {
119
+ return;
120
+ }
121
+ shuttingDown = true;
122
+ await stopServer(serverProcess);
123
+ }
124
+ process.once('SIGINT', () => {
125
+ void shutdown().finally(() => {
126
+ process.exit(process.exitCode ?? 0);
127
+ });
128
+ });
129
+ process.once('SIGTERM', () => {
130
+ void shutdown().finally(() => {
131
+ process.exit(process.exitCode ?? 0);
132
+ });
133
+ });
134
+ await syncAndServe();
135
+ if (!watchSchema) {
136
+ if (serverProcess) {
137
+ await waitForServerExit(serverProcess);
138
+ }
139
+ return;
140
+ }
141
+ watch(schemaPath, scheduleReload);
142
+ await new Promise((resolve) => {
143
+ const interval = setInterval(() => {
144
+ if (shuttingDown) {
145
+ clearInterval(interval);
146
+ resolve();
147
+ }
148
+ }, 100);
149
+ });
10
150
  }
package/dist/cli/init.js CHANGED
@@ -3,12 +3,13 @@ import { PACKAGE_NAME } from '../constants.js';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { mkdir, readdir, writeFile } from 'node:fs/promises';
5
5
  import path from 'node:path';
6
- import { APP_SCHEMA_TEMPLATE, createPackageJsonTemplate, DOCKER_COMPOSE_TEMPLATE, ENV_TEMPLATE, GITIGNORE_TEMPLATE, HEALTH_ROUTE_TEMPLATE, TSCONFIG_TEMPLATE, } from './templates.js';
6
+ import { APP_SCHEMA_TEMPLATE, createPackageJsonTemplate, DOCKER_COMPOSE_TEMPLATE, ENV_TEMPLATE, GITIGNORE_TEMPLATE, HEALTH_ROUTE_TEMPLATE, MAKEFILE_TEMPLATE, TSCONFIG_TEMPLATE, } from './templates.js';
7
7
  const INIT_FILES = [
8
8
  { relativePath: 'app.schema', content: APP_SCHEMA_TEMPLATE },
9
9
  { relativePath: '.env', content: ENV_TEMPLATE },
10
10
  { relativePath: '.gitignore', content: GITIGNORE_TEMPLATE },
11
11
  { relativePath: 'docker-compose.yml', content: DOCKER_COMPOSE_TEMPLATE },
12
+ { relativePath: 'Makefile', content: MAKEFILE_TEMPLATE },
12
13
  { relativePath: 'tsconfig.json', content: TSCONFIG_TEMPLATE },
13
14
  { relativePath: 'src/routes/health.ts', content: HEALTH_ROUTE_TEMPLATE },
14
15
  ];
@@ -101,8 +102,14 @@ export async function runInit(args) {
101
102
  if (skipInstall) {
102
103
  console.log(' npm install');
103
104
  }
104
- console.log(' docker compose up -d');
105
+ console.log(' make dev');
106
+ console.log('');
107
+ console.log(' # or run individually:');
108
+ console.log(' docker compose up -d --wait');
109
+ console.log(` npx ${PACKAGE_NAME} dev # generate + bootstrap + server + schema watch`);
110
+ console.log('');
111
+ console.log(' # split steps (dev already includes generate, bootstrap, and watch):');
105
112
  console.log(` npx ${PACKAGE_NAME} generate`);
106
113
  console.log(` npx ${PACKAGE_NAME} db:bootstrap`);
107
- console.log(` npx ${PACKAGE_NAME} dev`);
114
+ console.log(` npx ${PACKAGE_NAME} dev --no-watch`);
108
115
  }
@@ -1,7 +1,8 @@
1
- export declare const APP_SCHEMA_TEMPLATE = "models {\n model User {\n id: UUID @id @default(gen_random_uuid())\n email: VARCHAR(255) @unique\n name: VARCHAR(150)\n createdAt: TIMESTAMP @default(now())\n }\n}\n";
1
+ export declare const APP_SCHEMA_TEMPLATE = "extensions {\n\n}\n\nenums {\n\n}\n\nmodels {\n model User {\n id: UUID @id @default(gen_random_uuid())\n email: VARCHAR(255) @unique\n name: VARCHAR(150)\n createdAt: TIMESTAMP @default(now())\n }\n}\n";
2
2
  export declare const ENV_TEMPLATE = "DATABASE_URL=postgresql://postgrest:postgrest@localhost:5432/postgrest\nJWT_SECRET=\nJWT_ROLE_CLAIM=role\nJWT_USER_ID_CLAIM=sub\n";
3
3
  export declare const GITIGNORE_TEMPLATE = "node_modules/\ndist/\n.env\ndocker_data/\n.DS_Store\n*.log\nnpm-debug.log*\n";
4
4
  export declare const DOCKER_COMPOSE_TEMPLATE = "services:\n postgres:\n image: postgis/postgis:16-3.4\n container_name: schematic-pg-postgres\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/data\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U postgrest -d postgrest\"]\n interval: 5s\n timeout: 5s\n retries: 5\n";
5
5
  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";
6
+ export declare const MAKEFILE_TEMPLATE = ".PHONY: dev\n\ndev:\n\tdocker compose up -d --wait\n\tnpx schematic-pg dev\n";
6
7
  export declare const HEALTH_ROUTE_TEMPLATE = "import { Hono } from 'hono';\nimport type { AppEnv } from 'schematic-pg/api/types';\n\nconst router = new Hono<AppEnv>();\nrouter.get('/', (c) => c.json({ ok: true }));\nexport default router;\n";
7
8
  export declare function createPackageJsonTemplate(projectName: string): string;
@@ -1,5 +1,13 @@
1
1
  import { PACKAGE_NAME, PACKAGE_VERSION } from '../constants.js';
2
- export const APP_SCHEMA_TEMPLATE = `models {
2
+ export const APP_SCHEMA_TEMPLATE = `extensions {
3
+
4
+ }
5
+
6
+ enums {
7
+
8
+ }
9
+
10
+ models {
3
11
  model User {
4
12
  id: UUID @id @default(gen_random_uuid())
5
13
  email: VARCHAR(255) @unique
@@ -54,6 +62,12 @@ export const TSCONFIG_TEMPLATE = `{
54
62
  "include": ["generated/**/*", "src/**/*"]
55
63
  }
56
64
  `;
65
+ export const MAKEFILE_TEMPLATE = `.PHONY: dev
66
+
67
+ dev:
68
+ \tdocker compose up -d --wait
69
+ \tnpx ${PACKAGE_NAME} dev
70
+ `;
57
71
  export const HEALTH_ROUTE_TEMPLATE = `import { Hono } from 'hono';
58
72
  import type { AppEnv } from '${PACKAGE_NAME}/api/types';
59
73
 
@@ -0,0 +1,9 @@
1
+ import { DatabaseClient } from '../db/client.js';
2
+ export type WaitForDatabaseOptions = {
3
+ maxAttempts?: number;
4
+ intervalMs?: number;
5
+ client?: Pick<DatabaseClient, 'query'>;
6
+ sleep?: (ms: number) => Promise<void>;
7
+ };
8
+ export declare function pingDatabase(client: Pick<DatabaseClient, 'query'>): Promise<void>;
9
+ export declare function waitForDatabase(options?: WaitForDatabaseOptions): Promise<void>;
@@ -0,0 +1,39 @@
1
+ import { DatabaseClient } from '../db/client.js';
2
+ export async function pingDatabase(client) {
3
+ const result = await client.query('SELECT 1 AS ok');
4
+ const ok = result.rows[0]?.ok;
5
+ if (ok !== 1) {
6
+ throw new Error(`Unexpected ping result: ${String(ok)}`);
7
+ }
8
+ }
9
+ export async function waitForDatabase(options = {}) {
10
+ const maxAttempts = options.maxAttempts ?? 30;
11
+ const intervalMs = options.intervalMs ?? 1000;
12
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
13
+ const ownClient = options.client ? null : new DatabaseClient();
14
+ const client = options.client ?? ownClient;
15
+ try {
16
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
17
+ try {
18
+ await pingDatabase(client);
19
+ if (attempt > 1) {
20
+ process.stderr.write(`Database ready (attempt ${attempt}/${maxAttempts})\n`);
21
+ }
22
+ return;
23
+ }
24
+ catch (error) {
25
+ if (attempt === maxAttempts) {
26
+ const message = error instanceof Error ? error.message : String(error);
27
+ throw new Error(`Database not ready after ${maxAttempts} attempts (${intervalMs}ms interval): ${message}`);
28
+ }
29
+ process.stderr.write(`Waiting for database (${attempt}/${maxAttempts})...\n`);
30
+ await sleep(intervalMs);
31
+ }
32
+ }
33
+ }
34
+ finally {
35
+ if (ownClient) {
36
+ await ownClient.close();
37
+ }
38
+ }
39
+ }
package/dist/cli.js CHANGED
@@ -12,7 +12,7 @@ Commands:
12
12
  generate:sql [schema] Generate SQL DDL to stdout
13
13
  generate:client [schema] Generate db client files
14
14
  generate:api [schema] Generate API files
15
- dev [schema] Regenerate client + API and start server
15
+ dev [schema] [--no-watch] Generate, bootstrap DB, start server, watch schema
16
16
  db:ping Test database connection
17
17
  db:bootstrap [schema] Apply DDL and snapshot schema state
18
18
  db:diff [schema] Show schema diff (--name <name> to write migration)
@@ -50,7 +50,7 @@ async function main() {
50
50
  await generateApi(schemaPath);
51
51
  break;
52
52
  case 'dev':
53
- await runDev(schemaPath);
53
+ await runDev(args);
54
54
  break;
55
55
  case 'db:ping':
56
56
  await runDbPing();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "schematic-pg",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Single-file backend framework for PostgreSQL and Node.js",
5
5
  "type": "module",
6
6
  "license": "MIT",