schematic-pg 0.1.7 → 0.1.10

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.
Files changed (78) hide show
  1. package/README.md +237 -970
  2. package/dist/api/auth/jwt-crypto.d.ts +10 -0
  3. package/dist/api/auth/jwt-crypto.js +61 -0
  4. package/dist/api/auth/jwt-resolver.js +2 -27
  5. package/dist/api/auth/password/config.d.ts +32 -0
  6. package/dist/api/auth/password/config.js +35 -0
  7. package/dist/api/auth/password/errors.d.ts +8 -0
  8. package/dist/api/auth/password/errors.js +14 -0
  9. package/dist/api/auth/password/index.d.ts +3 -0
  10. package/dist/api/auth/password/index.js +3 -0
  11. package/dist/api/auth/password/password.d.ts +14 -0
  12. package/dist/api/auth/password/password.js +46 -0
  13. package/dist/api/auth/routes.d.ts +23 -0
  14. package/dist/api/auth/routes.js +118 -0
  15. package/dist/api/auth/token/config.d.ts +10 -0
  16. package/dist/api/auth/token/config.js +43 -0
  17. package/dist/api/auth/token/errors.d.ts +6 -0
  18. package/dist/api/auth/token/errors.js +12 -0
  19. package/dist/api/auth/token/index.d.ts +3 -0
  20. package/dist/api/auth/token/index.js +3 -0
  21. package/dist/api/auth/token/token.d.ts +11 -0
  22. package/dist/api/auth/token/token.js +31 -0
  23. package/dist/api/hooks/define.d.ts +10 -0
  24. package/dist/api/hooks/define.js +3 -0
  25. package/dist/api/hooks/index.d.ts +4 -0
  26. package/dist/api/hooks/index.js +2 -0
  27. package/dist/api/hooks/registry.d.ts +8 -0
  28. package/dist/api/hooks/registry.js +94 -0
  29. package/dist/api/hooks/types.d.ts +48 -0
  30. package/dist/api/hooks/types.js +1 -0
  31. package/dist/api/middleware/errors.js +11 -0
  32. package/dist/api-generator/app-generator.js +3 -0
  33. package/dist/api-generator/hook-scanner.d.ts +11 -0
  34. package/dist/api-generator/hook-scanner.js +36 -0
  35. package/dist/api-generator/hooks-generator.d.ts +2 -0
  36. package/dist/api-generator/hooks-generator.js +25 -0
  37. package/dist/api-generator/index.d.ts +2 -0
  38. package/dist/api-generator/index.js +7 -1
  39. package/dist/api-generator/route-generator.d.ts +3 -2
  40. package/dist/api-generator/route-generator.js +85 -64
  41. package/dist/cli/dev.js +5 -36
  42. package/dist/cli/generate.js +1 -0
  43. package/dist/cli/hooks.d.ts +6 -0
  44. package/dist/cli/hooks.js +85 -0
  45. package/dist/cli/init.js +9 -2
  46. package/dist/cli/paths.d.ts +1 -0
  47. package/dist/cli/paths.js +1 -0
  48. package/dist/cli/server.d.ts +5 -0
  49. package/dist/cli/server.js +60 -0
  50. package/dist/cli/start.d.ts +7 -0
  51. package/dist/cli/start.js +35 -0
  52. package/dist/cli/templates/agents.md +290 -0
  53. package/dist/cli/templates.d.ts +6 -3
  54. package/dist/cli/templates.js +58 -6
  55. package/dist/cli/wait-for-database.js +1 -1
  56. package/dist/cli.js +10 -0
  57. package/dist/db/db-client-generator.js +20 -2
  58. package/dist/db/include/executor.d.ts +2 -2
  59. package/dist/db/include/executor.js +7 -7
  60. package/dist/db/include/json-agg.d.ts +2 -2
  61. package/dist/db/include/json-agg.js +4 -4
  62. package/dist/db/include/load.d.ts +3 -3
  63. package/dist/db/include/load.js +8 -8
  64. package/dist/db/index.d.ts +4 -0
  65. package/dist/db/index.js +2 -0
  66. package/dist/db/model-client.d.ts +2 -2
  67. package/dist/db/model-client.js +3 -3
  68. package/dist/db/queryable.d.ts +5 -0
  69. package/dist/db/queryable.js +1 -0
  70. package/dist/db/raw.d.ts +22 -0
  71. package/dist/db/raw.js +35 -0
  72. package/dist/db/transaction.d.ts +2 -0
  73. package/dist/db/transaction.js +24 -0
  74. package/dist/routes/auth.d.ts +3 -0
  75. package/dist/routes/auth.js +5 -0
  76. package/dist/types/generated-db.stub.d.ts +5 -1
  77. package/dist/types/generated-db.stub.js +8 -1
  78. package/package.json +11 -4
@@ -0,0 +1,85 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { select } from '@inquirer/prompts';
5
+ import { discoverHooks } from '../api-generator/hook-scanner.js';
6
+ import { PACKAGE_NAME } from '../constants.js';
7
+ import { parse } from '../schema-dsl/index.js';
8
+ import { DEFAULT_HOOKS_DIR, resolveSchemaPath } from './paths.js';
9
+ import { createHookFileTemplate } from './templates.js';
10
+ function parseModelFlag(args) {
11
+ const modelIndex = args.indexOf('--model');
12
+ if (modelIndex === -1) {
13
+ return undefined;
14
+ }
15
+ const modelName = args[modelIndex + 1];
16
+ if (!modelName || modelName.startsWith('--')) {
17
+ throw new Error('Missing value for --model');
18
+ }
19
+ return modelName;
20
+ }
21
+ function resolveSchemaArg(args) {
22
+ const positional = [];
23
+ for (let index = 0; index < args.length; index += 1) {
24
+ const arg = args[index];
25
+ if (arg === '--model') {
26
+ index += 1;
27
+ continue;
28
+ }
29
+ if (!arg.startsWith('--')) {
30
+ positional.push(arg);
31
+ }
32
+ }
33
+ return positional[0];
34
+ }
35
+ function getHookFilePath(hooksDir, modelName) {
36
+ return path.join(hooksDir, `${modelName}.ts`);
37
+ }
38
+ function assertModelExists(schema, modelName) {
39
+ const model = schema.models.find((entry) => entry.name === modelName);
40
+ if (!model) {
41
+ throw new Error(`Model "${modelName}" was not found in schema`);
42
+ }
43
+ return model;
44
+ }
45
+ function assertHookFileDoesNotExist(hooksDir, modelName) {
46
+ const hookFilePath = getHookFilePath(hooksDir, modelName);
47
+ if (existsSync(hookFilePath)) {
48
+ throw new Error(`Hook file already exists: ${hookFilePath}`);
49
+ }
50
+ }
51
+ async function promptForModel(models, hooksDir, schema) {
52
+ const { modelsWithHooks } = discoverHooks(hooksDir, schema);
53
+ const availableModels = models
54
+ .map((model) => model.name)
55
+ .filter((modelName) => !modelsWithHooks.has(modelName))
56
+ .sort((left, right) => left.localeCompare(right));
57
+ if (availableModels.length === 0) {
58
+ throw new Error('All schema models already have hook files in src/hooks/');
59
+ }
60
+ return select({
61
+ message: 'Select a model to scaffold lifecycle hooks',
62
+ choices: availableModels.map((modelName) => ({
63
+ name: modelName,
64
+ value: modelName,
65
+ })),
66
+ });
67
+ }
68
+ export async function runHooksAdd(args, options = {}) {
69
+ const schemaPath = resolveSchemaPath(options.schemaPath ?? resolveSchemaArg(args));
70
+ const hooksDir = options.hooksDir ?? DEFAULT_HOOKS_DIR;
71
+ const source = await readFile(schemaPath, 'utf8');
72
+ const schema = parse(source);
73
+ if (schema.models.length === 0) {
74
+ throw new Error('Schema has no models');
75
+ }
76
+ const modelName = options.modelName ?? parseModelFlag(args) ?? (await promptForModel(schema.models, hooksDir, schema));
77
+ assertModelExists(schema, modelName);
78
+ assertHookFileDoesNotExist(hooksDir, modelName);
79
+ await mkdir(hooksDir, { recursive: true });
80
+ const hookFilePath = getHookFilePath(hooksDir, modelName);
81
+ await writeFile(hookFilePath, createHookFileTemplate(modelName), 'utf8');
82
+ console.log(`Created ${path.relative(process.cwd(), hookFilePath)}`);
83
+ console.log(`\nNext step: run \`${PACKAGE_NAME} generate:api\` to wire hooks into generated routes.`);
84
+ return hookFilePath;
85
+ }
package/dist/cli/init.js CHANGED
@@ -3,8 +3,9 @@ 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, MAKEFILE_TEMPLATE, TSCONFIG_TEMPLATE, } from './templates.js';
6
+ import { AGENTS_TEMPLATE, APP_SCHEMA_TEMPLATE, AUTH_ROUTE_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
+ { relativePath: 'AGENTS.md', content: AGENTS_TEMPLATE },
8
9
  { relativePath: 'app.schema', content: APP_SCHEMA_TEMPLATE },
9
10
  { relativePath: '.env', content: ENV_TEMPLATE },
10
11
  { relativePath: '.gitignore', content: GITIGNORE_TEMPLATE },
@@ -12,6 +13,7 @@ const INIT_FILES = [
12
13
  { relativePath: 'Makefile', content: MAKEFILE_TEMPLATE },
13
14
  { relativePath: 'tsconfig.json', content: TSCONFIG_TEMPLATE },
14
15
  { relativePath: 'src/routes/health.ts', content: HEALTH_ROUTE_TEMPLATE },
16
+ { relativePath: 'src/routes/auth.ts', content: AUTH_ROUTE_TEMPLATE },
15
17
  ];
16
18
  function resolveTargetDir(args) {
17
19
  const targetArg = args.find((arg) => !arg.startsWith('--'));
@@ -66,6 +68,7 @@ export async function runInit(args) {
66
68
  }
67
69
  await mkdir(targetDir, { recursive: true });
68
70
  await mkdir(path.join(targetDir, 'src/routes'), { recursive: true });
71
+ await mkdir(path.join(targetDir, 'src/hooks'), { recursive: true });
69
72
  for (const file of INIT_FILES) {
70
73
  const filePath = path.join(targetDir, file.relativePath);
71
74
  if (existsSync(filePath)) {
@@ -108,7 +111,11 @@ export async function runInit(args) {
108
111
  console.log(' docker compose up -d --wait');
109
112
  console.log(` npx ${PACKAGE_NAME} dev # generate + bootstrap + server + schema watch`);
110
113
  console.log('');
111
- console.log(' # split steps (dev already includes generate, bootstrap, and watch):');
114
+ console.log(' # production:');
115
+ console.log(` npx ${PACKAGE_NAME} generate`);
116
+ console.log(` npx ${PACKAGE_NAME} start # migrate DB + run server`);
117
+ console.log('');
118
+ console.log(' # split dev steps (dev already includes generate, bootstrap, and watch):');
112
119
  console.log(` npx ${PACKAGE_NAME} generate`);
113
120
  console.log(` npx ${PACKAGE_NAME} db:bootstrap`);
114
121
  console.log(` npx ${PACKAGE_NAME} dev --no-watch`);
@@ -1,5 +1,6 @@
1
1
  export declare const DEFAULT_SCHEMA_FILE = "app.schema";
2
2
  export declare const DEFAULT_OUTPUT_DIR = "generated";
3
3
  export declare const DEFAULT_CUSTOM_ROUTES_DIR: string;
4
+ export declare const DEFAULT_HOOKS_DIR: string;
4
5
  export declare function resolveSchemaPath(schemaArg?: string): string;
5
6
  export declare function resolveOutputDir(): string;
package/dist/cli/paths.js CHANGED
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  export const DEFAULT_SCHEMA_FILE = 'app.schema';
3
3
  export const DEFAULT_OUTPUT_DIR = 'generated';
4
4
  export const DEFAULT_CUSTOM_ROUTES_DIR = path.resolve('src/routes');
5
+ export const DEFAULT_HOOKS_DIR = path.resolve('src/hooks');
5
6
  export function resolveSchemaPath(schemaArg) {
6
7
  return path.resolve(schemaArg ?? DEFAULT_SCHEMA_FILE);
7
8
  }
@@ -0,0 +1,5 @@
1
+ import { type ChildProcess } from 'node:child_process';
2
+ export declare function startAppServer(appPath: string, env?: NodeJS.ProcessEnv): ChildProcess;
3
+ export declare function stopAppServer(serverProcess: ChildProcess | null): Promise<void>;
4
+ export declare function waitForAppServerExit(serverProcess: ChildProcess): Promise<void>;
5
+ export declare function runAppServerUntilExit(appPath: string, env?: NodeJS.ProcessEnv): Promise<number | null>;
@@ -0,0 +1,60 @@
1
+ import { spawn } from 'node:child_process';
2
+ const SERVER_STOP_TIMEOUT_MS = 5000;
3
+ export function startAppServer(appPath, env) {
4
+ return spawn(process.execPath, ['--import', 'tsx', appPath], {
5
+ stdio: 'inherit',
6
+ cwd: process.cwd(),
7
+ env: env ? { ...process.env, ...env } : process.env,
8
+ });
9
+ }
10
+ export function stopAppServer(serverProcess) {
11
+ if (!serverProcess || serverProcess.exitCode !== null || serverProcess.killed) {
12
+ return Promise.resolve();
13
+ }
14
+ return new Promise((resolve) => {
15
+ serverProcess.once('exit', () => resolve());
16
+ if (process.platform === 'win32') {
17
+ serverProcess.kill();
18
+ }
19
+ else {
20
+ serverProcess.kill('SIGTERM');
21
+ }
22
+ setTimeout(() => {
23
+ if (serverProcess.exitCode === null && !serverProcess.killed) {
24
+ serverProcess.kill('SIGKILL');
25
+ }
26
+ }, SERVER_STOP_TIMEOUT_MS);
27
+ });
28
+ }
29
+ export function waitForAppServerExit(serverProcess) {
30
+ return new Promise((resolve) => {
31
+ serverProcess.once('exit', () => resolve());
32
+ });
33
+ }
34
+ export async function runAppServerUntilExit(appPath, env) {
35
+ let serverProcess = null;
36
+ let shuttingDown = false;
37
+ async function shutdown() {
38
+ if (shuttingDown) {
39
+ return;
40
+ }
41
+ shuttingDown = true;
42
+ await stopAppServer(serverProcess);
43
+ }
44
+ process.once('SIGINT', () => {
45
+ void shutdown().finally(() => {
46
+ process.exit(process.exitCode ?? 0);
47
+ });
48
+ });
49
+ process.once('SIGTERM', () => {
50
+ void shutdown().finally(() => {
51
+ process.exit(process.exitCode ?? 0);
52
+ });
53
+ });
54
+ serverProcess = startAppServer(appPath, env);
55
+ return new Promise((resolve) => {
56
+ serverProcess.once('exit', (code) => {
57
+ resolve(code);
58
+ });
59
+ });
60
+ }
@@ -0,0 +1,7 @@
1
+ type StartOptions = {
2
+ schemaPath: string;
3
+ migrate: boolean;
4
+ };
5
+ export declare function parseStartArgs(args: string[]): StartOptions;
6
+ export declare function runStart(args?: string[]): Promise<void>;
7
+ export {};
@@ -0,0 +1,35 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { runDbMigrate } from './db.js';
4
+ import { DEFAULT_OUTPUT_DIR, resolveSchemaPath } from './paths.js';
5
+ import { runAppServerUntilExit } from './server.js';
6
+ import { waitForDatabase } from './wait-for-database.js';
7
+ export function parseStartArgs(args) {
8
+ let schemaPath = resolveSchemaPath();
9
+ let migrate = true;
10
+ for (const arg of args) {
11
+ if (arg === '--no-migrate') {
12
+ migrate = false;
13
+ continue;
14
+ }
15
+ if (!arg.startsWith('--')) {
16
+ schemaPath = resolveSchemaPath(arg);
17
+ }
18
+ }
19
+ return { schemaPath, migrate };
20
+ }
21
+ export async function runStart(args = []) {
22
+ const { schemaPath, migrate } = parseStartArgs(args);
23
+ const appPath = path.resolve(DEFAULT_OUTPUT_DIR, 'app.ts');
24
+ if (!existsSync(appPath)) {
25
+ throw new Error(`Missing ${appPath}. Run "schematic-pg generate" first to create the app entry point.`);
26
+ }
27
+ await waitForDatabase();
28
+ if (migrate) {
29
+ await runDbMigrate([schemaPath]);
30
+ }
31
+ const exitCode = await runAppServerUntilExit(appPath, { NODE_ENV: 'production' });
32
+ if (exitCode !== 0 && exitCode !== null) {
33
+ process.exitCode = exitCode;
34
+ }
35
+ }
@@ -0,0 +1,290 @@
1
+ # schematic-pg Agent Guide
2
+
3
+ Instructions for AI agents working in a schematic-pg project.
4
+
5
+ ## Overview
6
+
7
+ schematic-pg is a single-file backend framework for PostgreSQL and Node.js. **`app.schema` is the single source of truth** — it drives SQL DDL, the type-safe DB client, REST API routes, Zod validators, and ACL policies.
8
+
9
+ - One declarative schema file replaces scattered migrations, ORM models, and route handlers.
10
+ - Generated code uses parameterized raw SQL (no ORM).
11
+ - Framework runtime lives in `node_modules/schematic-pg` — it is not copied into your project.
12
+
13
+ ## Golden Rules
14
+
15
+ 1. **Never edit `generated/`** — it is overwritten on every `generate` / `dev` run.
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.
18
+ 4. **Use extension points** for app-specific logic: `src/routes/` (custom HTTP) and `src/hooks/` (lifecycle hooks).
19
+ 5. **Do not hand-write SQL** for CRUD — use the generated DB client or REST API.
20
+
21
+ ## Project Layout
22
+
23
+ ```
24
+ my-app/
25
+ ├── app.schema # Source of truth — edit this
26
+ ├── schema.sql # Generated PostgreSQL DDL (read-only)
27
+ ├── AGENTS.md # This file
28
+ ├── .env # DATABASE_URL, JWT_* settings
29
+ ├── docker-compose.yml # Local PostgreSQL
30
+ ├── generated/ # Generated — do not edit
31
+ │ ├── db.ts # createDbClient(pool)
32
+ │ ├── db-types.ts # Model interfaces
33
+ │ ├── app.ts # Hono server entry point
34
+ │ ├── routes/*.ts # CRUD routers per model
35
+ │ ├── policies.ts # ACL from @policy
36
+ │ └── schemas/validation.ts
37
+ └── src/
38
+ ├── routes/ # Custom Hono routers (hand-written)
39
+ └── hooks/ # Lifecycle hooks (hand-written)
40
+ ```
41
+
42
+ ## Dev Workflow
43
+
44
+ ```bash
45
+ make dev
46
+ # or:
47
+ docker compose up -d --wait
48
+ npx schematic-pg dev
49
+ ```
50
+
51
+ `dev` runs: generate → db:bootstrap → start server → watch `app.schema`.
52
+
53
+ **Environment variables** (`.env`):
54
+
55
+ | Variable | Purpose |
56
+ |----------|---------|
57
+ | `DATABASE_URL` | PostgreSQL connection string (required) |
58
+ | `PORT` | HTTP port (default `3000`) |
59
+ | `JWT_SECRET` | HMAC secret for Bearer JWT auth |
60
+ | `JWT_ROLE_CLAIM` | JWT claim for role (default `role`) |
61
+ | `JWT_USER_ID_CLAIM` | JWT claim for user id (default `sub`) |
62
+
63
+ ## Schema DSL Essentials
64
+
65
+ ```ts
66
+ extensions { pgcrypto; uuid-ossp }
67
+
68
+ enums {
69
+ UserRole { ADMIN, USER, PUBLIC }
70
+ }
71
+
72
+ models {
73
+ model User {
74
+ id: UUID @id @default(gen_random_uuid())
75
+ email: VARCHAR(255) @unique
76
+ role: UserRole @default(USER)
77
+
78
+ orders: Order[]
79
+
80
+ @policy(role: USER, allow: [select, update], where: "id = {{auth.user.id}}")
81
+ @policy(role: ADMIN, allow: all)
82
+
83
+ @@index(fields: [role])
84
+ }
85
+
86
+ model Order {
87
+ userId: UUID
88
+ user: User @relation(fields: [userId], references: [id])
89
+ }
90
+ }
91
+ ```
92
+
93
+ **Key concepts:**
94
+
95
+ - **Relations:** Put `@relation(fields: [...], references: [...])` on the FK-owning side. The inverse side is inferred.
96
+ - **Policies:** `@policy(role: ..., allow: [select|insert|update|delete|all], where: "...")` — `where` supports `{{auth.user.id}}`.
97
+ - **Validation:** `@regex(...)`, `@range(min: ..., max: ...)` flow into generated Zod schemas.
98
+ - **Indexes / triggers:** `@@index(...)`, `@@trigger { timing, event, level, execute: """...""" }`.
99
+
100
+ ## Database Client
101
+
102
+ Generated by `schematic-pg generate:client` (or `generate` / `dev`). Prisma-like API over parameterized SQL.
103
+
104
+ **Key files:** `generated/db.ts`, `generated/db-types.ts` (do not edit).
105
+
106
+ ### How to Get `db`
107
+
108
+ | Context | Access |
109
+ |---------|--------|
110
+ | Standalone script / test | `createDbClient(pool)` |
111
+ | Custom route (`src/routes/*.ts`) | `c.get('db')` |
112
+ | Lifecycle hook (`src/hooks/{Model}.ts`) | `ctx.db` |
113
+
114
+ ```typescript
115
+ import { Pool } from 'pg';
116
+ import { createDbClient } from './generated/db.js';
117
+
118
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
119
+ const db = createDbClient(pool);
120
+ ```
121
+
122
+ **Model naming:** `User` → `db.user`, `ProductOrder` → `db.productOrder`. API fields are camelCase; SQL columns are snake_case (automatic mapping).
123
+
124
+ ### Per-Model Methods
125
+
126
+ | Method | Notes |
127
+ |--------|-------|
128
+ | `create(data)` | Returns inserted row |
129
+ | `findUnique(where, opts?)` | Single row by PK/unique; supports `include` |
130
+ | `findFirst({ where, orderBy, include, … })` | First match |
131
+ | `findMany({ where, orderBy, take, skip, include, … })` | List + pagination |
132
+ | `count({ where })` | No `include` |
133
+ | `update({ where, data })` | Returns updated row |
134
+ | `updateMany({ where, data })` | Returns `{ count }` |
135
+ | `delete(where)` / `deleteMany({ where })` | Returns deleted row(s) or count |
136
+
137
+ ### Basic Querying
138
+
139
+ ```typescript
140
+ // Create
141
+ await db.user.create({ email: 'a@b.com', name: 'Alice' });
142
+
143
+ // Read
144
+ await db.user.findUnique({ id });
145
+ await db.user.findFirst({ where: { role: 'ADMIN' }, orderBy: { createdAt: 'desc' } });
146
+ await db.user.findMany({ where: { isActive: true }, take: 10, skip: 0 });
147
+ await db.user.count({ where: { role: 'ADMIN' } });
148
+
149
+ // Update / delete
150
+ await db.user.update({ where: { id }, data: { name: 'Bob' } });
151
+ await db.user.delete({ id });
152
+ ```
153
+
154
+ ### Where Filters
155
+
156
+ - Shorthand equality: `{ email: 'a@b.com' }`
157
+ - Operators: `equals`, `contains`, `startsWith`, `endsWith`, `gt`, `gte`, `lt`, `lte`, `in`
158
+ - Logical groups: `AND`, `OR`, `NOT`
159
+
160
+ ```typescript
161
+ await db.user.findMany({
162
+ where: {
163
+ AND: [{ role: { in: ['ADMIN', 'USER'] } }, { isActive: true }],
164
+ NOT: { email: { contains: 'spam' } },
165
+ },
166
+ });
167
+ ```
168
+
169
+ ### Eager Loading (`include`)
170
+
171
+ Available on `findUnique`, `findFirst`, `findMany` — not on `count`. Use relation **field names** from the schema (`profile`, `orders`).
172
+
173
+ ```typescript
174
+ await db.user.findMany({
175
+ include: {
176
+ profile: true,
177
+ orders: {
178
+ where: { status: 'PENDING' },
179
+ orderBy: { createdAt: 'desc' },
180
+ take: 5,
181
+ include: { products: { include: { product: true } } },
182
+ },
183
+ },
184
+ });
185
+ ```
186
+
187
+ Default strategy is split queries (avoids cartesian explosion). Pass `relationLoadStrategy: 'join'` for fewer round trips.
188
+
189
+ ### Types
190
+
191
+ Import from `generated/db-types.js`: `{Model}`, `{Model}CreateInput`, `{Model}UpdateInput`, `{Model}WhereInput`, `{Model}Include`.
192
+
193
+ - Fields with `@default` / auto-generated `@id` are optional on create input.
194
+ - `DECIMAL` maps to `string`; optional fields are `T | null`.
195
+
196
+ ### Errors
197
+
198
+ ```typescript
199
+ import { UniqueConstraintError } from 'schematic-pg/db/errors';
200
+
201
+ try {
202
+ await db.user.create({ email: 'taken@b.com', name: 'X' });
203
+ } catch (error) {
204
+ if (error instanceof UniqueConstraintError) {
205
+ console.log(error.fields); // ['email']
206
+ }
207
+ }
208
+ ```
209
+
210
+ Also available: `ForeignKeyConstraintError`, `DatabaseError`.
211
+
212
+ ### DB Access Rules
213
+
214
+ - Prefer `ctx.db` / `c.get('db')` in routes and hooks — do not create ad-hoc pools.
215
+ - Run `generate` (or `dev`) after schema changes before querying new models or fields.
216
+ - Use generated `{Model}WhereInput` types — do not hand-build SQL strings.
217
+ - Use `ctx.db` in `afterCreate` / `afterUpdate` / `afterDelete` for side effects (audit logs, related rows).
218
+
219
+ ## Extension Points
220
+
221
+ ### Custom Routes (`src/routes/`)
222
+
223
+ | Rule | Example |
224
+ |------|---------|
225
+ | Location | `src/routes/**/*.ts` |
226
+ | Export | `export default router` (`Hono<AppEnv>`) |
227
+ | Mount path | File path relative to `src/routes/` |
228
+
229
+ `src/routes/health.ts` → `GET /health`. Regenerate after adding files.
230
+
231
+ ```typescript
232
+ import { Hono } from 'hono';
233
+ import type { AppEnv } from 'schematic-pg/api/types';
234
+
235
+ const router = new Hono<AppEnv>();
236
+
237
+ router.get('/me', async (c) => {
238
+ const db = c.get('db');
239
+ const auth = c.get('auth');
240
+ // ...
241
+ });
242
+
243
+ export default router;
244
+ ```
245
+
246
+ ### Lifecycle Hooks (`src/hooks/`)
247
+
248
+ | Rule | Example |
249
+ |------|---------|
250
+ | Location | `src/hooks/{Model}.ts` (PascalCase) |
251
+ | Export | `export default defineHooks(...)` |
252
+ | Scaffold | `schematic-pg hooks:add --model User` |
253
+
254
+ Flow: `validate → assertPolicy → beforeHooks → db op → afterHooks → response`.
255
+
256
+ - `await next()` to proceed; `return ctx.abort(status, msg)` to cancel.
257
+ - `ctx.data` — mutable create/update payload (before hooks).
258
+ - `ctx.result` — DB row (after hooks).
259
+ - `ctx.db`, `ctx.auth` — available in all hooks.
260
+
261
+ ## CLI Cheat Sheet
262
+
263
+ ```bash
264
+ schematic-pg generate # schema.sql + db client + API
265
+ schematic-pg dev [--no-watch] # generate + bootstrap + server + watch
266
+ schematic-pg start [--no-migrate] # production: migrate + run server
267
+ schematic-pg db:bootstrap # first-time DDL apply
268
+ schematic-pg db:diff [--name label] # print or write migration
269
+ schematic-pg db:migrate # apply pending migrations
270
+ schematic-pg hooks:add [--model X] # scaffold src/hooks/{Model}.ts
271
+ ```
272
+
273
+ ## Auth & ACL
274
+
275
+ - Models **without** `@policy` are open (no ACL checks).
276
+ - Unauthenticated requests default to `{ role: 'PUBLIC' }`.
277
+ - `@policy` `allow` maps to HTTP: GET→select, POST→insert, PUT→update, DELETE→delete.
278
+ - Row-level `where` is injected on read/update/delete; POST checks permission only.
279
+
280
+ ## Generated Outputs (Read-Only)
281
+
282
+ | Output | Purpose |
283
+ |--------|---------|
284
+ | `schema.sql` | Idempotent PostgreSQL DDL |
285
+ | `generated/db.ts` | `createDbClient(pool)` |
286
+ | `generated/db-types.ts` | TypeScript interfaces |
287
+ | `generated/app.ts` | Hono server entry point |
288
+ | `generated/routes/*.ts` | CRUD routers |
289
+ | `generated/policies.ts` | ACL metadata |
290
+ | `generated/schemas/validation.ts` | Zod validators |
@@ -1,8 +1,11 @@
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
- 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";
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";
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";
3
4
  export declare const GITIGNORE_TEMPLATE = "node_modules/\ndist/\n.env\ndocker_data/\n.DS_Store\n*.log\nnpm-debug.log*\n";
4
- export declare const DOCKER_COMPOSE_TEMPLATE = "services:\n postgres:\n image: postgis/postgis:16-3.4\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/data\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U postgrest -d postgrest\"]\n interval: 5s\n timeout: 5s\n retries: 5\n";
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/data\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U postgrest -d postgrest\"]\n interval: 5s\n timeout: 5s\n retries: 5\n";
5
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";
6
7
  export declare const MAKEFILE_TEMPLATE = ".PHONY: dev\n\ndev:\n\tdocker compose up -d --wait\n\tnpx schematic-pg dev\n";
7
8
  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";
9
+ export declare const AUTH_ROUTE_TEMPLATE = "import { createAuthRouter } from 'schematic-pg/api/auth/routes';\n\nexport default createAuthRouter();\n";
8
10
  export declare function createPackageJsonTemplate(projectName: string): string;
11
+ export declare function createHookFileTemplate(modelName: string): string;
@@ -1,23 +1,35 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import path from 'node:path';
1
4
  import { PACKAGE_NAME, PACKAGE_VERSION } from '../constants.js';
5
+ const templatesDir = path.dirname(fileURLToPath(import.meta.url));
6
+ export const AGENTS_TEMPLATE = readFileSync(path.join(templatesDir, 'templates', 'agents.md'), 'utf8');
2
7
  export const APP_SCHEMA_TEMPLATE = `extensions {
3
8
 
4
9
  }
5
10
 
6
11
  enums {
7
-
12
+ UserRole { ADMIN, USER }
8
13
  }
9
14
 
10
15
  models {
11
16
  model User {
12
- id: UUID @id @default(gen_random_uuid())
13
- email: VARCHAR(255) @unique
14
- name: VARCHAR(150)
15
- createdAt: TIMESTAMP @default(now())
17
+ id: UUID @id @default(gen_random_uuid())
18
+ email: VARCHAR(255) @unique
19
+ name: VARCHAR(150)?
20
+ role: UserRole @default(USER)
21
+ passwordHash: VARCHAR(255)? @omit @unfilterable
22
+ createdAt: TIMESTAMP @default(now())
23
+
24
+ @policy(role: USER, allow: [select, update], where: "id = {{auth.user.id}}")
25
+ @policy(role: ADMIN, allow: all)
16
26
  }
17
27
  }
18
28
  `;
19
29
  export const ENV_TEMPLATE = `DATABASE_URL=postgresql://postgrest:postgrest@localhost:5432/postgrest
20
30
  JWT_SECRET=
31
+ AUTH_PEPPER=
32
+ AUTH_ACCESS_TOKEN_TTL=1h
21
33
  JWT_ROLE_CLAIM=role
22
34
  JWT_USER_ID_CLAIM=sub
23
35
  `;
@@ -31,7 +43,7 @@ npm-debug.log*
31
43
  `;
32
44
  export const DOCKER_COMPOSE_TEMPLATE = `services:
33
45
  postgres:
34
- image: postgis/postgis:16-3.4
46
+ image: postgres:18.4-bookworm
35
47
  container_name: schematic-pg
36
48
  restart: unless-stopped
37
49
  ports:
@@ -75,6 +87,10 @@ const router = new Hono<AppEnv>();
75
87
  router.get('/', (c) => c.json({ ok: true }));
76
88
  export default router;
77
89
  `;
90
+ export const AUTH_ROUTE_TEMPLATE = `import { createAuthRouter } from '${PACKAGE_NAME}/api/auth/routes';
91
+
92
+ export default createAuthRouter();
93
+ `;
78
94
  export function createPackageJsonTemplate(projectName) {
79
95
  return JSON.stringify({
80
96
  name: projectName,
@@ -83,6 +99,7 @@ export function createPackageJsonTemplate(projectName) {
83
99
  type: 'module',
84
100
  scripts: {
85
101
  dev: `${PACKAGE_NAME} dev`,
102
+ start: `${PACKAGE_NAME} start`,
86
103
  generate: `${PACKAGE_NAME} generate`,
87
104
  'db:bootstrap': `${PACKAGE_NAME} db:bootstrap`,
88
105
  'db:migrate': `${PACKAGE_NAME} db:migrate`,
@@ -101,3 +118,38 @@ export function createPackageJsonTemplate(projectName) {
101
118
  },
102
119
  }, null, 2);
103
120
  }
121
+ export function createHookFileTemplate(modelName) {
122
+ return `import { defineHooks } from '${PACKAGE_NAME}/api/hooks';
123
+ import type { ${modelName}, ${modelName}CreateInput, ${modelName}UpdateInput } from '../../generated/db-types.js';
124
+
125
+ export default defineHooks<${modelName}, ${modelName}CreateInput, ${modelName}UpdateInput>({
126
+ async beforeCreate(ctx, next) {
127
+ // ctx.data is the create payload (mutable). Call await next() to proceed.
128
+ // Cancel without calling next(): return ctx.abort(422, 'reason');
129
+ await next();
130
+ },
131
+
132
+ async afterCreate(ctx) {
133
+ // ctx.result is the created row. Use ctx.db / ctx.auth for side effects.
134
+ },
135
+
136
+ async beforeUpdate(ctx, next) {
137
+ // ctx.params — route params (e.g. id). ctx.data — update payload (mutable).
138
+ await next();
139
+ },
140
+
141
+ async afterUpdate(ctx) {
142
+ // ctx.result is the updated row.
143
+ },
144
+
145
+ async beforeDelete(ctx, next) {
146
+ // ctx.params — route params. No ctx.data on delete.
147
+ await next();
148
+ },
149
+
150
+ async afterDelete(ctx) {
151
+ // ctx.result is the deleted row.
152
+ },
153
+ });
154
+ `;
155
+ }
@@ -8,7 +8,7 @@ export async function pingDatabase(client) {
8
8
  }
9
9
  export async function waitForDatabase(options = {}) {
10
10
  const maxAttempts = options.maxAttempts ?? 30;
11
- const intervalMs = options.intervalMs ?? 1000;
11
+ const intervalMs = options.intervalMs ?? 3000;
12
12
  const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
13
13
  const ownClient = options.client ? null : new DatabaseClient();
14
14
  const client = options.client ?? ownClient;