@everystack/model 0.4.22 → 0.4.24

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/src/relations.ts CHANGED
@@ -4,16 +4,74 @@
4
4
  *
5
5
  * belongsTo(() => Author, 'authorId') // this.authorId → Author.<pk>
6
6
  * hasMany(() => Comment, 'postId') // this.<pk> → Comment.postId
7
+ * hasMany(() => Comment, 'postId', { dependent: 'nullify' }) // delete a post → postId NULL
8
+ * hasOne(() => Image, 'postId') // the first Image whose postId is this.<pk>
7
9
  *
8
10
  * A table-name target avoids sibling imports; linkModels resolves it against the
9
11
  * complete set. Model thunks remain supported for forward references.
12
+ *
13
+ * The third argument is a string (the `references` key, as it always was) or an options object
14
+ * (the ratified F7 API). `dependent` is the parent's word about delete behaviour; it compiles to
15
+ * the ON DELETE of the CHILD's foreign key (see dependents.ts). `orderBy`, `limit` and `where`
16
+ * are DISPLAY ONLY: they emit no SQL and never narrow a dependent. Their shape is checked at
17
+ * defineModel; their field names are TARGET fields, so linkModels checks them and normalizes the
18
+ * orderBy once (relation-display.ts): the target's primary key is appended as the tie-breaker.
10
19
  */
11
20
 
12
21
  import type { ModelDescriptor } from './model';
13
22
  import { referenceThunk } from './link';
14
23
 
24
+ /** What deleting a parent row does to its children, through the child's foreign key. */
25
+ export type HasManyDependent = 'restrict' | 'nullify' | 'cascade';
26
+
27
+ /** One ordering term of a relation's display order. */
28
+ export interface RelationOrder {
29
+ field: string;
30
+ order: 'asc' | 'desc';
31
+ }
32
+
33
+ /** An equality filter on the related rows: field → value. */
34
+ export type RelationWhere = Record<string, string | number | boolean | null>;
35
+
36
+ export interface BelongsToOptions {
37
+ /** The key on the target, when it is not the target's primary key. */
38
+ references?: string;
39
+ }
40
+
41
+ export interface HasManyOptions {
42
+ /** The key on this table, when it is not this table's primary key. */
43
+ references?: string;
44
+ /**
45
+ * What a delete of this row does to the target rows, through the target's foreign key on
46
+ * `column`: `'restrict'` → ON DELETE RESTRICT, `'nullify'` → ON DELETE SET NULL, `'cascade'` →
47
+ * ON DELETE CASCADE (Rails `delete_all`: the database removes the children and runs none of
48
+ * their hooks). The target field must declare `.references()` to this model; a dependent never
49
+ * creates a foreign key.
50
+ */
51
+ dependent?: HasManyDependent;
52
+ /**
53
+ * Display order: fields of the target. linkModels appends the target's primary key as the
54
+ * tie-breaker. Without it the order is unchanged (none is added).
55
+ */
56
+ orderBy?: RelationOrder[];
57
+ /** Display limit: a positive integer. */
58
+ limit?: number;
59
+ /** Display filter: equality on target fields; `null` means IS NULL. Not access control. */
60
+ where?: RelationWhere;
61
+ }
62
+
63
+ /** hasOne is one row: no `limit`, and no `dependent` in v1 (declare it on a hasMany). */
64
+ export interface HasOneOptions {
65
+ /** The key on this table, when it is not this table's primary key. */
66
+ references?: string;
67
+ /** Which row: target fields, then the target's primary key. Default: the primary key ascending. */
68
+ orderBy?: RelationOrder[];
69
+ /** Display filter: equality on target fields; `null` means IS NULL. Not access control. */
70
+ where?: RelationWhere;
71
+ }
72
+
15
73
  export interface Relation {
16
- kind: 'belongsTo' | 'hasMany';
74
+ kind: 'belongsTo' | 'hasMany' | 'hasOne';
17
75
  /** Thunk to the related model (forward-reference safe). */
18
76
  target: () => ModelDescriptor;
19
77
  /**
@@ -28,22 +86,166 @@ export interface Relation {
28
86
  * - hasMany: overrides RelationConfig.from (default: this table's pk)
29
87
  */
30
88
  references?: string;
89
+ /** hasMany only. Present only when declared. */
90
+ dependent?: HasManyDependent;
91
+ /**
92
+ * Present only when declared, or on a hasOne. After linkModels it is normalized: it ends with
93
+ * every primary-key column of the target (a hasOne with none declared is the key ascending).
94
+ */
95
+ orderBy?: RelationOrder[];
96
+ limit?: number;
97
+ where?: RelationWhere;
98
+ }
99
+
100
+ type Kind = Relation['kind'];
101
+
102
+ const OPTION_KEYS: Record<Kind, readonly string[]> = {
103
+ belongsTo: ['references'],
104
+ hasMany: ['references', 'dependent', 'orderBy', 'limit', 'where'],
105
+ hasOne: ['references', 'orderBy', 'where'],
106
+ };
107
+
108
+ /**
109
+ * Build a descriptor. A string or absent third argument yields exactly the legacy shape
110
+ * (`references` always present as a key), so an existing model's descriptor is unchanged. An
111
+ * options object adds a key only for an option it declares.
112
+ */
113
+ function relation(kind: Kind, target: (() => ModelDescriptor) | string, column: string, opts: unknown): Relation {
114
+ const call = `${kind}(…, '${column}')`;
115
+ if (opts === undefined || typeof opts === 'string') {
116
+ return { kind, target: referenceThunk(target), column, references: opts };
117
+ }
118
+ if (opts === null || typeof opts !== 'object' || Array.isArray(opts)) {
119
+ throw new Error(`${call}: the third argument is ${opts === null ? 'null' : Array.isArray(opts) ? 'an array' : typeof opts}; pass a references string or an options object`);
120
+ }
121
+ const allowed = OPTION_KEYS[kind];
122
+ for (const key of Object.keys(opts)) {
123
+ if (allowed.includes(key)) continue;
124
+ if (kind === 'hasOne' && key === 'dependent') {
125
+ throw new Error(`${call}: hasOne takes no dependent; declare dependent on a hasMany over the same column`);
126
+ }
127
+ if (kind === 'hasOne' && key === 'limit') throw new Error(`${call}: hasOne takes no limit: it is one row`);
128
+ const hint = key === 'dependent' ? "; dependent is declared on the parent's hasMany" : '';
129
+ throw new Error(`${call}: unknown option '${key}' (expected ${allowed.join(', ')})${hint}`);
130
+ }
131
+ const o = opts as HasManyOptions;
132
+ if (o.references !== undefined && typeof o.references !== 'string') {
133
+ throw new Error(`${call}: references must be a string`);
134
+ }
135
+ const out: Relation = { kind, target: referenceThunk(target), column, references: o.references };
136
+ if (o.dependent !== undefined) out.dependent = o.dependent;
137
+ // Copied, so a later edit to the caller's array or object changes nothing. Shapes are checked
138
+ // at defineModel (assertRelationShape); a malformed value is kept as given for that message.
139
+ if (o.orderBy !== undefined) {
140
+ out.orderBy = Array.isArray(o.orderBy)
141
+ ? o.orderBy.map((t) => (t !== null && typeof t === 'object' && !Array.isArray(t) ? { ...t } : t))
142
+ : o.orderBy;
143
+ }
144
+ if (o.limit !== undefined) out.limit = o.limit;
145
+ if (o.where !== undefined) {
146
+ out.where = o.where !== null && typeof o.where === 'object' && !Array.isArray(o.where) ? { ...o.where } : o.where;
147
+ }
148
+ return out;
31
149
  }
32
150
 
33
151
  /** Many-to-one: this row points at one target row via `column`. */
34
152
  export function belongsTo(
35
153
  target: (() => ModelDescriptor) | string,
36
154
  column: string,
37
- references?: string,
155
+ opts?: string | BelongsToOptions,
38
156
  ): Relation {
39
- return { kind: 'belongsTo', target: referenceThunk(target), column, references };
157
+ return relation('belongsTo', target, column, opts);
40
158
  }
41
159
 
42
160
  /** One-to-many: target rows point back at this row via `column`. */
43
161
  export function hasMany(
44
162
  target: (() => ModelDescriptor) | string,
45
163
  column: string,
46
- references?: string,
164
+ opts?: string | HasManyOptions,
165
+ ): Relation {
166
+ return relation('hasMany', target, column, opts);
167
+ }
168
+
169
+ /**
170
+ * One-to-one from the parent side: the first target row pointing back via `column`, in `orderBy`
171
+ * (default: the target's primary key ascending). Display only: it compiles no SQL.
172
+ */
173
+ export function hasOne(
174
+ target: (() => ModelDescriptor) | string,
175
+ column: string,
176
+ opts?: HasOneOptions,
47
177
  ): Relation {
48
- return { kind: 'hasMany', target: referenceThunk(target), column, references };
178
+ return relation('hasOne', target, column, opts);
179
+ }
180
+
181
+ /**
182
+ * The declaration rules that need no other model, checked by defineModel: an option or kind that
183
+ * is not implemented yet is refused by name, never silently ignored.
184
+ */
185
+ export function assertRelationShape(model: string, name: string, rel: Relation): void {
186
+ const at = `model '${model}': relation '${name}'`;
187
+ if (rel.kind !== 'belongsTo' && rel.kind !== 'hasMany' && rel.kind !== 'hasOne') {
188
+ throw new Error(`${at}: unknown relation kind '${String(rel.kind)}' (expected belongsTo, hasMany or hasOne)`);
189
+ }
190
+ if (rel.kind === 'belongsTo') {
191
+ for (const key of ['orderBy', 'limit', 'where'] as const) {
192
+ if (rel[key] !== undefined) throw new Error(`${at}: ${key} is a hasMany or hasOne option; belongsTo takes none`);
193
+ }
194
+ }
195
+ if (rel.kind === 'hasOne' && rel.limit !== undefined) throw new Error(`${at}: limit is a hasMany option; hasOne is one row`);
196
+ assertDisplayShape(at, rel);
197
+ if (rel.dependent === undefined) return;
198
+ if (rel.kind !== 'hasMany') throw new Error(`${at}: dependent is a hasMany option; ${rel.kind} takes none`);
199
+ if (rel.dependent !== 'restrict' && rel.dependent !== 'nullify' && rel.dependent !== 'cascade') {
200
+ throw new Error(`${at}: dependent '${String(rel.dependent)}' is not a dependent value (expected 'restrict', 'nullify' or 'cascade')`);
201
+ }
202
+ }
203
+
204
+ const isPlainObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v);
205
+
206
+ /** A value as an error message shows it. */
207
+ const shown = (v: unknown): string => {
208
+ if (v === undefined || typeof v === 'number') return String(v);
209
+ if (typeof v === 'string') return `'${v}'`;
210
+ try { return JSON.stringify(v) ?? String(v); } catch { return String(v); }
211
+ };
212
+
213
+ /**
214
+ * The display options' shape, checked at defineModel (no other model needed). Their field names
215
+ * name TARGET fields, so `normalizeRelationDisplay` (relation-display.ts) checks those once the target is known.
216
+ */
217
+ function assertDisplayShape(at: string, rel: Relation): void {
218
+ if (rel.orderBy !== undefined) {
219
+ const orderBy: unknown = rel.orderBy;
220
+ if (!Array.isArray(orderBy) || orderBy.length === 0) throw new Error(`${at}: orderBy must be a non-empty array of { field, order }`);
221
+ const seen = new Set<string>();
222
+ orderBy.forEach((term: unknown, i) => {
223
+ if (!isPlainObject(term) || typeof term.field !== 'string' || term.field === ''
224
+ || Object.keys(term).some((k) => k !== 'field' && k !== 'order')) {
225
+ throw new Error(`${at}: orderBy[${i}] must be { field: string, order: 'asc' | 'desc' }`);
226
+ }
227
+ if (term.order !== 'asc' && term.order !== 'desc') {
228
+ throw new Error(`${at}: orderBy[${i}].order is ${shown(term.order)}; expected 'asc' or 'desc'`);
229
+ }
230
+ if (seen.has(term.field)) throw new Error(`${at}: orderBy lists field '${term.field}' twice`);
231
+ seen.add(term.field);
232
+ });
233
+ }
234
+ if (rel.limit !== undefined) {
235
+ const limit: unknown = rel.limit;
236
+ if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1) {
237
+ throw new Error(`${at}: limit must be a positive integer, got ${shown(limit)}`);
238
+ }
239
+ }
240
+ if (rel.where !== undefined) {
241
+ const where: unknown = rel.where;
242
+ if (!isPlainObject(where) || Object.keys(where).length === 0) {
243
+ throw new Error(`${at}: where must be a non-empty object of field → value`);
244
+ }
245
+ for (const [key, value] of Object.entries(where)) {
246
+ const ok = value === null || typeof value === 'string' || typeof value === 'boolean'
247
+ || (typeof value === 'number' && Number.isFinite(value));
248
+ if (!ok) throw new Error(`${at}: where '${key}' must be a string, a finite number, a boolean or null, got ${shown(value)}`);
249
+ }
250
+ }
49
251
  }
package/src/wire.ts CHANGED
@@ -45,8 +45,9 @@
45
45
  * accept risks wrong ownership or a wrong derived object on a production database.
46
46
  *
47
47
  * v1 — the first version. Shapes as they stood when the guard was introduced.
48
+ * v2 — ProvisionWirePayload gains `lifecycleRole` (provision the reserved lifecycle role).
48
49
  */
49
- export const MODEL_WIRE_CONTRACT = 1;
50
+ export const MODEL_WIRE_CONTRACT = 2;
50
51
 
51
52
  /**
52
53
  * Every Model-derived wire payload carries this. The server refuses a payload whose `modelContract`
@@ -101,6 +102,8 @@ export interface Adoptee {
101
102
  export interface ProvisionWirePayload extends ModelWireEnvelope {
102
103
  /** The declared schema set, ordered and `public`-trailing — set as the ROLE default search_path. */
103
104
  searchPath?: string[];
105
+ /** The Models declare a `lifecycle: true` model: provision the reserved role everystack_lifecycle. */
106
+ lifecycleRole?: boolean;
104
107
  /** Accepted by the action's payload type; convergence itself runs as db:converge-ownership. */
105
108
  declaredOwners?: DeclaredOwner[];
106
109
  declaredObjects?: Adoptee[];