@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
package/src/derived.ts
CHANGED
|
@@ -88,7 +88,7 @@ function validateRelationFields(kind: string, name: string, fields: Record<strin
|
|
|
88
88
|
const s = builder.spec;
|
|
89
89
|
if (s.isPrimaryKey) throw new Error(`${at}: .primaryKey() is table structure — a relation column carries type, nullability, and array only.`);
|
|
90
90
|
if (s.isUnique) throw new Error(`${at}: .unique() is a table constraint — enforce it on the base table (or a matview index).`);
|
|
91
|
-
if (s.references) throw new Error(`${at}: .references() is a table FK — declare it on the base table; the body carries the join.`);
|
|
91
|
+
if (s.references || s.referencesExternal) throw new Error(`${at}: .references() is a table FK — declare it on the base table; the body carries the join.`);
|
|
92
92
|
if (s.defaultKind) throw new Error(`${at}: defaults are meaningless on a relation column — the body computes every value.`);
|
|
93
93
|
if (s.isDeprecated) throw new Error(`${at}: .deprecated() is the table contract phase — remove the column from the body instead.`);
|
|
94
94
|
if (s.isPrivate) throw new Error(`${at}: .private() hides a table column from the API — a relation exposes only what its body selects; drop the column from the body.`);
|
package/src/drizzle.ts
CHANGED
|
@@ -170,6 +170,10 @@ export function toDrizzleTable(model: ModelDescriptor): PgTableWithColumns<any>
|
|
|
170
170
|
|
|
171
171
|
if (spec.references) {
|
|
172
172
|
const target = spec.references as () => ModelDescriptor;
|
|
173
|
+
// The CHILD's declared actions only. A parent's hasMany `dependent` is a property of the
|
|
174
|
+
// model set (relationDeleteActions) and this table is built per model, so it is not here.
|
|
175
|
+
// The runtime table never emits DDL (the CLI does, from the set), so queries are unaffected.
|
|
176
|
+
// This is the one place it differs from the generated schema.generated.ts, which carries it.
|
|
173
177
|
const refOpts: { onDelete?: FieldSpec['onDelete']; onUpdate?: FieldSpec['onUpdate'] } = {};
|
|
174
178
|
if (spec.onDelete) refOpts.onDelete = spec.onDelete;
|
|
175
179
|
if (spec.onUpdate) refOpts.onUpdate = spec.onUpdate;
|
package/src/external.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An EXTERNAL foreign-key target: a table no model declares, named by its PostgreSQL identity.
|
|
3
|
+
*
|
|
4
|
+
* `field.uuid().references({ schema: 'auth', table: 'users', column: 'id' })` is how a model says
|
|
5
|
+
* "this column points into another system's table" (an auth provider's `auth.users`, a table a
|
|
6
|
+
* different service owns). Ratified rules:
|
|
7
|
+
*
|
|
8
|
+
* - schema, table and column are all REQUIRED and are three separate identifiers. Nothing is
|
|
9
|
+
* parsed for dots: an external FK crosses the governed boundary, so it says exactly where.
|
|
10
|
+
* - A string target stays a MODEL name. Only this object form names a table.
|
|
11
|
+
* - An object naming a table some model in the set declares is refused: use the model.
|
|
12
|
+
* - It is FK state only. It is never a relation (no belongsTo/hasMany, no embedding).
|
|
13
|
+
* - It compiles, diffs and fingerprints exactly like a model FK. Nothing downstream of the
|
|
14
|
+
* compiler can tell the two apart, so a live FK nothing declares is still visible drift.
|
|
15
|
+
*
|
|
16
|
+
* The names are PostgreSQL names, taken verbatim (no snake_casing: there is no model field to
|
|
17
|
+
* derive them from). They reach DDL quoted, so a name is refused when quoting cannot carry it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { ModelDescriptor } from './model';
|
|
21
|
+
|
|
22
|
+
/** A table no model declares: the composite `foreignKey(cols, { schema, table }, refColumns)` target. */
|
|
23
|
+
export interface ExternalTableRef {
|
|
24
|
+
schema: string;
|
|
25
|
+
table: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A column of a table no model declares: the `.references({ schema, table, column })` target. */
|
|
29
|
+
export interface ExternalColumnRef extends ExternalTableRef {
|
|
30
|
+
column: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** NAMEDATALEN - 1 on a stock PostgreSQL build; a longer name is truncated with only a NOTICE. */
|
|
34
|
+
const MAX_IDENTIFIER_BYTES = 63;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Why a name cannot be an external identifier, or null when it can. The DDL quotes every part
|
|
38
|
+
* (`"auth"."users"`) without doubling quotes, and the FK IR carries `schema.table` as one dotted
|
|
39
|
+
* string, so a quote, a dot or a control character cannot be carried safely.
|
|
40
|
+
*/
|
|
41
|
+
export function externalIdentifierRefusal(name: unknown): string | null {
|
|
42
|
+
if (typeof name !== 'string') return `is ${name === null ? 'null' : typeof name}, not a string`;
|
|
43
|
+
if (name.length === 0) return 'is empty';
|
|
44
|
+
if (name.includes('"')) return 'holds a double quote';
|
|
45
|
+
// A line terminator or other control character: a name holding one could end a comment in
|
|
46
|
+
// generated source or forge a line in plan output and logs. No real table needs one.
|
|
47
|
+
if (/[\u0000-\u001f\u007f\u2028\u2029]/.test(name)) return 'holds a control character or line terminator';
|
|
48
|
+
if (name.includes('.')) return "holds a '.' (a name is one identifier; the schema is its own field)";
|
|
49
|
+
const bytes = new TextEncoder().encode(name).length;
|
|
50
|
+
if (bytes > MAX_IDENTIFIER_BYTES) return `is ${bytes} bytes (PostgreSQL identifiers are at most ${MAX_IDENTIFIER_BYTES})`;
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `auth.users` — how every error and plan line names an external target. */
|
|
55
|
+
export function externalTargetName(ref: ExternalTableRef): string {
|
|
56
|
+
return `${String(ref.schema)}.${String(ref.table)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readRef<K extends string>(target: object, keys: readonly K[], call: string): Record<K, string> {
|
|
60
|
+
const shown = externalTargetName(target as ExternalTableRef);
|
|
61
|
+
for (const key of Object.keys(target)) {
|
|
62
|
+
if (!(keys as readonly string[]).includes(key)) {
|
|
63
|
+
throw new Error(`${call} target ${shown}: unknown key '${key}' (expected ${keys.join(', ')})`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const out = {} as Record<K, string>;
|
|
67
|
+
for (const key of keys) {
|
|
68
|
+
const value = (target as Record<string, unknown>)[key];
|
|
69
|
+
if (value === undefined) {
|
|
70
|
+
throw new Error(`${call} target ${shown}: ${key} is required (an external target names its ${keys.join(', ')}; there is no default)`);
|
|
71
|
+
}
|
|
72
|
+
const refusal = externalIdentifierRefusal(value);
|
|
73
|
+
if (refusal) throw new Error(`${call} target ${shown}: ${key} ${JSON.stringify(value)} ${refusal}`);
|
|
74
|
+
out[key] = value as string;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Validate and copy a `.references({ schema, table, column })` target. */
|
|
80
|
+
export function externalColumnRef(target: object): ExternalColumnRef {
|
|
81
|
+
return Object.freeze(readRef(target, ['schema', 'table', 'column'] as const, 'references({ schema, table, column })'));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Validate and copy a composite `foreignKey(cols, { schema, table }, refColumns)` target and its columns. */
|
|
85
|
+
export function externalTableRef(target: object, refColumns: readonly string[], call: string): ExternalTableRef {
|
|
86
|
+
const ref = readRef(target, ['schema', 'table'] as const, call);
|
|
87
|
+
for (const c of refColumns) {
|
|
88
|
+
const refusal = externalIdentifierRefusal(c);
|
|
89
|
+
if (refusal) throw new Error(`${call} target ${externalTargetName(ref)}: reference column ${JSON.stringify(c)} ${refusal}`);
|
|
90
|
+
}
|
|
91
|
+
return Object.freeze(ref);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Refuse an external target that a model in this set declares: the author must use the model.
|
|
96
|
+
* Compared on RESOLVED identities (the model's schema, default `public`, and its table) across
|
|
97
|
+
* the whole set. Called by linkModels and by every compile entry point that holds the set.
|
|
98
|
+
*/
|
|
99
|
+
export function assertExternalTargets(models: readonly ModelDescriptor[]): void {
|
|
100
|
+
const declared = new Map<string, ModelDescriptor>();
|
|
101
|
+
for (const m of models) declared.set(`${m.schema ?? 'public'}\u0000${m.table}`, m);
|
|
102
|
+
const owner = (m: ModelDescriptor): string => `${m.schema ?? 'public'}.${m.table}`;
|
|
103
|
+
const check = (m: ModelDescriptor, ref: ExternalTableRef, what: string): void => {
|
|
104
|
+
const hit = declared.get(`${ref.schema}\u0000${ref.table}`);
|
|
105
|
+
if (!hit) return;
|
|
106
|
+
throw new Error(
|
|
107
|
+
`model '${owner(m)}': ${what} references ${externalTargetName(ref)}, which model '${owner(hit)}' declares: `
|
|
108
|
+
+ `use the model (.references(() => Model) or a model-name string), not the external { schema, table } form`,
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
for (const m of models) {
|
|
112
|
+
for (const [key, f] of Object.entries(m.fields)) {
|
|
113
|
+
if (f.spec.referencesExternal) check(m, f.spec.referencesExternal, `field '${key}'`);
|
|
114
|
+
}
|
|
115
|
+
for (const c of m.constraints) {
|
|
116
|
+
if (c.kind === 'foreignKey' && c.external) check(m, c.external, `foreignKey(${c.columns.join(', ')})`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/field.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { RawSql } from './raw-sql';
|
|
|
15
15
|
import { pgTypeRefusal } from './pg-type';
|
|
16
16
|
import { assertConstraintName, assertFkValidate } from './constraints';
|
|
17
17
|
import { referenceThunk } from './link';
|
|
18
|
+
import { externalColumnRef, type ExternalColumnRef } from './external';
|
|
18
19
|
|
|
19
20
|
export type FieldType =
|
|
20
21
|
| 'text'
|
|
@@ -80,6 +81,13 @@ export interface FieldSpec {
|
|
|
80
81
|
isDeprecated: boolean;
|
|
81
82
|
/** `.references(() => Other)` — FK target, a model thunk. Used by relation/ownership work. */
|
|
82
83
|
references?: () => unknown;
|
|
84
|
+
/**
|
|
85
|
+
* `.references({ schema, table, column })` — an FK to a table NO model declares (another
|
|
86
|
+
* system's `auth.users`). Set instead of {@link references}, never with it. FK state only: it is
|
|
87
|
+
* never a relation, so nothing that follows a model thunk ever sees it. It compiles, diffs and
|
|
88
|
+
* fingerprints exactly like a model FK. See external.ts.
|
|
89
|
+
*/
|
|
90
|
+
referencesExternal?: ExternalColumnRef;
|
|
83
91
|
/** `ON DELETE` action for the FK. Omitted (or `'no action'`) is the Postgres default. */
|
|
84
92
|
onDelete?: ReferentialAction;
|
|
85
93
|
/** `ON UPDATE` action for the FK. Omitted (or `'no action'`) is the Postgres default. */
|
|
@@ -231,8 +239,12 @@ export class FieldBuilder {
|
|
|
231
239
|
}
|
|
232
240
|
|
|
233
241
|
/**
|
|
234
|
-
* Declare a foreign key. The target is a
|
|
235
|
-
* by linkModels when the complete set is available
|
|
242
|
+
* Declare a foreign key. The target is a model thunk or a model NAME (a string is always a
|
|
243
|
+
* model name, resolved by linkModels when the complete set is available; one that names no
|
|
244
|
+
* model is an error, never a table). A table no model declares is named as an object:
|
|
245
|
+
* `.references({ schema: 'auth', table: 'users', column: 'id' })`, all three required, each a
|
|
246
|
+
* PostgreSQL name taken verbatim. That form is FK state only, never a relation, and is refused
|
|
247
|
+
* when a model in the set declares the table (use the model). Optional
|
|
236
248
|
* `onDelete`/`onUpdate` referential actions are column-scoped, so they live here, not on
|
|
237
249
|
* the table — `field.uuid().references(() => User, { onDelete: 'cascade' })`.
|
|
238
250
|
*
|
|
@@ -242,8 +254,19 @@ export class FieldBuilder {
|
|
|
242
254
|
* {@link FieldSpec.referencesValidate} for why a fifteen-year-old schema needs to be able
|
|
243
255
|
* to say it.
|
|
244
256
|
*/
|
|
245
|
-
references(target: (() => unknown) | string, opts?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction; name?: string; validate?: boolean }): this {
|
|
246
|
-
|
|
257
|
+
references(target: (() => unknown) | string | ExternalColumnRef, opts?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction; name?: string; validate?: boolean }): this {
|
|
258
|
+
// A second call REPLACES the target, as every other repeated modifier on this builder
|
|
259
|
+
// overwrites: each branch clears the other slot, so a field never holds both (the compiler
|
|
260
|
+
// would read one and relations or `via` the other).
|
|
261
|
+
if (typeof target === 'function' || typeof target === 'string') {
|
|
262
|
+
this.spec.references = referenceThunk(target);
|
|
263
|
+
delete this.spec.referencesExternal;
|
|
264
|
+
} else if (target !== null && typeof target === 'object') {
|
|
265
|
+
this.spec.referencesExternal = externalColumnRef(target);
|
|
266
|
+
delete this.spec.references;
|
|
267
|
+
} else {
|
|
268
|
+
throw new Error(`references(…): the target is ${target === null ? 'null' : typeof target}; pass a model thunk, a model name, or { schema, table, column }`);
|
|
269
|
+
}
|
|
247
270
|
if (opts?.onDelete) this.spec.onDelete = opts.onDelete;
|
|
248
271
|
if (opts?.onUpdate) this.spec.onUpdate = opts.onUpdate;
|
|
249
272
|
if (opts?.name !== undefined) this.spec.constraintName = assertConstraintName(opts.name, 'foreign key');
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* handler config. See docs/plans/v3-model-architecture.md.
|
|
4
4
|
*
|
|
5
5
|
* Ships the declaration surface (`defineModel`, `field`, `can`, `belongsTo`/
|
|
6
|
-
* `hasMany`); `@everystack/cli` compiles models to migrations, RLS + grants, and
|
|
6
|
+
* `hasMany`/`hasOne`); `@everystack/cli` compiles models to migrations, RLS + grants, and
|
|
7
7
|
* handler config (`deriveHandlerConfig`).
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -14,6 +14,8 @@ export { canonicalColumnType, fieldFactoryCall, pgTypeRefusal } from './pg-type'
|
|
|
14
14
|
|
|
15
15
|
export { parseQualified } from './qualified';
|
|
16
16
|
export { linkModels, ModelLinkError } from './link';
|
|
17
|
+
export { assertExternalTargets, externalTargetName, externalIdentifierRefusal } from './external';
|
|
18
|
+
export type { ExternalTableRef, ExternalColumnRef } from './external';
|
|
17
19
|
|
|
18
20
|
export { unique, check, index, foreignKey, IndexBuilder, positionalCheckName } from './constraints';
|
|
19
21
|
export type { TableConstraint } from './constraints';
|
|
@@ -25,11 +27,13 @@ export { can, isColumnAbility, isPublicReadAbility, isDeleteAbility, needsSoftDe
|
|
|
25
27
|
export type { SoftDeleteFacts } from './can';
|
|
26
28
|
export type { Ability, AbilityCondition, Action } from './can';
|
|
27
29
|
|
|
28
|
-
export { belongsTo, hasMany } from './relations';
|
|
29
|
-
export type { Relation } from './relations';
|
|
30
|
+
export { belongsTo, hasMany, hasOne } from './relations';
|
|
31
|
+
export type { Relation, BelongsToOptions, HasManyOptions, HasOneOptions, HasManyDependent, RelationOrder, RelationWhere } from './relations';
|
|
32
|
+
export { relationDeleteActions } from './dependents';
|
|
33
|
+
export type { DependentActions } from './dependents';
|
|
30
34
|
|
|
31
|
-
export { defineModel, defineMaterializedTable, EXTRA_PRIVILEGES, DML_PRIVILEGES, TABLE_PRIVILEGES } from './model';
|
|
32
|
-
export type { ModelDescriptor, ModelDefinition, ModelHooks, WrittenBy, ExtraPrivilege, TablePrivilege, DmlPrivilege, MaterializedTableMarker, MaterializedTableDefinition } from './model';
|
|
35
|
+
export { defineModel, defineMaterializedTable, LIFECYCLE_COLUMNS, LIFECYCLE_OWNER_ROLE, EXTRA_PRIVILEGES, DML_PRIVILEGES, TABLE_PRIVILEGES } from './model';
|
|
36
|
+
export type { ModelDescriptor, ModelDefinition, ModelHooks, DestroyContext, DestroyHook, WrittenBy, ExtraPrivilege, TablePrivilege, DmlPrivilege, MaterializedTableMarker, MaterializedTableDefinition } from './model';
|
|
33
37
|
|
|
34
38
|
export { defineModule, moduleModels, moduleExtensions, moduleFunctionSql, moduleSequences, moduleDerived, moduleGovernedRoles } from './module';
|
|
35
39
|
export type { Module, ModuleDefinition } from './module';
|
package/src/link.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { ModelDescriptor } from './model';
|
|
2
2
|
import { parseQualified } from './qualified';
|
|
3
|
+
import { assertExternalTargets } from './external';
|
|
4
|
+
import { relationDeleteActions } from './dependents';
|
|
5
|
+
import { hasDisplayOptions, normalizeRelationDisplay } from './relation-display';
|
|
3
6
|
|
|
4
7
|
/** A failed or conflicting name binding must never become a usable model. */
|
|
5
8
|
export class ModelLinkError extends Error {
|
|
@@ -97,11 +100,14 @@ export function linkModels<T extends readonly ModelDescriptor[]>(models: T): T {
|
|
|
97
100
|
// Resolve everything before changing descriptors, so a typo can be corrected and retried
|
|
98
101
|
// without leaving earlier references bound to a different, incomplete set.
|
|
99
102
|
const replacements: Array<() => void> = [];
|
|
103
|
+
// What each name reference resolves to in THIS set, before anything is bound.
|
|
104
|
+
const resolvedNames = new Map<unknown, ModelDescriptor>();
|
|
100
105
|
for (const model of uniqueModels) {
|
|
101
106
|
for (const [key, field] of Object.entries(model.fields)) {
|
|
102
107
|
const target = field.spec.references;
|
|
103
108
|
if (target && isNameReference(target)) {
|
|
104
109
|
const resolved = resolve(target as NameReference<ModelDescriptor>, model, 'field', key);
|
|
110
|
+
resolvedNames.set(target, resolved);
|
|
105
111
|
if (target.boundTarget) continue;
|
|
106
112
|
const linked = Object.assign(() => resolved, {
|
|
107
113
|
refName: target.refName, refLocation: target.refLocation, boundTarget: resolved,
|
|
@@ -113,6 +119,7 @@ export function linkModels<T extends readonly ModelDescriptor[]>(models: T): T {
|
|
|
113
119
|
const target = relation.target;
|
|
114
120
|
if (isNameReference(target)) {
|
|
115
121
|
const resolved = resolve(target, model, 'relation', key);
|
|
122
|
+
resolvedNames.set(target, resolved);
|
|
116
123
|
if (target.boundTarget) continue;
|
|
117
124
|
const linked = Object.assign(() => resolved, {
|
|
118
125
|
refName: target.refName, refLocation: target.refLocation, boundTarget: resolved,
|
|
@@ -121,6 +128,39 @@ export function linkModels<T extends readonly ModelDescriptor[]>(models: T): T {
|
|
|
121
128
|
}
|
|
122
129
|
}
|
|
123
130
|
}
|
|
131
|
+
// An external `{ schema, table }` target naming a table this set models is refused: the
|
|
132
|
+
// author must use the model. Checked before anything is bound, like every error above.
|
|
133
|
+
try {
|
|
134
|
+
assertExternalTargets(uniqueModels);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
throw new ModelLinkError(err instanceof Error ? err.message : String(err));
|
|
137
|
+
}
|
|
138
|
+
// hasMany `dependent` → the child FK's ON DELETE: checked here, before anything is bound, and
|
|
139
|
+
// never written into a spec (the compiler derives it per set). Only a relation that declares a
|
|
140
|
+
// dependent has its target (or its child's FK target) evaluated.
|
|
141
|
+
try {
|
|
142
|
+
relationDeleteActions(uniqueModels, (thunk) => resolvedNames.get(thunk) ?? thunk() as ModelDescriptor);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if (err instanceof ModelLinkError) throw err;
|
|
145
|
+
throw new ModelLinkError(err instanceof Error ? err.message : String(err));
|
|
146
|
+
}
|
|
147
|
+
// Display options (orderBy, where; every hasOne) name TARGET fields: checked here, before
|
|
148
|
+
// anything is bound, and the orderBy normalized once (the target's primary key appended).
|
|
149
|
+
// Only such a relation has its target evaluated. Display options never reach a dependent.
|
|
150
|
+
for (const model of uniqueModels) {
|
|
151
|
+
for (const [key, relation] of Object.entries(model.relations)) {
|
|
152
|
+
if (relation.kind === 'belongsTo' || !hasDisplayOptions(relation)) continue;
|
|
153
|
+
let orderBy: ReturnType<typeof normalizeRelationDisplay>;
|
|
154
|
+
try {
|
|
155
|
+
const target = resolvedNames.get(relation.target) ?? relation.target();
|
|
156
|
+
orderBy = normalizeRelationDisplay(modelName(model), key, relation, target);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (err instanceof ModelLinkError) throw err;
|
|
159
|
+
throw new ModelLinkError(err instanceof Error ? err.message : String(err));
|
|
160
|
+
}
|
|
161
|
+
if (orderBy) replacements.push(() => { relation.orderBy = orderBy; });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
124
164
|
for (const replace of replacements) replace();
|
|
125
165
|
return models;
|
|
126
166
|
}
|
package/src/model.ts
CHANGED
|
@@ -11,12 +11,13 @@
|
|
|
11
11
|
* compiler (`compileTableContract`, what `db:generate` emits as RLS + grants).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import type
|
|
14
|
+
import { field, type FieldBuilder } from './field';
|
|
15
15
|
import { referenceLocation } from './link';
|
|
16
16
|
import type { Ability } from './can';
|
|
17
|
-
import { isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete, sessionlessAudience } from './can';
|
|
18
|
-
import type
|
|
17
|
+
import { can, isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete, sessionlessAudience } from './can';
|
|
18
|
+
import { assertRelationShape, type Relation } from './relations';
|
|
19
19
|
import { assertCheckNames, type TableConstraint } from './constraints';
|
|
20
|
+
import { lifecycleCascadeHarm } from './dependents';
|
|
20
21
|
import type { RawSql } from './raw-sql';
|
|
21
22
|
import { validateTriggersFor, type TriggerSpec, type DependsOnRef } from './derived';
|
|
22
23
|
import { TABLE_PRIVILEGES, type TablePrivilege } from './privileges';
|
|
@@ -26,6 +27,15 @@ import { TABLE_PRIVILEGES, type TablePrivilege } from './privileges';
|
|
|
26
27
|
* absorb (purge an S3 object on delete, stamp a column on create) — held
|
|
27
28
|
* verbatim and passed through to the handler's `hooks`.
|
|
28
29
|
*/
|
|
30
|
+
/** Context for a real removal. Hooks must be idempotent: retries can repeat external effects. */
|
|
31
|
+
export interface DestroyContext {
|
|
32
|
+
source: 'purge' | 'api';
|
|
33
|
+
user: Record<string, unknown> | null;
|
|
34
|
+
client: Record<string, unknown> | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type DestroyHook = (row: Record<string, unknown>, ctx: DestroyContext) => void | Promise<void>;
|
|
38
|
+
|
|
29
39
|
export interface ModelHooks {
|
|
30
40
|
beforeCreate?: (...args: any[]) => any;
|
|
31
41
|
afterCreate?: (...args: any[]) => any;
|
|
@@ -33,6 +43,13 @@ export interface ModelHooks {
|
|
|
33
43
|
afterUpdate?: (...args: any[]) => any;
|
|
34
44
|
beforeDelete?: (...args: any[]) => any;
|
|
35
45
|
afterDelete?: (...args: any[]) => any;
|
|
46
|
+
/** Runs after the DELETE statement, before commit. A throw rolls back only the database
|
|
47
|
+
* delete, never an external effect. No transaction is passed. Must be idempotent.
|
|
48
|
+
* Treat the row as read-only: it is the same object later serialized into the response. */
|
|
49
|
+
beforeDestroy?: DestroyHook;
|
|
50
|
+
/** Runs after commit, best-effort. Cannot undo the removal. Must be idempotent.
|
|
51
|
+
* Treat the row as read-only: it is the same object later serialized into the response. */
|
|
52
|
+
afterDestroy?: DestroyHook;
|
|
36
53
|
}
|
|
37
54
|
|
|
38
55
|
/**
|
|
@@ -115,6 +132,14 @@ export interface ModelDefinition {
|
|
|
115
132
|
* was always explicit; this matches it. Convention may propose it, never enforce it.
|
|
116
133
|
*/
|
|
117
134
|
softDelete?: boolean;
|
|
135
|
+
/**
|
|
136
|
+
* `lifecycle: true` — a soft delete with a delay. defineModel generates four nullable columns
|
|
137
|
+
* ({@link LIFECYCLE_COLUMNS}): `deletedAt` timestamptz, `deletedBy` text (no foreign key),
|
|
138
|
+
* `deletedBatch` uuid, `deletedXid` bigint. The author never declares them, never combines
|
|
139
|
+
* lifecycle with `softDelete`, and declares a single-column primary key; each is refused here.
|
|
140
|
+
* `true` is the only value today.
|
|
141
|
+
*/
|
|
142
|
+
lifecycle?: true;
|
|
118
143
|
/**
|
|
119
144
|
* The Postgres schema the table lives in. Default `'public'`. Package-owned
|
|
120
145
|
* tables declare their own (`'dist'`, `'ops'`, `'auth'`) so one db:generate run
|
|
@@ -195,6 +220,8 @@ export interface ModelDescriptor {
|
|
|
195
220
|
privileges: Record<string, TablePrivilege[]>;
|
|
196
221
|
/** `true` excludes `deleted_at IS NULL` rows from public reads and the data API. Declared, never inferred. */
|
|
197
222
|
softDelete: boolean;
|
|
223
|
+
/** Present only when declared: `fields` carries the generated {@link LIFECYCLE_COLUMNS}. */
|
|
224
|
+
lifecycle?: true;
|
|
198
225
|
/** The resolved Postgres schema (default `'public'`). Package tables live in their own. */
|
|
199
226
|
schema: string;
|
|
200
227
|
/** `true` removes the table from the generic data API (not in `exposedTables`). Default `false`. */
|
|
@@ -227,11 +254,194 @@ export interface MaterializedTableMarker {
|
|
|
227
254
|
comment?: string;
|
|
228
255
|
}
|
|
229
256
|
|
|
257
|
+
/** The field keys `lifecycle: true` generates, in the order defineModel appends them. */
|
|
258
|
+
export const LIFECYCLE_COLUMNS = ['deletedAt', 'deletedBy', 'deletedBatch', 'deletedXid'] as const;
|
|
259
|
+
|
|
260
|
+
/** Both spellings of each generated key: an author declaring either collides with the generated column. */
|
|
261
|
+
const LIFECYCLE_RESERVED = [...LIFECYCLE_COLUMNS, 'deleted_at', 'deleted_by', 'deleted_batch', 'deleted_xid'];
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The role a lifecycle model grants to its delete machinery. defineModel appends its abilities
|
|
265
|
+
* to every `lifecycle: true` model, so no author ability may name it, on any model.
|
|
266
|
+
*/
|
|
267
|
+
export const LIFECYCLE_OWNER_ROLE = 'everystack_lifecycle';
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The lifecycle role's abilities: read every row (deleted ones included), update only the four
|
|
271
|
+
* generated columns, delete. No insert, no manage.
|
|
272
|
+
*/
|
|
273
|
+
function lifecycleAbilities(): Ability[] {
|
|
274
|
+
const role = LIFECYCLE_OWNER_ROLE;
|
|
275
|
+
return [
|
|
276
|
+
can('read', { role }),
|
|
277
|
+
can('update', { role, columns: [...LIFECYCLE_COLUMNS] }),
|
|
278
|
+
can('delete', { role }),
|
|
279
|
+
];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function assertRoleNotReserved(table: string, def: ModelDefinition): void {
|
|
283
|
+
// A privileges entry is refused too, even an empty list: TABLE_PRIVILEGES includes TRUNCATE
|
|
284
|
+
// and TRIGGER, which would let the role bypass RLS or attach triggers beside the guards.
|
|
285
|
+
if (def.privileges && Object.prototype.hasOwnProperty.call(def.privileges, LIFECYCLE_OWNER_ROLE)) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
`model '${table}': privileges['${LIFECYCLE_OWNER_ROLE}'] — the role '${LIFECYCLE_OWNER_ROLE}' is reserved for lifecycle; ` +
|
|
288
|
+
`the compiler grants it automatically on a lifecycle: true model. Remove the entry.`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
for (const a of def.abilities ?? []) {
|
|
292
|
+
if (a.condition.role !== LIFECYCLE_OWNER_ROLE) continue;
|
|
293
|
+
throw new Error(
|
|
294
|
+
`model '${table}': can('${a.action}', { role: '${LIFECYCLE_OWNER_ROLE}' }) — the role '${LIFECYCLE_OWNER_ROLE}' ` +
|
|
295
|
+
`is reserved for lifecycle; the compiler grants it automatically on a lifecycle: true model. Remove the ability.`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Fresh builders on every call: a descriptor owns its fields, never shares them with another model. */
|
|
301
|
+
function lifecycleFields(): Record<(typeof LIFECYCLE_COLUMNS)[number], FieldBuilder> {
|
|
302
|
+
return {
|
|
303
|
+
deletedAt: field.timestamptz().nullable(),
|
|
304
|
+
deletedBy: field.text().nullable(),
|
|
305
|
+
deletedBatch: field.uuid().nullable(),
|
|
306
|
+
deletedXid: field.bigint().nullable(),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function assertLifecycle(table: string, def: ModelDefinition): void {
|
|
311
|
+
const value: unknown = def.lifecycle;
|
|
312
|
+
if (value === undefined) return;
|
|
313
|
+
if (value === false) {
|
|
314
|
+
throw new Error(`model '${table}': lifecycle: false is not supported yet. Omit the key for a model without lifecycle.`);
|
|
315
|
+
}
|
|
316
|
+
if (value !== true) {
|
|
317
|
+
throw new Error(`model '${table}': lifecycle: ${JSON.stringify(value)} — the only value is lifecycle: true.`);
|
|
318
|
+
}
|
|
319
|
+
if (def.softDelete !== undefined) {
|
|
320
|
+
throw new Error(
|
|
321
|
+
`model '${table}': lifecycle replaces softDelete — delete softDelete: ${String(def.softDelete)}. ` +
|
|
322
|
+
`lifecycle: true already declares what a delete does.`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
for (const name of LIFECYCLE_RESERVED) {
|
|
326
|
+
if (name in def.fields) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`model '${table}': field '${name}' — with lifecycle: true the compiler generates this column; delete the declaration.`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// The lifecycle role's policies and the deleted_at read guard are RLS policies; under disabled
|
|
333
|
+
// row security they filter nothing while reading as though they do.
|
|
334
|
+
if (def.rls === false) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
`model '${table}': lifecycle: true with rls: false — lifecycle hides deleted rows with RLS policies, ` +
|
|
337
|
+
`and rls: false declares there are none, so the guard would be dead. Either drop rls: false (lifecycle needs RLS) ` +
|
|
338
|
+
`or drop lifecycle: true.`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
// A cascade into this table, declared on this table's own foreign key. A parent's
|
|
342
|
+
// hasMany { dependent: 'cascade' } is checked in relationDeleteActions (allowed from a
|
|
343
|
+
// lifecycle parent, where it compiles to RESTRICT and the triggers soft-delete the children).
|
|
344
|
+
const tableName = def.schema && def.schema !== 'public' ? `${def.schema}.${table}` : table;
|
|
345
|
+
for (const [name, f] of Object.entries(def.fields)) {
|
|
346
|
+
if (f.spec.onDelete !== 'cascade') continue;
|
|
347
|
+
throw new Error(`model '${tableName}': field '${name}' declares onDelete 'cascade'. ${lifecycleCascadeHarm(`${tableName}.${name}`)}.`);
|
|
348
|
+
}
|
|
349
|
+
for (const c of def.constraints ?? []) {
|
|
350
|
+
if (c.kind !== 'foreignKey' || c.onDelete !== 'cascade') continue;
|
|
351
|
+
const where = `${tableName}.${c.columns.join(', ')}`;
|
|
352
|
+
throw new Error(`model '${tableName}': foreignKey(${c.columns.join(', ')}) declares onDelete 'cascade'. ${lifecycleCascadeHarm(where)}.`);
|
|
353
|
+
}
|
|
354
|
+
const pk = Object.entries(def.fields).filter(([, f]) => f.spec.isPrimaryKey).length;
|
|
355
|
+
if (pk !== 1) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
`model '${table}': lifecycle needs a single-column primary key — this model declares ${pk === 0 ? 'none' : `${pk} (composite)`}. ` +
|
|
358
|
+
`Mark exactly one field .primaryKey().`,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
// The generated trigger and function names are `<table>_lifecycle_transition` and `_guard`.
|
|
362
|
+
// PostgreSQL silently truncates an identifier past 63 bytes, so the declared name would not be the live one.
|
|
363
|
+
// UTF-8 bytes, as PostgreSQL counts them. TextEncoder rather than Buffer: a model also loads outside Node.
|
|
364
|
+
const longest = `${table}_lifecycle_transition`;
|
|
365
|
+
const bytes = new TextEncoder().encode(longest).length;
|
|
366
|
+
if (bytes > 63) {
|
|
367
|
+
throw new Error(
|
|
368
|
+
`model '${table}': lifecycle generates '${longest}' (${bytes} bytes), past PostgreSQL's 63-byte identifier limit. ` +
|
|
369
|
+
`A lifecycle table name may be at most 42 bytes; shorten the table name.`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Restore runs the table's delete rule inside a SECURITY DEFINER function as the lifecycle role.
|
|
376
|
+
* Refuse a delete rule whose meaning would change there: a subquery (`via`, or SELECT in
|
|
377
|
+
* where/sql) runs with that role's privileges, and current_user/session_user name that role or
|
|
378
|
+
* the login, not the caller. TRUNCATE is refused because it skips the delete triggers.
|
|
379
|
+
*/
|
|
380
|
+
function assertLifecycleRestoreSafe(table: string, def: ModelDefinition): void {
|
|
381
|
+
if (def.lifecycle !== true) return;
|
|
382
|
+
const why = 'restore runs the delete rule inside a SECURITY DEFINER function as the lifecycle owner';
|
|
383
|
+
const abilities = def.abilities ?? [];
|
|
384
|
+
// A role-less (or authenticated) delete on a model scoped only through `via` compiles to the
|
|
385
|
+
// via subquery too, though the ability itself names no via.
|
|
386
|
+
const viaScopedModel = !abilities.some((a) => a.condition.owner) && abilities.some((a) => a.condition.via);
|
|
387
|
+
for (const a of abilities) {
|
|
388
|
+
if (a.action !== 'delete' && a.action !== 'manage') continue;
|
|
389
|
+
// PUBLIC would get EXECUTE on restore, and a branch with no role behind it: anon restores.
|
|
390
|
+
if (a.condition.role && a.condition.role.toLowerCase() === 'public') {
|
|
391
|
+
throw new Error(
|
|
392
|
+
`model '${table}': can('${a.action}', { role: '${a.condition.role}' }) on a lifecycle: true model — ${why}, and ` +
|
|
393
|
+
`restore is granted to whoever may delete, so every role, anon included, could restore rows. ` +
|
|
394
|
+
`Use a named role (authenticated, or one of your own) for the delete rule.`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
const authClass = !a.condition.role || a.condition.role === 'authenticated';
|
|
398
|
+
if (a.condition.via || (viaScopedModel && authClass)) {
|
|
399
|
+
const spelled = a.condition.via ? `can('${a.action}', { via: '${a.condition.via}' })` : `can('${a.action}') on a model scoped by via`;
|
|
400
|
+
throw new Error(
|
|
401
|
+
`model '${table}': ${spelled} on a lifecycle: true model — ${why}, where a via ` +
|
|
402
|
+
`subquery would read the parent with that role's privileges. Use owner, or a role with a plain where, for the delete rule.`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
for (const key of ['where', 'sql'] as const) {
|
|
406
|
+
const text = (a.condition[key] as { sql?: unknown } | undefined)?.sql;
|
|
407
|
+
if (typeof text !== 'string') continue;
|
|
408
|
+
const offender = /\bselect\b/i.test(text)
|
|
409
|
+
? 'a subquery (SELECT)'
|
|
410
|
+
: /\bcurrent_user\b/i.test(text)
|
|
411
|
+
? 'current_user'
|
|
412
|
+
: /\bsession_user\b/i.test(text)
|
|
413
|
+
? 'session_user'
|
|
414
|
+
: null;
|
|
415
|
+
if (!offender) continue;
|
|
416
|
+
throw new Error(
|
|
417
|
+
`model '${table}': can('${a.action}', { ${key} }) uses ${offender} on a lifecycle: true model — ${why}, ` +
|
|
418
|
+
`where a subquery runs with that role's privileges and current_user/session_user do not name the caller. ` +
|
|
419
|
+
`Replace it with a predicate on the row's own columns or the request claims.`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
for (const [role, privs] of Object.entries(def.privileges ?? {})) {
|
|
424
|
+
if (!Array.isArray(privs) || !privs.includes('TRUNCATE')) continue;
|
|
425
|
+
throw new Error(
|
|
426
|
+
`model '${table}': privileges['${role}'] grants TRUNCATE on a lifecycle: true model — TRUNCATE skips the delete ` +
|
|
427
|
+
`triggers, so it would erase rows past the soft delete and restore. Remove TRUNCATE from the entry.`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
230
432
|
export function defineModel(table: string, def: ModelDefinition): ModelDescriptor {
|
|
433
|
+
// Refuse first, then inject: the author-declared collisions are checked against what the author wrote.
|
|
434
|
+
assertRoleNotReserved(table, def);
|
|
435
|
+
assertLifecycle(table, def);
|
|
436
|
+
assertLifecycleRestoreSafe(table, def);
|
|
437
|
+
const lifecycle = def.lifecycle === true;
|
|
438
|
+
const fields: Record<string, FieldBuilder> = lifecycle ? { ...def.fields, ...lifecycleFields() } : def.fields;
|
|
439
|
+
|
|
231
440
|
for (const [name, field] of Object.entries(def.fields)) {
|
|
232
441
|
referenceLocation(field.spec.references, `${table}.${name}`);
|
|
233
442
|
}
|
|
234
443
|
for (const [name, relation] of Object.entries(def.relations ?? {})) {
|
|
444
|
+
assertRelationShape(def.schema && def.schema !== 'public' ? `${def.schema}.${table}` : table, name, relation);
|
|
235
445
|
referenceLocation(relation.target, `${table}.${name}`);
|
|
236
446
|
}
|
|
237
447
|
const primaryKey = Object.entries(def.fields)
|
|
@@ -282,6 +492,15 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
282
492
|
}
|
|
283
493
|
}
|
|
284
494
|
|
|
495
|
+
// A generated lifecycle column is not an author field, so no author ability may scope by it.
|
|
496
|
+
// (A column ability's owner is already refused above against the author's fields.)
|
|
497
|
+
if (lifecycle && a.condition.owner && LIFECYCLE_RESERVED.includes(a.condition.owner)) {
|
|
498
|
+
throw new Error(
|
|
499
|
+
`model '${table}': can('${a.action}', { owner: '${a.condition.owner}' }) names '${a.condition.owner}', but no such field exists ` +
|
|
500
|
+
`among the model's fields — with lifecycle: true the compiler generates it, and an ability cannot scope by it.`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
285
504
|
// `owner`/`via` compare a row column to the caller's JWT claim; a no-session audience has
|
|
286
505
|
// none. Truthiness matches the compiler, which treats `owner: ''` as no row scope.
|
|
287
506
|
const rowScope = a.condition.owner ? 'owner' : a.condition.via ? 'via' : null;
|
|
@@ -365,6 +584,7 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
365
584
|
// literally, so a model keying the column `deleted_at` — the shape a snake_case brownfield
|
|
366
585
|
// schema pulls as — carried a soft-delete column past a guard that never looked at it.
|
|
367
586
|
const softDeleteField = 'deletedAt' in def.fields || 'deleted_at' in def.fields;
|
|
587
|
+
// A lifecycle model has declared what a delete does; its generated deletedAt is not an author field.
|
|
368
588
|
if (needsSoftDeleteDeclaration({ hasDeletedAtField: softDeleteField, hasPublicRead, grantsDelete }) && def.softDelete === undefined) {
|
|
369
589
|
// DURABILITY FIRST. The old message called this "a visibility decision", which is false
|
|
370
590
|
// for exactly the models that broke: with no public read, `softDelete: true` adds no
|
|
@@ -454,15 +674,18 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
454
674
|
|
|
455
675
|
return {
|
|
456
676
|
table,
|
|
457
|
-
fields
|
|
458
|
-
|
|
677
|
+
fields,
|
|
678
|
+
// Appended after every author check above: the internal set may name the generated columns.
|
|
679
|
+
abilities: lifecycle ? [...(def.abilities ?? []), ...lifecycleAbilities()] : def.abilities ?? [],
|
|
459
680
|
hooks: def.hooks,
|
|
460
681
|
relations: def.relations ?? {},
|
|
461
682
|
primaryKey,
|
|
462
683
|
constraints: def.constraints ?? [],
|
|
463
684
|
writtenBy: def.writtenBy ?? 'app',
|
|
464
685
|
privileges: def.privileges ?? {},
|
|
686
|
+
// Stays false on a lifecycle model: its reads are filtered by the RLS guard (L1.2a), its deletes arrive with the triggers.
|
|
465
687
|
softDelete: def.softDelete ?? false,
|
|
688
|
+
...(lifecycle ? { lifecycle: true as const } : {}),
|
|
466
689
|
schema: def.schema ?? 'public',
|
|
467
690
|
private: def.private ?? false,
|
|
468
691
|
rls: def.rls ?? true,
|
package/src/module.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* See docs/plans/v3-module-complete-generation.md.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import type
|
|
22
|
+
import { LIFECYCLE_OWNER_ROLE, type ModelDescriptor } from './model';
|
|
23
23
|
import type { DerivedDescriptor, SequenceDescriptor } from './derived';
|
|
24
24
|
import { rejectHoles, functionArgSignature } from './derived';
|
|
25
25
|
import { deriveFunctionSchemas } from './derive';
|
|
@@ -223,7 +223,12 @@ export function moduleDerived(modules: Module[]): DerivedDescriptor[] {
|
|
|
223
223
|
return modules.flatMap((m) => m.derived);
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
/**
|
|
226
|
+
/**
|
|
227
|
+
* The union of every module's governed roles — deduped and sorted. Widen-only by construction.
|
|
228
|
+
* A module holding a lifecycle model also governs {@link LIFECYCLE_OWNER_ROLE}, which the
|
|
229
|
+
* compiler grants on that model.
|
|
230
|
+
*/
|
|
227
231
|
export function moduleGovernedRoles(modules: Module[]): string[] {
|
|
228
|
-
|
|
232
|
+
const lifecycle = modules.some((m) => m.models.some((model) => model.lifecycle));
|
|
233
|
+
return [...new Set([...modules.flatMap((m) => m.governedRoles), ...(lifecycle ? [LIFECYCLE_OWNER_ROLE] : [])])].sort();
|
|
229
234
|
}
|