@everystack/mcp 0.3.3 → 0.4.1
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/dist/adding-database.md +36 -23
- package/dist/cli.md +44 -3
- package/dist/core.md +19 -1
- package/dist/database-operations.md +236 -0
- package/dist/deployment.md +2 -0
- package/dist/derived-objects.md +225 -0
- package/dist/expo-server-deploy.md +208 -0
- package/dist/index.cjs +118 -102
- package/dist/project-claude-md.md +15 -12
- package/dist/schema-patterns.md +92 -106
- package/package.json +3 -3
- package/src/gates/detectors/hand-written-migration.ts +12 -5
- package/src/index.ts +1 -1
- package/src/prompts/add-feature.ts +4 -4
- package/src/prompts/debug.ts +3 -4
- package/src/prompts/deploy.ts +17 -8
- package/src/prompts/design-schema.ts +45 -59
- package/src/prompts/new-app.ts +21 -19
- package/src/prompts/secure.ts +3 -3
- package/src/resources/adding-database.md +36 -23
- package/src/resources/cli.md +44 -3
- package/src/resources/core.md +19 -1
- package/src/resources/database-operations.md +236 -0
- package/src/resources/deployment.md +2 -0
- package/src/resources/derived-objects.md +225 -0
- package/src/resources/expo-server-deploy.md +208 -0
- package/src/resources/index.ts +22 -1
- package/src/resources/project-claude-md.md +15 -12
- package/src/resources/schema-patterns.md +92 -106
|
@@ -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.
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# Deploying an Expo Router Server App
|
|
2
|
+
|
|
3
|
+
> How `web.output: "server"` maps to AWS: what deploys where, where your `+api.ts` routes run, and where the everystack handler sits among them.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Read this when your app.json has `web.output: "server"` (SSR pages, `+api.ts` API routes) and you are deploying to AWS. This is the deploy chapter for the most common Expo Router app shape — if your app is fully static (`web.output: "static"`), everystack://deployment alone is enough.
|
|
8
|
+
|
|
9
|
+
## The Two-Deployable Model
|
|
10
|
+
|
|
11
|
+
The single most important fact: **the Expo server export is NOT the Lambda handler.** There are two deployables with two verbs:
|
|
12
|
+
|
|
13
|
+
| Deployable | Contains | Verb | Frequency |
|
|
14
|
+
|---|---|---|---|
|
|
15
|
+
| Infrastructure + entrypoints | VPC, RDS, buckets, Router, the Lambda entry files in `server/` | `sst deploy` | Rarely (infra changes) |
|
|
16
|
+
| App code | Your entire Expo export: SSR pages, ALL `+api.ts` routes, client bundles | `everystack update --channel <name>` | Every ship (same verb as OTA) |
|
|
17
|
+
|
|
18
|
+
`everystack update` runs the expo export and publishes it as a compressed archive to the Updates bucket. At runtime, the Lambda's SSR fallback downloads that archive to `/tmp` (cached across warm invocations) and serves it with `expo-server` — Expo's own server runtime. Your app code never gets bundled into the Lambda; it ships like an OTA update.
|
|
19
|
+
|
|
20
|
+
## Request Flow
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
CloudFront Router
|
|
24
|
+
└─ Api Lambda (server/api.ts — createPluginLambdaHandler)
|
|
25
|
+
├─ plugin routes claim their paths first (e.g. /api → everystack handler)
|
|
26
|
+
└─ everything else → ssrPlugin fallback → expo-server runs YOUR export:
|
|
27
|
+
├─ SSR pages (with loader())
|
|
28
|
+
└─ your +api.ts routes (mcp+api.ts, app/games/[id]+api.ts, ...)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Your `+api.ts` routes ship and serve as-is.** They ride the published bundle and expo-server dispatches to them inside the Lambda. Nothing has to be collapsed into the everystack handler.
|
|
32
|
+
|
|
33
|
+
## The Lambda Entrypoint
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
// server/api.ts — the Function handler for `sst deploy`
|
|
37
|
+
import { createPluginLambdaHandler, ssrPlugin } from '@everystack/server/plugin';
|
|
38
|
+
import { createAppContext, appPlugins } from './context';
|
|
39
|
+
|
|
40
|
+
export const handler = createPluginLambdaHandler({
|
|
41
|
+
context: () => createAppContext(),
|
|
42
|
+
plugins: appPlugins,
|
|
43
|
+
fallback: ssrPlugin(), // serves the published Expo export: SSR + your +api.ts routes
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## One Plugin List, Two Venues
|
|
48
|
+
|
|
49
|
+
Define your plugins once; mount them in both places. Locally, Expo Router serves them through a catch-all route. Deployed, the Lambda mounts the same list and claims those paths BEFORE the fallback — so the deployed `/api` runs with SST Resource linking, never the bundle's copy.
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
// server/context.ts — shared by local dev and every Lambda
|
|
53
|
+
import { createDb } from '@everystack/server/db';
|
|
54
|
+
import { apiPlugin } from './plugins/api'; // your everystack createHandler, as a plugin
|
|
55
|
+
import { authPlugin } from '@everystack/auth/plugin';
|
|
56
|
+
import { schema } from '../db';
|
|
57
|
+
|
|
58
|
+
export async function createAppContext() {
|
|
59
|
+
const { db } = createDb(schema); // Resource-linked on Lambda, DATABASE_URL locally
|
|
60
|
+
return { db, schema, environment: process.env.ENVIRONMENT ?? 'dev', /* verifyToken, ... */ };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const appPlugins = [authPlugin, apiPlugin];
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
// server/plugins/api.ts — the everystack handler as ONE plugin among your routes
|
|
68
|
+
import { createHandler } from '@everystack/api';
|
|
69
|
+
|
|
70
|
+
export async function apiPlugin(ctx) {
|
|
71
|
+
const handler = createHandler(ctx.db, ctx.schema, { basePath: '/api', /* options */ });
|
|
72
|
+
return { routes: [{ path: '/api', handler }] };
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
// app/api/[...path]+api.ts — LOCAL DEV venue (Expo Router serves this; the
|
|
78
|
+
// deployed Lambda claims /api first, so this copy never runs in production)
|
|
79
|
+
import { createPluginHandler } from '@everystack/server/plugin';
|
|
80
|
+
import { createAppContext, appPlugins } from '../../server/context';
|
|
81
|
+
|
|
82
|
+
const handler = createPluginHandler({ context: createAppContext, plugins: appPlugins });
|
|
83
|
+
export const GET = handler; export const POST = handler;
|
|
84
|
+
export const PATCH = handler; export const DELETE = handler;
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Your OTHER `+api.ts` routes (`app/mcp+api.ts`, `app/games/[id]+api.ts`, ...) need no counterpart: local dev serves them directly, deployed they serve through the fallback.
|
|
88
|
+
|
|
89
|
+
## The Ops Entrypoint (db:migrate, db:seed, console)
|
|
90
|
+
|
|
91
|
+
`everystack db:migrate` invokes a dedicated, privileged Lambda over IAM — it has no public URL and holds the operator credential so the API Lambda never does.
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
// server/ops.ts
|
|
95
|
+
import { createPluginLambdaHandler, dbPlugin } from '@everystack/server/plugin';
|
|
96
|
+
import { createAppContext, appPlugins } from './context';
|
|
97
|
+
|
|
98
|
+
export const handler = createPluginLambdaHandler({
|
|
99
|
+
http: false, // actions-only: rejects HTTP, answers IAM invokes
|
|
100
|
+
context: () => createAppContext({ admin: true }),
|
|
101
|
+
plugins: [
|
|
102
|
+
...appPlugins,
|
|
103
|
+
dbPlugin({
|
|
104
|
+
migrationsFolder: 'drizzle',
|
|
105
|
+
seed: async (db, schema) => (await import('../db/seed.js')).runSeed(db, schema),
|
|
106
|
+
// ^ lazy import — a top-level import would run at Lambda INIT and crash the boot
|
|
107
|
+
}),
|
|
108
|
+
],
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Minimal sst.config.ts
|
|
113
|
+
|
|
114
|
+
The complete infrastructure for this app shape (V2: server app + PostgreSQL). Copy, rename, deploy.
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
/// <reference path="./.sst/platform/config.d.ts" />
|
|
118
|
+
export default $config({
|
|
119
|
+
app(input) {
|
|
120
|
+
return {
|
|
121
|
+
name: 'my-app',
|
|
122
|
+
removal: input?.stage === 'production' ? 'retain' : 'remove',
|
|
123
|
+
home: 'aws',
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
async run() {
|
|
127
|
+
const vpc = new sst.aws.Vpc('Vpc');
|
|
128
|
+
const database = new sst.aws.Postgres('Database', {
|
|
129
|
+
vpc,
|
|
130
|
+
// Local dev: `sst dev` uses this instead of RDS
|
|
131
|
+
dev: { host: 'localhost', port: 5432, username: 'postgres', password: 'postgres', database: 'my_app_dev' },
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const updates = new sst.aws.Bucket('Updates'); // published Expo exports (server bundles)
|
|
135
|
+
const clientBundles = new sst.aws.Bucket('ClientBundles', { access: 'cloudfront' });
|
|
136
|
+
|
|
137
|
+
const jwtSecret = new sst.Secret('JwtSecret');
|
|
138
|
+
// Least-privilege API credential — written by `everystack db:provision`.
|
|
139
|
+
const databaseUrl = new sst.Secret('DATABASE_URL');
|
|
140
|
+
|
|
141
|
+
const router = new sst.aws.Router('Router', {
|
|
142
|
+
// Custom domain: Route 53 is the default DNS adapter. With your zone
|
|
143
|
+
// delegated to Route 53, this one line provisions the ACM certificate
|
|
144
|
+
// and DNS records automatically.
|
|
145
|
+
domain: $app.stage === 'production'
|
|
146
|
+
? 'my-app.com'
|
|
147
|
+
: `${$app.stage}.my-app.com`,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const api = new sst.aws.Function('Api', {
|
|
151
|
+
handler: 'server/api.handler',
|
|
152
|
+
runtime: 'nodejs20.x',
|
|
153
|
+
timeout: '30 seconds',
|
|
154
|
+
vpc,
|
|
155
|
+
link: [databaseUrl, updates, clientBundles, jwtSecret],
|
|
156
|
+
url: { router: { instance: router, path: '/' } },
|
|
157
|
+
environment: { ENVIRONMENT: $app.stage, SITE_URL: router.url },
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const ops = new sst.aws.Function('Ops', {
|
|
161
|
+
handler: 'server/ops.handler',
|
|
162
|
+
runtime: 'nodejs20.x',
|
|
163
|
+
timeout: '120 seconds',
|
|
164
|
+
vpc,
|
|
165
|
+
link: [database, databaseUrl, updates, clientBundles, jwtSecret],
|
|
166
|
+
copyFiles: [{ from: 'drizzle', to: 'drizzle' }], // migrations must ship with the ops bundle
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// The CLI discovers resources through these outputs — all five are required.
|
|
170
|
+
return {
|
|
171
|
+
routerUrl: router.url,
|
|
172
|
+
apiFunctionName: api.name,
|
|
173
|
+
opsFunctionName: ops.name,
|
|
174
|
+
updatesBucket: updates.name,
|
|
175
|
+
clientBundlesBucket: clientBundles.name,
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Local vs Deployed Database Connection
|
|
182
|
+
|
|
183
|
+
`createDb()` from `@everystack/server/db` serves both venues with one code path (requires `@everystack/server` >= 0.4.4):
|
|
184
|
+
|
|
185
|
+
- **Deployed:** the linked `DATABASE_URL` secret resolves via SST Resource; TLS defaults to `require` (RDS).
|
|
186
|
+
- **Local:** set `DATABASE_URL=postgres://user@localhost:5432/my_app_dev?sslmode=disable`. An explicit `sslmode` in the URL wins; the handler code does not change.
|
|
187
|
+
|
|
188
|
+
Do not hand-build a `postgres()` client from env vars to escape TLS — the URL is the contract.
|
|
189
|
+
|
|
190
|
+
## Deploy Loop
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
sst deploy --stage dev # infrastructure + entrypoints (rare)
|
|
194
|
+
everystack db:provision --stage dev # mint the least-privilege API credential (once per stage)
|
|
195
|
+
everystack update --channel dev # export + publish the app code (every ship)
|
|
196
|
+
everystack db:migrate --stage dev # run migrations via the ops Lambda
|
|
197
|
+
everystack db:seed --stage dev # dev only
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Subsequent app-code ships are `everystack update` alone — no `sst deploy`, no Lambda redeploy.
|
|
201
|
+
|
|
202
|
+
## Gotchas
|
|
203
|
+
|
|
204
|
+
- **Nothing serves until the first `everystack update`** — the fallback has no bundle to download yet, so `/` returns 404 while `/api` (plugin-claimed) already works.
|
|
205
|
+
- **A path claimed by a plugin never reaches your bundle's route.** If you mount the everystack handler at `/api`, a bundle route at `app/api/foo+api.ts` is shadowed in production (it still serves in local dev unless the catch-all shadows it there too — keep the surfaces aligned).
|
|
206
|
+
- **Lazy-load your seed** (shown above). A top-level `import { runSeed }` in an entrypoint runs at Lambda INIT, tries to reach the database before configuration, and kills the boot.
|
|
207
|
+
- **`copyFiles` the migrations folder** into the ops function, or `db:migrate` fails with ENOENT.
|
|
208
|
+
- **`+api.ts` routes run with the bundle's environment**, not per-route SST links. Resource-linked work (secrets, buckets) belongs in plugins on the Lambda side; keep bundle routes to app logic over the request.
|
package/src/resources/index.ts
CHANGED
|
@@ -116,9 +116,23 @@ const RESOURCES: ResourceDef[] = [
|
|
|
116
116
|
uri: 'everystack://schema-patterns',
|
|
117
117
|
name: 'Schema Design Patterns',
|
|
118
118
|
description:
|
|
119
|
-
'
|
|
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',
|
|
@@ -126,6 +140,13 @@ const RESOURCES: ResourceDef[] = [
|
|
|
126
140
|
'SST deployment: sst.config.ts setup, secrets, stages (dev/production), CloudFront, Lambda configuration, VPC, RDS Aurora Serverless, resource linking.',
|
|
127
141
|
filename: 'deployment.md',
|
|
128
142
|
},
|
|
143
|
+
{
|
|
144
|
+
uri: 'everystack://expo-server-deploy',
|
|
145
|
+
name: 'Deploying an Expo Router Server App',
|
|
146
|
+
description:
|
|
147
|
+
'How web.output:"server" maps to AWS: the two-deployable model (sst deploy vs everystack update), where +api.ts routes run (the SSR fallback serves the published bundle), the one-plugin-list/two-venues pattern (local catch-all route + deployed Lambda entrypoint), the ops entrypoint for db:migrate, custom domains on the Router, and the local-vs-deployed database URL (sslmode). Read this when deploying any Expo Router app with SSR or +api.ts routes.',
|
|
148
|
+
filename: 'expo-server-deploy.md',
|
|
149
|
+
},
|
|
129
150
|
{
|
|
130
151
|
uri: 'everystack://admin',
|
|
131
152
|
name: 'Admin Dashboard',
|
|
@@ -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
|
|
23
|
-
in `db/models
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
46
|
-
|
|
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)
|
|
88
|
-
|
|
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/
|
|
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.
|