schematic-pg 0.1.8 → 0.1.11
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 +154 -1102
- 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/middleware/errors.js +11 -0
- package/dist/cli/init.js +3 -1
- package/dist/cli/templates/agents.md +290 -0
- package/dist/cli/templates.d.ts +5 -3
- package/dist/cli/templates.js +22 -6
- package/dist/cli/wait-for-database.js +1 -1
- 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 +5 -4
|
@@ -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,9 +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;
|
|
9
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,
|
|
@@ -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;
|
|
@@ -31,10 +31,13 @@ export class DbClientGenerator {
|
|
|
31
31
|
"import type { Pool } from 'pg';",
|
|
32
32
|
`import { createModelClient } from '${PACKAGE_NAME}/db/model-client';`,
|
|
33
33
|
`import { hydrateModelMeta } from '${PACKAGE_NAME}/db/model-meta';`,
|
|
34
|
+
`import type { Queryable } from '${PACKAGE_NAME}/db/queryable';`,
|
|
35
|
+
`import { createRawClient } from '${PACKAGE_NAME}/db/raw';`,
|
|
36
|
+
`import { runInTransaction } from '${PACKAGE_NAME}/db/transaction';`,
|
|
34
37
|
`import type {\n ${imports},\n} from './db-types.js';`,
|
|
35
38
|
`import {\n ${this.schema.models.map((model) => `${getClientExportName(model.name)}ModelMeta`).join(',\n ')},\n} from './db-model-meta.js';`,
|
|
36
39
|
'',
|
|
37
|
-
'
|
|
40
|
+
'function buildModels(executor: Queryable) {',
|
|
38
41
|
...metaHydrations,
|
|
39
42
|
` const modelRegistry = new Map([`,
|
|
40
43
|
...registryEntries.map((entry) => ` ${entry},`),
|
|
@@ -42,6 +45,21 @@ export class DbClientGenerator {
|
|
|
42
45
|
'',
|
|
43
46
|
' return {',
|
|
44
47
|
clientEntries.map((entry) => ` ${entry},`).join('\n'),
|
|
48
|
+
' ...createRawClient(executor),',
|
|
49
|
+
' };',
|
|
50
|
+
'}',
|
|
51
|
+
'',
|
|
52
|
+
'/** All model clients scoped to a single transaction. Nested `$transaction` is unsupported. */',
|
|
53
|
+
'export type TxClient = ReturnType<typeof buildModels>;',
|
|
54
|
+
'',
|
|
55
|
+
'export function createDbClient(pool: Pool) {',
|
|
56
|
+
' async function $transaction<T>(fn: (tx: TxClient) => Promise<T>): Promise<T> {',
|
|
57
|
+
' return runInTransaction(pool, (client) => fn(buildModels(client)));',
|
|
58
|
+
' }',
|
|
59
|
+
'',
|
|
60
|
+
' return {',
|
|
61
|
+
' ...buildModels(pool),',
|
|
62
|
+
' $transaction,',
|
|
45
63
|
' };',
|
|
46
64
|
'}',
|
|
47
65
|
'',
|
|
@@ -65,7 +83,7 @@ export class DbClientGenerator {
|
|
|
65
83
|
}
|
|
66
84
|
generateClientEntry(modelName) {
|
|
67
85
|
const clientKey = getClientExportName(modelName);
|
|
68
|
-
return `${clientKey}: createModelClient<${modelName}, ${modelName}CreateInput, ${modelName}UpdateInput, ${modelName}WhereInput, ${modelName}OrderByInput>(${clientKey}Meta,
|
|
86
|
+
return `${clientKey}: createModelClient<${modelName}, ${modelName}CreateInput, ${modelName}UpdateInput, ${modelName}WhereInput, ${modelName}OrderByInput>(${clientKey}Meta, executor, modelRegistry)`;
|
|
69
87
|
}
|
|
70
88
|
}
|
|
71
89
|
export function generateDbClientFiles(schema) {
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Queryable } from '../queryable.js';
|
|
2
2
|
import type { LoadNode } from './planner.js';
|
|
3
|
-
export declare function loadIncludes(parentRows: Record<string, unknown>[], plan: LoadNode,
|
|
3
|
+
export declare function loadIncludes(parentRows: Record<string, unknown>[], plan: LoadNode, executor: Queryable): Promise<void>;
|
|
@@ -2,7 +2,7 @@ import { mapPgError } from '../errors.js';
|
|
|
2
2
|
import { mapRows } from '../row-mapper.js';
|
|
3
3
|
import { WhereTranslator } from '../where-translator.js';
|
|
4
4
|
import { dedupeKeys, extractParentKeys, stitch } from './hydrator.js';
|
|
5
|
-
export async function loadIncludes(parentRows, plan,
|
|
5
|
+
export async function loadIncludes(parentRows, plan, executor) {
|
|
6
6
|
for (const childPlan of plan.children) {
|
|
7
7
|
const relation = childPlan.relation;
|
|
8
8
|
if (!relation) {
|
|
@@ -13,8 +13,8 @@ export async function loadIncludes(parentRows, plan, pool) {
|
|
|
13
13
|
assignEmptyRelation(parentRows, relation);
|
|
14
14
|
continue;
|
|
15
15
|
}
|
|
16
|
-
const childRows = await fetchRelationRows(childPlan, parentKeys,
|
|
17
|
-
await loadIncludes(childRows, childPlan,
|
|
16
|
+
const childRows = await fetchRelationRows(childPlan, parentKeys, executor);
|
|
17
|
+
await loadIncludes(childRows, childPlan, executor);
|
|
18
18
|
stitch(parentRows, childRows, relation);
|
|
19
19
|
}
|
|
20
20
|
}
|
|
@@ -23,13 +23,13 @@ function assignEmptyRelation(parentRows, relation) {
|
|
|
23
23
|
parent[relation.name] = relation.unique ? null : [];
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
|
-
async function fetchRelationRows(node, parentKeys,
|
|
26
|
+
async function fetchRelationRows(node, parentKeys, executor) {
|
|
27
27
|
const relation = node.relation;
|
|
28
28
|
if (!relation) {
|
|
29
29
|
return [];
|
|
30
30
|
}
|
|
31
31
|
const query = buildRelationSelect(node, parentKeys);
|
|
32
|
-
const rows = await executeQuery(
|
|
32
|
+
const rows = await executeQuery(executor, query.sql, query.params, node.model);
|
|
33
33
|
return mapRows(rows, node.model);
|
|
34
34
|
}
|
|
35
35
|
function buildRelationSelect(node, parentKeys) {
|
|
@@ -79,9 +79,9 @@ function buildOrderByClause(plan) {
|
|
|
79
79
|
}
|
|
80
80
|
return parts.length > 0 ? `ORDER BY ${parts.join(', ')}` : '';
|
|
81
81
|
}
|
|
82
|
-
async function executeQuery(
|
|
82
|
+
async function executeQuery(executor, sql, params, model) {
|
|
83
83
|
try {
|
|
84
|
-
const result = await
|
|
84
|
+
const result = await executor.query(sql, params);
|
|
85
85
|
return result.rows;
|
|
86
86
|
}
|
|
87
87
|
catch (error) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { Pool } from 'pg';
|
|
2
1
|
import type { ModelMeta } from '../model-meta.js';
|
|
3
2
|
import type { FindArgs } from '../query-builder.js';
|
|
3
|
+
import type { Queryable } from '../queryable.js';
|
|
4
4
|
import type { LoadNode } from './planner.js';
|
|
5
|
-
export declare function fetchRootWithJsonAgg<T extends Record<string, unknown>>(model: ModelMeta, plan: LoadNode, args: FindArgs,
|
|
5
|
+
export declare function fetchRootWithJsonAgg<T extends Record<string, unknown>>(model: ModelMeta, plan: LoadNode, args: FindArgs, executor: Queryable): Promise<T[]>;
|
|
@@ -2,9 +2,9 @@ import { mapPgError } from '../errors.js';
|
|
|
2
2
|
import { QueryBuilder } from '../query-builder.js';
|
|
3
3
|
import { mapRow } from '../row-mapper.js';
|
|
4
4
|
const ROOT_ALIAS = 'root';
|
|
5
|
-
export async function fetchRootWithJsonAgg(model, plan, args,
|
|
5
|
+
export async function fetchRootWithJsonAgg(model, plan, args, executor) {
|
|
6
6
|
const query = buildJsonAggRootQuery(model, plan, args);
|
|
7
|
-
const rows = await executeQuery(
|
|
7
|
+
const rows = await executeQuery(executor, query.sql, query.params, model);
|
|
8
8
|
return rows.map((row) => hydrateJsonAggRow(row, model, plan));
|
|
9
9
|
}
|
|
10
10
|
function buildJsonAggRootQuery(model, plan, args) {
|
|
@@ -90,9 +90,9 @@ function hydrateJsonObject(value, plan) {
|
|
|
90
90
|
}
|
|
91
91
|
return mapped;
|
|
92
92
|
}
|
|
93
|
-
async function executeQuery(
|
|
93
|
+
async function executeQuery(executor, sql, params, model) {
|
|
94
94
|
try {
|
|
95
|
-
const result = await
|
|
95
|
+
const result = await executor.query(sql, params);
|
|
96
96
|
return result.rows;
|
|
97
97
|
}
|
|
98
98
|
catch (error) {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { Pool } from 'pg';
|
|
2
1
|
import type { ModelMeta } from '../model-meta.js';
|
|
3
2
|
import type { FindArgs } from '../query-builder.js';
|
|
3
|
+
import type { Queryable } from '../queryable.js';
|
|
4
4
|
import type { IncludeInput, IncludeOptions } from './types.js';
|
|
5
|
-
export declare function fetchWithIncludes<T extends Record<string, unknown>>(model: ModelMeta, registry: Map<string, ModelMeta>,
|
|
5
|
+
export declare function fetchWithIncludes<T extends Record<string, unknown>>(model: ModelMeta, registry: Map<string, ModelMeta>, executor: Queryable, rootArgs: FindArgs & {
|
|
6
6
|
include?: IncludeInput;
|
|
7
7
|
}, options?: IncludeOptions): Promise<T[]>;
|
|
8
|
-
export declare function attachIncludes<T extends Record<string, unknown>>(model: ModelMeta, registry: Map<string, ModelMeta>,
|
|
8
|
+
export declare function attachIncludes<T extends Record<string, unknown>>(model: ModelMeta, registry: Map<string, ModelMeta>, executor: Queryable, rootRows: T[], include: IncludeInput, rootArgs: FindArgs, options?: IncludeOptions): Promise<T[]>;
|
package/dist/db/include/load.js
CHANGED
|
@@ -4,7 +4,7 @@ import { mapRows } from '../row-mapper.js';
|
|
|
4
4
|
import { loadIncludes } from './executor.js';
|
|
5
5
|
import { fetchRootWithJsonAgg } from './json-agg.js';
|
|
6
6
|
import { buildLoadPlan } from './planner.js';
|
|
7
|
-
export async function fetchWithIncludes(model, registry,
|
|
7
|
+
export async function fetchWithIncludes(model, registry, executor, rootArgs, options = {}) {
|
|
8
8
|
if (!rootArgs.include) {
|
|
9
9
|
throw new Error('fetchWithIncludes requires include');
|
|
10
10
|
}
|
|
@@ -14,14 +14,14 @@ export async function fetchWithIncludes(model, registry, pool, rootArgs, options
|
|
|
14
14
|
plan.take = rootArgs.take;
|
|
15
15
|
plan.skip = rootArgs.skip;
|
|
16
16
|
if (plan.strategy === 'join') {
|
|
17
|
-
return fetchRootWithJsonAgg(model, plan, rootArgs,
|
|
17
|
+
return fetchRootWithJsonAgg(model, plan, rootArgs, executor);
|
|
18
18
|
}
|
|
19
|
-
const rows = await executeRootSelect(model,
|
|
19
|
+
const rows = await executeRootSelect(model, executor, rootArgs);
|
|
20
20
|
const mapped = mapRows(rows, model);
|
|
21
|
-
await loadIncludes(mapped, plan,
|
|
21
|
+
await loadIncludes(mapped, plan, executor);
|
|
22
22
|
return mapped;
|
|
23
23
|
}
|
|
24
|
-
export async function attachIncludes(model, registry,
|
|
24
|
+
export async function attachIncludes(model, registry, executor, rootRows, include, rootArgs, options = {}) {
|
|
25
25
|
if (rootRows.length === 0) {
|
|
26
26
|
return rootRows;
|
|
27
27
|
}
|
|
@@ -30,14 +30,14 @@ export async function attachIncludes(model, registry, pool, rootRows, include, r
|
|
|
30
30
|
plan.orderBy = rootArgs.orderBy;
|
|
31
31
|
plan.take = rootArgs.take;
|
|
32
32
|
plan.skip = rootArgs.skip;
|
|
33
|
-
await loadIncludes(rootRows, plan,
|
|
33
|
+
await loadIncludes(rootRows, plan, executor);
|
|
34
34
|
return rootRows;
|
|
35
35
|
}
|
|
36
|
-
async function executeRootSelect(model,
|
|
36
|
+
async function executeRootSelect(model, executor, args) {
|
|
37
37
|
const builder = new QueryBuilder(model);
|
|
38
38
|
const query = builder.select(args);
|
|
39
39
|
try {
|
|
40
|
-
const result = await
|
|
40
|
+
const result = await executor.query(query.sql, query.params);
|
|
41
41
|
return result.rows;
|
|
42
42
|
}
|
|
43
43
|
catch (error) {
|
package/dist/db/index.d.ts
CHANGED
|
@@ -4,6 +4,10 @@ export { getDatabaseUrl } from './config.js';
|
|
|
4
4
|
export { QueryBuilder } from './query-builder.js';
|
|
5
5
|
export { WhereTranslator } from './where-translator.js';
|
|
6
6
|
export { createModelClient } from './model-client.js';
|
|
7
|
+
export type { Queryable } from './queryable.js';
|
|
8
|
+
export { createRawClient } from './raw.js';
|
|
9
|
+
export type { RawClient } from './raw.js';
|
|
10
|
+
export { runInTransaction } from './transaction.js';
|
|
7
11
|
export { TypeGenerator, generateDbTypes } from './type-generator.js';
|
|
8
12
|
export { DbClientGenerator, generateDbClientFiles } from './db-client-generator.js';
|
|
9
13
|
export { buildModelMeta, buildModelMetaSnapshot, hydrateModelMeta } from './model-meta.js';
|
package/dist/db/index.js
CHANGED
|
@@ -4,6 +4,8 @@ export { getDatabaseUrl } from './config.js';
|
|
|
4
4
|
export { QueryBuilder } from './query-builder.js';
|
|
5
5
|
export { WhereTranslator } from './where-translator.js';
|
|
6
6
|
export { createModelClient } from './model-client.js';
|
|
7
|
+
export { createRawClient } from './raw.js';
|
|
8
|
+
export { runInTransaction } from './transaction.js';
|
|
7
9
|
export { TypeGenerator, generateDbTypes } from './type-generator.js';
|
|
8
10
|
export { DbClientGenerator, generateDbClientFiles } from './db-client-generator.js';
|
|
9
11
|
export { buildModelMeta, buildModelMetaSnapshot, hydrateModelMeta } from './model-meta.js';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { Pool } from 'pg';
|
|
2
1
|
import type { IncludeInput, IncludeOptions } from './include/types.js';
|
|
3
2
|
import type { ModelMeta } from './model-meta.js';
|
|
3
|
+
import type { Queryable } from './queryable.js';
|
|
4
4
|
export type ModelRegistry = Map<string, ModelMeta>;
|
|
5
5
|
export interface SelectArgs<TWhere, TOrderBy> {
|
|
6
6
|
where?: TWhere;
|
|
@@ -35,4 +35,4 @@ export interface ModelClient<T, TCreate, TUpdate, TWhere, TOrderBy> {
|
|
|
35
35
|
count: number;
|
|
36
36
|
}>;
|
|
37
37
|
}
|
|
38
|
-
export declare function createModelClient<T, TCreate, TUpdate, TWhere, TOrderBy>(model: ModelMeta,
|
|
38
|
+
export declare function createModelClient<T, TCreate, TUpdate, TWhere, TOrderBy>(model: ModelMeta, executor: Queryable, registry?: ModelRegistry): ModelClient<T, TCreate, TUpdate, TWhere, TOrderBy>;
|
package/dist/db/model-client.js
CHANGED
|
@@ -2,11 +2,11 @@ import { mapPgError } from './errors.js';
|
|
|
2
2
|
import { fetchWithIncludes } from './include/load.js';
|
|
3
3
|
import { QueryBuilder } from './query-builder.js';
|
|
4
4
|
import { mapRow, mapRows } from './row-mapper.js';
|
|
5
|
-
export function createModelClient(model,
|
|
5
|
+
export function createModelClient(model, executor, registry) {
|
|
6
6
|
const builder = new QueryBuilder(model);
|
|
7
7
|
async function execute(sql, params) {
|
|
8
8
|
try {
|
|
9
|
-
const result = await
|
|
9
|
+
const result = await executor.query(sql, params);
|
|
10
10
|
return result.rows;
|
|
11
11
|
}
|
|
12
12
|
catch (error) {
|
|
@@ -16,7 +16,7 @@ export function createModelClient(model, pool, registry) {
|
|
|
16
16
|
async function selectRows(args = {}) {
|
|
17
17
|
const findArgs = toFindArgs(args);
|
|
18
18
|
if (args.include && registry) {
|
|
19
|
-
return fetchWithIncludes(model, registry,
|
|
19
|
+
return fetchWithIncludes(model, registry, executor, {
|
|
20
20
|
...findArgs,
|
|
21
21
|
include: args.include,
|
|
22
22
|
}, {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|