@everystack/model 0.4.4 → 0.4.6
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 +18 -0
- package/src/derived.ts +48 -0
- package/src/index.ts +1 -0
- package/src/model.ts +28 -0
- package/src/module.ts +12 -6
package/package.json
CHANGED
package/src/can.ts
CHANGED
|
@@ -40,6 +40,24 @@ export interface AbilityCondition {
|
|
|
40
40
|
where?: unknown;
|
|
41
41
|
/** Escape hatch: a verbatim RLS predicate, preserved and diffed by normalized text. */
|
|
42
42
|
sql?: unknown;
|
|
43
|
+
/**
|
|
44
|
+
* The WRITE predicate, when it differs from the read predicate.
|
|
45
|
+
*
|
|
46
|
+
* A policy has two halves: `USING` decides which rows you may TARGET, `WITH CHECK`
|
|
47
|
+
* decides what a row may BECOME. Postgres defaults the second to the first, which is
|
|
48
|
+
* usually what you want — and is what every other condition here compiles to.
|
|
49
|
+
*
|
|
50
|
+
* They differ when a row may be written into a shape its author could not have selected
|
|
51
|
+
* by, or vice versa. The dangerous direction is a WITH CHECK WEAKER than the USING: if
|
|
52
|
+
* `USING` says "your own row, and only while it is a draft" but `WITH CHECK` says only
|
|
53
|
+
* "your own row", the owner can move their own row out of draft — past whatever the
|
|
54
|
+
* state guard was protecting.
|
|
55
|
+
*
|
|
56
|
+
* Declare it when the live policy has one, so the model states the real rule instead of
|
|
57
|
+
* silently tightening it to the USING clause. Omit it and the read predicate governs
|
|
58
|
+
* both, unchanged.
|
|
59
|
+
*/
|
|
60
|
+
check?: unknown;
|
|
43
61
|
}
|
|
44
62
|
|
|
45
63
|
export interface Ability {
|
package/src/derived.ts
CHANGED
|
@@ -196,6 +196,54 @@ export function arg(name: string, type: string, opts?: { default?: RawSql }): Ar
|
|
|
196
196
|
return { name, type, ...(opts?.default ? { default: opts.default.sql } : {}) };
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
/** SQL type aliases → the spelling PostgreSQL's `format_type` prints. Base names only. */
|
|
200
|
+
const BASE_TYPE_ALIASES: Record<string, string> = {
|
|
201
|
+
int: 'integer', int4: 'integer', int2: 'smallint', int8: 'bigint',
|
|
202
|
+
serial: 'integer', bigserial: 'bigint', smallserial: 'smallint',
|
|
203
|
+
bool: 'boolean', float4: 'real', float8: 'double precision', float: 'double precision',
|
|
204
|
+
decimal: 'numeric', varchar: 'character varying', char: 'character', bpchar: 'character',
|
|
205
|
+
timestamptz: 'timestamp with time zone', timetz: 'time with time zone',
|
|
206
|
+
timestamp: 'timestamp without time zone', time: 'time without time zone',
|
|
207
|
+
varbit: 'bit varying',
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* One declared argument type in the CATALOG's spelling: aliases resolved, typmods
|
|
212
|
+
* dropped, array dimensionality kept as `[]` per dimension.
|
|
213
|
+
*
|
|
214
|
+
* A function's identity in PostgreSQL is name + argument types, so this is what makes a
|
|
215
|
+
* DECLARED signature comparable to a live one. Typmods go because a function argument
|
|
216
|
+
* carries none — PostgreSQL discards length/precision on arguments, so `varchar(20)` and
|
|
217
|
+
* `varchar` are ONE type here. An unrecognized name (a domain, an enum, an extension
|
|
218
|
+
* type) passes through lowercased; the catalog prints those verbatim too.
|
|
219
|
+
*/
|
|
220
|
+
export function normalizeArgType(raw: string): string {
|
|
221
|
+
let t = raw.trim().replace(/\s+/g, ' ');
|
|
222
|
+
if (t === '') return t;
|
|
223
|
+
|
|
224
|
+
// Array dimensionality, both spellings: `text[]`, `text[3][3]`, `text ARRAY`, `text ARRAY[4]`.
|
|
225
|
+
let dims = 0;
|
|
226
|
+
for (;;) {
|
|
227
|
+
const arrayKeyword = /\s+array(\s*\[\s*\d*\s*\])?$/i.exec(t);
|
|
228
|
+
if (arrayKeyword) { dims += 1; t = t.slice(0, arrayKeyword.index).trim(); continue; }
|
|
229
|
+
const bracket = /\[\s*\d*\s*\]$/.exec(t);
|
|
230
|
+
if (bracket) { dims += 1; t = t.slice(0, bracket.index).trim(); continue; }
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
t = t.replace(/\([^)]*\)/g, ' ').replace(/\s+/g, ' ').trim();
|
|
235
|
+
// A quoted part ("char", "MyType") keeps its case; everything else folds down.
|
|
236
|
+
if (!t.includes('"')) t = t.toLowerCase();
|
|
237
|
+
t = t.replace(/^pg_catalog\./, '');
|
|
238
|
+
|
|
239
|
+
return (BASE_TYPE_ALIASES[t] ?? t) + '[]'.repeat(dims);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** `(integer, text)` — a declared signature in the catalog's spelling, parens included. */
|
|
243
|
+
export function functionArgSignature(args: readonly ArgSpec[]): string {
|
|
244
|
+
return `(${args.map((a) => normalizeArgType(a.type)).join(', ')})`;
|
|
245
|
+
}
|
|
246
|
+
|
|
199
247
|
/** `returns: setof(posts)` — a descriptor ref, so a typo is a TS error and the dep comes free. */
|
|
200
248
|
export interface SetofReturn {
|
|
201
249
|
setof: ModelDescriptor | ViewDescriptor | MaterializedViewDescriptor;
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,7 @@ export type { Module, ModuleDefinition } from './module';
|
|
|
31
31
|
export {
|
|
32
32
|
defineView, defineMaterializedView, defineFunction, defineSql, defineSequence,
|
|
33
33
|
trigger, arg, setof, validateTriggersFor, SIMPLE_DROP_KINDS,
|
|
34
|
+
normalizeArgType, functionArgSignature,
|
|
34
35
|
} from './derived';
|
|
35
36
|
export type {
|
|
36
37
|
DerivedDescriptor, ViewDescriptor, MaterializedViewDescriptor, FunctionDescriptor,
|
package/src/model.ts
CHANGED
|
@@ -74,6 +74,19 @@ export interface ModelDefinition {
|
|
|
74
74
|
* is past the state — a satisfied rename is an inert notice.
|
|
75
75
|
*/
|
|
76
76
|
renamedFrom?: string;
|
|
77
|
+
/**
|
|
78
|
+
* `movedFrom: 'old_schema'` — a one-shot migration hint, the cross-schema twin
|
|
79
|
+
* of `renamedFrom`. The SAME table name changed `schema` (e.g. `stats` → `curated`);
|
|
80
|
+
* the diff cannot distinguish a move from a CREATE-in-new-schema + an undeclared
|
|
81
|
+
* orphan left behind, so the marker carries the intent and the diff emits one
|
|
82
|
+
* `ALTER TABLE <old_schema>.<table> SET SCHEMA <schema>` (data, indexes, policies,
|
|
83
|
+
* grants, triggers and owned sequences all ride along) instead of CREATE plus a
|
|
84
|
+
* stranded copy. Names the OLD schema; the new schema is this model's `schema`.
|
|
85
|
+
* Remove it once every environment is past the state — a satisfied move is an
|
|
86
|
+
* inert notice. Cannot be combined with `renamedFrom` (declare the move first,
|
|
87
|
+
* the rename in a later increment).
|
|
88
|
+
*/
|
|
89
|
+
movedFrom?: string;
|
|
77
90
|
/**
|
|
78
91
|
* `true` removes the table from the generic data API — `deriveHandlerConfig`
|
|
79
92
|
* skips it, so it never enters `exposedTables`. Default `false`. Package-owned
|
|
@@ -111,6 +124,8 @@ export interface ModelDescriptor {
|
|
|
111
124
|
private: boolean;
|
|
112
125
|
/** One-shot rename hint: this table used to be named `renamedFrom` (same schema). */
|
|
113
126
|
renamedFrom?: string;
|
|
127
|
+
/** One-shot move hint: this table used to live in schema `movedFrom` (same name) — emits SET SCHEMA. */
|
|
128
|
+
movedFrom?: string;
|
|
114
129
|
/** Triggers on this table (compute — reconciled, never migrated; excluded from the base fingerprint). */
|
|
115
130
|
triggers: TriggerSpec[];
|
|
116
131
|
/**
|
|
@@ -161,6 +176,18 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
161
176
|
|
|
162
177
|
validateTriggersFor('table', table, def.triggers ?? []);
|
|
163
178
|
|
|
179
|
+
// A rename and a move in one increment can't be composed from two independent markers
|
|
180
|
+
// (each names only half the source — old name OR old schema — so the diff would miss the
|
|
181
|
+
// real `old_schema.old_name` table and silently degrade to CREATE + orphan, the exact bug
|
|
182
|
+
// both markers exist to prevent). Forbid the pair; sequence them across two increments.
|
|
183
|
+
if (def.renamedFrom && def.movedFrom) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`model '${table}': cannot declare both renamedFrom ('${def.renamedFrom}') and movedFrom ('${def.movedFrom}') ` +
|
|
186
|
+
`in one increment — the diff can't locate the source. Apply the move first (movedFrom), then the rename ` +
|
|
187
|
+
`(renamedFrom) in a later increment.`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
164
191
|
return {
|
|
165
192
|
table,
|
|
166
193
|
fields: def.fields,
|
|
@@ -173,6 +200,7 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
173
200
|
schema: def.schema ?? 'public',
|
|
174
201
|
private: def.private ?? false,
|
|
175
202
|
...(def.renamedFrom ? { renamedFrom: def.renamedFrom } : {}),
|
|
203
|
+
...(def.movedFrom ? { movedFrom: def.movedFrom } : {}),
|
|
176
204
|
triggers: def.triggers ?? [],
|
|
177
205
|
};
|
|
178
206
|
}
|
package/src/module.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import type { ModelDescriptor } from './model';
|
|
23
23
|
import type { DerivedDescriptor, SequenceDescriptor } from './derived';
|
|
24
|
-
import { rejectHoles } from './derived';
|
|
24
|
+
import { rejectHoles, functionArgSignature } from './derived';
|
|
25
25
|
|
|
26
26
|
export interface ModuleDefinition {
|
|
27
27
|
/** The package's Models (schema + authz). */
|
|
@@ -71,15 +71,21 @@ export function defineModule(def: ModuleDefinition): Module {
|
|
|
71
71
|
rejectHoles('defineModule', 'models', def.models);
|
|
72
72
|
rejectHoles('defineModule', 'derived', def.derived);
|
|
73
73
|
rejectHoles('defineModule', 'sequences', def.sequences);
|
|
74
|
-
// Derived identity is one name per kind
|
|
75
|
-
//
|
|
76
|
-
//
|
|
74
|
+
// Derived identity is one name per kind — except FUNCTIONS, which PostgreSQL identifies
|
|
75
|
+
// by name + argument types. Two same-name relations collide in the catalog and are
|
|
76
|
+
// caught here, at composition, before any SQL exists; two functions differing in their
|
|
77
|
+
// signature are two objects and always were. (Overloads used to be rejected outright
|
|
78
|
+
// because everything downstream keyed on `schema.name` — the drop rendered no argument
|
|
79
|
+
// list and provenance held one row for two live functions. It carries the signature now.)
|
|
77
80
|
const seen = new Set<string>();
|
|
78
81
|
for (const d of def.derived ?? []) {
|
|
79
|
-
const
|
|
82
|
+
const kind = d.kind === 'sql' ? d.objectKind : d.kind;
|
|
83
|
+
const signature = d.kind === 'function' ? functionArgSignature(d.args) : '';
|
|
84
|
+
const identity = `${kind}:${d.name}${signature}`;
|
|
80
85
|
if (seen.has(identity)) {
|
|
81
86
|
throw new Error(
|
|
82
|
-
`defineModule: two derived declarations of '${d.name}' (${identity}) — one ${d.kind} per
|
|
87
|
+
`defineModule: two derived declarations of '${d.name}${signature}' (${identity}) — one ${d.kind} per `
|
|
88
|
+
+ `${d.kind === 'function' ? 'signature (same name, DIFFERENT argument types, is an overload and is fine)' : 'name'}.`,
|
|
83
89
|
);
|
|
84
90
|
}
|
|
85
91
|
seen.add(identity);
|