@everystack/mcp 0.3.3 → 0.4.0

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.
@@ -0,0 +1,225 @@
1
+ # Derived Objects: functions, views, materialized views, triggers, sequences
2
+
3
+ > Derived objects don't migrate — they **deploy**. Base tables hold irreplaceable data
4
+ > and change through generated migrations. Functions, views, and materialized views are
5
+ > **compute**: declared source whose results are rebuildable from the tables at any time.
6
+ > You edit their descriptors, and `db:reconcile` makes the database match — no migration
7
+ > file, no journal entry, no numbered anything. Git is the history.
8
+
9
+ Read this when you need to add or change a view, materialized view, function, trigger, or
10
+ standalone sequence. For tables (`defineModel`) and the operational verbs
11
+ (`db:sync`/`db:plan`/`db:apply`), see `everystack://schema-patterns` and
12
+ `everystack://database-operations`.
13
+
14
+ ## Two layers, two lifecycles
15
+
16
+ A schema is two different things, and conflating them is the mistake:
17
+
18
+ | Layer | Objects | Changes via | History |
19
+ |-------|---------|-------------|---------|
20
+ | **State** | tables, constraints, authz, sequences | Models → `db:generate` → migration | migration + `db:pull` |
21
+ | **Compute** | functions, views, matviews, triggers | descriptors → `db:reconcile` | git |
22
+
23
+ Versioning compute through migrations is a category error — the same as writing a
24
+ migration to edit a Lambda handler. A one-line fix to a materialized view should be a
25
+ one-line git diff, not a restatement of the whole view inside a numbered file. (In one
26
+ real analytics app, 34 of 43 migrations were pure derived-layer churn, including a 701 KB
27
+ migration that fixed a one-line regex.) Descriptors end that.
28
+
29
+ ## The declared home
30
+
31
+ The whole database — both layers — lives in `db/models/`, in one format:
32
+
33
+ ```
34
+ db/
35
+ ├── models/ # the declared database: defineModel tables + derived descriptors
36
+ │ ├── post.ts
37
+ │ ├── derived.ts # defineView / defineMaterializedView / defineFunction / defineSql
38
+ │ └── index.ts # defineModule({ models, sequences, derived })
39
+ └── schema.generated.ts # generated artifact (never edited; db:check refuses drift)
40
+ ```
41
+
42
+ **`db/sql/` is retired.** Raw-SQL derived objects moved into descriptors, and every CLI
43
+ verb hard-fails on a `db/sql/` directory that still carries `.sql` files. If you are
44
+ adopting an app that still has one, see "Adopting an existing database" below.
45
+
46
+ The boundary rule, uniform with models: **structure is declared** (identity,
47
+ dependencies, authz, security posture, indexes, signatures, trigger events);
48
+ **expressions stay `sql``** (the SELECT, the function body, a WHEN predicate). No query
49
+ DSL, no string mini-grammars in declared properties.
50
+
51
+ ```ts
52
+ import { defineView, defineMaterializedView, defineFunction, defineSql, defineModule,
53
+ trigger, arg, setof, field, can, index, sql } from '@everystack/model';
54
+
55
+ // A VIEW — securityInvoker is the one forced decision.
56
+ export const activeUsers = defineView('active_users', {
57
+ securityInvoker: true, // REQUIRED, no default:
58
+ // true = readers' own RLS/grants apply through the view
59
+ // false = definer semantics (the sealed-slice pattern —
60
+ // a view grant WITHOUT a table grant; the body must seal)
61
+ abilities: [can('read')], // grants are AUTHORED, never inherited
62
+ dependsOn: [users], // declared, then VERIFIED against the live edges (drift-checked)
63
+ fields: { id: field.uuid().notNull(), name: field.text().notNull() }, // OPTIONAL: typed SSR
64
+ // reads — emits a pgView().existing() into schema.generated.ts;
65
+ // never touches the DDL or the content hash
66
+ as: sql`SELECT id, name FROM users WHERE deleted_at IS NULL`,
67
+ });
68
+
69
+ // A MATERIALIZED VIEW — grants-only authz; refresh/populate declare the HOW.
70
+ export const leaderboard = defineMaterializedView('leaderboard', {
71
+ abilities: [can('read')],
72
+ dependsOn: [scores, activeUsers],
73
+ indexes: [index(['user_id']).unique(), index('tags').using('gin')],
74
+ refresh: 'concurrently', // compile-gated: REQUIRES a unique, non-partial index
75
+ populate: 'deferred', // create WITH NO DATA, refresh outside the creation lock
76
+ as: sql`SELECT user_id, sum(points) AS points FROM scores GROUP BY user_id`,
77
+ });
78
+
79
+ // A FUNCTION — signature structured, body raw.
80
+ export const searchPosts = defineFunction('search_posts', {
81
+ args: [arg('query', 'text')],
82
+ returns: setof(posts), // a descriptor ref — a typo is a TS error, the dep comes free
83
+ language: 'sql',
84
+ volatility: 'stable', // default 'volatile'; 'immutable' enables expression indexes
85
+ abilities: [can('execute', { role: 'authenticated' })], // role REQUIRED; PUBLIC is always revoked
86
+ body: sql`SELECT * FROM posts WHERE ts @@ websearch_to_tsquery(query)`,
87
+ });
88
+
89
+ // A SECURITY DEFINER function MUST pin its search_path (the classic landmine):
90
+ export const promote = defineFunction('promote_user', {
91
+ args: [arg('uid', 'uuid')], returns: 'void', language: 'plpgsql',
92
+ security: 'definer', searchPath: ['pg_catalog', 'public'], // required with definer
93
+ abilities: [can('execute', { role: 'admin' })],
94
+ body: sql`BEGIN UPDATE users SET role = 'admin' WHERE id = uid; END`,
95
+ });
96
+
97
+ // A TRIGGER FUNCTION is a standalone function returning 'trigger'…
98
+ export const touchUpdatedAt = defineFunction('touch_updated_at', {
99
+ returns: 'trigger', language: 'plpgsql',
100
+ body: sql`BEGIN NEW.updated_at = now(); RETURN NEW; END`,
101
+ });
102
+
103
+ // …and the TRIGGER is declared ON THE MODEL it fires on (owner consent, visible where the
104
+ // table lives) — see defineModel below.
105
+
106
+ export const appModule = defineModule({
107
+ models: [posts, users],
108
+ derived: [activeUsers, leaderboard, searchPosts, promote, touchUpdatedAt],
109
+ // sequences: [invoiceNo], // standalone sequences are STATE — see below
110
+ });
111
+ ```
112
+
113
+ Triggers ride the model:
114
+
115
+ ```ts
116
+ export const Post = defineModel('posts', {
117
+ fields: { id: field.uuid().primaryKey(), updatedAt: field.timestamptz() },
118
+ abilities: [can('read')],
119
+ triggers: [trigger('touch', {
120
+ timing: 'before', events: ['update'], forEach: 'row',
121
+ execute: touchUpdatedAt, // the trigger function MUST be in the module's derived set
122
+ })],
123
+ });
124
+ ```
125
+
126
+ ## The rules that make it safe (all enforced)
127
+
128
+ - **`securityInvoker` is a required decision** on every view — no default. Pick `true`
129
+ (readers' own RLS applies) or `false` (definer semantics, the sealed slice).
130
+ - **Grants are authored, never inherited.** `can('read')` on a relation → `anon` +
131
+ `authenticated`; `can('read', { role })` narrows. A granted view whose readers can't
132
+ reach its underlying tables is a **compile error** — the reachability gate.
133
+ - **SECURITY DEFINER without a pinned `searchPath` is rejected** at define time.
134
+ - **`can('execute')` needs a role** — bare execute has no safe audience; PUBLIC is always
135
+ revoked by the compiler.
136
+ - **A missing import is caught loudly.** An `undefined` in `dependsOn`/`derived`/a
137
+ trigger's `execute` (a renamed or missing named import) is rejected at define time,
138
+ naming the descriptor and the index — never a silent drop.
139
+ - **`dependsOn` is declared, then verified.** The reachability gate walks it at compile
140
+ time; the live catalog's actual edges check it at plan time. A view that reads a table
141
+ it never declared (or declares one it never reads) is a `db:check` failure.
142
+ - **REFRESH CONCURRENTLY requires a unique, non-partial index** — compile-gated.
143
+ - **Content hashing is comment- and whitespace-insensitive** — reformatting a body is a
144
+ no-op; only a real change reconciles.
145
+
146
+ The escape hatch `defineSql` handles what the structured vocabulary can't say yet
147
+ (aggregates, operators, event triggers) — still declared, hashed, dependency-ordered. It
148
+ refuses a `CREATE [MATERIALIZED] VIEW` so accidental relations land in the read model
149
+ where the gates can see them; its SQL is otherwise trusted as authored.
150
+
151
+ ## Sequences are STATE, not compute
152
+
153
+ A standalone sequence (one not owned by a serial column) holds a counter value, so it
154
+ migrates with the base schema — declare it with `defineSequence` and pass it on
155
+ `defineModule({ sequences })`. It is fingerprinted with the tables and created before them.
156
+ This is the one derived-looking object that is state, not compute.
157
+
158
+ ```ts
159
+ export const invoiceNo = defineSequence('invoice_no', { start: 1000 });
160
+ ```
161
+
162
+ ## Deploying: `db:reconcile`
163
+
164
+ ```bash
165
+ everystack db:reconcile # plan (read-only; works via --stage or --database-url)
166
+ everystack db:reconcile --check # CI gate: exit 1 when anything differs
167
+ everystack db:reconcile --apply --database-url … # execute (direct connection required)
168
+ ```
169
+
170
+ Planning works over the read-only ops Lambda (`--stage`); **`--apply` needs a direct
171
+ connection** — the deployed query path is read-only by design. On a dev database, prefer
172
+ `db:sync` (it does state + compute in one verb); reach for `db:reconcile --apply` when you
173
+ want compute only.
174
+
175
+ A plan shows the rebuild cost before you say yes — dependent cascades, matview sizes, row
176
+ counts:
177
+
178
+ ```
179
+ = 51 up to date
180
+ ~ replace public.score (source changed)
181
+ ↻ refresh public.season_scores (depends on replaced function public.score; 1.2 GB, ~4M rows)
182
+ + create public.weekly_top (new in source)
183
+ ≠ regrant public.leaderboard — live grants differ (REVOKE SELECT ON leaderboard FROM intern)
184
+ estimated rebuild: 1.2 GB across 1 matview(s)
185
+ ```
186
+
187
+ `--apply` sends all DDL as **one transaction** (a failed create rolls back the drops
188
+ before it), records provenance and a `schema_log` memoir (the full DDL, when, by whom,
189
+ from which git commit), and rebuilds dependents automatically in dependency order.
190
+
191
+ ## Semantics worth knowing
192
+
193
+ - **Drift is never overwritten silently.** A hand-edited managed object (someone ran
194
+ `CREATE OR REPLACE` against the DB) makes the plan report drift and `--apply` refuse.
195
+ Fix the source to match, or rebuild from source with `--overwrite-drift`.
196
+ - **Grant drift converges without ceremony** — a hand-run GRANT/REVOKE surfaces as an
197
+ idempotent `regrant` back to the declared abilities. Declared authz is authoritative.
198
+ - **Changing a function REFRESHes the matviews that call it** — their rows are stale the
199
+ moment the function changes, and the reconciler knows it.
200
+ - **Unmanaged objects are never touched** — live derived objects with no source and no
201
+ provenance are listed and left alone.
202
+
203
+ ## Adopting an existing database (baseline / rebaseline)
204
+
205
+ - **First contact wants `--baseline`.** Against a database that already has your derived
206
+ layer (built by old migrations), the reconciler refuses to guess whether live matches
207
+ source. `everystack db:pull` renders the live layer as descriptors (bodies from the
208
+ catalog's canonical deparse, grants as abilities, `dependsOn` from live edges). The path:
209
+ **pull → review → commit → `db:reconcile --apply --baseline` → a silent check.**
210
+ Use `db:pull --derived-out db/models/derived.ts` to write the derived layer straight
211
+ into its own file when you already have a hand-maintained models barrel.
212
+ - **A `db/sql`-era migration wants `--rebaseline`.** When provenance exists but the source
213
+ was re-rendered (descriptors render canonical SQL, so the source hash differs even for
214
+ identical SQL), `--rebaseline` re-records the new source hash for every object whose live
215
+ definition still matches what the reconciler last applied — no drop, no create, no
216
+ matview repopulation. The live side is VERIFIED; the source side is TRUSTED. Migrate
217
+ first (verbatim), edit after. `--baseline` and `--rebaseline` compose in one run.
218
+
219
+ ## Current limitations
220
+
221
+ - **Function identity is by name** — overloads are rejected at define time. Rename one.
222
+ - **Function signature changes** (return/arg types) can't `CREATE OR REPLACE` — drop the
223
+ old function in a reviewed step, or rename.
224
+ - Postgres can't see function→function dependencies inside bodies — `dependsOn` is your
225
+ declaration there.
@@ -116,9 +116,23 @@ const RESOURCES: ResourceDef[] = [
116
116
  uri: 'everystack://schema-patterns',
117
117
  name: 'Schema Design Patterns',
118
118
  description:
119
- 'Drizzle schema design: pgTable, column types, relations, indexes, timestamps, soft delete, UUID vs serial, migration workflow, drizzle-kit commands.',
119
+ 'How to declare TABLES (0.4.0): db/models/ with defineModel (field, can, relations, constraints) — NOT hand-written pgTable or drizzle-kit. Column types, keys, indexes, timestamps, soft delete, UUID vs serial, the private()/readonly()/deprecated() write surface. Read everystack://derived-objects for views/matviews/functions/triggers, and everystack://database-operations for how it reaches a database.',
120
120
  filename: 'schema-patterns.md',
121
121
  },
122
+ {
123
+ uri: 'everystack://derived-objects',
124
+ name: 'Derived Objects (Views, Matviews, Functions, Triggers)',
125
+ description:
126
+ 'How to add or change a view, materialized view, function, trigger, or standalone sequence in 0.4.0 — declared descriptors (defineView / defineMaterializedView / defineFunction / defineSql / trigger() / defineSequence) on defineModule({ derived }), deployed by db:reconcile (NEVER migrated). Read this whenever a schema needs compute, not just tables. db/sql/ is retired.',
127
+ filename: 'derived-objects.md',
128
+ },
129
+ {
130
+ uri: 'everystack://database-operations',
131
+ name: 'Database Operations (migrate, import data, SAFE remote apply)',
132
+ description:
133
+ 'The operational playbook: local dev (db:sync, db:branch, db:template:refresh), importing data properly (db:seed, db:backfill, ingest pipeline — never ad-hoc INSERTs), and migrating a REMOTE stage SAFELY (db:check → db:plan → db:apply with fingerprint + fast-forward verification; destructive plans require --confirm + snapshot + approvers). Snapshot-first (db:snapshot vs db:backup) and remote data import (db:seed --stage, pipeline:run, db:fork). READ THIS before touching a deployed database.',
134
+ filename: 'database-operations.md',
135
+ },
122
136
  {
123
137
  uri: 'everystack://deployment',
124
138
  name: 'Deployment Guide',
@@ -19,12 +19,14 @@ These are enforced (everystack cheat gates) and load-bearing. Do not work around
19
19
  - **Data lives in PostgreSQL, served through the API — never bundle data into the app.** A
20
20
  large `.json`/`.csv` of computed data in the bundle is wrong; model it and serve it, or
21
21
  render an empty state if it does not exist yet.
22
- - **Nobody authors migrations. Schema work has three homes.** Tables and authz are DECLARED
23
- in `db/models/` with `defineModel` edit the Model, then `everystack db:sync` moves the
24
- dev database to your checkout (state + authz + derived, fingerprint-verified). Functions,
25
- views, and matviews are **authored** in `db/sql/` and deployed by `db:reconcile`/`db:sync`.
26
- One-shot DATA moves are authored in `db/backfills/*.sql` and run via `everystack
27
- db:backfill` deliberately, never as a schema side effect. Never hand-write a SQL
22
+ - **Nobody authors migrations. Schema work has two homes.** The WHOLE declared database
23
+ lives in `db/models/`: tables and authz with `defineModel`, and the derived layer
24
+ functions, views, matviews as descriptors (`defineView` / `defineMaterializedView` /
25
+ `defineFunction` / `defineSql`, triggers via `trigger()` on the model). Edit the
26
+ declaration, then `everystack db:sync` moves the dev database to your checkout (state +
27
+ authz + derived, fingerprint-verified). One-shot DATA moves are authored in
28
+ `db/backfills/*.sql` and run via `everystack db:backfill` — deliberately, never as a
29
+ schema side effect. Never hand-write a SQL
28
30
  migration, never edit `db/schema.generated.ts`; `everystack db:check` must pass (the CI
29
31
  gate: the declared state composes, generated artifacts match regeneration).
30
32
  - **Protected stages take plans, not syncs.** `everystack db:plan` mints a reviewable edge
@@ -42,8 +44,8 @@ These are enforced (everystack cheat gates) and load-bearing. Do not work around
42
44
 
43
45
  ## Start Here
44
46
 
45
- - `db/models/` — `defineModel` tables (the source of truth for schema + authz)
46
- - `db/sql/` — functions, views, matviews (authored SQL; deploys via `db:reconcile`/`db:sync`)
47
+ - `db/models/` — the declared database: `defineModel` tables + derived descriptors
48
+ (`defineView`/`defineMaterializedView`/`defineFunction`/`defineSql`; deploys via `db:reconcile`/`db:sync`)
47
49
  - `db/backfills/` — one-shot data jobs (authored SQL; run via `db:backfill`, own record)
48
50
  - `app/` — Expo Router pages (screens, navigation, API routes)
49
51
  - `server/` — Lambda handlers (api.ts, worker.ts, image.ts)
@@ -84,9 +86,9 @@ Declare tables with `defineModel` (`field`, `can`, relations). A package's full
84
86
  live state is content-addressed (`db:fingerprint`), and every apply verifies against it. You
85
87
  never hand-write migrations, RLS, or handler access-control — they are derived, so they cannot
86
88
  drift; `deriveHandlerConfig(models)` derives the API config. The derived layer (functions,
87
- views, matviews) is the exception that proves the rule: that SQL is authored, in `db/sql/`,
88
- and *deployed* by reconcile hand-edits straight against the database surface as drift, and a
89
- comment-only edit is a no-op. Contraction is a two-step ceremony: `field.deprecated()` first
89
+ views, matviews) follows the same rule: descriptors declare the structure (identity, deps,
90
+ authz, security posture), the body stays `sql\`\``, and reconcile *deploys* it hand-edits
91
+ straight against the database surface as drift, and a comment-only edit is a no-op. Contraction is a two-step ceremony: `field.deprecated()` first
90
92
  (the column stays readable, new writes are rejected, generated types strike it through), the
91
93
  physical drop later — a destructive plan, confirmed, snapshotted, approver-gated.
92
94
 
@@ -118,7 +120,8 @@ Every feature starts with a failing test. Tests in `__tests__/` mirroring source
118
120
 
119
121
  - Don't bundle large data into the app — it lives in the DB, served by the API.
120
122
  - Don't hand-write migrations or edit `db/schema.ts` — edit the Model, run `db:generate`;
121
- for functions/views/matviews edit `db/sql/` and run `db:reconcile`.
123
+ for functions/views/matviews edit the descriptors in `db/models/` and run `db:reconcile`
124
+ (`db/sql/` is retired — the verbs fail on it with the migration path).
122
125
  - Don't hand-write RLS — declare `can()` abilities.
123
126
  - Don't hand-roll a component that exists in `@everystack/ui`; don't use inline `StyleSheet`.
124
127
  - Don't put a secret behind `EXPO_PUBLIC_*` — that ships to the client.
@@ -1,167 +1,153 @@
1
1
  # Schema Design Patterns
2
2
 
3
- > Drizzle schema design, migrations, and RLS setup for everystack apps.
3
+ > Declaring TABLES with `defineModel` in `db/models/` fields, authz, relations, indexes.
4
4
 
5
5
  ## When to Use
6
- Read this when designing your database schema or adding tables to an existing app.
6
+ Read this when designing your database schema or adding tables to an existing app. This
7
+ resource covers **tables** (`defineModel`). For the derived layer —
8
+ views, materialized views, functions, triggers, standalone sequences — read
9
+ `everystack://derived-objects` (they deploy via `db:reconcile`, not migrations). For the
10
+ operational verbs that move a database (`db:sync`, `db:generate`, `db:plan`/`db:apply`,
11
+ `db:check`), read `everystack://database-operations`.
7
12
 
8
13
  ## Schema Location
9
14
 
10
- Schemas live in `db/schema.ts` (or `db/schema/` directory for larger apps). This file is the single source of truth for both the handler and SSR.
15
+ The whole declared database lives in `db/models/` one `defineModel` per table, composed
16
+ onto a `defineModule`. This is the single source of truth for the handler, SSR, RLS, and
17
+ grants. You do **not** hand-write `db/schema.ts`, and you do **not** run
18
+ `drizzle-kit generate`/`drizzle-kit migrate` — the Model is authored, and everystack
19
+ generates the migration (`db:generate`) and the typed artifact (`db/schema.generated.ts`,
20
+ never edited). Never hand-write a SQL migration.
11
21
 
12
22
  ## Basic Table
13
23
 
14
24
  ```typescript
15
- import { pgTable, serial, text, integer, timestamp, uuid } from 'drizzle-orm/pg-core';
16
-
17
- export const posts = pgTable('posts', {
18
- id: uuid('id').defaultRandom().primaryKey(),
19
- body: text('body').notNull(),
20
- authorId: uuid('author_id').notNull().references(() => users.id),
21
- status: text('status').default('draft').notNull(),
22
- createdAt: timestamp('created_at').defaultNow().notNull(),
23
- updatedAt: timestamp('updated_at').defaultNow().notNull(),
24
- deletedAt: timestamp('deleted_at'),
25
+ import { defineModel, field, can } from '@everystack/model';
26
+
27
+ export const Post = defineModel('posts', {
28
+ fields: {
29
+ id: field.uuid().primaryKey().defaultRandom(),
30
+ body: field.text().notNull(),
31
+ authorId: field.uuid().notNull().references('users', 'id'),
32
+ status: field.text().notNull().default('draft'),
33
+ createdAt: field.timestamptz().notNull().defaultNow(),
34
+ updatedAt: field.timestamptz().notNull().defaultNow(),
35
+ deletedAt: field.timestamptz(),
36
+ },
37
+ abilities: [can('read'), can('create'), can('update'), can('delete')],
25
38
  });
26
39
  ```
27
40
 
41
+ Column names are `snake_case` in the database; the field key is the app-side name — the
42
+ generator maps `authorId` → `author_id` for you.
43
+
28
44
  ## Common Patterns
29
45
 
30
46
  ### UUID vs Serial Primary Keys
31
47
  Prefer UUID for user-facing IDs (prevents enumeration attacks). Use serial for internal-only tables.
32
48
 
33
49
  ```typescript
34
- id: uuid('id').defaultRandom().primaryKey(), // Preferred for API-exposed tables
35
- id: serial('id').primaryKey(), // OK for internal tables
50
+ id: field.uuid().primaryKey().defaultRandom(), // Preferred for API-exposed tables
51
+ id: field.serial().primaryKey(), // OK for internal tables
36
52
  ```
37
53
 
38
54
  ### Timestamps
39
55
  Always include `createdAt` and `updatedAt`:
40
56
  ```typescript
41
- createdAt: timestamp('created_at').defaultNow().notNull(),
42
- updatedAt: timestamp('updated_at').defaultNow().notNull(),
57
+ createdAt: field.timestamptz().notNull().defaultNow(),
58
+ updatedAt: field.timestamptz().notNull().defaultNow(),
43
59
  ```
44
60
 
45
61
  ### Soft Delete
46
62
  Add `deletedAt` for soft-deletable tables:
47
63
  ```typescript
48
- deletedAt: timestamp('deleted_at'),
64
+ deletedAt: field.timestamptz(),
49
65
  ```
50
66
  Configure in handler: `softDelete: { column: 'deletedAt', tables: ['posts'] }`.
51
67
 
52
68
  ### Foreign Keys
53
69
  ```typescript
54
- authorId: uuid('author_id').notNull().references(() => users.id),
70
+ authorId: field.uuid().notNull().references('users', 'id'),
55
71
  ```
56
72
 
57
73
  ### Enums
58
74
  ```typescript
59
- import { pgEnum } from 'drizzle-orm/pg-core';
60
-
61
- export const roleEnum = pgEnum('role', ['user', 'admin']);
62
-
63
- export const users = pgTable('users', {
64
- role: roleEnum('role').default('user').notNull(),
65
- });
75
+ role: field.enum('role', ['user', 'admin']).notNull().default('user'),
66
76
  ```
67
77
 
68
- ## Relations (Drizzle)
78
+ ## Relations
69
79
 
70
- ```typescript
71
- import { relations } from 'drizzle-orm';
80
+ Declare relations on the Model — the generator emits both the Drizzle relations (used by
81
+ the relational query API, `db.query.posts.findMany({ with: { author: true } })`) and the
82
+ handler's embedding config, so they can't drift apart.
72
83
 
73
- export const postsRelations = relations(posts, ({ one, many }) => ({
74
- author: one(users, { fields: [posts.authorId], references: [users.id] }),
75
- comments: many(comments),
76
- }));
84
+ ```typescript
85
+ export const Post = defineModel('posts', {
86
+ fields: { /* … */ },
87
+ relations: {
88
+ author: { model: 'users', from: 'authorId', to: 'id' },
89
+ comments: { model: 'comments', from: 'id', to: 'postId', many: true },
90
+ },
91
+ abilities: [can('read')],
92
+ });
77
93
  ```
78
94
 
79
- Drizzle relations are used by the relational query API (`db.query.posts.findMany({ with: { author: true } })`). The handler also needs its own `relations` config for embedding.
80
-
81
95
  ## Indexes
82
96
 
83
97
  ```typescript
84
- import { index, uniqueIndex } from 'drizzle-orm/pg-core';
85
-
86
- export const posts = pgTable('posts', {
87
- // columns...
88
- }, (table) => [
89
- index('posts_author_id_idx').on(table.authorId),
90
- uniqueIndex('posts_slug_idx').on(table.slug),
91
- ]);
98
+ import { defineModel, field, can, index } from '@everystack/model';
99
+
100
+ export const Post = defineModel('posts', {
101
+ fields: { /* … */ },
102
+ indexes: [
103
+ index(['authorId']),
104
+ index(['slug']).unique(),
105
+ ],
106
+ abilities: [can('read')],
107
+ });
92
108
  ```
93
109
 
94
- ## Migration Workflow
95
-
96
- ```bash
97
- # Generate migration from schema changes
98
- npx drizzle-kit generate
99
-
100
- # Apply migrations locally
101
- npx drizzle-kit migrate
110
+ ## Authorization is declared, not migrated
102
111
 
103
- # Apply migrations on deployed Lambda
104
- everystack db:migrate
105
- ```
112
+ You never hand-write `CREATE ROLE`, `GRANT`, or `CREATE POLICY`. Abilities on the Model
113
+ compile to grants + RLS, and `db:sync`/`db:apply` deploy them. `can('read')` grants `anon`
114
+ + `authenticated`; `can('read', { role: 'admin' })` narrows to a role. RLS is required —
115
+ the CI gate (`db:check`) refuses a table without it. See `everystack://security` for the
116
+ policy patterns the abilities compile to.
106
117
 
107
- Migration files go in `drizzle/` directory. Each migration is a SQL file.
108
-
109
- ### RLS Migration Template
110
-
111
- After creating tables, add a migration for roles and policies:
112
-
113
- ```sql
114
- -- Create roles (idempotent)
115
- DO $$ BEGIN
116
- IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticator') THEN
117
- CREATE ROLE authenticator LOGIN NOINHERIT;
118
- END IF;
119
- IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'anon') THEN
120
- CREATE ROLE anon NOLOGIN;
121
- END IF;
122
- IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticated') THEN
123
- CREATE ROLE authenticated NOLOGIN;
124
- END IF;
125
- IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'admin') THEN
126
- CREATE ROLE admin NOLOGIN;
127
- END IF;
128
- END $$;
129
-
130
- GRANT anon TO authenticator;
131
- GRANT authenticated TO authenticator;
132
- GRANT admin TO authenticator;
133
-
134
- -- Table grants
135
- GRANT USAGE ON SCHEMA public TO anon, authenticated, admin;
136
- GRANT SELECT ON posts TO anon;
137
- GRANT SELECT, INSERT, UPDATE, DELETE ON posts TO authenticated;
138
- GRANT SELECT, INSERT, UPDATE, DELETE ON posts TO admin;
139
-
140
- -- Enable RLS
141
- ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
142
- ALTER TABLE posts FORCE ROW LEVEL SECURITY;
143
-
144
- -- Policies (see everystack://security for templates)
118
+ ```typescript
119
+ export const Post = defineModel('posts', {
120
+ fields: { /* … */ },
121
+ abilities: [
122
+ can('read'), // anon + authenticated
123
+ can('create', { role: 'authenticated' }),
124
+ can('update', { role: 'authenticated', own: 'authorId' }), // row ownership
125
+ can('delete', { role: 'admin' }),
126
+ ],
127
+ });
145
128
  ```
146
129
 
147
130
  ## Handler Config for Schema
148
131
 
132
+ The handler config is **derived** from the Models (`deriveHandlerConfig(models)`), so
133
+ `exposedTables`, `hiddenColumns`, `protectedFields`, and `relations` come from the
134
+ declarations rather than a hand-kept parallel list:
135
+
149
136
  ```typescript
150
- createHandler(db, schema, {
151
- exposedTables: ['posts', 'profiles'], // Only these are API-accessible
152
- hiddenColumns: { users: ['passwordHash', 'resetToken'] },
153
- protectedFields: { profiles: ['role', 'deletedAt'] },
154
- relations: {
155
- posts: { author: { table: 'users', from: 'authorId', to: 'id' } },
156
- },
157
- });
137
+ import { deriveHandlerConfig } from '@everystack/model';
138
+ import { models } from '../db/models';
139
+
140
+ createHandler(db, schema, deriveHandlerConfig(models));
158
141
  ```
159
142
 
160
143
  ## Gotchas
161
144
 
162
- - Drizzle column names (camelCase) and SQL column names (snake_case) are both specified: `authorId: uuid('author_id')`
163
- - The handler's `relations` config uses Drizzle property names, not SQL column names
164
- - `exposedTables` uses Drizzle table variable names, not SQL table names
165
- - Always run `drizzle-kit generate` after schema changes
166
- - RLS migration must be run AFTER table creation migration
167
- - Connect as `authenticator` in production (never RDS master/superuser)
145
+ - Field keys are app-side names (camelCase); the database column is `snake_case` the
146
+ generator maps `authorId` `author_id`.
147
+ - Edit the Model, then move the database with `db:sync` (dev) or `db:generate` +
148
+ `db:plan`/`db:apply` (protected stages). Never run `drizzle-kit generate`.
149
+ - RLS and grants are declared with `can()`, never hand-written; `db:check` fails a table
150
+ without RLS.
151
+ - `db/schema.generated.ts` is a generated artifact — never edit it; `db:check` refuses
152
+ drift.
153
+ - Connect as `authenticator` in production (never RDS master/superuser).