@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/model",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
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": {
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: `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.
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 identity = `${d.kind === 'sql' ? d.objectKind : d.kind}:${d.name}`;
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 name (function overloads are v1-rejected; rename, or collapse to one signature with arg defaults).`,
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);