@everystack/model 0.4.20 → 0.4.21

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/CHANGELOG.md CHANGED
@@ -4,6 +4,38 @@ All notable changes to `@everystack/model` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.21] - 2026-10-14
8
+
9
+ ### Added
10
+
11
+ - apply state prerequisites atomically
12
+ - link table name references through model thunks
13
+ - use table names in the example and gate import cycles
14
+
15
+ ### Fixed
16
+
17
+ - break the model and auth import cycles; gate new ones
18
+ - link cached placeholder references to their targets
19
+ - reject conflicting name bindings
20
+ - rowOwnership exceptRoles lifts the backstop for manage roles
21
+ - only a plain manage role lifts the rowOwnership backstop
22
+
23
+ ## [Unreleased]
24
+
25
+ ### Added
26
+
27
+ - apply state prerequisites atomically
28
+
29
+ ## [0.4.20] - 2026-09-26
30
+
31
+ ### BREAKING CHANGES
32
+
33
+ - defineModel refuses owner or via under a no-session audience
34
+
35
+ ### Fixed
36
+
37
+ - refuse role: 'anon' combined with owner or via
38
+
7
39
  ## [0.4.19] - 2026-09-16
8
40
 
9
41
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/model",
3
- "version": "0.4.20",
3
+ "version": "0.4.21",
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/derive.ts CHANGED
@@ -14,6 +14,8 @@
14
14
 
15
15
  import type { ModelDescriptor } from './model';
16
16
  import type { ModelHooks } from './model';
17
+ import type { AbilityCondition } from './can';
18
+ import { linkModels } from './link';
17
19
 
18
20
  /** The handler's RelationConfig shape, mirrored structurally (no @everystack/api dep). */
19
21
  export interface RelationConfig {
@@ -27,7 +29,8 @@ export interface DerivedHandlerConfig {
27
29
  exposedTables: string[];
28
30
  hiddenColumns: Record<string, string[]>;
29
31
  protectedFields: Record<string, string[]>;
30
- rowOwnership: Record<string, { column: string; userField: string }>;
32
+ /** `exceptRoles` is present only when a `can('manage', { role })` exists for the table. */
33
+ rowOwnership: Record<string, { column: string; userField: string; exceptRoles?: string[] }>;
31
34
  softDelete: { tables: string[] };
32
35
  relations: Record<string, Record<string, RelationConfig>>;
33
36
  hooks: Record<string, ModelHooks>;
@@ -44,10 +47,11 @@ function primaryKeyOf(model: ModelDescriptor): string {
44
47
  }
45
48
 
46
49
  export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerConfig {
50
+ linkModels(models);
47
51
  const exposedTables: string[] = [];
48
52
  const hiddenColumns: Record<string, string[]> = {};
49
53
  const protectedFields: Record<string, string[]> = {};
50
- const rowOwnership: Record<string, { column: string; userField: string }> = {};
54
+ const rowOwnership: DerivedHandlerConfig['rowOwnership'] = {};
51
55
  const softDeleteTables: string[] = [];
52
56
  const relations: Record<string, Record<string, RelationConfig>> = {};
53
57
  const hooks: Record<string, ModelHooks> = {};
@@ -81,6 +85,19 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
81
85
  column: ownerAbility.condition.owner!,
82
86
  userField: ownerAbility.condition.userField ?? DEFAULT_USER_FIELD,
83
87
  };
88
+ // A role that may `manage` the table is exempt from the backstop, mirroring the
89
+ // RLS policy compiled from the same ability. Only a plain `can('manage', { role })`
90
+ // counts: a role-scoped update or delete alone does not lift it, and neither does a
91
+ // manage ability with any other condition, which narrows what RLS lets it write
92
+ // (fail closed: a whitelist of `role`, not a blacklist of the rest).
93
+ const exceptRoles = [
94
+ ...new Set(
95
+ model.abilities
96
+ .filter((a) => a.action === 'manage' && isPlainRole(a.condition))
97
+ .map((a) => a.condition.role as string),
98
+ ),
99
+ ];
100
+ if (exceptRoles.length) rowOwnership[table].exceptRoles = exceptRoles;
84
101
  }
85
102
 
86
103
  // Declared, not inferred — one `softDelete` decision drives both the API filter and the
@@ -127,6 +144,12 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
127
144
  };
128
145
  }
129
146
 
147
+ /** True when the condition is exactly `{ role: <string> }`: every other key is absent or undefined. */
148
+ function isPlainRole(condition: AbilityCondition): boolean {
149
+ return typeof condition.role === 'string'
150
+ && Object.entries(condition).every(([key, value]) => key === 'role' || value === undefined);
151
+ }
152
+
130
153
  function fieldsWhere(
131
154
  model: ModelDescriptor,
132
155
  pred: (f: ModelDescriptor['fields'][string]) => boolean,
package/src/derived.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  import type { RawSql } from './raw-sql';
18
18
  import type { Ability } from './can';
19
19
  import type { ModelDescriptor } from './model';
20
- import { TABLE_PRIVILEGES } from './model';
20
+ import { TABLE_PRIVILEGES } from './privileges';
21
21
  import type { IndexBuilder } from './constraints';
22
22
  import type { FieldBuilder } from './field';
23
23
 
package/src/field.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  import type { RawSql } from './raw-sql';
15
15
  import { pgTypeRefusal } from './pg-type';
16
16
  import { assertConstraintName, assertFkValidate } from './constraints';
17
+ import { referenceThunk } from './link';
17
18
 
18
19
  export type FieldType =
19
20
  | 'text'
@@ -230,7 +231,8 @@ export class FieldBuilder {
230
231
  }
231
232
 
232
233
  /**
233
- * Declare a foreign key. The target is a model thunk (forward-reference safe). Optional
234
+ * Declare a foreign key. The target is a table name or model thunk. Names are resolved
235
+ * by linkModels when the complete set is available. Optional
234
236
  * `onDelete`/`onUpdate` referential actions are column-scoped, so they live here, not on
235
237
  * the table — `field.uuid().references(() => User, { onDelete: 'cascade' })`.
236
238
  *
@@ -240,8 +242,8 @@ export class FieldBuilder {
240
242
  * {@link FieldSpec.referencesValidate} for why a fifteen-year-old schema needs to be able
241
243
  * to say it.
242
244
  */
243
- references(target: () => unknown, opts?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction; name?: string; validate?: boolean }): this {
244
- this.spec.references = target;
245
+ references(target: (() => unknown) | string, opts?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction; name?: string; validate?: boolean }): this {
246
+ this.spec.references = referenceThunk(target);
245
247
  if (opts?.onDelete) this.spec.onDelete = opts.onDelete;
246
248
  if (opts?.onUpdate) this.spec.onUpdate = opts.onUpdate;
247
249
  if (opts?.name !== undefined) this.spec.constraintName = assertConstraintName(opts.name, 'foreign key');
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export type { FieldSpec, FieldType, DefaultKind, ReferentialAction } from './fie
13
13
  export { canonicalColumnType, fieldFactoryCall, pgTypeRefusal } from './pg-type';
14
14
 
15
15
  export { parseQualified } from './qualified';
16
+ export { linkModels, ModelLinkError } from './link';
16
17
 
17
18
  export { unique, check, index, foreignKey, IndexBuilder } from './constraints';
18
19
  export type { TableConstraint } from './constraints';
package/src/link.ts ADDED
@@ -0,0 +1,126 @@
1
+ import type { ModelDescriptor } from './model';
2
+ import { parseQualified } from './qualified';
3
+
4
+ /** A failed or conflicting name binding must never become a usable model. */
5
+ export class ModelLinkError extends Error {
6
+ constructor(message: string) {
7
+ super(message);
8
+ this.name = 'ModelLinkError';
9
+ }
10
+ }
11
+
12
+ type NameReference<T> = (() => T) & {
13
+ refName: string;
14
+ refLocation?: string;
15
+ boundTarget?: T;
16
+ linkedTarget?: () => T;
17
+ };
18
+
19
+ function isNameReference<T>(target: () => T): target is NameReference<T> {
20
+ return 'refName' in target && typeof target.refName === 'string';
21
+ }
22
+
23
+ /** Keep descriptors callable until the complete model set is available. */
24
+ export function referenceThunk<T>(target: (() => T) | string): () => T {
25
+ if (typeof target !== 'string') return target;
26
+ const placeholder: NameReference<T> = Object.assign((): T => {
27
+ // Lazy consumers can capture this function before linking (Drizzle caches tables).
28
+ // They must recover after the caller follows the error's linking instruction.
29
+ if (placeholder.linkedTarget) return placeholder.linkedTarget();
30
+ throw new ModelLinkError(
31
+ `reference '${placeholder.refName}' on '${placeholder.refLocation ?? 'unknown'}' is not linked: export your models through linkModels()`,
32
+ );
33
+ }, { refName: target });
34
+ return placeholder;
35
+ }
36
+
37
+ /** Called at declaration, where the owning table and field/relation name are known. */
38
+ export function referenceLocation(target: (() => unknown) | undefined, location: string): void {
39
+ if (target && isNameReference(target)) target.refLocation = location;
40
+ }
41
+
42
+ /**
43
+ * Resolve name references against this set only, then replace their placeholders with
44
+ * bound model thunks that retain their names. Returns the same array; repeated calls preserve thunk identities.
45
+ * Existing model thunks are never evaluated or replaced.
46
+ */
47
+ export function linkModels<T extends readonly ModelDescriptor[]>(models: T): T {
48
+ const uniqueModels = [...new Set(models)];
49
+ const byName = new Map<string, ModelDescriptor[]>();
50
+ for (const model of uniqueModels) {
51
+ const candidates = byName.get(model.table) ?? [];
52
+ candidates.push(model);
53
+ byName.set(model.table, candidates);
54
+ }
55
+ const modelName = (model: ModelDescriptor): string =>
56
+ model.schema === 'public' ? model.table : `${model.schema}.${model.table}`;
57
+
58
+ // One resolver for fields and relations. Prefixing a schema through the same parser
59
+ // distinguishes bare names from explicit public names, including quoted dots.
60
+ function resolve(target: NameReference<ModelDescriptor>, model: ModelDescriptor, kind: string, key: string): ModelDescriptor {
61
+ const name = target.refName;
62
+ let parsed: ReturnType<typeof parseQualified>;
63
+ let qualified: boolean;
64
+ try {
65
+ parsed = parseQualified(name);
66
+ qualified = parseQualified(`public.${name}`).name !== parsed.name;
67
+ } catch (err) {
68
+ throw new ModelLinkError(`reference '${name}' on '${target.refLocation ?? 'unknown'}': ${err instanceof Error ? err.message : String(err)}`);
69
+ }
70
+ const candidates = (byName.get(parsed.name) ?? [])
71
+ .filter(candidate => !qualified || candidate.schema === parsed.schema);
72
+ const context = `model '${modelName(model)}': ${kind} '${key}'`;
73
+ if (!candidates.length) {
74
+ if (target.boundTarget) return target.boundTarget;
75
+ const defined = uniqueModels.map(modelName).sort().join(', ');
76
+ throw new ModelLinkError(`${context} targets '${name}', which is not a defined model (defined: ${defined})`);
77
+ }
78
+ if (candidates.length > 1) {
79
+ const names = candidates.map(candidate => `${candidate.schema}.${candidate.table}`).sort().join(', ');
80
+ throw new ModelLinkError(`${context} targets ambiguous model '${name}' (candidates: ${names})`);
81
+ }
82
+ const resolved = candidates[0];
83
+ if (target.boundTarget && target.boundTarget !== resolved) {
84
+ const previous = target.boundTarget;
85
+ const oldName = `${previous.schema}.${previous.table}`;
86
+ const newName = `${resolved.schema}.${resolved.table}`;
87
+ const difference = oldName === newName
88
+ ? `a different object for the same table '${oldName}' (models loaded twice)`
89
+ : `a different table ('${oldName}' instead of '${newName}')`;
90
+ throw new ModelLinkError(
91
+ `reference '${name}' on '${target.refLocation ?? 'unknown'}' is already linked to ${difference}; models are shared objects, link them from one set`,
92
+ );
93
+ }
94
+ return resolved;
95
+ }
96
+
97
+ // Resolve everything before changing descriptors, so a typo can be corrected and retried
98
+ // without leaving earlier references bound to a different, incomplete set.
99
+ const replacements: Array<() => void> = [];
100
+ for (const model of uniqueModels) {
101
+ for (const [key, field] of Object.entries(model.fields)) {
102
+ const target = field.spec.references;
103
+ if (target && isNameReference(target)) {
104
+ const resolved = resolve(target as NameReference<ModelDescriptor>, model, 'field', key);
105
+ if (target.boundTarget) continue;
106
+ const linked = Object.assign(() => resolved, {
107
+ refName: target.refName, refLocation: target.refLocation, boundTarget: resolved,
108
+ });
109
+ replacements.push(() => { target.boundTarget = resolved; target.linkedTarget = linked; field.spec.references = linked; });
110
+ }
111
+ }
112
+ for (const [key, relation] of Object.entries(model.relations)) {
113
+ const target = relation.target;
114
+ if (isNameReference(target)) {
115
+ const resolved = resolve(target, model, 'relation', key);
116
+ if (target.boundTarget) continue;
117
+ const linked = Object.assign(() => resolved, {
118
+ refName: target.refName, refLocation: target.refLocation, boundTarget: resolved,
119
+ });
120
+ replacements.push(() => { target.boundTarget = resolved; target.linkedTarget = linked; relation.target = linked; });
121
+ }
122
+ }
123
+ }
124
+ for (const replace of replacements) replace();
125
+ return models;
126
+ }
package/src/model.ts CHANGED
@@ -12,12 +12,14 @@
12
12
  */
13
13
 
14
14
  import type { FieldBuilder } from './field';
15
+ import { referenceLocation } from './link';
15
16
  import type { Ability } from './can';
16
17
  import { isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete, sessionlessAudience } from './can';
17
18
  import type { Relation } from './relations';
18
19
  import type { TableConstraint } from './constraints';
19
20
  import type { RawSql } from './raw-sql';
20
21
  import { validateTriggersFor, type TriggerSpec, type DependsOnRef } from './derived';
22
+ import { TABLE_PRIVILEGES, type TablePrivilege } from './privileges';
21
23
 
22
24
  /**
23
25
  * Per-table mutation hooks. Imperative function bodies the framework cannot
@@ -51,51 +53,8 @@ export interface ModelHooks {
51
53
  */
52
54
  export type WrittenBy = 'app' | 'worker' | 'functions';
53
55
 
54
- /**
55
- * A table privilege that is NOT one of the four CRUD verbs — the ones `can()` has no word for.
56
- *
57
- * `can('manage')` means exactly INSERT/SELECT/UPDATE/DELETE, and that is deliberate: a verb
58
- * that quietly expanded to "every privilege" would hand out TRUNCATE, which is not a DELETE
59
- * and is not filtered by a DELETE policy. So these three are named one at a time, by the
60
- * author, in {@link ModelDefinition.privileges}.
61
- *
62
- * Why the vocabulary stops here: a real database routinely grants its admin role REFERENCES,
63
- * TRIGGER and TRUNCATE, and everystack had no way to SAY that — so the first plan against an
64
- * existing schema revoked all three on every table. That is our spelling, not their bug.
65
- */
66
- export type ExtraPrivilege = 'REFERENCES' | 'TRIGGER' | 'TRUNCATE';
67
-
68
- /** The declarable privilege set — the one definition; the CLI's classifier reads it too. */
69
- export const EXTRA_PRIVILEGES: readonly ExtraPrivilege[] = ['REFERENCES', 'TRIGGER', 'TRUNCATE'];
70
-
71
- /** The four CRUD privileges. Normally they arrive via `can()`, which decides the policy too. */
72
- export const DML_PRIVILEGES = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'] as const;
73
- export type DmlPrivilege = (typeof DML_PRIVILEGES)[number];
74
-
75
- /**
76
- * Every table privilege this language can spell.
77
- *
78
- * The DML four used to be REFUSED here, so that a grant could never be declared without the
79
- * policy that gates it. That invariant was already vacuous for any database being adopted: a
80
- * live table can hold `GRANT SELECT TO admin` with no policy covering admin, and refusing to
81
- * say it did not make it not so — it only made the database unspellable.
82
- *
83
- * That matters because `db:fingerprint` compares the LIVE schema against the MODELS. A state
84
- * the language cannot express is a permanent MISMATCH, so adopting such a database required
85
- * mutating it first, which inverts who is the source of truth during adoption.
86
- *
87
- * The rule this settles (Fable, 2026-08-06): **expressibility is total over the fingerprint's
88
- * domain; opinion lives in the linter, never in the parser.** Declaring a naked DML grant is
89
- * still a mistake in hand-authored code — `db:check` says so, as an ERROR on a table without
90
- * RLS (where it is an unrestricted table-wide privilege) and a WARNING on a table with RLS
91
- * (where it is dead until someone adds a broad policy or disables RLS). It is no longer a
92
- * parse error, because `db:pull` has to be able to render what is actually there.
93
- *
94
- * Policies still come ONLY from `can()`. This key can never mint row access by itself.
95
- */
96
- export type TablePrivilege = ExtraPrivilege | DmlPrivilege;
97
-
98
- export const TABLE_PRIVILEGES: readonly TablePrivilege[] = [...EXTRA_PRIVILEGES, ...DML_PRIVILEGES];
56
+ export { EXTRA_PRIVILEGES, DML_PRIVILEGES, TABLE_PRIVILEGES } from './privileges';
57
+ export type { ExtraPrivilege, DmlPrivilege, TablePrivilege } from './privileges';
99
58
 
100
59
  export interface ModelDefinition {
101
60
  fields: Record<string, FieldBuilder>;
@@ -269,6 +228,12 @@ export interface MaterializedTableMarker {
269
228
  }
270
229
 
271
230
  export function defineModel(table: string, def: ModelDefinition): ModelDescriptor {
231
+ for (const [name, field] of Object.entries(def.fields)) {
232
+ referenceLocation(field.spec.references, `${table}.${name}`);
233
+ }
234
+ for (const [name, relation] of Object.entries(def.relations ?? {})) {
235
+ referenceLocation(relation.target, `${table}.${name}`);
236
+ }
272
237
  const primaryKey = Object.entries(def.fields)
273
238
  .filter(([, f]) => f.spec.isPrimaryKey)
274
239
  .map(([name]) => name);
package/src/module.ts CHANGED
@@ -24,6 +24,7 @@ import type { DerivedDescriptor, SequenceDescriptor } from './derived';
24
24
  import { rejectHoles, functionArgSignature } from './derived';
25
25
  import { deriveFunctionSchemas } from './derive';
26
26
  import { parseQualified } from './qualified';
27
+ import { linkModels } from './link';
27
28
 
28
29
  export interface ModuleDefinition {
29
30
  /** The package's Models (schema + authz). */
@@ -199,7 +200,7 @@ function assertFunctionsAreDeclared(def: ModuleDefinition): void {
199
200
  /** The flattened model list across modules — for everything that still takes a model array
200
201
  * (handler-config derivation, FORCE carveouts, the drift guard). */
201
202
  export function moduleModels(modules: Module[]): ModelDescriptor[] {
202
- return modules.flatMap((m) => m.models);
203
+ return linkModels(modules.flatMap((m) => m.models));
203
204
  }
204
205
 
205
206
  /** The deduped, sorted extension list across modules — emitted as `CREATE EXTENSION` first. */
@@ -0,0 +1,45 @@
1
+ /**
2
+ * A table privilege that is NOT one of the four CRUD verbs — the ones `can()` has no word for.
3
+ *
4
+ * `can('manage')` means exactly INSERT/SELECT/UPDATE/DELETE, and that is deliberate: a verb
5
+ * that quietly expanded to "every privilege" would hand out TRUNCATE, which is not a DELETE
6
+ * and is not filtered by a DELETE policy. So these three are named one at a time, by the
7
+ * author, in {@link ModelDefinition.privileges}.
8
+ *
9
+ * Why the vocabulary stops here: a real database routinely grants its admin role REFERENCES,
10
+ * TRIGGER and TRUNCATE, and everystack had no way to SAY that — so the first plan against an
11
+ * existing schema revoked all three on every table. That is our spelling, not their bug.
12
+ */
13
+ export type ExtraPrivilege = 'REFERENCES' | 'TRIGGER' | 'TRUNCATE';
14
+
15
+ /** The declarable privilege set — the one definition; the CLI's classifier reads it too. */
16
+ export const EXTRA_PRIVILEGES: readonly ExtraPrivilege[] = ['REFERENCES', 'TRIGGER', 'TRUNCATE'];
17
+
18
+ /** The four CRUD privileges. Normally they arrive via `can()`, which decides the policy too. */
19
+ export const DML_PRIVILEGES = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'] as const;
20
+ export type DmlPrivilege = (typeof DML_PRIVILEGES)[number];
21
+
22
+ /**
23
+ * Every table privilege this language can spell.
24
+ *
25
+ * The DML four used to be REFUSED here, so that a grant could never be declared without the
26
+ * policy that gates it. That invariant was already vacuous for any database being adopted: a
27
+ * live table can hold `GRANT SELECT TO admin` with no policy covering admin, and refusing to
28
+ * say it did not make it not so — it only made the database unspellable.
29
+ *
30
+ * That matters because `db:fingerprint` compares the LIVE schema against the MODELS. A state
31
+ * the language cannot express is a permanent MISMATCH, so adopting such a database required
32
+ * mutating it first, which inverts who is the source of truth during adoption.
33
+ *
34
+ * The rule this settles (Fable, 2026-08-06): **expressibility is total over the fingerprint's
35
+ * domain; opinion lives in the linter, never in the parser.** Declaring a naked DML grant is
36
+ * still a mistake in hand-authored code — `db:check` says so, as an ERROR on a table without
37
+ * RLS (where it is an unrestricted table-wide privilege) and a WARNING on a table with RLS
38
+ * (where it is dead until someone adds a broad policy or disables RLS). It is no longer a
39
+ * parse error, because `db:pull` has to be able to render what is actually there.
40
+ *
41
+ * Policies still come ONLY from `can()`. This key can never mint row access by itself.
42
+ */
43
+ export type TablePrivilege = ExtraPrivilege | DmlPrivilege;
44
+
45
+ export const TABLE_PRIVILEGES: readonly TablePrivilege[] = [...EXTRA_PRIVILEGES, ...DML_PRIVILEGES];
package/src/relations.ts CHANGED
@@ -5,11 +5,12 @@
5
5
  * belongsTo(() => Author, 'authorId') // this.authorId → Author.<pk>
6
6
  * hasMany(() => Comment, 'postId') // this.<pk> → Comment.postId
7
7
  *
8
- * The target is a thunk so models can reference each other regardless of file or
9
- * declaration order.
8
+ * A table-name target avoids sibling imports; linkModels resolves it against the
9
+ * complete set. Model thunks remain supported for forward references.
10
10
  */
11
11
 
12
12
  import type { ModelDescriptor } from './model';
13
+ import { referenceThunk } from './link';
13
14
 
14
15
  export interface Relation {
15
16
  kind: 'belongsTo' | 'hasMany';
@@ -31,18 +32,18 @@ export interface Relation {
31
32
 
32
33
  /** Many-to-one: this row points at one target row via `column`. */
33
34
  export function belongsTo(
34
- target: () => ModelDescriptor,
35
+ target: (() => ModelDescriptor) | string,
35
36
  column: string,
36
37
  references?: string,
37
38
  ): Relation {
38
- return { kind: 'belongsTo', target, column, references };
39
+ return { kind: 'belongsTo', target: referenceThunk(target), column, references };
39
40
  }
40
41
 
41
42
  /** One-to-many: target rows point back at this row via `column`. */
42
43
  export function hasMany(
43
- target: () => ModelDescriptor,
44
+ target: (() => ModelDescriptor) | string,
44
45
  column: string,
45
46
  references?: string,
46
47
  ): Relation {
47
- return { kind: 'hasMany', target, column, references };
48
+ return { kind: 'hasMany', target: referenceThunk(target), column, references };
48
49
  }
package/src/wire.ts CHANGED
@@ -143,8 +143,8 @@ export interface DerivedDescriptor {
143
143
  /** Schema name, defaulted to `public` when unqualified. */
144
144
  schema: string;
145
145
  name: string;
146
- /** `schema.name` — the reconciler's join key against the live catalog.
147
- * (Triggers: `schema.table.name` — compound identity, joined against pg_trigger.) */
146
+ /** Functions use `schema.name(argtypes)` — their full PostgreSQL identity and reconciler join
147
+ * key. Other objects use `schema.name`; triggers use `schema.table.name` and join pg_trigger. */
148
148
  identity: string;
149
149
  /** The CREATE statement as authored (no trailing semicolon). */
150
150
  sql: string;