@everystack/model 0.4.5 → 0.4.7

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.5",
3
+ "version": "0.4.7",
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
@@ -25,12 +25,13 @@ export type { Relation } from './relations';
25
25
  export { defineModel, defineMaterializedTable } from './model';
26
26
  export type { ModelDescriptor, ModelDefinition, ModelHooks, WrittenBy, MaterializedTableMarker, MaterializedTableDefinition } from './model';
27
27
 
28
- export { defineModule, moduleModels, moduleExtensions, moduleFunctionSql, moduleSequences, moduleDerived } from './module';
28
+ export { defineModule, moduleModels, moduleExtensions, moduleFunctionSql, moduleSequences, moduleDerived, moduleGovernedRoles } from './module';
29
29
  export type { Module, ModuleDefinition } from './module';
30
30
 
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/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). */
@@ -41,6 +41,28 @@ export interface ModuleDefinition {
41
41
  * deployed by `db:reconcile`, never migrated. Supersedes the `functions` thunk.
42
42
  */
43
43
  derived?: DerivedDescriptor[];
44
+ /**
45
+ * Roles this module's authz GOVERNS, beyond the ones its models already name.
46
+ *
47
+ * The reconciler treats declared authz as authoritative over the roles it governs and
48
+ * leaves every other grantee alone. Without that, a live role the model vocabulary
49
+ * cannot express — a migrator, an ETL account, a BI reader that predates everystack —
50
+ * has every privilege REVOKED the first time a plan is applied, because the reconciler
51
+ * unions declared ∪ live grantees and finds no declaration. On the first brownfield
52
+ * schema this was measured against, that was 32 revokes that would have broken the
53
+ * adopter's deploy pipeline mid-deploy.
54
+ *
55
+ * This list only ever WIDENS the governed set: `PUBLIC` and the fixed vocabulary
56
+ * (`anon`, `authenticated`, `admin`) plus every role the models name are always
57
+ * governed, and naming them here changes nothing. You cannot use this to stop the
58
+ * reconciler governing a role your models grant to — that would be a way to hide a
59
+ * grant from the very thing that audits it.
60
+ *
61
+ * An ungoverned grantee is NOT ignored: every plan, check and dry-run enumerates it by
62
+ * name with its exact privileges, and a grantee that is not in the stage's adoption
63
+ * baseline fails the gate. Exempt from reconciliation, never exempt from the report.
64
+ */
65
+ governedRoles?: string[];
44
66
  /**
45
67
  * Package-owned SQL emitted after the tables — SECURITY DEFINER functions, triggers, etc.
46
68
  * A thunk so it is generated lazily and can take run-specific input (the app's event-trigger
@@ -60,6 +82,8 @@ export interface Module {
60
82
  sequences: SequenceDescriptor[];
61
83
  /** Derived (compute) objects — reconciled, never migrated. */
62
84
  derived: DerivedDescriptor[];
85
+ /** Roles this module's authz governs beyond the ones its models name. Widen-only. */
86
+ governedRoles: string[];
63
87
  /** Function-SQL thunks, normalized to an array so composing modules just concatenates. */
64
88
  functions: Array<() => string>;
65
89
  }
@@ -71,15 +95,21 @@ export function defineModule(def: ModuleDefinition): Module {
71
95
  rejectHoles('defineModule', 'models', def.models);
72
96
  rejectHoles('defineModule', 'derived', def.derived);
73
97
  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.
98
+ // Derived identity is one name per kind — except FUNCTIONS, which PostgreSQL identifies
99
+ // by name + argument types. Two same-name relations collide in the catalog and are
100
+ // caught here, at composition, before any SQL exists; two functions differing in their
101
+ // signature are two objects and always were. (Overloads used to be rejected outright
102
+ // because everything downstream keyed on `schema.name` — the drop rendered no argument
103
+ // list and provenance held one row for two live functions. It carries the signature now.)
77
104
  const seen = new Set<string>();
78
105
  for (const d of def.derived ?? []) {
79
- const identity = `${d.kind === 'sql' ? d.objectKind : d.kind}:${d.name}`;
106
+ const kind = d.kind === 'sql' ? d.objectKind : d.kind;
107
+ const signature = d.kind === 'function' ? functionArgSignature(d.args) : '';
108
+ const identity = `${kind}:${d.name}${signature}`;
80
109
  if (seen.has(identity)) {
81
110
  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).`,
111
+ `defineModule: two derived declarations of '${d.name}${signature}' (${identity}) — one ${d.kind} per `
112
+ + `${d.kind === 'function' ? 'signature (same name, DIFFERENT argument types, is an overload and is fine)' : 'name'}.`,
83
113
  );
84
114
  }
85
115
  seen.add(identity);
@@ -89,6 +119,7 @@ export function defineModule(def: ModuleDefinition): Module {
89
119
  extensions: def.extensions ?? [],
90
120
  sequences: def.sequences ?? [],
91
121
  derived: def.derived ?? [],
122
+ governedRoles: [...new Set(def.governedRoles ?? [])].sort(),
92
123
  functions: def.functions ? [def.functions] : [],
93
124
  };
94
125
  }
@@ -118,3 +149,8 @@ export function moduleSequences(modules: Module[]): SequenceDescriptor[] {
118
149
  export function moduleDerived(modules: Module[]): DerivedDescriptor[] {
119
150
  return modules.flatMap((m) => m.derived);
120
151
  }
152
+
153
+ /** The union of every module's governed roles — deduped and sorted. Widen-only by construction. */
154
+ export function moduleGovernedRoles(modules: Module[]): string[] {
155
+ return [...new Set(modules.flatMap((m) => m.governedRoles))].sort();
156
+ }