@everystack/model 0.3.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.
- package/LICENSE +681 -0
- package/README.md +146 -0
- package/package.json +40 -0
- package/src/can.ts +36 -0
- package/src/constraints.ts +90 -0
- package/src/derive.ts +130 -0
- package/src/field.ts +193 -0
- package/src/index.ts +28 -0
- package/src/model.ts +68 -0
- package/src/raw-sql.ts +16 -0
- package/src/relations.ts +48 -0
package/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# @everystack/model
|
|
2
|
+
|
|
3
|
+
Declare a table once — its columns, validation, authorization, and relations — and compile it to Postgres migrations, RLS policies, and handler config. One declaration, three targets, no drift between them.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @everystack/model
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`zod` is an optional peer dependency, needed only when a field uses `.validate(...)`.
|
|
12
|
+
|
|
13
|
+
## Declare a model
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { defineModel, field, can, unique, index, check, foreignKey, sql } from '@everystack/model';
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
|
|
19
|
+
export const Account = defineModel('accounts', {
|
|
20
|
+
fields: {
|
|
21
|
+
id: field.uuid().primaryKey().defaultRandom(),
|
|
22
|
+
email: field.text().notNull().unique(),
|
|
23
|
+
displayName: field.text().notNull(),
|
|
24
|
+
bio: field.text().nullable().validate(z.string().max(160)),
|
|
25
|
+
status: field.enum('account_status', ['active', 'suspended', 'closed']).notNull().default('active'),
|
|
26
|
+
createdAt: field.timestamptz().notNull().defaultNow(),
|
|
27
|
+
},
|
|
28
|
+
abilities: [
|
|
29
|
+
can('read'), // anyone can read
|
|
30
|
+
can('update', { owner: 'id' }), // you can update your own row
|
|
31
|
+
],
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const Note = defineModel('notes', {
|
|
35
|
+
fields: {
|
|
36
|
+
id: field.uuid().primaryKey().defaultRandom(),
|
|
37
|
+
accountId: field.uuid().notNull().references(() => Account, { onDelete: 'cascade' }),
|
|
38
|
+
title: field.text().notNull(),
|
|
39
|
+
createdAt: field.timestamptz().notNull().defaultNow(),
|
|
40
|
+
},
|
|
41
|
+
abilities: [
|
|
42
|
+
can('read', { owner: 'accountId' }),
|
|
43
|
+
can('create', { owner: 'accountId' }),
|
|
44
|
+
can('update', { owner: 'accountId' }),
|
|
45
|
+
can('delete', { owner: 'accountId' }),
|
|
46
|
+
],
|
|
47
|
+
constraints: [
|
|
48
|
+
unique('accountId', 'title'), // composite unique
|
|
49
|
+
index('createdAt').where(sql`pinned = true`), // partial index
|
|
50
|
+
check(sql`char_length(title) > 0`), // table-level CHECK
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export const models = [Account, Note];
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Fields
|
|
58
|
+
|
|
59
|
+
`field.*()` builds a column. The type is the Postgres type; the chained modifiers are the constraints.
|
|
60
|
+
|
|
61
|
+
| Factory | Postgres type |
|
|
62
|
+
|---------|---------------|
|
|
63
|
+
| `field.uuid()` | `uuid` |
|
|
64
|
+
| `field.text()` | `text` |
|
|
65
|
+
| `field.varchar(n?)` | `character varying(n)` |
|
|
66
|
+
| `field.integer()` | `integer` |
|
|
67
|
+
| `field.bigint()` / `field.bigserial()` | `bigint` / `bigserial` |
|
|
68
|
+
| `field.numeric(p?, s?)` | `numeric(p, s)` |
|
|
69
|
+
| `field.boolean()` | `boolean` |
|
|
70
|
+
| `field.timestamptz()` / `field.timestamp()` | `timestamp with[out] time zone` |
|
|
71
|
+
| `field.date()` | `date` |
|
|
72
|
+
| `field.jsonb()` | `jsonb` |
|
|
73
|
+
| `field.enum('name', [...])` | a `CREATE TYPE … AS ENUM` (name is explicit, so `db:pull` round-trips it) |
|
|
74
|
+
|
|
75
|
+
Modifiers: `.primaryKey()`, `.notNull()` / `.nullable()`, `.unique()`, `.default(value)` / `.defaultNow()` / `.defaultRandom()`, `.references(() => Other, { onDelete, onUpdate })`, `.private()` (never serialized), `.readonly()` (never accepted on write), `.validate(zodSchema)`, and `.renamedFrom('oldField')` (turns a drop+add into a data-preserving `RENAME COLUMN`).
|
|
76
|
+
|
|
77
|
+
`.validate(...)` does double duty: the SQL-expressible refinements (`min`/`max`/`length`/range/enum) become a Postgres `CHECK` — the backstop for the SSR-direct write path that never runs app-layer Zod — while richer refinements stay app-layer only.
|
|
78
|
+
|
|
79
|
+
## Constraints
|
|
80
|
+
|
|
81
|
+
The table-level surface, composed in `constraints: [...]`, for what a column-scoped `field()` can't carry:
|
|
82
|
+
|
|
83
|
+
- `unique('a', 'b')` — a composite unique constraint.
|
|
84
|
+
- `index('a').unique().where(sql\`…\`)` — a standalone index, optionally unique and/or partial.
|
|
85
|
+
- `check(sql\`a >= b\`)` — a multi-column CHECK.
|
|
86
|
+
- `foreignKey(['a', 'b'], () => Other, ['x', 'y'], { onDelete })` — a composite foreign key.
|
|
87
|
+
|
|
88
|
+
## Authorization
|
|
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.
|
|
91
|
+
|
|
92
|
+
## Relations
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
import { belongsTo, hasMany } from '@everystack/model';
|
|
96
|
+
|
|
97
|
+
defineModel('notes', {
|
|
98
|
+
fields: { /* … */ },
|
|
99
|
+
relations: {
|
|
100
|
+
account: belongsTo(() => Account, 'accountId'),
|
|
101
|
+
comments: hasMany(() => Comment, 'noteId'),
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Compile
|
|
107
|
+
|
|
108
|
+
`@everystack/model` is the declaration surface; `@everystack/cli` is the compiler. With your `models` array:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
everystack db:generate --stage dev # diff models vs the live DB → one ordered migration (data + authz)
|
|
112
|
+
everystack db:pull --stage dev # reverse an existing database into model source (the brownfield on-ramp)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`db:generate` is non-destructive by default — drops are held back as comments unless you pass `--allow-drops`. A faithful `db:pull` then `db:generate` is a no-op: the declaration and the database it built are the same thing.
|
|
116
|
+
|
|
117
|
+
### Handler config
|
|
118
|
+
|
|
119
|
+
`deriveHandlerConfig(models)` compiles the same Models into the authorization config `createHandler` consumes (exposed tables, hidden/protected columns, relations, row ownership) — so the HTTP handler and the database enforce the *same* declared rules, with no second source of truth.
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
import { deriveHandlerConfig } from '@everystack/model';
|
|
123
|
+
const handler = createHandler(db, schema, deriveHandlerConfig(models));
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Programmatic API (`@everystack/cli/model`)
|
|
127
|
+
|
|
128
|
+
The same compiler the CLI uses is importable for your own CI and tests — point it at your live DB and assert the declaration and the database agree, or red-team the deployed authorization:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
import {
|
|
132
|
+
compileMigration, generateMigrationSql, introspectSchema, diffSchema, // data layer
|
|
133
|
+
compileTableContract, introspectContract, diffContracts, // authz layer
|
|
134
|
+
buildOwnerProbeSql, evaluateOwnerProbe, // owner-isolation red-team
|
|
135
|
+
} from '@everystack/cli/model';
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- **Data** — `introspectSchema(run)` reads the whole DB; `diffSchema(desired, current)` / `generateMigrationSql(models, current)` produce the structural delta. A green `db:generate` is this returning empty.
|
|
139
|
+
- **Authz** — `compileTableContract(model)` is the declared RLS/grants; `introspectContract(run)` is what's live; `diffContracts(declared, live)` is the drift audit (`db:authz:diff`).
|
|
140
|
+
- **Red-team** — `buildOwnerProbeSql` / `evaluateOwnerProbe` drive two JWT identities per owner-scoped table to catch IDOR (`db:authz:owner`).
|
|
141
|
+
|
|
142
|
+
`run` is any `QueryRunner` — a function that executes SQL against your DB (e.g. wrapping an `everystack db:query` invoke or a direct connection). This is what the example app's dogfood test uses to prove its real Models round-trip against real Postgres.
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
AGPL-3.0-only
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@everystack/model",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Declare a table's schema, validation, and authorization once — compiled to migrations, RLS, and handler config",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./src/index.ts",
|
|
16
|
+
"default": "./src/index.ts"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"zod": "*"
|
|
21
|
+
},
|
|
22
|
+
"peerDependenciesMeta": {
|
|
23
|
+
"zod": {
|
|
24
|
+
"optional": true
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/jest": "29.5.14",
|
|
29
|
+
"@types/node": "22.19.18",
|
|
30
|
+
"jest": "29.7.0",
|
|
31
|
+
"ts-jest": "29.4.9",
|
|
32
|
+
"typescript": "5.9.3",
|
|
33
|
+
"zod": "3.25.67"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "jest",
|
|
37
|
+
"build": "tsc --build",
|
|
38
|
+
"lint": "tsc --noEmit"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/can.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CanCan-shaped authorization rules. `can(action, condition)` declares one rule.
|
|
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`).
|
|
8
|
+
*
|
|
9
|
+
* Default-deny is the model's contract: no matching `can()` means denied.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** CanCan verbs. `manage` is "all actions". */
|
|
13
|
+
export type Action = 'read' | 'create' | 'update' | 'delete' | 'manage';
|
|
14
|
+
|
|
15
|
+
export interface AbilityCondition {
|
|
16
|
+
/** Role-gated: policy `TO <role>` + grant. */
|
|
17
|
+
role?: string;
|
|
18
|
+
/** Row owner only: the column holding the owner id (`USING (<col> = auth.uid())`). */
|
|
19
|
+
owner?: string;
|
|
20
|
+
/** Field in the JWT claims holding the owner id. Default: `sub`. */
|
|
21
|
+
userField?: string;
|
|
22
|
+
/** Scalar/column predicate — a sql-tagged expression. Compiled in Phase 1. */
|
|
23
|
+
where?: unknown;
|
|
24
|
+
/** Escape hatch: a verbatim RLS predicate, preserved and diffed by normalized text. */
|
|
25
|
+
sql?: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface Ability {
|
|
29
|
+
action: Action;
|
|
30
|
+
condition: AbilityCondition;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Declare one authorization rule. */
|
|
34
|
+
export function can(action: Action, condition: AbilityCondition = {}): Ability {
|
|
35
|
+
return { action, condition };
|
|
36
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
6
|
+
*
|
|
7
|
+
* Columns are named by their **field keys** (camelCase, as the model is written); the
|
|
8
|
+
* compiler snake_cases them to SQL identifiers — symmetric with `.references()`. A
|
|
9
|
+
* `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
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { RawSql } from './raw-sql';
|
|
15
|
+
import type { ReferentialAction } from './field';
|
|
16
|
+
|
|
17
|
+
export type TableConstraint =
|
|
18
|
+
/** `UNIQUE (a, b)` over two or more field keys — the composite the field builder can't carry. */
|
|
19
|
+
| { kind: 'unique'; columns: string[] }
|
|
20
|
+
/** `CHECK (<predicate>)` — a raw SQL predicate, typically spanning columns. */
|
|
21
|
+
| { kind: 'check'; predicate: string }
|
|
22
|
+
/** A standalone index — `CREATE [UNIQUE] INDEX … (cols) [WHERE <predicate>]`. */
|
|
23
|
+
| { kind: 'index'; columns: string[]; isUnique: boolean; predicate?: string }
|
|
24
|
+
/**
|
|
25
|
+
* A multi-column `FOREIGN KEY (a, b) REFERENCES other (x, y)` — the composite a column-scoped
|
|
26
|
+
* `.references()` can't carry. Local field keys, a model thunk for the target, and the target's
|
|
27
|
+
* field keys; optional `onDelete`/`onUpdate` referential actions.
|
|
28
|
+
*/
|
|
29
|
+
| { kind: 'foreignKey'; columns: string[]; references: () => unknown; refColumns: string[]; onDelete?: ReferentialAction; onUpdate?: ReferentialAction };
|
|
30
|
+
|
|
31
|
+
/** A composite unique constraint over the given field keys: `unique('authorId', 'slug')`. */
|
|
32
|
+
export function unique(...columns: string[]): TableConstraint {
|
|
33
|
+
return { kind: 'unique', columns };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A table CHECK from a raw predicate: `check(sql\`published_at >= created_at\`)`. */
|
|
37
|
+
export function check(predicate: RawSql): TableConstraint {
|
|
38
|
+
return { kind: 'check', predicate: predicate.sql };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A composite foreign key — the multi-column analog of `.references()`, which only a table-level
|
|
43
|
+
* surface can express: `foreignKey(['orgId', 'teamId'], () => Team, ['orgId', 'teamId'])`. The
|
|
44
|
+
* target columns are explicit (a composite FK references a composite key, not just the PK), and
|
|
45
|
+
* the referential actions ride here the same way they ride on `.references()`.
|
|
46
|
+
*/
|
|
47
|
+
export function foreignKey(
|
|
48
|
+
columns: string[],
|
|
49
|
+
references: () => unknown,
|
|
50
|
+
refColumns: string[],
|
|
51
|
+
opts?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction },
|
|
52
|
+
): TableConstraint {
|
|
53
|
+
return {
|
|
54
|
+
kind: 'foreignKey',
|
|
55
|
+
columns,
|
|
56
|
+
references,
|
|
57
|
+
refColumns,
|
|
58
|
+
...(opts?.onDelete ? { onDelete: opts.onDelete } : {}),
|
|
59
|
+
...(opts?.onUpdate ? { onUpdate: opts.onUpdate } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A standalone index over the given field keys, chainable: `index('authorId')`,
|
|
65
|
+
* `index('authorId').unique()`, `index('slug').where(sql\`deleted_at IS NULL\`)` (partial).
|
|
66
|
+
* Indexes that merely back a primary key or unique constraint are NOT declared here — those
|
|
67
|
+
* come from `.primaryKey()` / `.unique()` / the `unique(...)` constraint.
|
|
68
|
+
*/
|
|
69
|
+
export class IndexBuilder {
|
|
70
|
+
readonly kind = 'index' as const;
|
|
71
|
+
isUnique = false;
|
|
72
|
+
predicate?: string;
|
|
73
|
+
constructor(readonly columns: string[]) {}
|
|
74
|
+
|
|
75
|
+
/** Make it a UNIQUE index. */
|
|
76
|
+
unique(): this {
|
|
77
|
+
this.isUnique = true;
|
|
78
|
+
return this;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A partial-index predicate: `index('slug').where(sql\`deleted_at IS NULL\`)`. */
|
|
82
|
+
where(predicate: RawSql): this {
|
|
83
|
+
this.predicate = predicate.sql;
|
|
84
|
+
return this;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function index(...columns: string[]): IndexBuilder {
|
|
89
|
+
return new IndexBuilder(columns);
|
|
90
|
+
}
|
package/src/derive.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `deriveHandlerConfig` — compile a set of models down to the per-table slice of
|
|
3
|
+
* the API handler's options blob.
|
|
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.
|
|
9
|
+
*
|
|
10
|
+
* Scope boundary: only the PER-TABLE surface derives here. App/handler-level
|
|
11
|
+
* options (`auth`, `pgSettings`, `rpc`, `basePath`, `audit`, `maxLimit`,
|
|
12
|
+
* `naming`, `hideSchemaErrors`) are not per-model and stay explicit at the
|
|
13
|
+
* `createHandler` call.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ModelDescriptor } from './model';
|
|
17
|
+
import type { ModelHooks } from './model';
|
|
18
|
+
|
|
19
|
+
/** The handler's RelationConfig shape, mirrored structurally (no @everystack/api dep). */
|
|
20
|
+
export interface RelationConfig {
|
|
21
|
+
table: string;
|
|
22
|
+
from: string;
|
|
23
|
+
to: string;
|
|
24
|
+
many?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DerivedHandlerConfig {
|
|
28
|
+
exposedTables: string[];
|
|
29
|
+
hiddenColumns: Record<string, string[]>;
|
|
30
|
+
protectedFields: Record<string, string[]>;
|
|
31
|
+
rowOwnership: Record<string, { column: string; userField: string }>;
|
|
32
|
+
softDelete: { tables: string[] };
|
|
33
|
+
relations: Record<string, Record<string, RelationConfig>>;
|
|
34
|
+
hooks: Record<string, ModelHooks>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Conventional soft-delete column — matches the handler's default. */
|
|
38
|
+
const SOFT_DELETE_COLUMN = 'deletedAt';
|
|
39
|
+
/** Conventional JWT claim holding the owner id. */
|
|
40
|
+
const DEFAULT_USER_FIELD = 'sub';
|
|
41
|
+
|
|
42
|
+
/** A model's primary key for relation resolution (single-PK default; falls back to `id`). */
|
|
43
|
+
function primaryKeyOf(model: ModelDescriptor): string {
|
|
44
|
+
return model.primaryKey[0] ?? 'id';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerConfig {
|
|
48
|
+
const exposedTables: string[] = [];
|
|
49
|
+
const hiddenColumns: Record<string, string[]> = {};
|
|
50
|
+
const protectedFields: Record<string, string[]> = {};
|
|
51
|
+
const rowOwnership: Record<string, { column: string; userField: string }> = {};
|
|
52
|
+
const softDeleteTables: string[] = [];
|
|
53
|
+
const relations: Record<string, Record<string, RelationConfig>> = {};
|
|
54
|
+
const hooks: Record<string, ModelHooks> = {};
|
|
55
|
+
|
|
56
|
+
for (const model of models) {
|
|
57
|
+
const { table } = model;
|
|
58
|
+
exposedTables.push(table);
|
|
59
|
+
|
|
60
|
+
// .private() → hiddenColumns (omit the key entirely when there are none, so
|
|
61
|
+
// the derived blob matches a hand-written `{}` exactly).
|
|
62
|
+
const privateCols = fieldsWhere(model, (f) => f.spec.isPrivate);
|
|
63
|
+
if (privateCols.length) hiddenColumns[table] = privateCols;
|
|
64
|
+
|
|
65
|
+
// .readonly() → protectedFields (mass-assignment guard).
|
|
66
|
+
const readonlyCols = fieldsWhere(model, (f) => f.spec.isReadonly);
|
|
67
|
+
if (readonlyCols.length) protectedFields[table] = readonlyCols;
|
|
68
|
+
|
|
69
|
+
// The first owner-shaped ability → the handler's rowOwnership IDOR backstop.
|
|
70
|
+
// (RLS, compiled from the same ability in Phase 1, is the primary enforcement;
|
|
71
|
+
// this is the HTTP-path guard. A model's owner abilities share one column.)
|
|
72
|
+
const ownerAbility = model.abilities.find((a) => a.condition.owner);
|
|
73
|
+
if (ownerAbility) {
|
|
74
|
+
rowOwnership[table] = {
|
|
75
|
+
column: ownerAbility.condition.owner!,
|
|
76
|
+
userField: ownerAbility.condition.userField ?? DEFAULT_USER_FIELD,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Convention: a `deletedAt` field opts the table into soft-delete.
|
|
81
|
+
if (SOFT_DELETE_COLUMN in model.fields) softDeleteTables.push(table);
|
|
82
|
+
|
|
83
|
+
if (model.hooks) hooks[table] = model.hooks;
|
|
84
|
+
|
|
85
|
+
const relationNames = Object.keys(model.relations);
|
|
86
|
+
if (relationNames.length) {
|
|
87
|
+
const resolved: Record<string, RelationConfig> = {};
|
|
88
|
+
for (const name of relationNames) {
|
|
89
|
+
const rel = model.relations[name];
|
|
90
|
+
const target = rel.target();
|
|
91
|
+
if (rel.kind === 'belongsTo') {
|
|
92
|
+
// this.<column> → target.<pk>
|
|
93
|
+
resolved[name] = {
|
|
94
|
+
table: target.table,
|
|
95
|
+
from: rel.column,
|
|
96
|
+
to: rel.references ?? primaryKeyOf(target),
|
|
97
|
+
};
|
|
98
|
+
} else {
|
|
99
|
+
// this.<pk> → target.<column>
|
|
100
|
+
resolved[name] = {
|
|
101
|
+
table: target.table,
|
|
102
|
+
from: rel.references ?? primaryKeyOf(model),
|
|
103
|
+
to: rel.column,
|
|
104
|
+
many: true,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
relations[table] = resolved;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
exposedTables,
|
|
114
|
+
hiddenColumns,
|
|
115
|
+
protectedFields,
|
|
116
|
+
rowOwnership,
|
|
117
|
+
softDelete: { tables: softDeleteTables },
|
|
118
|
+
relations,
|
|
119
|
+
hooks,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function fieldsWhere(
|
|
124
|
+
model: ModelDescriptor,
|
|
125
|
+
pred: (f: ModelDescriptor['fields'][string]) => boolean,
|
|
126
|
+
): string[] {
|
|
127
|
+
return Object.entries(model.fields)
|
|
128
|
+
.filter(([, f]) => pred(f))
|
|
129
|
+
.map(([name]) => name);
|
|
130
|
+
}
|
package/src/field.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The field descriptor — Layer 1 of the Model, in its Phase-0 form.
|
|
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.
|
|
10
|
+
*
|
|
11
|
+
* See docs/plans/v3-model-implementation.md (Phase 0) and v3-model-api.md.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type FieldType =
|
|
15
|
+
| 'text'
|
|
16
|
+
| 'uuid'
|
|
17
|
+
| 'bigint'
|
|
18
|
+
| 'bigserial'
|
|
19
|
+
| 'integer'
|
|
20
|
+
| 'boolean'
|
|
21
|
+
| 'timestamptz'
|
|
22
|
+
| 'timestamp'
|
|
23
|
+
| 'date'
|
|
24
|
+
| 'numeric'
|
|
25
|
+
| 'varchar'
|
|
26
|
+
| 'jsonb'
|
|
27
|
+
| 'enum';
|
|
28
|
+
|
|
29
|
+
export type DefaultKind = 'value' | 'now' | 'random';
|
|
30
|
+
|
|
31
|
+
/** A foreign-key referential action (`ON DELETE` / `ON UPDATE`). `'no action'` is the default. */
|
|
32
|
+
export type ReferentialAction = 'cascade' | 'restrict' | 'no action' | 'set null' | 'set default';
|
|
33
|
+
|
|
34
|
+
/** The flattened state a {@link FieldBuilder} accumulates. Read by the derivation. */
|
|
35
|
+
export interface FieldSpec {
|
|
36
|
+
type: FieldType;
|
|
37
|
+
/** The Postgres type name for an `enum` field (`CREATE TYPE <name> AS ENUM …`). Always explicit. */
|
|
38
|
+
enumName?: string;
|
|
39
|
+
/** Allowed values for `enum` fields, in declaration order (the enum's value order). */
|
|
40
|
+
enumValues?: readonly string[];
|
|
41
|
+
/** `numeric(precision, scale)` — total digits and digits after the decimal point. */
|
|
42
|
+
precision?: number;
|
|
43
|
+
scale?: number;
|
|
44
|
+
/** `varchar(length)` — maximum character length. */
|
|
45
|
+
length?: number;
|
|
46
|
+
isPrimaryKey: boolean;
|
|
47
|
+
isNotNull: boolean;
|
|
48
|
+
isUnique: boolean;
|
|
49
|
+
/** `.private()` — never serialized into API responses (→ `hiddenColumns`). */
|
|
50
|
+
isPrivate: boolean;
|
|
51
|
+
/** `.readonly()` — never accepted on write (→ `protectedFields`). */
|
|
52
|
+
isReadonly: boolean;
|
|
53
|
+
/** `.references(() => Other)` — FK target, a model thunk. Used by relation/ownership work. */
|
|
54
|
+
references?: () => unknown;
|
|
55
|
+
/** `ON DELETE` action for the FK. Omitted (or `'no action'`) is the Postgres default. */
|
|
56
|
+
onDelete?: ReferentialAction;
|
|
57
|
+
/** `ON UPDATE` action for the FK. Omitted (or `'no action'`) is the Postgres default. */
|
|
58
|
+
onUpdate?: ReferentialAction;
|
|
59
|
+
default?: unknown;
|
|
60
|
+
defaultKind?: DefaultKind;
|
|
61
|
+
/** A Zod schema, stored verbatim. Consumed by the field bridge (Phase 4); inert today. */
|
|
62
|
+
validation?: unknown;
|
|
63
|
+
/**
|
|
64
|
+
* `.renamedFrom('oldField')` — a one-shot migration hint. When the diff sees the old
|
|
65
|
+
* column still present and this (new) column absent, it emits `RENAME COLUMN` instead
|
|
66
|
+
* of a data-losing drop+add. Rename intent cannot be recovered from two snapshots, so
|
|
67
|
+
* it is declared here. The marker goes inert once the rename is applied (the new name
|
|
68
|
+
* is present) and can be removed — the diff says so when it sees a satisfied one.
|
|
69
|
+
*/
|
|
70
|
+
renamedFrom?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Fluent, mutable builder. Every modifier returns `this` so declarations chain:
|
|
75
|
+
* `field.text().notNull().private()`. The accumulated {@link FieldSpec} is read
|
|
76
|
+
* off `.spec` by the derivation.
|
|
77
|
+
*/
|
|
78
|
+
export class FieldBuilder {
|
|
79
|
+
readonly spec: FieldSpec;
|
|
80
|
+
|
|
81
|
+
constructor(type: FieldType, extra?: Pick<FieldSpec, 'enumName' | 'enumValues' | 'precision' | 'scale' | 'length'>) {
|
|
82
|
+
this.spec = {
|
|
83
|
+
type,
|
|
84
|
+
enumName: extra?.enumName,
|
|
85
|
+
enumValues: extra?.enumValues,
|
|
86
|
+
precision: extra?.precision,
|
|
87
|
+
scale: extra?.scale,
|
|
88
|
+
length: extra?.length,
|
|
89
|
+
isPrimaryKey: false,
|
|
90
|
+
isNotNull: false,
|
|
91
|
+
isUnique: false,
|
|
92
|
+
isPrivate: false,
|
|
93
|
+
isReadonly: false,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
primaryKey(): this {
|
|
98
|
+
this.spec.isPrimaryKey = true;
|
|
99
|
+
this.spec.isNotNull = true;
|
|
100
|
+
return this;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
notNull(): this {
|
|
104
|
+
this.spec.isNotNull = true;
|
|
105
|
+
return this;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
nullable(): this {
|
|
109
|
+
this.spec.isNotNull = false;
|
|
110
|
+
return this;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
unique(): this {
|
|
114
|
+
this.spec.isUnique = true;
|
|
115
|
+
return this;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Hide from every API response. Compiles to the handler's `hiddenColumns`. */
|
|
119
|
+
private(): this {
|
|
120
|
+
this.spec.isPrivate = true;
|
|
121
|
+
return this;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Reject on write (mass-assignment guard). Compiles to the handler's `protectedFields`. */
|
|
125
|
+
readonly(): this {
|
|
126
|
+
this.spec.isReadonly = true;
|
|
127
|
+
return this;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Declare a foreign key. The target is a model thunk (forward-reference safe). Optional
|
|
132
|
+
* `onDelete`/`onUpdate` referential actions are column-scoped, so they live here, not on
|
|
133
|
+
* the table — `field.uuid().references(() => User, { onDelete: 'cascade' })`.
|
|
134
|
+
*/
|
|
135
|
+
references(target: () => unknown, opts?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction }): this {
|
|
136
|
+
this.spec.references = target;
|
|
137
|
+
if (opts?.onDelete) this.spec.onDelete = opts.onDelete;
|
|
138
|
+
if (opts?.onUpdate) this.spec.onUpdate = opts.onUpdate;
|
|
139
|
+
return this;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
default(value: unknown): this {
|
|
143
|
+
this.spec.default = value;
|
|
144
|
+
this.spec.defaultKind = 'value';
|
|
145
|
+
return this;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
defaultNow(): this {
|
|
149
|
+
this.spec.defaultKind = 'now';
|
|
150
|
+
return this;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
defaultRandom(): this {
|
|
154
|
+
this.spec.defaultKind = 'random';
|
|
155
|
+
return this;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Attach a Zod schema. Stored now, compiled to CHECK + runtime validation in Phase 4. */
|
|
159
|
+
validate(schema: unknown): this {
|
|
160
|
+
this.spec.validation = schema;
|
|
161
|
+
return this;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Mark this field as the rename of a former one (`oldField` is the previous *field*
|
|
166
|
+
* name, snake_cased to its column by the generator). A one-shot hint the schema diff
|
|
167
|
+
* reads to emit `RENAME COLUMN` instead of drop+add, preserving the column's data.
|
|
168
|
+
*/
|
|
169
|
+
renamedFrom(oldField: string): this {
|
|
170
|
+
this.spec.renamedFrom = oldField;
|
|
171
|
+
return this;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The field factory. `field.text()`, `field.uuid()`, `field.enum('status', [...])`, … */
|
|
176
|
+
export const field = {
|
|
177
|
+
text: (): FieldBuilder => new FieldBuilder('text'),
|
|
178
|
+
uuid: (): FieldBuilder => new FieldBuilder('uuid'),
|
|
179
|
+
bigint: (): FieldBuilder => new FieldBuilder('bigint'),
|
|
180
|
+
bigserial: (): FieldBuilder => new FieldBuilder('bigserial'),
|
|
181
|
+
integer: (): FieldBuilder => new FieldBuilder('integer'),
|
|
182
|
+
boolean: (): FieldBuilder => new FieldBuilder('boolean'),
|
|
183
|
+
timestamptz: (): FieldBuilder => new FieldBuilder('timestamptz'),
|
|
184
|
+
timestamp: (): FieldBuilder => new FieldBuilder('timestamp'),
|
|
185
|
+
date: (): FieldBuilder => new FieldBuilder('date'),
|
|
186
|
+
/** `numeric(precision, scale)` — both optional; `numeric()` is unbounded. */
|
|
187
|
+
numeric: (precision?: number, scale?: number): FieldBuilder => new FieldBuilder('numeric', { precision, scale }),
|
|
188
|
+
/** `varchar(length)` — length optional; `varchar()` is unbounded `character varying`. */
|
|
189
|
+
varchar: (length?: number): FieldBuilder => new FieldBuilder('varchar', { length }),
|
|
190
|
+
jsonb: (): FieldBuilder => new FieldBuilder('jsonb'),
|
|
191
|
+
/** A Postgres enum. The `name` is the `CREATE TYPE` name and is always explicit, so `db:pull` round-trips it. */
|
|
192
|
+
enum: (name: string, values: readonly string[]): FieldBuilder => new FieldBuilder('enum', { enumName: name, enumValues: values }),
|
|
193
|
+
} as const;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystack/model — declare a table once; compile it to migrations, RLS, and
|
|
3
|
+
* handler config. See docs/plans/v3-model-architecture.md.
|
|
4
|
+
*
|
|
5
|
+
* Phase 0 ships the declaration surface (`defineModel`, `field`, `can`,
|
|
6
|
+
* `belongsTo`/`hasMany`) and the first compile target: handler-config derivation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { field, FieldBuilder } from './field';
|
|
10
|
+
export type { FieldSpec, FieldType, DefaultKind, ReferentialAction } from './field';
|
|
11
|
+
|
|
12
|
+
export { unique, check, index, foreignKey, IndexBuilder } from './constraints';
|
|
13
|
+
export type { TableConstraint } from './constraints';
|
|
14
|
+
|
|
15
|
+
export { sql } from './raw-sql';
|
|
16
|
+
export type { RawSql } from './raw-sql';
|
|
17
|
+
|
|
18
|
+
export { can } from './can';
|
|
19
|
+
export type { Ability, AbilityCondition, Action } from './can';
|
|
20
|
+
|
|
21
|
+
export { belongsTo, hasMany } from './relations';
|
|
22
|
+
export type { Relation } from './relations';
|
|
23
|
+
|
|
24
|
+
export { defineModel } from './model';
|
|
25
|
+
export type { ModelDescriptor, ModelDefinition, ModelHooks } from './model';
|
|
26
|
+
|
|
27
|
+
export { deriveHandlerConfig } from './derive';
|
|
28
|
+
export type { DerivedHandlerConfig, RelationConfig } from './derive';
|