@everystack/model 0.4.11 → 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.11",
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/index.ts CHANGED
@@ -12,6 +12,8 @@ export type { FieldSpec, FieldType, DefaultKind, ReferentialAction } from './fie
12
12
 
13
13
  export { canonicalColumnType, fieldFactoryCall, pgTypeRefusal } from './pg-type';
14
14
 
15
+ export { parseQualified } from './qualified';
16
+
15
17
  export { unique, check, index, foreignKey, IndexBuilder } from './constraints';
16
18
  export type { TableConstraint } from './constraints';
17
19
 
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[] {
@@ -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
+ }