@everystack/model 0.3.0 → 0.3.4

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 CHANGED
@@ -87,7 +87,14 @@ The table-level surface, composed in `constraints: [...]`, for what a column-sco
87
87
 
88
88
  ## Authorization
89
89
 
90
- `can(action, condition)` declares one rule (default-deny: no matching `can()` means denied). Conditions: `{ owner: 'column' }` (row owner, via the JWT `sub` claim by default — override with `userField`), `{ role: 'admin' }`, or a raw `{ sql: … }` escape hatch. These compile to RLS policies and grants.
90
+ `can(action, condition)` declares one rule (default-deny: no matching `can()` means denied). Conditions:
91
+
92
+ - `{ owner: 'column' }` — row owner, via the JWT `sub` claim by default (override with `userField`). Also feeds the handler's `rowOwnership` IDOR backstop.
93
+ - `{ role: 'authenticated' }` — role-gated (policy `TO <role>` + grant).
94
+ - `{ where: 'deleted_at IS NULL' }` — a column/scalar predicate, compiled to the policy's `USING` clause.
95
+ - `{ sql: … }` — a verbatim RLS predicate escape hatch.
96
+
97
+ These compile to RLS policies and grants (via `@everystack/cli`'s `compileTableContract`, what `db:generate` emits). `can('manage', …)` covers all actions.
91
98
 
92
99
  ## Relations
93
100
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/model",
3
- "version": "0.3.0",
3
+ "version": "0.3.4",
4
4
  "description": "Declare a table's schema, validation, and authorization once — compiled to migrations, RLS, and handler config",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
@@ -14,12 +14,20 @@
14
14
  ".": {
15
15
  "types": "./src/index.ts",
16
16
  "default": "./src/index.ts"
17
+ },
18
+ "./drizzle": {
19
+ "types": "./src/drizzle.ts",
20
+ "default": "./src/drizzle.ts"
17
21
  }
18
22
  },
19
23
  "peerDependencies": {
24
+ "drizzle-orm": "*",
20
25
  "zod": "*"
21
26
  },
22
27
  "peerDependenciesMeta": {
28
+ "drizzle-orm": {
29
+ "optional": true
30
+ },
23
31
  "zod": {
24
32
  "optional": true
25
33
  }
@@ -27,6 +35,7 @@
27
35
  "devDependencies": {
28
36
  "@types/jest": "29.5.14",
29
37
  "@types/node": "22.19.18",
38
+ "drizzle-orm": "0.41.0",
30
39
  "jest": "29.7.0",
31
40
  "ts-jest": "29.4.9",
32
41
  "typescript": "5.9.3",
package/src/can.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * CanCan-shaped authorization rules. `can(action, condition)` declares one rule.
3
3
  *
4
- * Phase 0 consumes only the `owner` shape (→ the handler's `rowOwnership` IDOR
5
- * backstop). The full set of condition shapes — `role`, `where`, `sql` — is
6
- * stored verbatim now and compiled to RLS policies + grants in Phase 1
7
- * (`compileAbilities`, the inverse of the contract suite's `humanizePredicate`).
4
+ * The `owner` shape feeds the handler's `rowOwnership` IDOR backstop. The full
5
+ * set of condition shapes — `role`, `owner`, `via`, `where`, `sql` — is compiled
6
+ * to RLS policies + grants by `@everystack/cli`'s `compileTableContract` (what
7
+ * `db:generate` emits), the inverse of the contract suite's `humanizePredicate`.
8
8
  *
9
9
  * Default-deny is the model's contract: no matching `can()` means denied.
10
10
  */
@@ -17,9 +17,26 @@ export interface AbilityCondition {
17
17
  role?: string;
18
18
  /** Row owner only: the column holding the owner id (`USING (<col> = auth.uid())`). */
19
19
  owner?: string;
20
+ /**
21
+ * Transitive owner — names a local FK *field* (e.g. `uploadId`) whose `.references()`
22
+ * target carries the real owner. The compiler follows the reference to the parent's
23
+ * owner ability and emits a parent-subquery predicate:
24
+ * `<fk_col> IN (SELECT id FROM <parent> WHERE <parent_owner_col> = <claim>)`.
25
+ * Use when a child row is owned through its parent (image_variants → uploads), not
26
+ * by a column on the child itself. The parent model must declare an `owner` ability.
27
+ */
28
+ via?: string;
20
29
  /** Field in the JWT claims holding the owner id. Default: `sub`. */
21
30
  userField?: string;
22
- /** Scalar/column predicate — a sql-tagged expression. Compiled in Phase 1. */
31
+ /**
32
+ * Column-scoped exposure for a `read` ability. Names the field keys a role may read
33
+ * (compiled to a `GRANT SELECT (cols)` column grant, not a table grant) instead of the
34
+ * whole row — e.g. `can('read', { owner: 'id', columns: ['id', 'email', 'role'] })` on
35
+ * `auth.users` exposes id/email/role to the owner, never the password hash. Pairs with
36
+ * `owner` (the self policy scopes the rows; the columns scope the fields).
37
+ */
38
+ columns?: string[];
39
+ /** Scalar/column predicate — a sql-tagged expression compiled to an RLS USING clause. */
23
40
  where?: unknown;
24
41
  /** Escape hatch: a verbatim RLS predicate, preserved and diffed by normalized text. */
25
42
  sql?: unknown;
@@ -1,14 +1,13 @@
1
1
  /**
2
2
  * Table-level constraint builders — the surface for what a column-scoped `field()`
3
- * can't express: a composite `unique(...)` and a multi-column `check(...)`. They are
4
- * composed in a `constraints: [...]` array on `defineModel`, the same builder idiom as
5
- * `field`/`can`, so the Model reads as intent rather than config.
3
+ * can't express: a composite `unique(...)`, a multi-column `check(...)`, a standalone
4
+ * `index(...)` (optionally `.unique()`/`.where()`), and a composite `foreignKey(...)`.
5
+ * They are composed in a `constraints: [...]` array on `defineModel`, the same builder
6
+ * idiom as `field`/`can`, so the Model reads as intent rather than config.
6
7
  *
7
8
  * Columns are named by their **field keys** (camelCase, as the model is written); the
8
9
  * compiler snake_cases them to SQL identifiers — symmetric with `.references()`. A
9
10
  * `check(...)` takes raw `sql\`…\`` because a predicate is, by nature, SQL.
10
- *
11
- * (Indexes join this surface in a later increment; the union is open for it.)
12
11
  */
13
12
 
14
13
  import type { RawSql } from './raw-sql';
package/src/derive.ts CHANGED
@@ -2,10 +2,9 @@
2
2
  * `deriveHandlerConfig` — compile a set of models down to the per-table slice of
3
3
  * the API handler's options blob.
4
4
  *
5
- * This is the Phase-0 payoff: today the example hand-authors `exposedTables`,
6
- * `hiddenColumns`, `protectedFields`, `rowOwnership`, `softDelete`, `relations`,
7
- * and `hooks` as a scattered literal kept in sync with the schema by hand. Those
8
- * are all *views of the model*, so they derive from it — no drift.
5
+ * `exposedTables`, `hiddenColumns`, `protectedFields`, `rowOwnership`,
6
+ * `softDelete`, `relations`, and `hooks` are all *views of the model*, so they
7
+ * derive from it instead of a hand-maintained literal that drifts from the schema.
9
8
  *
10
9
  * Scope boundary: only the PER-TABLE surface derives here. App/handler-level
11
10
  * options (`auth`, `pgSettings`, `rpc`, `basePath`, `audit`, `maxLimit`,
@@ -54,6 +53,11 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
54
53
  const hooks: Record<string, ModelHooks> = {};
55
54
 
56
55
  for (const model of models) {
56
+ // A private model is generated (schema + authz) but is not a generic-API
57
+ // resource — it contributes nothing to the handler config (no exposedTables
58
+ // entry, no hidden/protected/ownership/relation/hook derivation).
59
+ if (model.private) continue;
60
+
57
61
  const { table } = model;
58
62
  exposedTables.push(table);
59
63
 
@@ -128,3 +132,33 @@ function fieldsWhere(
128
132
  .filter(([, f]) => pred(f))
129
133
  .map(([name]) => name);
130
134
  }
135
+
136
+ /**
137
+ * The schema-qualified tables that are ENABLE-not-FORCE — every model whose write
138
+ * principal is not the app (`writtenBy !== 'app'`). These are the FORCE-RLS carveouts
139
+ * db:doctor must not flag: the worker / SECDEF functions write them as the owner and
140
+ * need the owner's RLS bypass (on RDS the owner is not a superuser, so FORCE would
141
+ * block its own writes).
142
+ *
143
+ * This replaces the hand-maintained `forceRlsCarveouts` string list in an app's ops
144
+ * config — the carveout is now read from each table's own `writtenBy` declaration,
145
+ * so it can't drift from the contract (the bare-name bug that list once caused).
146
+ * Deterministic: sorted.
147
+ */
148
+ export function deriveForceRlsCarveouts(models: ModelDescriptor[]): string[] {
149
+ return models
150
+ .filter((m) => m.writtenBy !== 'app')
151
+ .map((m) => `${m.schema}.${m.table}`)
152
+ .sort();
153
+ }
154
+
155
+ /**
156
+ * The distinct schemas that own a `writtenBy: 'functions'` table (e.g. `['auth']`). A
157
+ * functions-written table is ENABLE-not-FORCE because the package's SECURITY DEFINER
158
+ * functions write it as the owner — so db:doctor's `functions` teeth assert those schemas'
159
+ * SECDEF functions exist and pin their search_path. Derived from the same `writtenBy`
160
+ * declaration as the carveouts, so the two can't disagree. Deterministic: sorted.
161
+ */
162
+ export function deriveFunctionSchemas(models: ModelDescriptor[]): string[] {
163
+ return [...new Set(models.filter((m) => m.writtenBy === 'functions').map((m) => m.schema))].sort();
164
+ }
package/src/drizzle.ts ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * `toDrizzleTable` — the runtime bridge. A {@link ModelDescriptor} already owns
3
+ * everything a hand-written drizzle table carries (column names, types,
4
+ * nullability, defaults, primary key, foreign keys, table-level constraints), so
5
+ * this derivation lets a Model *be* the single source for the drizzle-orm table
6
+ * object. An app's `db/schema.ts` then collapses to derived re-exports.
7
+ *
8
+ * Drizzle-only: it imports `drizzle-orm/pg-core`, so it lives under the package's
9
+ * `./drizzle` subpath and is deliberately NOT re-exported from `src/index.ts` —
10
+ * the core index stays drizzle-free (drizzle-orm is an optional peer).
11
+ *
12
+ * Forward/circular references resolve through a module-level registry: a model is
13
+ * built at most once, so a parent referenced from a child's FK thunk yields the
14
+ * *same* table instance every consumer sees.
15
+ */
16
+
17
+ import {
18
+ pgTable,
19
+ pgSchema,
20
+ pgEnum,
21
+ text,
22
+ uuid,
23
+ integer,
24
+ bigint,
25
+ serial,
26
+ bigserial,
27
+ real,
28
+ doublePrecision,
29
+ boolean,
30
+ timestamp,
31
+ date,
32
+ numeric,
33
+ varchar,
34
+ jsonb,
35
+ primaryKey,
36
+ unique,
37
+ check,
38
+ type PgColumnBuilderBase,
39
+ type PgTableWithColumns,
40
+ type AnyPgColumn,
41
+ } from 'drizzle-orm/pg-core';
42
+ import { sql } from 'drizzle-orm';
43
+
44
+ import type { ModelDescriptor } from './model';
45
+ import type { FieldSpec } from './field';
46
+
47
+ /** Built tables, keyed by the {@link ModelDescriptor} object — the FK-sharing + idempotency cache. */
48
+ const tableRegistry = new Map<ModelDescriptor, PgTableWithColumns<any>>();
49
+
50
+ /** Postgres enum types, keyed by `enumName`, so a type shared across fields/tables is created once. */
51
+ const enumRegistry = new Map<string, ReturnType<typeof pgEnum>>();
52
+
53
+ /** `authorId` → `author_id`. The single snake-case rule used everywhere a field key becomes a SQL identifier. */
54
+ function snake(name: string): string {
55
+ return name.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase());
56
+ }
57
+
58
+ function getEnum(spec: FieldSpec): ReturnType<typeof pgEnum> {
59
+ const name = spec.enumName;
60
+ if (!name) throw new Error('enum field is missing enumName');
61
+ let e = enumRegistry.get(name);
62
+ if (!e) {
63
+ e = pgEnum(name, (spec.enumValues ?? []) as [string, ...string[]]);
64
+ enumRegistry.set(name, e);
65
+ }
66
+ return e;
67
+ }
68
+
69
+ /** Map a field's Postgres type to a drizzle pg-core column builder (no modifiers yet). */
70
+ function baseColumn(columnName: string, spec: FieldSpec): PgColumnBuilderBase {
71
+ switch (spec.type) {
72
+ case 'text':
73
+ return text(columnName);
74
+ case 'uuid':
75
+ return uuid(columnName);
76
+ case 'integer':
77
+ return integer(columnName);
78
+ case 'bigint':
79
+ return bigint(columnName, { mode: 'number' });
80
+ case 'serial':
81
+ return serial(columnName);
82
+ case 'bigserial':
83
+ return bigserial(columnName, { mode: 'number' });
84
+ case 'real':
85
+ return real(columnName);
86
+ case 'doublePrecision':
87
+ return doublePrecision(columnName);
88
+ case 'boolean':
89
+ return boolean(columnName);
90
+ case 'timestamptz':
91
+ return timestamp(columnName, { withTimezone: true });
92
+ case 'timestamp':
93
+ return timestamp(columnName);
94
+ case 'date':
95
+ return date(columnName);
96
+ case 'numeric':
97
+ return spec.precision !== undefined
98
+ ? numeric(columnName, { precision: spec.precision, scale: spec.scale })
99
+ : numeric(columnName);
100
+ case 'varchar':
101
+ return spec.length !== undefined ? varchar(columnName, { length: spec.length }) : varchar(columnName);
102
+ case 'jsonb':
103
+ return jsonb(columnName);
104
+ case 'enum':
105
+ return getEnum(spec)(columnName);
106
+ default: {
107
+ const exhaustive: never = spec.type;
108
+ throw new Error(`Unsupported field type: ${String(exhaustive)}`);
109
+ }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Derive a runtime drizzle-orm table from a Model. Idempotent and FK-safe: the same
115
+ * descriptor always yields the same cached table instance, so a parent referenced by a
116
+ * child's FK thunk and the parent built directly are one object.
117
+ */
118
+ export function toDrizzleTable(model: ModelDescriptor): PgTableWithColumns<any> {
119
+ const cached = tableRegistry.get(model);
120
+ if (cached) return cached;
121
+
122
+ const isComposite = model.primaryKey.length > 1;
123
+
124
+ const columns: Record<string, PgColumnBuilderBase> = {};
125
+
126
+ for (const [key, builder] of Object.entries(model.fields)) {
127
+ const spec = builder.spec;
128
+ const columnName = snake(key);
129
+
130
+ // Modifier chaining loses the precise builder type per branch; `any` is the
131
+ // standard drizzle-internal escape hatch (mirrors the field.ts convention).
132
+ let col: any = baseColumn(columnName, spec);
133
+
134
+ if (spec.isArray) col = col.array();
135
+ if (spec.isNotNull) col = col.notNull();
136
+ if (spec.isUnique) col = col.unique();
137
+
138
+ if (spec.defaultKind === 'now') col = col.defaultNow();
139
+ else if (spec.defaultKind === 'random') col = col.defaultRandom();
140
+ else if (spec.defaultKind === 'value') col = col.default(spec.default);
141
+
142
+ // Single-column PK lives on the column; composite PKs are emitted table-level below.
143
+ if (spec.isPrimaryKey && !isComposite) col = col.primaryKey();
144
+
145
+ if (spec.references) {
146
+ const target = spec.references as () => ModelDescriptor;
147
+ const refOpts: { onDelete?: FieldSpec['onDelete']; onUpdate?: FieldSpec['onUpdate'] } = {};
148
+ if (spec.onDelete) refOpts.onDelete = spec.onDelete;
149
+ if (spec.onUpdate) refOpts.onUpdate = spec.onUpdate;
150
+ // Resolve the parent table lazily — the registry guarantees one shared instance
151
+ // and lets circular references converge.
152
+ col = col.references(() => {
153
+ const parent = toDrizzleTable(target());
154
+ const parentPkKey = parentPrimaryKeyColumnKey(target());
155
+ return (parent as any)[parentPkKey];
156
+ }, refOpts);
157
+ }
158
+
159
+ columns[key] = col;
160
+ }
161
+
162
+ // Second-arg extra-config: composite PK, then table-level constraints.
163
+ const extraConfig = (table: Record<string, AnyPgColumn>): unknown[] => {
164
+ const extras: unknown[] = [];
165
+
166
+ if (isComposite) {
167
+ const pkCols = model.primaryKey.map((k) => table[k]) as [AnyPgColumn, ...AnyPgColumn[]];
168
+ extras.push(primaryKey({ columns: pkCols }));
169
+ }
170
+
171
+ for (const c of model.constraints) {
172
+ switch (c.kind) {
173
+ case 'unique': {
174
+ const uCols = c.columns.map((k) => table[k]) as [AnyPgColumn, ...AnyPgColumn[]];
175
+ extras.push(unique().on(...uCols));
176
+ break;
177
+ }
178
+ case 'check':
179
+ extras.push(check(`${model.table}_check`, sql.raw(c.predicate)));
180
+ break;
181
+ // 'index' and 'foreignKey' table constraints are compiled at the DDL layer;
182
+ // they are not needed for the runtime drizzle table object (the FK relationship
183
+ // for queries comes from column `.references()`), so they are intentionally
184
+ // not materialized here.
185
+ default:
186
+ break;
187
+ }
188
+ }
189
+
190
+ return extras;
191
+ };
192
+
193
+ // A package table lives in its own schema (dist/ops/auth); an app table stays in
194
+ // public, where drizzle's pgTable leaves the schema implicit.
195
+ const table = (model.schema && model.schema !== 'public'
196
+ ? pgSchema(model.schema).table(model.table, columns as any, extraConfig as any)
197
+ : pgTable(model.table, columns as any, extraConfig as any)) as PgTableWithColumns<any>;
198
+ tableRegistry.set(model, table);
199
+ return table;
200
+ }
201
+
202
+ /** The drizzle column *key* for a model's (single) primary key — FK targets reference it. */
203
+ function parentPrimaryKeyColumnKey(parent: ModelDescriptor): string {
204
+ const pk = parent.primaryKey[0];
205
+ if (!pk) throw new Error(`Referenced model "${parent.table}" has no primary key`);
206
+ return pk;
207
+ }
208
+
209
+ /** Test/tooling seam: clear the module caches so a fresh derivation can be observed. */
210
+ export function __resetDrizzleRegistry(): void {
211
+ tableRegistry.clear();
212
+ enumRegistry.clear();
213
+ }
package/src/field.ts CHANGED
@@ -1,22 +1,25 @@
1
1
  /**
2
- * The field descriptor — Layer 1 of the Model, in its Phase-0 form.
2
+ * The field descriptor — Layer 1 of the Model.
3
3
  *
4
- * Today a `field()` records *intent*: the Postgres type and the exposure /
5
- * constraint modifiers the handler-config derivation needs (`.private()`,
6
- * `.readonly()`, primary key, references). It does NOT yet generate DDL or Zod —
7
- * that is the Zod⊗Postgres bridge (Phase 4), which enriches THIS SAME builder
8
- * surface without changing how a model is written. A model authored now keeps
9
- * working when the bridge lands.
4
+ * A `field()` records the Postgres type and the exposure / constraint modifiers
5
+ * (`.private()`, `.readonly()`, primary key, references, `.validate()`).
6
+ * `@everystack/cli` compiles those to column DDL and to the per-table handler
7
+ * config; `.validate()`'s SQL-expressible refinements become CHECK constraints
8
+ * (richer Zod refinements stay app-layer). The model just declares — authoring
9
+ * stays the same as the compile targets grow.
10
10
  *
11
- * See docs/plans/v3-model-implementation.md (Phase 0) and v3-model-api.md.
11
+ * See docs/plans/v3-model-api.md.
12
12
  */
13
13
 
14
14
  export type FieldType =
15
15
  | 'text'
16
16
  | 'uuid'
17
17
  | 'bigint'
18
+ | 'serial'
18
19
  | 'bigserial'
19
20
  | 'integer'
21
+ | 'real'
22
+ | 'doublePrecision'
20
23
  | 'boolean'
21
24
  | 'timestamptz'
22
25
  | 'timestamp'
@@ -46,6 +49,8 @@ export interface FieldSpec {
46
49
  isPrimaryKey: boolean;
47
50
  isNotNull: boolean;
48
51
  isUnique: boolean;
52
+ /** `.array()` — a Postgres array of the base type (`text` → `text[]`). */
53
+ isArray: boolean;
49
54
  /** `.private()` — never serialized into API responses (→ `hiddenColumns`). */
50
55
  isPrivate: boolean;
51
56
  /** `.readonly()` — never accepted on write (→ `protectedFields`). */
@@ -89,6 +94,7 @@ export class FieldBuilder {
89
94
  isPrimaryKey: false,
90
95
  isNotNull: false,
91
96
  isUnique: false,
97
+ isArray: false,
92
98
  isPrivate: false,
93
99
  isReadonly: false,
94
100
  };
@@ -115,6 +121,12 @@ export class FieldBuilder {
115
121
  return this;
116
122
  }
117
123
 
124
+ /** A Postgres array of the base type: `field.text().array()` → `text[]`. */
125
+ array(): this {
126
+ this.spec.isArray = true;
127
+ return this;
128
+ }
129
+
118
130
  /** Hide from every API response. Compiles to the handler's `hiddenColumns`. */
119
131
  private(): this {
120
132
  this.spec.isPrivate = true;
@@ -177,8 +189,14 @@ export const field = {
177
189
  text: (): FieldBuilder => new FieldBuilder('text'),
178
190
  uuid: (): FieldBuilder => new FieldBuilder('uuid'),
179
191
  bigint: (): FieldBuilder => new FieldBuilder('bigint'),
180
- bigserial: (): FieldBuilder => new FieldBuilder('bigserial'),
192
+ // A serial column is NOT NULL by Postgres definition, so the builder starts that way.
193
+ serial: (): FieldBuilder => new FieldBuilder('serial').notNull(),
194
+ bigserial: (): FieldBuilder => new FieldBuilder('bigserial').notNull(),
181
195
  integer: (): FieldBuilder => new FieldBuilder('integer'),
196
+ /** `real` — single-precision float (float4). */
197
+ real: (): FieldBuilder => new FieldBuilder('real'),
198
+ /** `double precision` — double-precision float (float8). */
199
+ doublePrecision: (): FieldBuilder => new FieldBuilder('doublePrecision'),
182
200
  boolean: (): FieldBuilder => new FieldBuilder('boolean'),
183
201
  timestamptz: (): FieldBuilder => new FieldBuilder('timestamptz'),
184
202
  timestamp: (): FieldBuilder => new FieldBuilder('timestamp'),
package/src/index.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  * @everystack/model — declare a table once; compile it to migrations, RLS, and
3
3
  * handler config. See docs/plans/v3-model-architecture.md.
4
4
  *
5
- * Phase 0 ships the declaration surface (`defineModel`, `field`, `can`,
6
- * `belongsTo`/`hasMany`) and the first compile target: handler-config derivation.
5
+ * Ships the declaration surface (`defineModel`, `field`, `can`, `belongsTo`/
6
+ * `hasMany`); `@everystack/cli` compiles models to migrations, RLS + grants, and
7
+ * handler config (`deriveHandlerConfig`).
7
8
  */
8
9
 
9
10
  export { field, FieldBuilder } from './field';
@@ -22,7 +23,10 @@ export { belongsTo, hasMany } from './relations';
22
23
  export type { Relation } from './relations';
23
24
 
24
25
  export { defineModel } from './model';
25
- export type { ModelDescriptor, ModelDefinition, ModelHooks } from './model';
26
+ export type { ModelDescriptor, ModelDefinition, ModelHooks, WrittenBy } from './model';
26
27
 
27
- export { deriveHandlerConfig } from './derive';
28
+ export { defineModule, moduleModels, moduleExtensions, moduleFunctionSql } from './module';
29
+ export type { Module, ModuleDefinition } from './module';
30
+
31
+ export { deriveHandlerConfig, deriveForceRlsCarveouts, deriveFunctionSchemas } from './derive';
28
32
  export type { DerivedHandlerConfig, RelationConfig } from './derive';
package/src/model.ts CHANGED
@@ -6,8 +6,9 @@
6
6
  * functional `field`/`can` builders, and structurally resists the fat-model trap
7
7
  * (a model *declares*; behavior lives in the functions it references).
8
8
  *
9
- * Phase 0 returns a plain descriptor. Later phases read it from two directions —
10
- * the handler-config derivation (now) and the abilities→contract compiler (Phase 1).
9
+ * Returns a plain descriptor that `@everystack/cli` reads from two directions —
10
+ * handler-config derivation (`deriveHandlerConfig`) and the abilities→contract
11
+ * compiler (`compileTableContract`, what `db:generate` emits as RLS + grants).
11
12
  */
12
13
 
13
14
  import type { FieldBuilder } from './field';
@@ -29,6 +30,24 @@ export interface ModelHooks {
29
30
  afterDelete?: (...args: any[]) => any;
30
31
  }
31
32
 
33
+ /**
34
+ * Who holds the pen on a table — the write principal. The compiler derives RLS
35
+ * posture from it via the axiom "FORCE iff the write principal is subject to its
36
+ * own RLS policies":
37
+ *
38
+ * - `'app'` — app roles write through their own policies; subject to RLS -> FORCE.
39
+ * - `'worker'` — the worker writes it on the owner connection, bypassing RLS
40
+ * (on RDS the owner is not a superuser, so a FORCEd table would
41
+ * block the owner's own writes) -> ENABLE-not-FORCE.
42
+ * - `'functions'` — written only by SECURITY DEFINER functions the package owns,
43
+ * running as owner -> ENABLE-not-FORCE. `db:doctor` additionally
44
+ * asserts those functions exist with a pinned `search_path`.
45
+ *
46
+ * Default `'app'`. This also replaces the app's hand-maintained `forceRlsCarveouts`
47
+ * list: `db:doctor` reads the carveout intent (`writtenBy !== 'app'`) off the contract.
48
+ */
49
+ export type WrittenBy = 'app' | 'worker' | 'functions';
50
+
32
51
  export interface ModelDefinition {
33
52
  fields: Record<string, FieldBuilder>;
34
53
  abilities?: Ability[];
@@ -36,6 +55,22 @@ export interface ModelDefinition {
36
55
  relations?: Record<string, Relation>;
37
56
  /** Table-level constraints the column-scoped `field()` can't express — composite `unique`, multi-column `check`. */
38
57
  constraints?: TableConstraint[];
58
+ /** The write principal — drives RLS posture. Default `'app'`. See {@link WrittenBy}. */
59
+ writtenBy?: WrittenBy;
60
+ /**
61
+ * The Postgres schema the table lives in. Default `'public'`. Package-owned
62
+ * tables declare their own (`'dist'`, `'ops'`, `'auth'`) so one db:generate run
63
+ * can span schemas — the model's schema always wins over a generate call's default.
64
+ */
65
+ schema?: string;
66
+ /**
67
+ * `true` removes the table from the generic data API — `deriveHandlerConfig`
68
+ * skips it, so it never enters `exposedTables`. Default `false`. Package-owned
69
+ * operational tables (`dist.blobs`, `ops.runs`, `auth.users`) are reached through
70
+ * dedicated handlers, not generic CRUD; they are still generated (schema + authz),
71
+ * just not exposed. Named `private` to avoid colliding with `schema: 'public'`.
72
+ */
73
+ private?: boolean;
39
74
  }
40
75
 
41
76
  export interface ModelDescriptor {
@@ -49,6 +84,12 @@ export interface ModelDescriptor {
49
84
  primaryKey: string[];
50
85
  /** Table-level constraints (composite unique, multi-column check), in declaration order. */
51
86
  constraints: TableConstraint[];
87
+ /** The resolved write principal (default `'app'`) — drives RLS posture. See {@link WrittenBy}. */
88
+ writtenBy: WrittenBy;
89
+ /** The resolved Postgres schema (default `'public'`). Package tables live in their own. */
90
+ schema: string;
91
+ /** `true` removes the table from the generic data API (not in `exposedTables`). Default `false`. */
92
+ private: boolean;
52
93
  }
53
94
 
54
95
  export function defineModel(table: string, def: ModelDefinition): ModelDescriptor {
@@ -64,5 +105,8 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
64
105
  relations: def.relations ?? {},
65
106
  primaryKey,
66
107
  constraints: def.constraints ?? [],
108
+ writtenBy: def.writtenBy ?? 'app',
109
+ schema: def.schema ?? 'public',
110
+ private: def.private ?? false,
67
111
  };
68
112
  }
package/src/module.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `defineModule` — Layer 3. A package owns not just its Models (schema + authz) but its
3
+ * whole slice of the database: the extensions its SQL needs and the package-owned SQL it
4
+ * ships (SECURITY DEFINER functions, triggers). A Module bundles the three so the one
5
+ * generator can emit a COMPLETE migration — no hand-written bootstrap left.
6
+ *
7
+ * The split is deliberate:
8
+ * - `models` — declarative schema + authz, round-tripped through the contract (the
9
+ * dogfood proves these).
10
+ * - `extensions` — `CREATE EXTENSION IF NOT EXISTS`, emitted first, deduped across modules.
11
+ * - `functions` — imperative package SQL (plpgsql functions, triggers). NOT modeled — it
12
+ * is contributed verbatim and emitted AFTER the tables it references. A
13
+ * thunk, so generation stays lazy (and an app can pass run-specific args,
14
+ * e.g. which tables get event triggers).
15
+ *
16
+ * Roles are NOT a Module concern — they are cluster-global and owned by `db:provision`, so a
17
+ * Module's GRANTs assume the roles already exist (exactly as the dogfood pre-creates them).
18
+ *
19
+ * See docs/plans/v3-module-complete-generation.md.
20
+ */
21
+
22
+ import type { ModelDescriptor } from './model';
23
+
24
+ export interface ModuleDefinition {
25
+ /** The package's Models (schema + authz). */
26
+ models?: ModelDescriptor[];
27
+ /** Postgres extensions the module's SQL needs (e.g. `'pgcrypto'`). Emitted once, first. */
28
+ extensions?: string[];
29
+ /**
30
+ * Package-owned SQL emitted after the tables — SECURITY DEFINER functions, triggers, etc.
31
+ * A thunk so it is generated lazily and can take run-specific input (the app's event-trigger
32
+ * table list, say). Not part of the declarative contract; emitted verbatim.
33
+ */
34
+ functions?: () => string;
35
+ }
36
+
37
+ /** A normalized package DB slice — what {@link defineModule} produces and the generator composes. */
38
+ export interface Module {
39
+ models: ModelDescriptor[];
40
+ extensions: string[];
41
+ /** Function-SQL thunks, normalized to an array so composing modules just concatenates. */
42
+ functions: Array<() => string>;
43
+ }
44
+
45
+ /** Declare a package's database slice. Empty fields default to nothing — a models-only module is fine. */
46
+ export function defineModule(def: ModuleDefinition): Module {
47
+ return {
48
+ models: def.models ?? [],
49
+ extensions: def.extensions ?? [],
50
+ functions: def.functions ? [def.functions] : [],
51
+ };
52
+ }
53
+
54
+ /** The flattened model list across modules — for everything that still takes a model array
55
+ * (handler-config derivation, FORCE carveouts, the drift guard). */
56
+ export function moduleModels(modules: Module[]): ModelDescriptor[] {
57
+ return modules.flatMap((m) => m.models);
58
+ }
59
+
60
+ /** The deduped, sorted extension list across modules — emitted as `CREATE EXTENSION` first. */
61
+ export function moduleExtensions(modules: Module[]): string[] {
62
+ return [...new Set(modules.flatMap((m) => m.extensions))].sort();
63
+ }
64
+
65
+ /** The package SQL across modules, each thunk invoked — emitted after the tables. */
66
+ export function moduleFunctionSql(modules: Module[]): string[] {
67
+ return modules.flatMap((m) => m.functions).map((fn) => fn());
68
+ }