@everystack/model 0.4.22 → 0.4.23
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 +38 -0
- package/README.md +205 -2
- package/package.json +1 -1
- package/src/constraints.ts +21 -3
- package/src/dependents.ts +168 -0
- package/src/derive.ts +46 -11
- package/src/derived.ts +1 -1
- package/src/drizzle.ts +4 -0
- package/src/external.ts +119 -0
- package/src/field.ts +27 -4
- package/src/index.ts +9 -5
- package/src/link.ts +40 -0
- package/src/model.ts +228 -5
- package/src/module.ts +8 -3
- package/src/relation-display.ts +48 -0
- package/src/relations.ts +207 -5
- package/src/wire.ts +4 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The link-time half of the relation display options: the checks and the orderBy normalization
|
|
3
|
+
* that need the resolved TARGET model. A leaf: it imports only types, so linkModels can use it
|
|
4
|
+
* without an import cycle through relations.ts (which imports referenceThunk from link.ts).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ModelDescriptor } from './model';
|
|
8
|
+
import type { Relation, RelationOrder } from './relations';
|
|
9
|
+
|
|
10
|
+
/** True when linkModels must evaluate this relation's target to normalize its display options. */
|
|
11
|
+
export function hasDisplayOptions(rel: Relation): boolean {
|
|
12
|
+
return rel.kind === 'hasOne' || rel.orderBy !== undefined || rel.where !== undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const nameOf = (m: ModelDescriptor): string => (!m.schema || m.schema === 'public' ? m.table : `${m.schema}.${m.table}`);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The normalized orderBy of a hasMany or hasOne against its resolved TARGET, or undefined when
|
|
19
|
+
* there is none (a hasMany with no orderBy keeps today's order: none is added). Pure: linkModels
|
|
20
|
+
* writes the result, once every reference in the set has resolved.
|
|
21
|
+
*
|
|
22
|
+
* Every orderBy and where field must be a field of the target. The target's primary key is
|
|
23
|
+
* appended, ascending, for each key column the orderBy does not list, so the order is total. A
|
|
24
|
+
* hasOne with no orderBy is ordered by that key. Idempotent: a normalized orderBy lists the key.
|
|
25
|
+
*/
|
|
26
|
+
export function normalizeRelationDisplay(model: string, name: string, rel: Relation, target: ModelDescriptor): RelationOrder[] | undefined {
|
|
27
|
+
const at = `model '${model}': relation '${name}'`;
|
|
28
|
+
for (const key of Object.keys(rel.where ?? {})) {
|
|
29
|
+
if (!Object.prototype.hasOwnProperty.call(target.fields, key)) {
|
|
30
|
+
throw new Error(`${at}: where field '${key}' is not a field of model '${nameOf(target)}'`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (rel.orderBy === undefined && rel.kind !== 'hasOne') return undefined;
|
|
34
|
+
const declared = rel.orderBy ?? [];
|
|
35
|
+
for (const term of declared) {
|
|
36
|
+
if (!Object.prototype.hasOwnProperty.call(target.fields, term.field)) {
|
|
37
|
+
throw new Error(`${at}: orderBy field '${term.field}' is not a field of model '${nameOf(target)}'`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (target.primaryKey.length === 0) {
|
|
41
|
+
throw new Error(`${at}: model '${nameOf(target)}' has no primary key to break orderBy ties; declare one`);
|
|
42
|
+
}
|
|
43
|
+
const listed = new Set(declared.map((t) => t.field));
|
|
44
|
+
return [
|
|
45
|
+
...declared.map((t) => ({ field: t.field, order: t.order })),
|
|
46
|
+
...target.primaryKey.filter((k) => !listed.has(k)).map((k) => ({ field: k, order: 'asc' as const })),
|
|
47
|
+
];
|
|
48
|
+
}
|
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
|
-
|
|
155
|
+
opts?: string | BelongsToOptions,
|
|
38
156
|
): Relation {
|
|
39
|
-
return
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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[];
|