@everystack/model 0.3.6 → 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/package.json +1 -1
- package/src/can.ts +2 -2
- package/src/constraints.ts +39 -7
- package/src/derived.ts +515 -0
- package/src/drizzle.ts +1 -0
- package/src/field.ts +17 -1
- package/src/index.ts +12 -1
- package/src/model.ts +19 -0
- package/src/module.ts +52 -0
package/package.json
CHANGED
package/src/can.ts
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* Default-deny is the model's contract: no matching `can()` means denied.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
/** CanCan verbs. `manage` is "all actions". */
|
|
13
|
-
export type Action = 'read' | 'create' | 'update' | 'delete' | 'manage';
|
|
12
|
+
/** CanCan verbs. `manage` is "all actions". `execute` is FUNCTION-only (rejected on tables/relations). */
|
|
13
|
+
export type Action = 'read' | 'create' | 'update' | 'delete' | 'manage' | 'execute';
|
|
14
14
|
|
|
15
15
|
export interface AbilityCondition {
|
|
16
16
|
/** Role-gated: policy `TO <role>` + grant. */
|
package/src/constraints.ts
CHANGED
|
@@ -18,8 +18,8 @@ export type TableConstraint =
|
|
|
18
18
|
| { kind: 'unique'; columns: string[] }
|
|
19
19
|
/** `CHECK (<predicate>)` — a raw SQL predicate, typically spanning columns. */
|
|
20
20
|
| { kind: 'check'; predicate: string }
|
|
21
|
-
/** A standalone index — `CREATE [UNIQUE] INDEX … (cols) [WHERE <predicate>]`. */
|
|
22
|
-
| { kind: 'index'; columns: string[]; isUnique: boolean; predicate?: string }
|
|
21
|
+
/** A standalone index — `CREATE [UNIQUE] INDEX [USING <method>] … (keys) [INCLUDE (cols)] [WHERE <predicate>]`. */
|
|
22
|
+
| { kind: 'index'; columns: string[]; isUnique: boolean; predicate?: string; method?: string; includeColumns?: string[]; rawPositions?: boolean[] }
|
|
23
23
|
/**
|
|
24
24
|
* A multi-column `FOREIGN KEY (a, b) REFERENCES other (x, y)` — the composite a column-scoped
|
|
25
25
|
* `.references()` can't carry. Local field keys, a model thunk for the target, and the target's
|
|
@@ -60,8 +60,16 @@ export function foreignKey(
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
* A standalone index over the given
|
|
64
|
-
* `index('authorId').unique()`, `index('slug').where(sql\`deleted_at IS NULL\`)` (partial)
|
|
63
|
+
* A standalone index over the given keys, chainable: `index('authorId')`,
|
|
64
|
+
* `index('authorId').unique()`, `index('slug').where(sql\`deleted_at IS NULL\`)` (partial),
|
|
65
|
+
* `index('tags').using('gin')`, `index(sql\`lower(email)\`)` (expression).
|
|
66
|
+
*
|
|
67
|
+
* Entries are field keys (strings — the compiler snake_cases them) or raw `sql\`\`` for
|
|
68
|
+
* anything richer: an expression (`lower(email)`), a direction (`created_at DESC`), an
|
|
69
|
+
* opclass (`title gin_trgm_ops`). Raw entries pass to SQL verbatim — they are written in
|
|
70
|
+
* SQL column names, not field keys — and are part of the index's content identity, so a
|
|
71
|
+
* pulled expression index round-trips instead of churning (Brick E).
|
|
72
|
+
*
|
|
65
73
|
* Indexes that merely back a primary key or unique constraint are NOT declared here — those
|
|
66
74
|
* come from `.primaryKey()` / `.unique()` / the `unique(...)` constraint.
|
|
67
75
|
*/
|
|
@@ -69,7 +77,19 @@ export class IndexBuilder {
|
|
|
69
77
|
readonly kind = 'index' as const;
|
|
70
78
|
isUnique = false;
|
|
71
79
|
predicate?: string;
|
|
72
|
-
|
|
80
|
+
/** Access method (`'gin'`, `'gist'`, `'hash'`, `'brin'`, `'spgist'`); omitted = btree. */
|
|
81
|
+
method?: string;
|
|
82
|
+
/** `INCLUDE (…)` covering columns — field keys, snake_cased by the compiler. */
|
|
83
|
+
includeColumns?: string[];
|
|
84
|
+
/** The key texts, in order — field keys for plain entries, verbatim SQL for raw ones. */
|
|
85
|
+
readonly columns: string[];
|
|
86
|
+
/** Which positions are raw `sql\`\`` (never snake_cased, never quoted). */
|
|
87
|
+
readonly rawPositions: boolean[];
|
|
88
|
+
|
|
89
|
+
constructor(entries: Array<string | RawSql>) {
|
|
90
|
+
this.columns = entries.map((e) => (typeof e === 'string' ? e : e.sql));
|
|
91
|
+
this.rawPositions = entries.map((e) => typeof e !== 'string');
|
|
92
|
+
}
|
|
73
93
|
|
|
74
94
|
/** Make it a UNIQUE index. */
|
|
75
95
|
unique(): this {
|
|
@@ -82,8 +102,20 @@ export class IndexBuilder {
|
|
|
82
102
|
this.predicate = predicate.sql;
|
|
83
103
|
return this;
|
|
84
104
|
}
|
|
105
|
+
|
|
106
|
+
/** The access method: `index('tags').using('gin')`. Omitted = btree (never spelled). */
|
|
107
|
+
using(method: string): this {
|
|
108
|
+
this.method = method.toLowerCase();
|
|
109
|
+
return this;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Covering columns: `index('authorId').include('title')` → `INCLUDE ("title")`. */
|
|
113
|
+
include(...columns: string[]): this {
|
|
114
|
+
this.includeColumns = columns;
|
|
115
|
+
return this;
|
|
116
|
+
}
|
|
85
117
|
}
|
|
86
118
|
|
|
87
|
-
export function index(...
|
|
88
|
-
return new IndexBuilder(
|
|
119
|
+
export function index(...entries: Array<string | RawSql>): IndexBuilder {
|
|
120
|
+
return new IndexBuilder(entries);
|
|
89
121
|
}
|
package/src/derived.ts
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declarative derived layer — views, materialized views, functions, sequences,
|
|
3
|
+
* triggers, and the gate-tight escape hatch. The `defineModel` philosophy extended to
|
|
4
|
+
* every object the database holds (docs/plans/read-model-everywhere.md).
|
|
5
|
+
*
|
|
6
|
+
* The boundary rule, uniform with models: STRUCTURE DECLARED (identity, deps, authz,
|
|
7
|
+
* security posture, indexes, signatures, trigger events), EXPRESSIONS AS sql`` (the
|
|
8
|
+
* SELECT, the function body, a WHEN predicate). No query DSL; no string mini-grammars
|
|
9
|
+
* in declared properties.
|
|
10
|
+
*
|
|
11
|
+
* Every rejection here is a define-time (author-time) error whose message carries its
|
|
12
|
+
* fix — the earliest gate there is. Lifecycle note: these descriptors are COMPUTE
|
|
13
|
+
* (deployed by db:reconcile, never migrated) — except `defineSequence`, which is STATE
|
|
14
|
+
* (a sequence holds a counter value; it migrates with the base schema).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { RawSql } from './raw-sql';
|
|
18
|
+
import type { Ability } from './can';
|
|
19
|
+
import type { ModelDescriptor } from './model';
|
|
20
|
+
import type { IndexBuilder } from './constraints';
|
|
21
|
+
import type { FieldBuilder } from './field';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Shared shapes
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/** Anything a derived object can declare a dependency on — order derives from these refs. */
|
|
28
|
+
export type DependsOnRef = ModelDescriptor | DerivedDescriptor;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The loud undefined gate. A missing named import under CJS interop evaluates to
|
|
32
|
+
* `undefined` and, unchecked, dies layers later as `'table' in undefined` with no
|
|
33
|
+
* context. Every ref list rejects the hole HERE, naming the owner, the list, the
|
|
34
|
+
* index, and the likely cause.
|
|
35
|
+
*/
|
|
36
|
+
export function rejectHoles(context: string, listName: string, list: readonly unknown[] | undefined): void {
|
|
37
|
+
if (!list) return;
|
|
38
|
+
list.forEach((entry, i) => {
|
|
39
|
+
if (entry == null) {
|
|
40
|
+
throw new Error(`${context}: ${listName}[${i}] is ${entry === undefined ? 'undefined' : 'null'} — renamed or missing import?`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Relations (views/matviews) carry read-only authz in v1; functions carry execute.
|
|
47
|
+
* The row-scoped condition keys (`owner`, `via`, `where`, `sql`) are meaningless on
|
|
48
|
+
* a relation — rows are filtered by the body or the underlying RLS — and rejected.
|
|
49
|
+
*/
|
|
50
|
+
function validateRelationAbilities(kind: string, name: string, abilities: Ability[]): void {
|
|
51
|
+
for (const a of abilities) {
|
|
52
|
+
if (a.action !== 'read') {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`${kind} '${name}': can('${a.action}') is not a relation ability — views and matviews are read surfaces in v1. Write through the table (INSTEAD-OF write-throughs are future work).`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (a.condition.owner || a.condition.via || a.condition.where || a.condition.sql) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`${kind} '${name}': row conditions cannot attach to a relation — a matview cannot carry policies and a view's rows are filtered by its body or the underlying RLS. Filter in the body, or use securityInvoker: true over the RLS'd table.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validatePrivateContradiction(kind: string, name: string, priv: boolean | undefined, abilities: Ability[] | undefined): void {
|
|
66
|
+
if (priv && abilities?.length) {
|
|
67
|
+
throw new Error(`${kind} '${name}': private: true declares it dark — it cannot also declare abilities. Drop one.`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A relation's `fields` declare its OUTPUT SHAPE — type + nullability + array, nothing
|
|
73
|
+
* else. Everything table-structural on a field builder (keys, FKs, defaults, write-surface
|
|
74
|
+
* markers, migration hints) is meaningless on a column a body computes, and rejected with
|
|
75
|
+
* the fix. The declared shape is a runtime-typing annotation: it never changes the CREATE
|
|
76
|
+
* statement, the content hash, or the reconcile plan — only the generated drizzle source.
|
|
77
|
+
*/
|
|
78
|
+
function validateRelationFields(kind: string, name: string, fields: Record<string, FieldBuilder> | undefined, priv: boolean | undefined): void {
|
|
79
|
+
if (!fields) return;
|
|
80
|
+
if (priv) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`${kind} '${name}': private: true declares it dark — typed fields declare a read surface no role can reach. Drop one.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
for (const [key, builder] of Object.entries(fields)) {
|
|
86
|
+
const at = `${kind} '${name}', fields.${key}`;
|
|
87
|
+
const s = builder.spec;
|
|
88
|
+
if (s.isPrimaryKey) throw new Error(`${at}: .primaryKey() is table structure — a relation column carries type, nullability, and array only.`);
|
|
89
|
+
if (s.isUnique) throw new Error(`${at}: .unique() is a table constraint — enforce it on the base table (or a matview index).`);
|
|
90
|
+
if (s.references) throw new Error(`${at}: .references() is a table FK — declare it on the base table; the body carries the join.`);
|
|
91
|
+
if (s.defaultKind) throw new Error(`${at}: defaults are meaningless on a relation column — the body computes every value.`);
|
|
92
|
+
if (s.isDeprecated) throw new Error(`${at}: .deprecated() is the table contract phase — remove the column from the body instead.`);
|
|
93
|
+
if (s.isPrivate) throw new Error(`${at}: .private() hides a table column from the API — a relation exposes only what its body selects; drop the column from the body.`);
|
|
94
|
+
if (s.isReadonly) throw new Error(`${at}: .readonly() marks a write surface — relations are read-only already.`);
|
|
95
|
+
if (s.renamedFrom) throw new Error(`${at}: .renamedFrom() is a migration hint — relations rebuild from their body, they don't migrate.`);
|
|
96
|
+
if (s.validation) throw new Error(`${at}: .validate() shapes the write surface — relations take no writes.`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Triggers — pure structure; a trigger has no body at all
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
export type TriggerTiming = 'before' | 'after' | 'insteadOf';
|
|
105
|
+
export type TriggerEvent = 'insert' | 'update' | 'delete' | 'truncate';
|
|
106
|
+
|
|
107
|
+
/** Canonical event order — events are deduped and sorted so the SAME trigger always hashes the same. */
|
|
108
|
+
const EVENT_ORDER: TriggerEvent[] = ['insert', 'update', 'delete', 'truncate'];
|
|
109
|
+
|
|
110
|
+
export interface TriggerDefinition {
|
|
111
|
+
timing: TriggerTiming;
|
|
112
|
+
events: TriggerEvent[];
|
|
113
|
+
forEach: 'row' | 'statement';
|
|
114
|
+
/** `UPDATE OF (columns)` — only with an `update` event. */
|
|
115
|
+
of?: string[];
|
|
116
|
+
/** The SQL `WHEN` predicate — an expression, stays raw. */
|
|
117
|
+
condition?: RawSql;
|
|
118
|
+
/** The trigger function — a shared, standalone {@link defineFunction} returning `trigger`. */
|
|
119
|
+
execute: FunctionDescriptor;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface TriggerSpec {
|
|
123
|
+
name: string;
|
|
124
|
+
timing: TriggerTiming;
|
|
125
|
+
events: TriggerEvent[];
|
|
126
|
+
forEach: 'row' | 'statement';
|
|
127
|
+
of?: string[];
|
|
128
|
+
condition?: string;
|
|
129
|
+
execute: FunctionDescriptor;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A trigger on the model (or view) it is declared on. Deps are free: the table dep is
|
|
134
|
+
* the declaration site; the function dep is `execute:`. Target-specific legality
|
|
135
|
+
* (INSTEAD OF needs a view; views take no BEFORE/AFTER row triggers) is enforced where
|
|
136
|
+
* the trigger attaches — defineModel / defineView.
|
|
137
|
+
*/
|
|
138
|
+
export function trigger(name: string, def: TriggerDefinition): TriggerSpec {
|
|
139
|
+
if (def.execute == null) {
|
|
140
|
+
throw new Error(`trigger '${name}': execute is ${def.execute === undefined ? 'undefined' : 'null'} — renamed or missing import?`);
|
|
141
|
+
}
|
|
142
|
+
const events = EVENT_ORDER.filter((e) => def.events.includes(e));
|
|
143
|
+
if (events.length === 0) throw new Error(`trigger '${name}': declare at least one event.`);
|
|
144
|
+
if (def.of && !events.includes('update')) {
|
|
145
|
+
throw new Error(`trigger '${name}': of: (UPDATE OF columns) requires an update event.`);
|
|
146
|
+
}
|
|
147
|
+
if (events.includes('truncate')) {
|
|
148
|
+
if (def.forEach === 'row') throw new Error(`trigger '${name}': truncate is statement-only — forEach: 'statement'.`);
|
|
149
|
+
if (def.condition) throw new Error(`trigger '${name}': truncate takes no condition (WHEN).`);
|
|
150
|
+
}
|
|
151
|
+
if (def.timing === 'insteadOf') {
|
|
152
|
+
if (def.forEach !== 'row') throw new Error(`trigger '${name}': INSTEAD OF must be forEach: 'row'.`);
|
|
153
|
+
if (def.condition) throw new Error(`trigger '${name}': INSTEAD OF takes no condition (WHEN).`);
|
|
154
|
+
if (def.of) throw new Error(`trigger '${name}': INSTEAD OF takes no of: (UPDATE OF columns).`);
|
|
155
|
+
}
|
|
156
|
+
if (def.execute.returns !== 'trigger') {
|
|
157
|
+
throw new Error(`trigger '${name}': execute must be a function with returns: 'trigger' — '${def.execute.name}' returns '${String(typeof def.execute.returns === 'string' ? def.execute.returns : 'setof')}'.`);
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
name,
|
|
161
|
+
timing: def.timing,
|
|
162
|
+
events,
|
|
163
|
+
forEach: def.forEach,
|
|
164
|
+
...(def.of ? { of: def.of } : {}),
|
|
165
|
+
...(def.condition ? { condition: def.condition.sql } : {}),
|
|
166
|
+
execute: def.execute,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The legality rules that depend on the trigger's TARGET (table vs view). */
|
|
171
|
+
export function validateTriggersFor(target: 'table' | 'view', owner: string, triggers: TriggerSpec[]): void {
|
|
172
|
+
rejectHoles(`${target} '${owner}'`, 'triggers', triggers);
|
|
173
|
+
for (const t of triggers) {
|
|
174
|
+
if (target === 'table' && t.timing === 'insteadOf') {
|
|
175
|
+
throw new Error(`table '${owner}', trigger '${t.name}': INSTEAD OF is a view trigger — attach it to the defineView it rewrites.`);
|
|
176
|
+
}
|
|
177
|
+
if (target === 'view' && t.timing !== 'insteadOf' && t.forEach === 'row') {
|
|
178
|
+
throw new Error(`view '${owner}', trigger '${t.name}': views take INSTEAD OF row triggers or BEFORE/AFTER statement-level triggers — never BEFORE/AFTER row.`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Functions — signature structured, body raw
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
export interface ArgSpec {
|
|
188
|
+
name: string;
|
|
189
|
+
type: string;
|
|
190
|
+
/** A raw SQL default for the argument. */
|
|
191
|
+
default?: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** One positional argument. An ARRAY of these is the signature — never object key order. */
|
|
195
|
+
export function arg(name: string, type: string, opts?: { default?: RawSql }): ArgSpec {
|
|
196
|
+
return { name, type, ...(opts?.default ? { default: opts.default.sql } : {}) };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** `returns: setof(posts)` — a descriptor ref, so a typo is a TS error and the dep comes free. */
|
|
200
|
+
export interface SetofReturn {
|
|
201
|
+
setof: ModelDescriptor | ViewDescriptor | MaterializedViewDescriptor;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function setof(ref: SetofReturn['setof']): SetofReturn {
|
|
205
|
+
if (ref == null) {
|
|
206
|
+
throw new Error(`setof(): ref is ${ref === undefined ? 'undefined' : 'null'} — renamed or missing import?`);
|
|
207
|
+
}
|
|
208
|
+
return { setof: ref };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface FunctionDefinition {
|
|
212
|
+
args?: ArgSpec[];
|
|
213
|
+
returns: string | SetofReturn;
|
|
214
|
+
language: 'sql' | 'plpgsql';
|
|
215
|
+
/** Default `'invoker'` (the safe one). `'definer'` REQUIRES a pinned searchPath. */
|
|
216
|
+
security?: 'invoker' | 'definer';
|
|
217
|
+
/** An ARRAY of schemas, not a comma string. */
|
|
218
|
+
searchPath?: string[];
|
|
219
|
+
/** Default `'volatile'` (PostgreSQL's own). `immutable` enables expression indexes. */
|
|
220
|
+
volatility?: 'immutable' | 'stable' | 'volatile';
|
|
221
|
+
dependsOn?: DependsOnRef[];
|
|
222
|
+
/** Execute authz only, role REQUIRED — `PUBLIC` is always revoked by the compiler. */
|
|
223
|
+
abilities?: Ability[];
|
|
224
|
+
comment?: string;
|
|
225
|
+
body: RawSql;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface FunctionDescriptor {
|
|
229
|
+
kind: 'function';
|
|
230
|
+
name: string;
|
|
231
|
+
args: ArgSpec[];
|
|
232
|
+
returns: string | SetofReturn;
|
|
233
|
+
language: 'sql' | 'plpgsql';
|
|
234
|
+
security: 'invoker' | 'definer';
|
|
235
|
+
searchPath?: string[];
|
|
236
|
+
volatility: 'immutable' | 'stable' | 'volatile';
|
|
237
|
+
dependsOn: DependsOnRef[];
|
|
238
|
+
abilities: Ability[];
|
|
239
|
+
comment?: string;
|
|
240
|
+
body: string;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function defineFunction(name: string, def: FunctionDefinition): FunctionDescriptor {
|
|
244
|
+
rejectHoles(`function '${name}'`, 'dependsOn', def.dependsOn);
|
|
245
|
+
const security = def.security ?? 'invoker';
|
|
246
|
+
if (security === 'definer' && (!def.searchPath || def.searchPath.length === 0)) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`function '${name}': SECURITY DEFINER without a pinned searchPath is the classic landmine — pin searchPath: ['pg_catalog'] (plus the schemas the body needs), or use security: 'invoker'.`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
const args = def.args ?? [];
|
|
252
|
+
const seen = new Set<string>();
|
|
253
|
+
for (const a of args) {
|
|
254
|
+
if (seen.has(a.name)) throw new Error(`function '${name}': duplicate argument '${a.name}'.`);
|
|
255
|
+
seen.add(a.name);
|
|
256
|
+
}
|
|
257
|
+
for (const a of def.abilities ?? []) {
|
|
258
|
+
if (a.action !== 'execute') {
|
|
259
|
+
throw new Error(`function '${name}': can('${a.action}') is not a function ability — functions carry can('execute', { role }) only.`);
|
|
260
|
+
}
|
|
261
|
+
if (!a.condition.role) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`function '${name}': bare can('execute') has no safe default audience — declare { role: 'authenticated' }, or { role: 'anon' } for public-callable.`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
kind: 'function',
|
|
269
|
+
name,
|
|
270
|
+
args,
|
|
271
|
+
returns: def.returns,
|
|
272
|
+
language: def.language,
|
|
273
|
+
security,
|
|
274
|
+
...(def.searchPath ? { searchPath: def.searchPath } : {}),
|
|
275
|
+
volatility: def.volatility ?? 'volatile',
|
|
276
|
+
dependsOn: def.dependsOn ?? [],
|
|
277
|
+
abilities: def.abilities ?? [],
|
|
278
|
+
...(def.comment ? { comment: def.comment } : {}),
|
|
279
|
+
body: def.body.sql,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// Views — securityInvoker is the forced decision
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
export interface ViewDefinition {
|
|
288
|
+
/**
|
|
289
|
+
* REQUIRED, no default — the one decision that distinguishes the two legitimate
|
|
290
|
+
* shapes: `true` = readers' own RLS/grants apply through the view; `false` =
|
|
291
|
+
* definer semantics, the "safe slice" pattern (view grant WITHOUT table grant —
|
|
292
|
+
* the body must seal what it exposes).
|
|
293
|
+
*/
|
|
294
|
+
securityInvoker: boolean;
|
|
295
|
+
abilities?: Ability[];
|
|
296
|
+
dependsOn?: DependsOnRef[];
|
|
297
|
+
/** `INSTEAD OF` row triggers, or BEFORE/AFTER statement-level. */
|
|
298
|
+
triggers?: TriggerSpec[];
|
|
299
|
+
comment?: string;
|
|
300
|
+
/** Declared dark — intentionally no grants; satisfies the read-gap gate honestly. */
|
|
301
|
+
private?: boolean;
|
|
302
|
+
/**
|
|
303
|
+
* The declared output shape — same `field.*` builders as models, type/nullability/array
|
|
304
|
+
* only. Opt-in: declaring fields emits a typed `pgView(...).existing()` into
|
|
305
|
+
* `schema.generated.ts` (typed SSR reads); omitting them leaves the relation out of the
|
|
306
|
+
* generated drizzle schema. Never touches the CREATE statement or the content hash.
|
|
307
|
+
*
|
|
308
|
+
* CONTRACT: each field key binds the snake_case column of that name (`displayName` →
|
|
309
|
+
* `display_name`) — the SELECT must emit that output column, aliasing where needed
|
|
310
|
+
* (`count(*) AS n`). Fields are author-declared, not introspected: a wrong NAME fails
|
|
311
|
+
* every read of the relation loudly (drizzle selects all declared columns), but a
|
|
312
|
+
* wrong type or nullability mistypes reads SILENTLY. Keep them truthful.
|
|
313
|
+
*/
|
|
314
|
+
fields?: Record<string, FieldBuilder>;
|
|
315
|
+
as: RawSql;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export interface ViewDescriptor {
|
|
319
|
+
kind: 'view';
|
|
320
|
+
name: string;
|
|
321
|
+
securityInvoker: boolean;
|
|
322
|
+
abilities: Ability[];
|
|
323
|
+
dependsOn: DependsOnRef[];
|
|
324
|
+
triggers: TriggerSpec[];
|
|
325
|
+
comment?: string;
|
|
326
|
+
private: boolean;
|
|
327
|
+
fields?: Record<string, FieldBuilder>;
|
|
328
|
+
as: string;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function defineView(name: string, def: ViewDefinition): ViewDescriptor {
|
|
332
|
+
rejectHoles(`view '${name}'`, 'dependsOn', def.dependsOn);
|
|
333
|
+
if (typeof def.securityInvoker !== 'boolean') {
|
|
334
|
+
throw new Error(
|
|
335
|
+
`view '${name}': securityInvoker is a required decision — true (readers' own RLS applies through the view) or false (definer semantics: the sealed-slice pattern; the body must not leak).`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
validateRelationAbilities('view', name, def.abilities ?? []);
|
|
339
|
+
validatePrivateContradiction('view', name, def.private, def.abilities);
|
|
340
|
+
validateRelationFields('view', name, def.fields, def.private);
|
|
341
|
+
validateTriggersFor('view', name, def.triggers ?? []);
|
|
342
|
+
return {
|
|
343
|
+
kind: 'view',
|
|
344
|
+
name,
|
|
345
|
+
securityInvoker: def.securityInvoker,
|
|
346
|
+
abilities: def.abilities ?? [],
|
|
347
|
+
dependsOn: def.dependsOn ?? [],
|
|
348
|
+
triggers: def.triggers ?? [],
|
|
349
|
+
...(def.comment ? { comment: def.comment } : {}),
|
|
350
|
+
private: def.private ?? false,
|
|
351
|
+
...(def.fields ? { fields: def.fields } : {}),
|
|
352
|
+
as: def.as.sql,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
// Materialized views — grants-only authz; refresh/populate declare the HOW
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
export interface MaterializedViewDefinition {
|
|
361
|
+
abilities?: Ability[];
|
|
362
|
+
dependsOn?: DependsOnRef[];
|
|
363
|
+
indexes?: IndexBuilder[];
|
|
364
|
+
/** `'concurrently'` — compile-gated on a declared unique, non-partial index (PG requires it). */
|
|
365
|
+
refresh?: 'concurrently';
|
|
366
|
+
/** `'deferred'` — create WITH NO DATA, refresh outside the creation lock. */
|
|
367
|
+
populate?: 'deferred';
|
|
368
|
+
comment?: string;
|
|
369
|
+
private?: boolean;
|
|
370
|
+
/** The declared output shape — see {@link ViewDefinition.fields}. */
|
|
371
|
+
fields?: Record<string, FieldBuilder>;
|
|
372
|
+
as: RawSql;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export interface MaterializedViewDescriptor {
|
|
376
|
+
kind: 'materialized view';
|
|
377
|
+
name: string;
|
|
378
|
+
abilities: Ability[];
|
|
379
|
+
dependsOn: DependsOnRef[];
|
|
380
|
+
indexes: IndexBuilder[];
|
|
381
|
+
refresh?: 'concurrently';
|
|
382
|
+
populate?: 'deferred';
|
|
383
|
+
comment?: string;
|
|
384
|
+
private: boolean;
|
|
385
|
+
fields?: Record<string, FieldBuilder>;
|
|
386
|
+
as: string;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function defineMaterializedView(name: string, def: MaterializedViewDefinition): MaterializedViewDescriptor {
|
|
390
|
+
rejectHoles(`materialized view '${name}'`, 'dependsOn', def.dependsOn);
|
|
391
|
+
validateRelationAbilities('materialized view', name, def.abilities ?? []);
|
|
392
|
+
validatePrivateContradiction('materialized view', name, def.private, def.abilities);
|
|
393
|
+
validateRelationFields('materialized view', name, def.fields, def.private);
|
|
394
|
+
const indexes = def.indexes ?? [];
|
|
395
|
+
if (def.refresh === 'concurrently' && !indexes.some((ix) => ix.isUnique && !ix.predicate)) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
`materialized view '${name}': REFRESH CONCURRENTLY requires a unique, non-partial index — declare indexes: [index('...').unique()].`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
kind: 'materialized view',
|
|
402
|
+
name,
|
|
403
|
+
abilities: def.abilities ?? [],
|
|
404
|
+
dependsOn: def.dependsOn ?? [],
|
|
405
|
+
indexes,
|
|
406
|
+
...(def.refresh ? { refresh: def.refresh } : {}),
|
|
407
|
+
...(def.populate ? { populate: def.populate } : {}),
|
|
408
|
+
...(def.comment ? { comment: def.comment } : {}),
|
|
409
|
+
private: def.private ?? false,
|
|
410
|
+
...(def.fields ? { fields: def.fields } : {}),
|
|
411
|
+
as: def.as.sql,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
// Sequences — STATE, not compute (a sequence holds a counter value)
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
export interface SequenceDefinition {
|
|
420
|
+
as?: 'integer' | 'bigint';
|
|
421
|
+
start?: number;
|
|
422
|
+
increment?: number;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export interface SequenceDescriptor {
|
|
426
|
+
kind: 'sequence';
|
|
427
|
+
name: string;
|
|
428
|
+
as: 'integer' | 'bigint';
|
|
429
|
+
start?: number;
|
|
430
|
+
increment?: number;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* A standalone sequence — one not owned by any serial column (those stay implicit).
|
|
435
|
+
* Declared on the module (`defineModule({ sequences })`), compiled into the MIGRATION
|
|
436
|
+
* stream before tables, fingerprinted with the base schema — the `extensions`
|
|
437
|
+
* precedent. This is what a `field.defaultSql(sql\`nextval('...')\`)` draws from when
|
|
438
|
+
* the sequence has no serial owner.
|
|
439
|
+
*/
|
|
440
|
+
export function defineSequence(name: string, def: SequenceDefinition = {}): SequenceDescriptor {
|
|
441
|
+
return {
|
|
442
|
+
kind: 'sequence',
|
|
443
|
+
name,
|
|
444
|
+
as: def.as ?? 'bigint',
|
|
445
|
+
...(def.start != null ? { start: def.start } : {}),
|
|
446
|
+
...(def.increment != null ? { increment: def.increment } : {}),
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
// The escape hatch — declared, hashed, ordered, and gate-tight
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
/** Kinds whose DROP is derivable from the name alone (`DROP <KIND> IF EXISTS <name>`). */
|
|
455
|
+
export const SIMPLE_DROP_KINDS = new Set(['domain', 'collation', 'event trigger']);
|
|
456
|
+
|
|
457
|
+
const CREATES_RELATION_RE = /^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(MATERIALIZED\s+VIEW|VIEW)\b/i;
|
|
458
|
+
|
|
459
|
+
export interface SqlDefinition {
|
|
460
|
+
/** The PostgreSQL object kind (`'aggregate'`, `'domain'`, `'statement'`, …). REQUIRED. */
|
|
461
|
+
kind: string;
|
|
462
|
+
dependsOn?: DependsOnRef[];
|
|
463
|
+
as: RawSql;
|
|
464
|
+
/** REQUIRED unless the kind's DROP is derivable from the name ({@link SIMPLE_DROP_KINDS}). */
|
|
465
|
+
drop?: RawSql;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export interface SqlDescriptor {
|
|
469
|
+
kind: 'sql';
|
|
470
|
+
/** The declared PostgreSQL object kind (the descriptor discriminant stays 'sql'). */
|
|
471
|
+
objectKind: string;
|
|
472
|
+
name: string;
|
|
473
|
+
dependsOn: DependsOnRef[];
|
|
474
|
+
as: string;
|
|
475
|
+
drop?: string;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* The generic declared object — for anything the structured vocabulary doesn't cover
|
|
480
|
+
* yet (aggregates, operators, event triggers). Still declared, content-hashed, and
|
|
481
|
+
* dependency-ordered like everything else. The relation refusal below is a guard
|
|
482
|
+
* against the ACCIDENTAL `CREATE VIEW` landing in the escape hatch — it is not an
|
|
483
|
+
* analyzer, and determined SQL has other ways to create readable state. Authored SQL
|
|
484
|
+
* here is trusted (the same boundary as a raw db/sql file before it), and the B2/B4
|
|
485
|
+
* gates skip 'sql'-kind objects: what this hatch declares is on its author.
|
|
486
|
+
*/
|
|
487
|
+
export function defineSql(name: string, def: SqlDefinition): SqlDescriptor {
|
|
488
|
+
rejectHoles(`sql '${name}'`, 'dependsOn', def.dependsOn);
|
|
489
|
+
if (!def.kind || !def.kind.trim()) {
|
|
490
|
+
throw new Error(`defineSql '${name}': kind is required — the reconciler drops and joins objects by kind.`);
|
|
491
|
+
}
|
|
492
|
+
const m = def.as.sql.match(CREATES_RELATION_RE);
|
|
493
|
+
if (m) {
|
|
494
|
+
const suggested = /MATERIALIZED/i.test(m[1]) ? 'defineMaterializedView' : 'defineView';
|
|
495
|
+
throw new Error(
|
|
496
|
+
`defineSql '${name}': the escape hatch cannot create a ${m[1].toLowerCase()} — relations carry the read model. Use ${suggested}.`,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
if (!def.drop && !SIMPLE_DROP_KINDS.has(def.kind.trim().toLowerCase())) {
|
|
500
|
+
throw new Error(
|
|
501
|
+
`defineSql '${name}': kind '${def.kind}' has no derivable DROP — declare drop: sql\`DROP ...\` so the reconciler can remove what it created.`,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
kind: 'sql',
|
|
506
|
+
objectKind: def.kind.trim().toLowerCase(),
|
|
507
|
+
name,
|
|
508
|
+
dependsOn: def.dependsOn ?? [],
|
|
509
|
+
as: def.as.sql,
|
|
510
|
+
...(def.drop ? { drop: def.drop.sql } : {}),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Everything the derived (compute) stream carries — sequences are state and NOT here. */
|
|
515
|
+
export type DerivedDescriptor = ViewDescriptor | MaterializedViewDescriptor | FunctionDescriptor | SqlDescriptor;
|
package/src/drizzle.ts
CHANGED
|
@@ -138,6 +138,7 @@ export function toDrizzleTable(model: ModelDescriptor): PgTableWithColumns<any>
|
|
|
138
138
|
if (spec.defaultKind === 'now') col = col.defaultNow();
|
|
139
139
|
else if (spec.defaultKind === 'random') col = col.defaultRandom();
|
|
140
140
|
else if (spec.defaultKind === 'value') col = col.default(spec.default);
|
|
141
|
+
else if (spec.defaultKind === 'sql') col = col.default(sql.raw(String(spec.default)));
|
|
141
142
|
|
|
142
143
|
// Single-column PK lives on the column; composite PKs are emitted table-level below.
|
|
143
144
|
if (spec.isPrimaryKey && !isComposite) col = col.primaryKey();
|
package/src/field.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* See docs/plans/v3-model-api.md.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import type { RawSql } from './raw-sql';
|
|
15
|
+
|
|
14
16
|
export type FieldType =
|
|
15
17
|
| 'text'
|
|
16
18
|
| 'uuid'
|
|
@@ -29,7 +31,7 @@ export type FieldType =
|
|
|
29
31
|
| 'jsonb'
|
|
30
32
|
| 'enum';
|
|
31
33
|
|
|
32
|
-
export type DefaultKind = 'value' | 'now' | 'random';
|
|
34
|
+
export type DefaultKind = 'value' | 'now' | 'random' | 'sql';
|
|
33
35
|
|
|
34
36
|
/** A foreign-key referential action (`ON DELETE` / `ON UPDATE`). `'no action'` is the default. */
|
|
35
37
|
export type ReferentialAction = 'cascade' | 'restrict' | 'no action' | 'set null' | 'set default';
|
|
@@ -188,6 +190,20 @@ export class FieldBuilder {
|
|
|
188
190
|
return this;
|
|
189
191
|
}
|
|
190
192
|
|
|
193
|
+
/**
|
|
194
|
+
* A raw SQL default — the escape hatch for expressions no builder maps: a
|
|
195
|
+
* foreign/shared sequence (`nextval('contracts_id_seq')`), an interval
|
|
196
|
+
* arithmetic (`now() + interval '7 days'`). Stored verbatim behind the
|
|
197
|
+
* `sql\`\`` brand — the same author-controlled trust boundary as `check()`.
|
|
198
|
+
* A `nextval` default here is never the `serial` shorthand: serial would
|
|
199
|
+
* mint a NEW sequence; this declares a draw from an existing one.
|
|
200
|
+
*/
|
|
201
|
+
defaultSql(expression: RawSql): this {
|
|
202
|
+
this.spec.default = expression.sql;
|
|
203
|
+
this.spec.defaultKind = 'sql';
|
|
204
|
+
return this;
|
|
205
|
+
}
|
|
206
|
+
|
|
191
207
|
/** Attach a Zod schema. Stored now, compiled to CHECK + runtime validation in Phase 4. */
|
|
192
208
|
validate(schema: unknown): this {
|
|
193
209
|
this.spec.validation = schema;
|
package/src/index.ts
CHANGED
|
@@ -25,8 +25,19 @@ export type { Relation } from './relations';
|
|
|
25
25
|
export { defineModel } from './model';
|
|
26
26
|
export type { ModelDescriptor, ModelDefinition, ModelHooks, WrittenBy } from './model';
|
|
27
27
|
|
|
28
|
-
export { defineModule, moduleModels, moduleExtensions, moduleFunctionSql } from './module';
|
|
28
|
+
export { defineModule, moduleModels, moduleExtensions, moduleFunctionSql, moduleSequences, moduleDerived } from './module';
|
|
29
29
|
export type { Module, ModuleDefinition } from './module';
|
|
30
30
|
|
|
31
|
+
export {
|
|
32
|
+
defineView, defineMaterializedView, defineFunction, defineSql, defineSequence,
|
|
33
|
+
trigger, arg, setof, validateTriggersFor, SIMPLE_DROP_KINDS,
|
|
34
|
+
} from './derived';
|
|
35
|
+
export type {
|
|
36
|
+
DerivedDescriptor, ViewDescriptor, MaterializedViewDescriptor, FunctionDescriptor,
|
|
37
|
+
SqlDescriptor, SequenceDescriptor, TriggerSpec, TriggerDefinition, TriggerTiming,
|
|
38
|
+
TriggerEvent, ArgSpec, SetofReturn, DependsOnRef, ViewDefinition,
|
|
39
|
+
MaterializedViewDefinition, FunctionDefinition, SqlDefinition, SequenceDefinition,
|
|
40
|
+
} from './derived';
|
|
41
|
+
|
|
31
42
|
export { deriveHandlerConfig, deriveForceRlsCarveouts, deriveFunctionSchemas } from './derive';
|
|
32
43
|
export type { DerivedHandlerConfig, RelationConfig } from './derive';
|
package/src/model.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { FieldBuilder } from './field';
|
|
|
15
15
|
import type { Ability } from './can';
|
|
16
16
|
import type { Relation } from './relations';
|
|
17
17
|
import type { TableConstraint } from './constraints';
|
|
18
|
+
import { validateTriggersFor, type TriggerSpec } from './derived';
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Per-table mutation hooks. Imperative function bodies the framework cannot
|
|
@@ -80,6 +81,14 @@ export interface ModelDefinition {
|
|
|
80
81
|
* just not exposed. Named `private` to avoid colliding with `schema: 'public'`.
|
|
81
82
|
*/
|
|
82
83
|
private?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Triggers on this table — declared HERE because a trigger is existentially bound
|
|
86
|
+
* to its table and the table's owner must see what runs on every write (owner
|
|
87
|
+
* consent, visible in the model). Lifecycle note: declaration home ≠ lifecycle —
|
|
88
|
+
* triggers are COMPUTE, compiled into the derived stream (db:reconcile), never
|
|
89
|
+
* migrations; the base-schema fingerprint excludes this key.
|
|
90
|
+
*/
|
|
91
|
+
triggers?: TriggerSpec[];
|
|
83
92
|
}
|
|
84
93
|
|
|
85
94
|
export interface ModelDescriptor {
|
|
@@ -101,6 +110,8 @@ export interface ModelDescriptor {
|
|
|
101
110
|
private: boolean;
|
|
102
111
|
/** One-shot rename hint: this table used to be named `renamedFrom` (same schema). */
|
|
103
112
|
renamedFrom?: string;
|
|
113
|
+
/** Triggers on this table (compute — reconciled, never migrated; excluded from the base fingerprint). */
|
|
114
|
+
triggers: TriggerSpec[];
|
|
104
115
|
}
|
|
105
116
|
|
|
106
117
|
export function defineModel(table: string, def: ModelDefinition): ModelDescriptor {
|
|
@@ -108,6 +119,13 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
108
119
|
.filter(([, f]) => f.spec.isPrimaryKey)
|
|
109
120
|
.map(([name]) => name);
|
|
110
121
|
|
|
122
|
+
for (const a of def.abilities ?? []) {
|
|
123
|
+
if (a.action === 'execute') {
|
|
124
|
+
throw new Error(`model '${table}': can('execute') is a function ability — declare it on the defineFunction it gates.`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
validateTriggersFor('table', table, def.triggers ?? []);
|
|
128
|
+
|
|
111
129
|
return {
|
|
112
130
|
table,
|
|
113
131
|
fields: def.fields,
|
|
@@ -120,5 +138,6 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
120
138
|
schema: def.schema ?? 'public',
|
|
121
139
|
private: def.private ?? false,
|
|
122
140
|
...(def.renamedFrom ? { renamedFrom: def.renamedFrom } : {}),
|
|
141
|
+
triggers: def.triggers ?? [],
|
|
123
142
|
};
|
|
124
143
|
}
|
package/src/module.ts
CHANGED
|
@@ -20,16 +20,34 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import type { ModelDescriptor } from './model';
|
|
23
|
+
import type { DerivedDescriptor, SequenceDescriptor } from './derived';
|
|
24
|
+
import { rejectHoles } from './derived';
|
|
23
25
|
|
|
24
26
|
export interface ModuleDefinition {
|
|
25
27
|
/** The package's Models (schema + authz). */
|
|
26
28
|
models?: ModelDescriptor[];
|
|
27
29
|
/** Postgres extensions the module's SQL needs (e.g. `'pgcrypto'`). Emitted once, first. */
|
|
28
30
|
extensions?: string[];
|
|
31
|
+
/**
|
|
32
|
+
* Standalone sequences (not owned by any serial column) — STATE, compiled into the
|
|
33
|
+
* migration stream before tables and fingerprinted with the base schema (the
|
|
34
|
+
* `extensions` precedent). What a `field.defaultSql(sql\`nextval('…')\`)` draws from
|
|
35
|
+
* when no serial column owns the sequence.
|
|
36
|
+
*/
|
|
37
|
+
sequences?: SequenceDescriptor[];
|
|
38
|
+
/**
|
|
39
|
+
* The module's derived (compute) objects — views, materialized views, functions, and
|
|
40
|
+
* `defineSql` escape-hatch objects. Declared, content-hashed, dependency-ordered;
|
|
41
|
+
* deployed by `db:reconcile`, never migrated. Supersedes the `functions` thunk.
|
|
42
|
+
*/
|
|
43
|
+
derived?: DerivedDescriptor[];
|
|
29
44
|
/**
|
|
30
45
|
* Package-owned SQL emitted after the tables — SECURITY DEFINER functions, triggers, etc.
|
|
31
46
|
* A thunk so it is generated lazily and can take run-specific input (the app's event-trigger
|
|
32
47
|
* table list, say). Not part of the declarative contract; emitted verbatim.
|
|
48
|
+
*
|
|
49
|
+
* @deprecated Transitional — declare `derived: [defineFunction(…), …]` instead:
|
|
50
|
+
* descriptors are hashed, ordered, and gate-checked; the thunk sees none of that.
|
|
33
51
|
*/
|
|
34
52
|
functions?: () => string;
|
|
35
53
|
}
|
|
@@ -38,15 +56,39 @@ export interface ModuleDefinition {
|
|
|
38
56
|
export interface Module {
|
|
39
57
|
models: ModelDescriptor[];
|
|
40
58
|
extensions: string[];
|
|
59
|
+
/** Standalone sequences — state layer (migrated, fingerprinted). */
|
|
60
|
+
sequences: SequenceDescriptor[];
|
|
61
|
+
/** Derived (compute) objects — reconciled, never migrated. */
|
|
62
|
+
derived: DerivedDescriptor[];
|
|
41
63
|
/** Function-SQL thunks, normalized to an array so composing modules just concatenates. */
|
|
42
64
|
functions: Array<() => string>;
|
|
43
65
|
}
|
|
44
66
|
|
|
45
67
|
/** Declare a package's database slice. Empty fields default to nothing — a models-only module is fine. */
|
|
46
68
|
export function defineModule(def: ModuleDefinition): Module {
|
|
69
|
+
// The loud undefined gate — a missing import composes as a hole; name it here,
|
|
70
|
+
// at the module boundary, before any list is flattened or iterated.
|
|
71
|
+
rejectHoles('defineModule', 'models', def.models);
|
|
72
|
+
rejectHoles('defineModule', 'derived', def.derived);
|
|
73
|
+
rejectHoles('defineModule', 'sequences', def.sequences);
|
|
74
|
+
// Derived identity is one name per kind: `DROP FUNCTION` renders no argument list, so
|
|
75
|
+
// v1 rejects function overloads outright, and same-name relations collide in the
|
|
76
|
+
// catalog anyway — caught here, at composition, before any SQL exists.
|
|
77
|
+
const seen = new Set<string>();
|
|
78
|
+
for (const d of def.derived ?? []) {
|
|
79
|
+
const identity = `${d.kind === 'sql' ? d.objectKind : d.kind}:${d.name}`;
|
|
80
|
+
if (seen.has(identity)) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`defineModule: two derived declarations of '${d.name}' (${identity}) — one ${d.kind} per name (function overloads are v1-rejected; rename, or collapse to one signature with arg defaults).`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
seen.add(identity);
|
|
86
|
+
}
|
|
47
87
|
return {
|
|
48
88
|
models: def.models ?? [],
|
|
49
89
|
extensions: def.extensions ?? [],
|
|
90
|
+
sequences: def.sequences ?? [],
|
|
91
|
+
derived: def.derived ?? [],
|
|
50
92
|
functions: def.functions ? [def.functions] : [],
|
|
51
93
|
};
|
|
52
94
|
}
|
|
@@ -66,3 +108,13 @@ export function moduleExtensions(modules: Module[]): string[] {
|
|
|
66
108
|
export function moduleFunctionSql(modules: Module[]): string[] {
|
|
67
109
|
return modules.flatMap((m) => m.functions).map((fn) => fn());
|
|
68
110
|
}
|
|
111
|
+
|
|
112
|
+
/** The flattened standalone-sequence list across modules — the state-layer pre-table phase. */
|
|
113
|
+
export function moduleSequences(modules: Module[]): SequenceDescriptor[] {
|
|
114
|
+
return modules.flatMap((m) => m.sequences);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The flattened derived-object list across modules — what the reconcile compiler consumes. */
|
|
118
|
+
export function moduleDerived(modules: Module[]): DerivedDescriptor[] {
|
|
119
|
+
return modules.flatMap((m) => m.derived);
|
|
120
|
+
}
|