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.
- package/README.md +237 -970
- package/dist/api/auth/jwt-crypto.d.ts +10 -0
- package/dist/api/auth/jwt-crypto.js +61 -0
- package/dist/api/auth/jwt-resolver.js +2 -27
- package/dist/api/auth/password/config.d.ts +32 -0
- package/dist/api/auth/password/config.js +35 -0
- package/dist/api/auth/password/errors.d.ts +8 -0
- package/dist/api/auth/password/errors.js +14 -0
- package/dist/api/auth/password/index.d.ts +3 -0
- package/dist/api/auth/password/index.js +3 -0
- package/dist/api/auth/password/password.d.ts +14 -0
- package/dist/api/auth/password/password.js +46 -0
- package/dist/api/auth/routes.d.ts +23 -0
- package/dist/api/auth/routes.js +118 -0
- package/dist/api/auth/token/config.d.ts +10 -0
- package/dist/api/auth/token/config.js +43 -0
- package/dist/api/auth/token/errors.d.ts +6 -0
- package/dist/api/auth/token/errors.js +12 -0
- package/dist/api/auth/token/index.d.ts +3 -0
- package/dist/api/auth/token/index.js +3 -0
- package/dist/api/auth/token/token.d.ts +11 -0
- package/dist/api/auth/token/token.js +31 -0
- package/dist/api/hooks/define.d.ts +10 -0
- package/dist/api/hooks/define.js +3 -0
- package/dist/api/hooks/index.d.ts +4 -0
- package/dist/api/hooks/index.js +2 -0
- package/dist/api/hooks/registry.d.ts +8 -0
- package/dist/api/hooks/registry.js +94 -0
- package/dist/api/hooks/types.d.ts +48 -0
- package/dist/api/hooks/types.js +1 -0
- package/dist/api/middleware/errors.js +11 -0
- package/dist/api-generator/app-generator.js +3 -0
- package/dist/api-generator/hook-scanner.d.ts +11 -0
- package/dist/api-generator/hook-scanner.js +36 -0
- package/dist/api-generator/hooks-generator.d.ts +2 -0
- package/dist/api-generator/hooks-generator.js +25 -0
- package/dist/api-generator/index.d.ts +2 -0
- package/dist/api-generator/index.js +7 -1
- package/dist/api-generator/route-generator.d.ts +3 -2
- package/dist/api-generator/route-generator.js +85 -64
- package/dist/cli/dev.js +5 -36
- package/dist/cli/generate.js +1 -0
- package/dist/cli/hooks.d.ts +6 -0
- package/dist/cli/hooks.js +85 -0
- package/dist/cli/init.js +9 -2
- package/dist/cli/paths.d.ts +1 -0
- package/dist/cli/paths.js +1 -0
- package/dist/cli/server.d.ts +5 -0
- package/dist/cli/server.js +60 -0
- package/dist/cli/start.d.ts +7 -0
- package/dist/cli/start.js +35 -0
- package/dist/cli/templates/agents.md +290 -0
- package/dist/cli/templates.d.ts +6 -3
- package/dist/cli/templates.js +58 -6
- package/dist/cli/wait-for-database.js +1 -1
- package/dist/cli.js +10 -0
- package/dist/db/db-client-generator.js +20 -2
- package/dist/db/include/executor.d.ts +2 -2
- package/dist/db/include/executor.js +7 -7
- package/dist/db/include/json-agg.d.ts +2 -2
- package/dist/db/include/json-agg.js +4 -4
- package/dist/db/include/load.d.ts +3 -3
- package/dist/db/include/load.js +8 -8
- package/dist/db/index.d.ts +4 -0
- package/dist/db/index.js +2 -0
- package/dist/db/model-client.d.ts +2 -2
- package/dist/db/model-client.js +3 -3
- package/dist/db/queryable.d.ts +5 -0
- package/dist/db/queryable.js +1 -0
- package/dist/db/raw.d.ts +22 -0
- package/dist/db/raw.js +35 -0
- package/dist/db/transaction.d.ts +2 -0
- package/dist/db/transaction.js +24 -0
- package/dist/routes/auth.d.ts +3 -0
- package/dist/routes/auth.js +5 -0
- package/dist/types/generated-db.stub.d.ts +5 -1
- package/dist/types/generated-db.stub.js +8 -1
- 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(' #
|
|
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`);
|
package/dist/cli/paths.d.ts
CHANGED
|
@@ -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,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 |
|
package/dist/cli/templates.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
export declare const
|
|
2
|
-
export declare const
|
|
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:
|
|
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;
|
package/dist/cli/templates.js
CHANGED
|
@@ -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:
|
|
13
|
-
email:
|
|
14
|
-
name:
|
|
15
|
-
|
|
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:
|
|
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 ??
|
|
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;
|