@everystack/model 0.4.10 → 0.4.12

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.10",
3
+ "version": "0.4.12",
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
@@ -15,6 +15,26 @@ export type Action = 'read' | 'create' | 'update' | 'delete' | 'manage' | 'execu
15
15
  export interface AbilityCondition {
16
16
  /** Role-gated: policy `TO <role>` + grant. */
17
17
  role?: string;
18
+ /**
19
+ * The policy's AUDIENCE, decoupled from its grantees. Exactly one value: `'public'`.
20
+ *
21
+ * A bare `can('read')` means two things at once — *who holds the privilege* (a GRANT to
22
+ * anon and to authenticated) and *who the policy names* (`TO anon`, `TO authenticated`).
23
+ * One knob, two axes. A brownfield schema that wrote the ordinary Postgres idiom — a
24
+ * single policy `TO PUBLIC` — could not be expressed at all, so the plan proposed dropping
25
+ * it and creating two role-scoped ones. PUBLIC is an OPEN set and `{anon, authenticated}`
26
+ * is not, so that is a real narrowing: any other role loses the read.
27
+ *
28
+ * `policyRoles: 'public'` emits ONE policy `TO PUBLIC` and leaves the GRANTS exactly as
29
+ * they are derived today. **It can never add a grantee** — an open audience over a closed
30
+ * grant set widens nothing, because Postgres checks the GRANT first.
31
+ *
32
+ * Only `'public'` is admitted, deliberately. A free-form role list here would be a second
33
+ * way to spell the `role:` axis and could name a role no grant backs, which reads as access
34
+ * that does not exist. With the domain restricted to one open set, `policyRoles ⊇ grantees`
35
+ * holds structurally and there is no inverse case to guard.
36
+ */
37
+ policyRoles?: 'public';
18
38
  /** Row owner only: the column holding the owner id (`USING (<col> = auth.uid())`). */
19
39
  owner?: string;
20
40
  /**
@@ -49,11 +69,12 @@ export interface AbilityCondition {
49
69
  */
50
70
  ownerExpr?: unknown;
51
71
  /**
52
- * Column-scoped exposure for a `read` ability. Names the field keys a role may read
53
- * (compiled to a `GRANT SELECT (cols)` column grant, not a table grant) instead of the
54
- * whole row — e.g. `can('read', { owner: 'id', columns: ['id', 'email', 'role'] })` on
55
- * `auth.users` exposes id/email/role to the owner, never the password hash. Pairs with
56
- * `owner` (the self policy scopes the rows; the columns scope the fields).
72
+ * Column-scoped exposure for a `read` or `update` ability. Names the field keys a role may
73
+ * read or update (compiled to `GRANT SELECT|UPDATE (cols)`, never a table grant) instead of
74
+ * the whole row — e.g. `can('read', { owner: 'id', columns: ['id', 'email', 'role'] })` on
75
+ * `auth.users` exposes id/email/role to the owner, never the password hash. Every column
76
+ * ability must name a `role`, an `owner`, or both: the policy scopes rows and the columns
77
+ * scope fields.
57
78
  */
58
79
  columns?: string[];
59
80
  /** Scalar/column predicate — a sql-tagged expression compiled to an RLS USING clause. */
@@ -85,6 +106,38 @@ export interface Ability {
85
106
  condition: AbilityCondition;
86
107
  }
87
108
 
109
+ /**
110
+ * Is this an ability whose privilege is scoped to listed columns rather than the
111
+ * whole table? `defineModel` rejects every malformed near-miss before a descriptor
112
+ * reaches a compiler, so consumers can use this as the one classification rule.
113
+ */
114
+ export function isColumnAbility(ability: Ability): boolean {
115
+ return (ability.action === 'read' || ability.action === 'update')
116
+ && Array.isArray(ability.condition.columns)
117
+ && ability.condition.columns.length > 0
118
+ && Boolean(ability.condition.role || ability.condition.owner);
119
+ }
120
+
121
+ /**
122
+ * Is this ability a PUBLIC read — a read path open to a caller with no session?
123
+ *
124
+ * The visibility question the soft-delete guard turns on: "what can someone with no session
125
+ * see?". Both a role-less read and `role: 'anon'` answer it the same way, because `anon` IS
126
+ * the anonymous role; an `owner` or `via` read answers it differently, because those scope
127
+ * rows to a session that exists.
128
+ *
129
+ * ONE definition, because there were two. `defineModel`'s refusal fired only on a role-less
130
+ * read while `db:pull`'s renderer already counted `role: 'anon'` — so a table whose
131
+ * authenticated read diverges (which pull renders as an anon-scoped read) slipped past the
132
+ * refusal while still being rendered as public. Two surfaces, two answers, one question.
133
+ */
134
+ export function isPublicReadAbility(ability: Ability): boolean {
135
+ return ability.action === 'read'
136
+ && !ability.condition.owner
137
+ && !ability.condition.via
138
+ && (!ability.condition.role || ability.condition.role === 'anon');
139
+ }
140
+
88
141
  /** Declare one authorization rule. */
89
142
  export function can(action: Action, condition: AbilityCondition = {}): Ability {
90
143
  return { action, condition };
package/src/drizzle.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  pgTable,
19
19
  pgSchema,
20
20
  pgEnum,
21
+ customType,
21
22
  text,
22
23
  uuid,
23
24
  integer,
@@ -106,6 +107,13 @@ function baseColumn(columnName: string, spec: FieldSpec): PgColumnBuilderBase {
106
107
  return json(columnName);
107
108
  case 'enum':
108
109
  return getEnum(spec)(columnName);
110
+ case 'pgType': {
111
+ // A verbatim type: drizzle carries the exact string as the column's SQL
112
+ // type and the value stays `unknown` — no client-side typing is promised.
113
+ const typeName = spec.pgTypeName;
114
+ if (!typeName) throw new Error('pgType field is missing pgTypeName');
115
+ return customType<{ data: unknown }>({ dataType: () => typeName })(columnName);
116
+ }
109
117
  default: {
110
118
  const exhaustive: never = spec.type;
111
119
  throw new Error(`Unsupported field type: ${String(exhaustive)}`);
package/src/field.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { RawSql } from './raw-sql';
15
+ import { pgTypeRefusal } from './pg-type';
15
16
 
16
17
  export type FieldType =
17
18
  | 'text'
@@ -30,7 +31,8 @@ export type FieldType =
30
31
  | 'varchar'
31
32
  | 'json'
32
33
  | 'jsonb'
33
- | 'enum';
34
+ | 'enum'
35
+ | 'pgType';
34
36
 
35
37
  export type DefaultKind = 'value' | 'now' | 'random' | 'sql';
36
38
 
@@ -49,6 +51,14 @@ export interface FieldSpec {
49
51
  scale?: number;
50
52
  /** `varchar(length)` — maximum character length. */
51
53
  length?: number;
54
+ /**
55
+ * The verbatim Postgres type of a `pgType` field — the exact canonical
56
+ * `format_type` spelling (`hstore`, `geometry(Geometry,4326)`, `tsvector`).
57
+ * The string IS the type's identity: introspection, the differ, and the
58
+ * fingerprint all compare it byte-for-byte, so a pulled column round-trips
59
+ * with no coercion and no drift.
60
+ */
61
+ pgTypeName?: string;
52
62
  isPrimaryKey: boolean;
53
63
  isNotNull: boolean;
54
64
  isUnique: boolean;
@@ -94,7 +104,7 @@ export interface FieldSpec {
94
104
  export class FieldBuilder {
95
105
  readonly spec: FieldSpec;
96
106
 
97
- constructor(type: FieldType, extra?: Pick<FieldSpec, 'enumName' | 'enumValues' | 'precision' | 'scale' | 'length'>) {
107
+ constructor(type: FieldType, extra?: Pick<FieldSpec, 'enumName' | 'enumValues' | 'precision' | 'scale' | 'length' | 'pgTypeName'>) {
98
108
  this.spec = {
99
109
  type,
100
110
  enumName: extra?.enumName,
@@ -102,6 +112,7 @@ export class FieldBuilder {
102
112
  precision: extra?.precision,
103
113
  scale: extra?.scale,
104
114
  length: extra?.length,
115
+ pgTypeName: extra?.pgTypeName,
105
116
  isPrimaryKey: false,
106
117
  isNotNull: false,
107
118
  isUnique: false,
@@ -252,4 +263,19 @@ export const field = {
252
263
  json: (): FieldBuilder => new FieldBuilder('json'),
253
264
  /** A Postgres enum. The `name` is the `CREATE TYPE` name and is always explicit, so `db:pull` round-trips it. */
254
265
  enum: (name: string, values: readonly string[]): FieldBuilder => new FieldBuilder('enum', { enumName: name, enumValues: values }),
266
+ /**
267
+ * A verbatim Postgres type the vocabulary has no first-class field for —
268
+ * `field.pgType('hstore')`, `field.pgType('geometry(Geometry,4326)')`,
269
+ * `field.pgType('tsvector')`. The exact string is the column's type, end to
270
+ * end: DDL emits it verbatim, introspection reports it verbatim, and the
271
+ * differ/fingerprint compare it byte-for-byte — so an extension-typed column
272
+ * round-trips with no coercion. The spelling must be canonical and must not
273
+ * shadow a first-class field (one type, one spelling): non-canonical or
274
+ * first-class spellings are refused with the correct call named.
275
+ */
276
+ pgType: (typeName: string): FieldBuilder => {
277
+ const refusal = pgTypeRefusal(typeName);
278
+ if (refusal) throw new Error(refusal);
279
+ return new FieldBuilder('pgType', { pgTypeName: typeName });
280
+ },
255
281
  } as const;
package/src/index.ts CHANGED
@@ -10,13 +10,17 @@
10
10
  export { field, FieldBuilder } from './field';
11
11
  export type { FieldSpec, FieldType, DefaultKind, ReferentialAction } from './field';
12
12
 
13
+ export { canonicalColumnType, fieldFactoryCall, pgTypeRefusal } from './pg-type';
14
+
15
+ export { parseQualified } from './qualified';
16
+
13
17
  export { unique, check, index, foreignKey, IndexBuilder } from './constraints';
14
18
  export type { TableConstraint } from './constraints';
15
19
 
16
20
  export { sql } from './raw-sql';
17
21
  export type { RawSql } from './raw-sql';
18
22
 
19
- export { can } from './can';
23
+ export { can, isColumnAbility, isPublicReadAbility } from './can';
20
24
  export type { Ability, AbilityCondition, Action } from './can';
21
25
 
22
26
  export { belongsTo, hasMany } from './relations';
package/src/model.ts CHANGED
@@ -13,6 +13,7 @@
13
13
 
14
14
  import type { FieldBuilder } from './field';
15
15
  import type { Ability } from './can';
16
+ import { isPublicReadAbility } from './can';
16
17
  import type { Relation } from './relations';
17
18
  import type { TableConstraint } from './constraints';
18
19
  import type { RawSql } from './raw-sql';
@@ -207,6 +208,72 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
207
208
  if (a.action === 'execute') {
208
209
  throw new Error(`model '${table}': can('execute') is a function ability — declare it on the defineFunction it gates.`);
209
210
  }
211
+
212
+ // A column list is authorization, not a selection hint. If it reaches a table-grant path
213
+ // unnoticed, Postgres receives a whole-table privilege. Admit only the shapes the column
214
+ // compiler understands, and reject an empty list because it is indistinguishable from no
215
+ // column scope at all.
216
+ if (a.condition.columns !== undefined) {
217
+ if (a.action !== 'read' && a.action !== 'update') {
218
+ throw new Error(
219
+ `model '${table}': columns apply only to can('read') or can('update'), not can('${a.action}').`,
220
+ );
221
+ }
222
+ if (!Array.isArray(a.condition.columns) || a.condition.columns.length === 0) {
223
+ throw new Error(
224
+ `model '${table}': columns must be a non-empty array on can('${a.action}'); an empty list would erase the column scope.`,
225
+ );
226
+ }
227
+ if (!a.condition.role && !a.condition.owner) {
228
+ throw new Error(
229
+ `model '${table}': columns require role and/or owner on can('${a.action}'); otherwise the intended column grant has no declared grantee or row gate.`,
230
+ );
231
+ }
232
+ if (a.condition.policyRoles !== undefined) {
233
+ throw new Error(
234
+ `model '${table}': columns cannot use policyRoles. A column ability's policy is TO its declared role (or authenticated for owner-only), never an open audience.`,
235
+ );
236
+ }
237
+ if (a.condition.owner && !(a.condition.owner in def.fields)) {
238
+ throw new Error(
239
+ `model '${table}': columns names owner '${a.condition.owner}', but no such field exists.`,
240
+ );
241
+ }
242
+ for (const column of a.condition.columns) {
243
+ if (!(column in def.fields)) {
244
+ throw new Error(
245
+ `model '${table}': columns names '${column}', but no such field exists. Column abilities use model field keys.`,
246
+ );
247
+ }
248
+ }
249
+ }
250
+
251
+ // `policyRoles` decouples the policy's AUDIENCE from its GRANTEES, and it is deliberately
252
+ // amputated to one open set. Anything else would be a second spelling of the `role:` axis
253
+ // and could name a role no grant backs — a policy that reads as access which does not
254
+ // exist. A key that cannot say NO is not a fix, so it says NO here, at authoring time.
255
+ if (a.condition.policyRoles !== undefined) {
256
+ if (a.condition.policyRoles !== 'public') {
257
+ throw new Error(
258
+ `model '${table}': policyRoles admits exactly one value, 'public' — got ` +
259
+ `${JSON.stringify(a.condition.policyRoles)}. It exists to express a single policy TO PUBLIC, ` +
260
+ `the open audience a brownfield schema already wrote. To gate on a named role use role: '<name>', ` +
261
+ `which sets the grant too.`,
262
+ );
263
+ }
264
+ if (a.action !== 'read') {
265
+ throw new Error(
266
+ `model '${table}': policyRoles is a READ audience — can('${a.action}', { policyRoles: 'public' }) would ` +
267
+ `open a write policy to every role. Declare writes with role:/owner:.`,
268
+ );
269
+ }
270
+ if (a.condition.role) {
271
+ throw new Error(
272
+ `model '${table}': policyRoles: 'public' and role: '${a.condition.role}' both name the policy's audience. ` +
273
+ `Declare one. policyRoles states the audience and leaves the grants alone; role states both.`,
274
+ );
275
+ }
276
+ }
210
277
  }
211
278
 
212
279
  // An array field's value default must be a JS array — `.default([])`, not the
@@ -230,10 +297,19 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
230
297
  // so there is no default: on the exact set of models where the answer changes behaviour (a
231
298
  // `deletedAt` field plus a public read), it must be declared. `softDelete: true` is what the
232
299
  // inference used to do.
233
- const hasPublicRead = (def.abilities ?? []).some(
234
- (a) => a.action === 'read' && !a.condition.role && !a.condition.owner && !a.condition.via,
235
- );
236
- if ('deletedAt' in def.fields && hasPublicRead && def.softDelete === undefined) {
300
+ // `isPublicReadAbility`, not a local copy: this refusal and db:pull's renderer were asking
301
+ // the same question — "can a caller with no session see these rows?" — and answering it
302
+ // differently. The renderer already counted `role: 'anon'`; this refusal did not, so a table
303
+ // whose authenticated read diverges (rendered as an anon-scoped read) slipped past the very
304
+ // decision the refusal exists to force. It now fires on the anonymous audience however it is
305
+ // spelled. Explicit `softDelete: false` remains the one-line answer, and pull writes that
306
+ // line into every model it renders, so a pulled checkout keeps loading.
307
+ const hasPublicRead = (def.abilities ?? []).some(isPublicReadAbility);
308
+ // Both spellings of the field key. The guard used to test `'deletedAt' in def.fields`
309
+ // literally, so a model keying the column `deleted_at` — the shape a snake_case brownfield
310
+ // schema pulls as — carried a soft-delete column past a guard that never looked at it.
311
+ const softDeleteField = 'deletedAt' in def.fields || 'deleted_at' in def.fields;
312
+ if (softDeleteField && hasPublicRead && def.softDelete === undefined) {
237
313
  throw new Error(
238
314
  `model '${table}': has a 'deletedAt' field and a public read, so it must declare softDelete. `
239
315
  + `softDelete: true excludes soft-deleted rows from the public read policies (what the compiler used to infer from the field's presence); `
package/src/module.ts CHANGED
@@ -22,6 +22,8 @@
22
22
  import type { ModelDescriptor } from './model';
23
23
  import type { DerivedDescriptor, SequenceDescriptor } from './derived';
24
24
  import { rejectHoles, functionArgSignature } from './derived';
25
+ import { deriveFunctionSchemas } from './derive';
26
+ import { parseQualified } from './qualified';
25
27
 
26
28
  export interface ModuleDefinition {
27
29
  /** The package's Models (schema + authz). */
@@ -114,6 +116,7 @@ export function defineModule(def: ModuleDefinition): Module {
114
116
  }
115
117
  seen.add(identity);
116
118
  }
119
+ assertFunctionsAreDeclared(def);
117
120
  return {
118
121
  models: def.models ?? [],
119
122
  extensions: def.extensions ?? [],
@@ -124,6 +127,75 @@ export function defineModule(def: ModuleDefinition): Module {
124
127
  };
125
128
  }
126
129
 
130
+ /**
131
+ * `writtenBy: 'functions'` is a PROMISE that functions do the writing. This is where the
132
+ * promise gets checked.
133
+ *
134
+ * The defect it closes, found 2026-08-04: `@everystack/auth` declared seven models
135
+ * `writtenBy: 'functions'` and shipped 21 SECURITY DEFINER functions — through the deprecated
136
+ * `functions` thunk, which exactly ONE command reads (`db:generate --init`). `db:reconcile`
137
+ * reads `derived`, and `derived` was empty. So a brownfield adopter installed the tables, got
138
+ * ENABLE-not-FORCE RLS on the strength of `writtenBy: 'functions'`, installed none of the
139
+ * functions that were supposed to be the only writers, and every gate reported clean. The
140
+ * declaration and the thing it depends on were checked by nobody.
141
+ *
142
+ * The check is per-MODULE, not across the composed set: a module that owns a
143
+ * functions-written model owns the functions that write it, and firing here can name the
144
+ * module at fault. Across modules the error arrives after everything is flattened and can
145
+ * only say that something, somewhere, is missing.
146
+ *
147
+ * A thunk downgrades the error to a warning rather than satisfying the check — the thunk lane
148
+ * does install, just on one command, so a consumer still on it is degraded and not broken.
149
+ */
150
+ function assertFunctionsAreDeclared(def: ModuleDefinition): void {
151
+ const models = def.models ?? [];
152
+ const owed = deriveFunctionSchemas(models);
153
+ const declared = new Set(
154
+ (def.derived ?? [])
155
+ .filter((d) => (d.kind === 'sql' ? d.objectKind : d.kind) === 'function')
156
+ .map((d) => parseQualified(d.name).schema),
157
+ );
158
+ const missing = owed.filter((schema) => !declared.has(schema));
159
+
160
+ if (missing.length === 0) {
161
+ if (def.functions) {
162
+ console.warn(
163
+ 'defineModule: the `functions` thunk is deprecated and only `db:generate --init` reads it — '
164
+ + '`db:reconcile` reads `derived`, so anything declared there alone never reaches a brownfield '
165
+ + 'database. Move it to `derived: [defineFunction(…)]`.',
166
+ );
167
+ }
168
+ return;
169
+ }
170
+
171
+ const blame = missing
172
+ .map((schema) => {
173
+ const tables = models
174
+ .filter((m) => m.writtenBy === 'functions' && m.schema === schema)
175
+ .map((m) => `${m.schema}.${m.table}`)
176
+ .sort();
177
+ return `schema '${schema}' (${tables.join(', ')})`;
178
+ })
179
+ .join('; ');
180
+
181
+ if (def.functions) {
182
+ console.warn(
183
+ `defineModule: ${blame} declares writtenBy: 'functions' but every function is in the deprecated `
184
+ + '`functions` thunk, which only `db:generate --init` reads. `db:reconcile` reads `derived`, so a '
185
+ + 'brownfield database gets the tables and none of their writers. Move them to '
186
+ + '`derived: [defineFunction(…)]`.',
187
+ );
188
+ return;
189
+ }
190
+
191
+ throw new Error(
192
+ `defineModule: ${blame} declares writtenBy: 'functions' but the module declares no function in `
193
+ + `that schema. writtenBy: 'functions' is what turns RLS FORCE off, so the tables ship writable `
194
+ + `only by functions that do not exist. Declare them as \`derived: [defineFunction('${missing[0]}.…', …)]\`, `
195
+ + `or change writtenBy if functions are not the writer.`,
196
+ );
197
+ }
198
+
127
199
  /** The flattened model list across modules — for everything that still takes a model array
128
200
  * (handler-config derivation, FORCE carveouts, the drift guard). */
129
201
  export function moduleModels(modules: Module[]): ModelDescriptor[] {
package/src/pg-type.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * pg-type — the ONE definition of a column type's spelling.
3
+ *
4
+ * Three questions, answered here and only here, so every surface (the field
5
+ * factory, db:pull's renderer, introspection, the differ, the fingerprint)
6
+ * agrees by construction:
7
+ *
8
+ * 1. What is the canonical spelling of a `format_type` string?
9
+ * (`canonicalColumnType` — the v5 rule: a redundant default precision is
10
+ * not part of a type's identity.)
11
+ * 2. Which spellings are already owned by a first-class `field.*()` factory?
12
+ * (`fieldFactoryCall` — the inverse map db:pull renders with, and the map
13
+ * `field.pgType` refuses against, so one type always has one spelling.)
14
+ * 3. Everything else is carried VERBATIM by `field.pgType(...)` — the type
15
+ * string itself is the identity, end to end.
16
+ */
17
+
18
+ /**
19
+ * Canonicalize a `format_type` spelling. Only the redundant default precision,
20
+ * only the timestamp/time family: `timestamp(6) without time zone` is the same
21
+ * type as `timestamp without time zone` (6 is the family default), so both
22
+ * spell the canonical, shorter form. `timestamp(3)` is a real difference and
23
+ * passes through untouched.
24
+ */
25
+ export function canonicalColumnType(type: string): string {
26
+ return type.replace(/^(timestamp|timestamptz|time|timetz)\(6\)(\s|$)/, '$1$2');
27
+ }
28
+
29
+ /** `format_type` → the `field.*()` factory that produces it (the non-parameterized types). */
30
+ const FIELD_FACTORY: Record<string, string> = {
31
+ uuid: 'uuid',
32
+ text: 'text',
33
+ integer: 'integer',
34
+ bigint: 'bigint',
35
+ real: 'real',
36
+ 'double precision': 'doublePrecision',
37
+ boolean: 'boolean',
38
+ json: 'json',
39
+ jsonb: 'jsonb',
40
+ date: 'date',
41
+ 'timestamp with time zone': 'timestamptz',
42
+ 'timestamp without time zone': 'timestamp',
43
+ timestamp: 'timestamp',
44
+ };
45
+
46
+ /**
47
+ * The full `field.*()` call for a `format_type` string, or null when no
48
+ * first-class field maps it. Handles the parameterized types `format_type`
49
+ * spells out — `numeric(p,s)` and `character varying(n)` — so a pulled column
50
+ * round-trips back to the same DDL. A scale-0 numeric renders as the cleaner
51
+ * `field.numeric(p)` (identical to `(p, 0)`).
52
+ */
53
+ export function fieldFactoryCall(type: string): string | null {
54
+ // An array is its base type + `.array()` — exactly how the compiler emits it
55
+ // (`format_type` spells arrays `text[]`). An array of an unmapped base stays null.
56
+ if (type.endsWith('[]')) {
57
+ const base = fieldFactoryCall(type.slice(0, -2));
58
+ return base ? `${base}.array()` : null;
59
+ }
60
+
61
+ const direct = FIELD_FACTORY[type];
62
+ if (direct) return `field.${direct}()`;
63
+
64
+ const num = type.match(/^numeric(?:\((\d+),(\d+)\))?$/);
65
+ if (num) {
66
+ if (num[1] == null) return 'field.numeric()';
67
+ return num[2] === '0' ? `field.numeric(${num[1]})` : `field.numeric(${num[1]}, ${num[2]})`;
68
+ }
69
+
70
+ const vc = type.match(/^character varying(?:\((\d+)\))?$/);
71
+ if (vc) return vc[1] != null ? `field.varchar(${vc[1]})` : 'field.varchar()';
72
+
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Validate the argument of `field.pgType(...)`. Returns the reason the spelling
78
+ * is refused, or null when it may be carried verbatim. Refusals exist so a type
79
+ * never has two spellings in the vocabulary — the split-identity family of bugs.
80
+ */
81
+ export function pgTypeRefusal(typeName: string): string | null {
82
+ const t = typeName.trim();
83
+ if (!t) return 'field.pgType() needs a Postgres type name';
84
+ if (t !== typeName) return `field.pgType('${typeName}'): no surrounding whitespace — write '${t}'`;
85
+ if (t.endsWith('[]')) {
86
+ return `field.pgType('${t}'): declare the scalar and add .array() — field.pgType('${t.slice(0, -2)}').array()`;
87
+ }
88
+ const canonical = canonicalColumnType(t);
89
+ if (canonical !== t) {
90
+ return `field.pgType('${t}'): the canonical spelling is '${canonical}' — a redundant default precision is not part of the type's identity`;
91
+ }
92
+ const firstClass = fieldFactoryCall(t);
93
+ if (firstClass) {
94
+ return `field.pgType('${t}'): this type is first-class — use ${firstClass}`;
95
+ }
96
+ return null;
97
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Splitting a declared name into `{ schema, name }`.
3
+ *
4
+ * This lives in `@everystack/model` because the names it parses are MODEL vocabulary: a
5
+ * descriptor declares `defineFunction('auth.sign_in_session', …)` and a model declares its
6
+ * `schema` separately, so anything that has to line those two up — the composition guard here,
7
+ * every emitter and the reconciler in `@everystack/cli` — must agree on where the dot is. The
8
+ * cli re-exports this from `derived-source.ts`, so there is ONE definition and its callers
9
+ * cannot drift apart. (Same reasoning as `fieldFactoryCall`: the parser belongs with the
10
+ * vocabulary it parses, not with the first consumer that needed it.)
11
+ *
12
+ * `public` is the unqualified default because that is what PostgreSQL resolves a bare name to
13
+ * under the default `search_path` — the parse must match the server, not a convention.
14
+ */
15
+
16
+ /** Strip quotes from an identifier and split an optionally schema-qualified name. */
17
+ export function parseQualified(raw: string): { schema: string; name: string } {
18
+ const parts: string[] = [];
19
+ let current = '';
20
+ let quoted = false;
21
+ for (let i = 0; i < raw.length; i++) {
22
+ const ch = raw[i];
23
+ if (ch === '"') {
24
+ if (quoted && raw[i + 1] === '"') { current += '"'; i++; continue; }
25
+ quoted = !quoted;
26
+ continue;
27
+ }
28
+ if (ch === '.' && !quoted) { parts.push(current); current = ''; continue; }
29
+ current += ch;
30
+ }
31
+ parts.push(current);
32
+ if (parts.length >= 2) return { schema: parts[0], name: parts.slice(1).join('.') };
33
+ return { schema: 'public', name: parts[0] };
34
+ }