@shaferllc/keel 0.12.0 → 0.36.0
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/README.md +30 -3
- package/dist/core/application.js +9 -0
- package/dist/core/auth.d.ts +41 -0
- package/dist/core/auth.js +70 -0
- package/dist/core/cache.d.ts +41 -0
- package/dist/core/cache.js +81 -0
- package/dist/core/casts.d.ts +19 -0
- package/dist/core/casts.js +69 -0
- package/dist/core/crypto.d.ts +24 -0
- package/dist/core/crypto.js +97 -0
- package/dist/core/database.d.ts +58 -0
- package/dist/core/database.js +140 -0
- package/dist/core/debug.d.ts +12 -0
- package/dist/core/debug.js +55 -0
- package/dist/core/events.d.ts +21 -0
- package/dist/core/events.js +46 -0
- package/dist/core/exceptions.d.ts +10 -1
- package/dist/core/exceptions.js +10 -1
- package/dist/core/factory.d.ts +84 -0
- package/dist/core/factory.js +154 -0
- package/dist/core/helpers.d.ts +13 -0
- package/dist/core/helpers.js +24 -0
- package/dist/core/http/kernel.js +21 -2
- package/dist/core/http/router.d.ts +32 -8
- package/dist/core/http/router.js +74 -5
- package/dist/core/index.d.ts +33 -2
- package/dist/core/index.js +18 -1
- package/dist/core/logger.d.ts +29 -0
- package/dist/core/logger.js +49 -0
- package/dist/core/mail.d.ts +94 -0
- package/dist/core/mail.js +157 -0
- package/dist/core/migrations.d.ts +76 -0
- package/dist/core/migrations.js +211 -0
- package/dist/core/model.d.ts +75 -0
- package/dist/core/model.js +202 -0
- package/dist/core/notification.d.ts +82 -0
- package/dist/core/notification.js +129 -0
- package/dist/core/queue.d.ts +73 -0
- package/dist/core/queue.js +88 -0
- package/dist/core/rate-limit.d.ts +23 -0
- package/dist/core/rate-limit.js +50 -0
- package/dist/core/relations.d.ts +90 -0
- package/dist/core/relations.js +229 -0
- package/dist/core/request.d.ts +53 -1
- package/dist/core/request.js +187 -0
- package/dist/core/session.d.ts +46 -0
- package/dist/core/session.js +112 -0
- package/dist/core/static.d.ts +27 -0
- package/dist/core/static.js +61 -0
- package/package.json +2 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema builder + migrator. Define migrations as `{ name, up, down }`, describe
|
|
3
|
+
* tables with a fluent schema builder, and run them through your registered
|
|
4
|
+
* connection. Dialect-aware SQL (sqlite / mysql / postgres), edge-safe.
|
|
5
|
+
*
|
|
6
|
+
* const migrations: Migration[] = [{
|
|
7
|
+
* name: "01_create_users",
|
|
8
|
+
* up: (s) => s.createTable("users", (t) => {
|
|
9
|
+
* t.id();
|
|
10
|
+
* t.string("email").unique();
|
|
11
|
+
* t.timestamps();
|
|
12
|
+
* }),
|
|
13
|
+
* down: (s) => s.dropTable("users"),
|
|
14
|
+
* }];
|
|
15
|
+
*
|
|
16
|
+
* await new Migrator(connection, "postgres").up(migrations);
|
|
17
|
+
*/
|
|
18
|
+
import type { Connection, Dialect } from "./database.js";
|
|
19
|
+
type ColumnType = "id" | "string" | "text" | "integer" | "bigInteger" | "boolean" | "timestamp" | "json";
|
|
20
|
+
export declare class Column {
|
|
21
|
+
private column;
|
|
22
|
+
private type;
|
|
23
|
+
private length?;
|
|
24
|
+
private _nullable;
|
|
25
|
+
private _unique;
|
|
26
|
+
private _default;
|
|
27
|
+
private hasDefault;
|
|
28
|
+
constructor(column: string, type: ColumnType, length?: number | undefined);
|
|
29
|
+
nullable(): this;
|
|
30
|
+
unique(): this;
|
|
31
|
+
default(value: unknown): this;
|
|
32
|
+
toSql(dialect: Dialect): string;
|
|
33
|
+
}
|
|
34
|
+
export declare class TableBuilder {
|
|
35
|
+
readonly columns: Column[];
|
|
36
|
+
private add;
|
|
37
|
+
id(name?: string): Column;
|
|
38
|
+
string(name: string, length?: number): Column;
|
|
39
|
+
text(name: string): Column;
|
|
40
|
+
integer(name: string): Column;
|
|
41
|
+
bigInteger(name: string): Column;
|
|
42
|
+
boolean(name: string): Column;
|
|
43
|
+
timestamp(name: string): Column;
|
|
44
|
+
json(name: string): Column;
|
|
45
|
+
/** Adds nullable created_at + updated_at columns. */
|
|
46
|
+
timestamps(): void;
|
|
47
|
+
toCreateSql(table: string, dialect: Dialect): string;
|
|
48
|
+
}
|
|
49
|
+
export declare class SchemaBuilder {
|
|
50
|
+
private conn;
|
|
51
|
+
private dialect;
|
|
52
|
+
constructor(conn: Connection, dialect: Dialect);
|
|
53
|
+
createTable(name: string, build: (table: TableBuilder) => void): Promise<void>;
|
|
54
|
+
dropTable(name: string): Promise<void>;
|
|
55
|
+
raw(sql: string, bindings?: unknown[]): Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
export interface Migration {
|
|
58
|
+
name: string;
|
|
59
|
+
up(schema: SchemaBuilder): void | Promise<void>;
|
|
60
|
+
down(schema: SchemaBuilder): void | Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
export declare class Migrator {
|
|
63
|
+
private conn;
|
|
64
|
+
private dialect;
|
|
65
|
+
constructor(conn: Connection, dialect?: Dialect);
|
|
66
|
+
private ph;
|
|
67
|
+
private ensure;
|
|
68
|
+
/** Names of migrations already applied. */
|
|
69
|
+
ran(): Promise<string[]>;
|
|
70
|
+
private maxBatch;
|
|
71
|
+
/** Run all pending migrations. Returns the names applied. */
|
|
72
|
+
up(migrations: Migration[]): Promise<string[]>;
|
|
73
|
+
/** Roll back the most recent batch. Returns the names rolled back. */
|
|
74
|
+
down(migrations: Migration[]): Promise<string[]>;
|
|
75
|
+
}
|
|
76
|
+
export {};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema builder + migrator. Define migrations as `{ name, up, down }`, describe
|
|
3
|
+
* tables with a fluent schema builder, and run them through your registered
|
|
4
|
+
* connection. Dialect-aware SQL (sqlite / mysql / postgres), edge-safe.
|
|
5
|
+
*
|
|
6
|
+
* const migrations: Migration[] = [{
|
|
7
|
+
* name: "01_create_users",
|
|
8
|
+
* up: (s) => s.createTable("users", (t) => {
|
|
9
|
+
* t.id();
|
|
10
|
+
* t.string("email").unique();
|
|
11
|
+
* t.timestamps();
|
|
12
|
+
* }),
|
|
13
|
+
* down: (s) => s.dropTable("users"),
|
|
14
|
+
* }];
|
|
15
|
+
*
|
|
16
|
+
* await new Migrator(connection, "postgres").up(migrations);
|
|
17
|
+
*/
|
|
18
|
+
function sqlType(type, dialect, length) {
|
|
19
|
+
switch (type) {
|
|
20
|
+
case "id":
|
|
21
|
+
return dialect === "postgres"
|
|
22
|
+
? "SERIAL PRIMARY KEY"
|
|
23
|
+
: dialect === "mysql"
|
|
24
|
+
? "INT AUTO_INCREMENT PRIMARY KEY"
|
|
25
|
+
: "INTEGER PRIMARY KEY AUTOINCREMENT";
|
|
26
|
+
case "string":
|
|
27
|
+
return `VARCHAR(${length ?? 255})`;
|
|
28
|
+
case "text":
|
|
29
|
+
return "TEXT";
|
|
30
|
+
case "integer":
|
|
31
|
+
return "INTEGER";
|
|
32
|
+
case "bigInteger":
|
|
33
|
+
return "BIGINT";
|
|
34
|
+
case "boolean":
|
|
35
|
+
return dialect === "sqlite" ? "INTEGER" : "BOOLEAN";
|
|
36
|
+
case "timestamp":
|
|
37
|
+
return dialect === "sqlite" ? "DATETIME" : "TIMESTAMP";
|
|
38
|
+
case "json":
|
|
39
|
+
return dialect === "postgres" ? "JSONB" : "TEXT";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export class Column {
|
|
43
|
+
column;
|
|
44
|
+
type;
|
|
45
|
+
length;
|
|
46
|
+
_nullable = false;
|
|
47
|
+
_unique = false;
|
|
48
|
+
_default;
|
|
49
|
+
hasDefault = false;
|
|
50
|
+
constructor(column, type, length) {
|
|
51
|
+
this.column = column;
|
|
52
|
+
this.type = type;
|
|
53
|
+
this.length = length;
|
|
54
|
+
}
|
|
55
|
+
nullable() {
|
|
56
|
+
this._nullable = true;
|
|
57
|
+
return this;
|
|
58
|
+
}
|
|
59
|
+
unique() {
|
|
60
|
+
this._unique = true;
|
|
61
|
+
return this;
|
|
62
|
+
}
|
|
63
|
+
default(value) {
|
|
64
|
+
this._default = value;
|
|
65
|
+
this.hasDefault = true;
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
toSql(dialect) {
|
|
69
|
+
let sql = `${this.column} ${sqlType(this.type, dialect, this.length)}`;
|
|
70
|
+
if (this.type !== "id")
|
|
71
|
+
sql += this._nullable ? "" : " NOT NULL";
|
|
72
|
+
if (this._unique)
|
|
73
|
+
sql += " UNIQUE";
|
|
74
|
+
if (this.hasDefault) {
|
|
75
|
+
const v = this._default;
|
|
76
|
+
const rendered = typeof v === "string"
|
|
77
|
+
? `'${v}'`
|
|
78
|
+
: typeof v === "boolean"
|
|
79
|
+
? dialect === "sqlite"
|
|
80
|
+
? v
|
|
81
|
+
? "1"
|
|
82
|
+
: "0"
|
|
83
|
+
: String(v)
|
|
84
|
+
: String(v);
|
|
85
|
+
sql += ` DEFAULT ${rendered}`;
|
|
86
|
+
}
|
|
87
|
+
return sql;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export class TableBuilder {
|
|
91
|
+
columns = [];
|
|
92
|
+
add(name, type, length) {
|
|
93
|
+
const col = new Column(name, type, length);
|
|
94
|
+
this.columns.push(col);
|
|
95
|
+
return col;
|
|
96
|
+
}
|
|
97
|
+
id(name = "id") {
|
|
98
|
+
return this.add(name, "id");
|
|
99
|
+
}
|
|
100
|
+
string(name, length = 255) {
|
|
101
|
+
return this.add(name, "string", length);
|
|
102
|
+
}
|
|
103
|
+
text(name) {
|
|
104
|
+
return this.add(name, "text");
|
|
105
|
+
}
|
|
106
|
+
integer(name) {
|
|
107
|
+
return this.add(name, "integer");
|
|
108
|
+
}
|
|
109
|
+
bigInteger(name) {
|
|
110
|
+
return this.add(name, "bigInteger");
|
|
111
|
+
}
|
|
112
|
+
boolean(name) {
|
|
113
|
+
return this.add(name, "boolean");
|
|
114
|
+
}
|
|
115
|
+
timestamp(name) {
|
|
116
|
+
return this.add(name, "timestamp");
|
|
117
|
+
}
|
|
118
|
+
json(name) {
|
|
119
|
+
return this.add(name, "json");
|
|
120
|
+
}
|
|
121
|
+
/** Adds nullable created_at + updated_at columns. */
|
|
122
|
+
timestamps() {
|
|
123
|
+
this.timestamp("created_at").nullable();
|
|
124
|
+
this.timestamp("updated_at").nullable();
|
|
125
|
+
}
|
|
126
|
+
toCreateSql(table, dialect) {
|
|
127
|
+
return `CREATE TABLE ${table} (${this.columns.map((c) => c.toSql(dialect)).join(", ")})`;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export class SchemaBuilder {
|
|
131
|
+
conn;
|
|
132
|
+
dialect;
|
|
133
|
+
constructor(conn, dialect) {
|
|
134
|
+
this.conn = conn;
|
|
135
|
+
this.dialect = dialect;
|
|
136
|
+
}
|
|
137
|
+
async createTable(name, build) {
|
|
138
|
+
const table = new TableBuilder();
|
|
139
|
+
build(table);
|
|
140
|
+
await this.conn.write(table.toCreateSql(name, this.dialect), []);
|
|
141
|
+
}
|
|
142
|
+
async dropTable(name) {
|
|
143
|
+
await this.conn.write(`DROP TABLE IF EXISTS ${name}`, []);
|
|
144
|
+
}
|
|
145
|
+
async raw(sql, bindings = []) {
|
|
146
|
+
await this.conn.write(sql, bindings);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export class Migrator {
|
|
150
|
+
conn;
|
|
151
|
+
dialect;
|
|
152
|
+
constructor(conn, dialect = "sqlite") {
|
|
153
|
+
this.conn = conn;
|
|
154
|
+
this.dialect = dialect;
|
|
155
|
+
}
|
|
156
|
+
ph(sql) {
|
|
157
|
+
if (this.dialect !== "postgres")
|
|
158
|
+
return sql;
|
|
159
|
+
let i = 0;
|
|
160
|
+
return sql.replace(/\?/g, () => `$${++i}`);
|
|
161
|
+
}
|
|
162
|
+
async ensure() {
|
|
163
|
+
await this.conn.write("CREATE TABLE IF NOT EXISTS migrations (name VARCHAR(255) PRIMARY KEY, batch INTEGER NOT NULL)", []);
|
|
164
|
+
}
|
|
165
|
+
/** Names of migrations already applied. */
|
|
166
|
+
async ran() {
|
|
167
|
+
await this.ensure();
|
|
168
|
+
const rows = await this.conn.select("SELECT name FROM migrations", []);
|
|
169
|
+
return rows.map((r) => String(r.name));
|
|
170
|
+
}
|
|
171
|
+
async maxBatch() {
|
|
172
|
+
const rows = await this.conn.select("SELECT MAX(batch) AS b FROM migrations", []);
|
|
173
|
+
return Number(rows[0]?.b ?? 0);
|
|
174
|
+
}
|
|
175
|
+
/** Run all pending migrations. Returns the names applied. */
|
|
176
|
+
async up(migrations) {
|
|
177
|
+
const ran = await this.ran();
|
|
178
|
+
const batch = (await this.maxBatch()) + 1;
|
|
179
|
+
const schema = new SchemaBuilder(this.conn, this.dialect);
|
|
180
|
+
const applied = [];
|
|
181
|
+
for (const m of migrations) {
|
|
182
|
+
if (ran.includes(m.name))
|
|
183
|
+
continue;
|
|
184
|
+
await m.up(schema);
|
|
185
|
+
await this.conn.write(this.ph("INSERT INTO migrations (name, batch) VALUES (?, ?)"), [
|
|
186
|
+
m.name,
|
|
187
|
+
batch,
|
|
188
|
+
]);
|
|
189
|
+
applied.push(m.name);
|
|
190
|
+
}
|
|
191
|
+
return applied;
|
|
192
|
+
}
|
|
193
|
+
/** Roll back the most recent batch. Returns the names rolled back. */
|
|
194
|
+
async down(migrations) {
|
|
195
|
+
await this.ensure();
|
|
196
|
+
const batch = await this.maxBatch();
|
|
197
|
+
if (!batch)
|
|
198
|
+
return [];
|
|
199
|
+
const rows = await this.conn.select(this.ph("SELECT name FROM migrations WHERE batch = ?"), [batch]);
|
|
200
|
+
const schema = new SchemaBuilder(this.conn, this.dialect);
|
|
201
|
+
const rolled = [];
|
|
202
|
+
for (const name of rows.map((r) => String(r.name)).reverse()) {
|
|
203
|
+
const migration = migrations.find((m) => m.name === name);
|
|
204
|
+
if (migration)
|
|
205
|
+
await migration.down(schema);
|
|
206
|
+
await this.conn.write(this.ph("DELETE FROM migrations WHERE name = ?"), [name]);
|
|
207
|
+
rolled.push(name);
|
|
208
|
+
}
|
|
209
|
+
return rolled;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny active-record Model over the query builder. Subclass it, point it at a
|
|
3
|
+
* table, and get `find` / `all` / `create` / `save` / `delete` — no ORM to
|
|
4
|
+
* configure. Backed by whatever `Connection` you registered with
|
|
5
|
+
* `setConnection()`, so it runs on Node and the edge.
|
|
6
|
+
*
|
|
7
|
+
* class User extends Model {
|
|
8
|
+
* static table = "users";
|
|
9
|
+
* declare id: number;
|
|
10
|
+
* declare email: string;
|
|
11
|
+
* }
|
|
12
|
+
*
|
|
13
|
+
* const user = await User.find(1);
|
|
14
|
+
* const created = await User.create({ email });
|
|
15
|
+
* user.email = "new@x.com";
|
|
16
|
+
* await user.save();
|
|
17
|
+
*/
|
|
18
|
+
import { type QueryBuilder, type Row } from "./database.js";
|
|
19
|
+
import { BelongsTo, BelongsToMany, HasMany, HasOne } from "./relations.js";
|
|
20
|
+
import { type Casts } from "./casts.js";
|
|
21
|
+
type ModelClass<T extends Model> = (new (attributes?: Row) => T) & typeof Model;
|
|
22
|
+
export declare class Model {
|
|
23
|
+
static table: string;
|
|
24
|
+
static primaryKey: string;
|
|
25
|
+
/** Columns mass-assignable via `create`/`fill` (allowlist). */
|
|
26
|
+
static fillable: string[];
|
|
27
|
+
/** Columns NOT mass-assignable (denylist). Ignored when `fillable` is set. */
|
|
28
|
+
static guarded: string[];
|
|
29
|
+
/** Column -> cast type; values round-trip as real JS types. */
|
|
30
|
+
static casts: Casts;
|
|
31
|
+
[key: string]: unknown;
|
|
32
|
+
constructor(attributes?: Row);
|
|
33
|
+
/** A raw query builder scoped to this model's table. */
|
|
34
|
+
static query(): QueryBuilder;
|
|
35
|
+
/** Keep only the attributes mass-assignment allows (fillable / guarded). */
|
|
36
|
+
static filterFillable(attributes: Row): Row;
|
|
37
|
+
/** Cast attributes to their storable primitives for a write. */
|
|
38
|
+
static toDatabase(attributes: Row): Row;
|
|
39
|
+
static all<T extends Model>(this: ModelClass<T>): Promise<T[]>;
|
|
40
|
+
static find<T extends Model>(this: ModelClass<T>, id: unknown): Promise<T | null>;
|
|
41
|
+
static findOrFail<T extends Model>(this: ModelClass<T>, id: unknown): Promise<T>;
|
|
42
|
+
static first<T extends Model>(this: ModelClass<T>): Promise<T | null>;
|
|
43
|
+
/** Fetch models matching a simple equality condition. */
|
|
44
|
+
static where<T extends Model>(this: ModelClass<T>, column: string, value: unknown): Promise<T[]>;
|
|
45
|
+
static create<T extends Model>(this: ModelClass<T>, attributes: Row): Promise<T>;
|
|
46
|
+
/**
|
|
47
|
+
* Eager-load relations onto an array of models with one extra query each —
|
|
48
|
+
* the fix for N+1. Each name must be a relationship method on the model.
|
|
49
|
+
*/
|
|
50
|
+
static load<T extends Model>(models: T[], ...names: string[]): Promise<T[]>;
|
|
51
|
+
private ctor;
|
|
52
|
+
/** The default foreign key a child of this model would carry (e.g. `user_id`). */
|
|
53
|
+
private foreignKeyName;
|
|
54
|
+
/** This model has many `related` rows, joined by a foreign key on `related`. */
|
|
55
|
+
hasMany<T extends Model>(related: ModelClass<T>, foreignKey?: string, localKey?: string): HasMany<T>;
|
|
56
|
+
/** This model has one `related` row, joined by a foreign key on `related`. */
|
|
57
|
+
hasOne<T extends Model>(related: ModelClass<T>, foreignKey?: string, localKey?: string): HasOne<T>;
|
|
58
|
+
/** This model belongs to a `related` row via a foreign key it carries. */
|
|
59
|
+
belongsTo<T extends Model>(related: ModelClass<T>, foreignKey?: string, ownerKey?: string): BelongsTo<T>;
|
|
60
|
+
/** Many-to-many through a pivot table (default name: the two tables, sorted). */
|
|
61
|
+
belongsToMany<T extends Model>(related: ModelClass<T>, pivotTable?: string, foreignPivotKey?: string, relatedPivotKey?: string, parentKey?: string, relatedKey?: string): BelongsToMany<T>;
|
|
62
|
+
/** Store an eager-loaded relation result under `name`. */
|
|
63
|
+
setRelation(name: string, value: unknown): this;
|
|
64
|
+
/** Read a previously loaded relation (returns undefined if not loaded). */
|
|
65
|
+
getRelation<T = unknown>(name: string): T | undefined;
|
|
66
|
+
/** Insert (no primary key) or update (has one). */
|
|
67
|
+
save(): Promise<this>;
|
|
68
|
+
delete(): Promise<void>;
|
|
69
|
+
/** Merge mass-assignable attributes into the model (cast, not saved). */
|
|
70
|
+
fill(attributes: Row): this;
|
|
71
|
+
/** Force-assign attributes, bypassing mass-assignment guarding (still cast). */
|
|
72
|
+
forceFill(attributes: Row): this;
|
|
73
|
+
toJSON(): Row;
|
|
74
|
+
}
|
|
75
|
+
export {};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny active-record Model over the query builder. Subclass it, point it at a
|
|
3
|
+
* table, and get `find` / `all` / `create` / `save` / `delete` — no ORM to
|
|
4
|
+
* configure. Backed by whatever `Connection` you registered with
|
|
5
|
+
* `setConnection()`, so it runs on Node and the edge.
|
|
6
|
+
*
|
|
7
|
+
* class User extends Model {
|
|
8
|
+
* static table = "users";
|
|
9
|
+
* declare id: number;
|
|
10
|
+
* declare email: string;
|
|
11
|
+
* }
|
|
12
|
+
*
|
|
13
|
+
* const user = await User.find(1);
|
|
14
|
+
* const created = await User.create({ email });
|
|
15
|
+
* user.email = "new@x.com";
|
|
16
|
+
* await user.save();
|
|
17
|
+
*/
|
|
18
|
+
import { db } from "./database.js";
|
|
19
|
+
import { NotFoundException } from "./exceptions.js";
|
|
20
|
+
import { BelongsTo, BelongsToMany, HasMany, HasOne } from "./relations.js";
|
|
21
|
+
import { applyCasts, castGet, castSet } from "./casts.js";
|
|
22
|
+
/**
|
|
23
|
+
* Loaded relations live off the model itself so they never leak into `save()`
|
|
24
|
+
* (which spreads own columns) — they're keyed here by the owning instance.
|
|
25
|
+
*/
|
|
26
|
+
const relationStore = new WeakMap();
|
|
27
|
+
function serialize(value) {
|
|
28
|
+
if (Array.isArray(value))
|
|
29
|
+
return value.map(serialize);
|
|
30
|
+
if (value instanceof Model)
|
|
31
|
+
return value.toJSON();
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
export class Model {
|
|
35
|
+
static table = "";
|
|
36
|
+
static primaryKey = "id";
|
|
37
|
+
/** Columns mass-assignable via `create`/`fill` (allowlist). */
|
|
38
|
+
static fillable = [];
|
|
39
|
+
/** Columns NOT mass-assignable (denylist). Ignored when `fillable` is set. */
|
|
40
|
+
static guarded = [];
|
|
41
|
+
/** Column -> cast type; values round-trip as real JS types. */
|
|
42
|
+
static casts = {};
|
|
43
|
+
constructor(attributes = {}) {
|
|
44
|
+
// Hydration is unguarded (rows come from the database) but always cast.
|
|
45
|
+
Object.assign(this, applyCasts(attributes, this.constructor.casts, castGet));
|
|
46
|
+
}
|
|
47
|
+
/* ------------------------------ static -------------------------------- */
|
|
48
|
+
/** A raw query builder scoped to this model's table. */
|
|
49
|
+
static query() {
|
|
50
|
+
return db(this.table);
|
|
51
|
+
}
|
|
52
|
+
/** Keep only the attributes mass-assignment allows (fillable / guarded). */
|
|
53
|
+
static filterFillable(attributes) {
|
|
54
|
+
if (this.fillable.length) {
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const key of this.fillable)
|
|
57
|
+
if (key in attributes)
|
|
58
|
+
out[key] = attributes[key];
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
if (this.guarded.length) {
|
|
62
|
+
const out = { ...attributes };
|
|
63
|
+
for (const key of this.guarded)
|
|
64
|
+
delete out[key];
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
return { ...attributes };
|
|
68
|
+
}
|
|
69
|
+
/** Cast attributes to their storable primitives for a write. */
|
|
70
|
+
static toDatabase(attributes) {
|
|
71
|
+
return applyCasts(attributes, this.casts, castSet);
|
|
72
|
+
}
|
|
73
|
+
static async all() {
|
|
74
|
+
const rows = await db(this.table).get();
|
|
75
|
+
return rows.map((row) => new this(row));
|
|
76
|
+
}
|
|
77
|
+
static async find(id) {
|
|
78
|
+
const row = await db(this.table).where(this.primaryKey, id).first();
|
|
79
|
+
return row ? new this(row) : null;
|
|
80
|
+
}
|
|
81
|
+
static async findOrFail(id) {
|
|
82
|
+
const model = await this.find(id);
|
|
83
|
+
if (!model)
|
|
84
|
+
throw new NotFoundException(`${this.name} ${String(id)} not found`);
|
|
85
|
+
return model;
|
|
86
|
+
}
|
|
87
|
+
static async first() {
|
|
88
|
+
const row = await db(this.table).first();
|
|
89
|
+
return row ? new this(row) : null;
|
|
90
|
+
}
|
|
91
|
+
/** Fetch models matching a simple equality condition. */
|
|
92
|
+
static async where(column, value) {
|
|
93
|
+
const rows = await db(this.table).where(column, value).get();
|
|
94
|
+
return rows.map((row) => new this(row));
|
|
95
|
+
}
|
|
96
|
+
static async create(attributes) {
|
|
97
|
+
const filtered = this.filterFillable(attributes);
|
|
98
|
+
const id = await db(this.table).insertGetId(this.toDatabase(filtered));
|
|
99
|
+
const model = new this(filtered);
|
|
100
|
+
if (id != null)
|
|
101
|
+
model[this.primaryKey] = id;
|
|
102
|
+
return model;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Eager-load relations onto an array of models with one extra query each —
|
|
106
|
+
* the fix for N+1. Each name must be a relationship method on the model.
|
|
107
|
+
*/
|
|
108
|
+
static async load(models, ...names) {
|
|
109
|
+
if (!models.length)
|
|
110
|
+
return models;
|
|
111
|
+
for (const name of names) {
|
|
112
|
+
const method = models[0][name];
|
|
113
|
+
if (typeof method !== "function") {
|
|
114
|
+
throw new Error(`${models[0].constructor.name} has no relation "${name}"`);
|
|
115
|
+
}
|
|
116
|
+
const relation = method.call(models[0]);
|
|
117
|
+
await relation.eager(models, name);
|
|
118
|
+
}
|
|
119
|
+
return models;
|
|
120
|
+
}
|
|
121
|
+
/* ----------------------------- instance ------------------------------- */
|
|
122
|
+
ctor() {
|
|
123
|
+
return this.constructor;
|
|
124
|
+
}
|
|
125
|
+
/* --------------------------- relationships ---------------------------- */
|
|
126
|
+
/** The default foreign key a child of this model would carry (e.g. `user_id`). */
|
|
127
|
+
foreignKeyName() {
|
|
128
|
+
return `${this.constructor.name.toLowerCase()}_${this.ctor().primaryKey}`;
|
|
129
|
+
}
|
|
130
|
+
/** This model has many `related` rows, joined by a foreign key on `related`. */
|
|
131
|
+
hasMany(related, foreignKey = this.foreignKeyName(), localKey = this.ctor().primaryKey) {
|
|
132
|
+
return new HasMany(this, related, foreignKey, localKey);
|
|
133
|
+
}
|
|
134
|
+
/** This model has one `related` row, joined by a foreign key on `related`. */
|
|
135
|
+
hasOne(related, foreignKey = this.foreignKeyName(), localKey = this.ctor().primaryKey) {
|
|
136
|
+
return new HasOne(this, related, foreignKey, localKey);
|
|
137
|
+
}
|
|
138
|
+
/** This model belongs to a `related` row via a foreign key it carries. */
|
|
139
|
+
belongsTo(related, foreignKey = `${related.name.toLowerCase()}_${related.primaryKey}`, ownerKey = related.primaryKey) {
|
|
140
|
+
return new BelongsTo(this, related, foreignKey, ownerKey);
|
|
141
|
+
}
|
|
142
|
+
/** Many-to-many through a pivot table (default name: the two tables, sorted). */
|
|
143
|
+
belongsToMany(related, pivotTable = [this.constructor.name, related.name]
|
|
144
|
+
.map((n) => n.toLowerCase())
|
|
145
|
+
.sort()
|
|
146
|
+
.join("_"), foreignPivotKey = `${this.constructor.name.toLowerCase()}_${this.ctor().primaryKey}`, relatedPivotKey = `${related.name.toLowerCase()}_${related.primaryKey}`, parentKey = this.ctor().primaryKey, relatedKey = related.primaryKey) {
|
|
147
|
+
return new BelongsToMany(this, related, pivotTable, foreignPivotKey, relatedPivotKey, parentKey, relatedKey);
|
|
148
|
+
}
|
|
149
|
+
/** Store an eager-loaded relation result under `name`. */
|
|
150
|
+
setRelation(name, value) {
|
|
151
|
+
const bag = relationStore.get(this) ?? {};
|
|
152
|
+
bag[name] = value;
|
|
153
|
+
relationStore.set(this, bag);
|
|
154
|
+
return this;
|
|
155
|
+
}
|
|
156
|
+
/** Read a previously loaded relation (returns undefined if not loaded). */
|
|
157
|
+
getRelation(name) {
|
|
158
|
+
return relationStore.get(this)?.[name];
|
|
159
|
+
}
|
|
160
|
+
/** Insert (no primary key) or update (has one). */
|
|
161
|
+
async save() {
|
|
162
|
+
const ctor = this.ctor();
|
|
163
|
+
const { table, primaryKey } = ctor;
|
|
164
|
+
const data = ctor.toDatabase({ ...this });
|
|
165
|
+
const idValue = data[primaryKey];
|
|
166
|
+
delete data[primaryKey];
|
|
167
|
+
if (idValue != null) {
|
|
168
|
+
await db(table).where(primaryKey, idValue).update(data);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
const id = await db(table).insertGetId(data);
|
|
172
|
+
if (id != null)
|
|
173
|
+
this[primaryKey] = id;
|
|
174
|
+
}
|
|
175
|
+
return this;
|
|
176
|
+
}
|
|
177
|
+
async delete() {
|
|
178
|
+
const { table, primaryKey } = this.ctor();
|
|
179
|
+
await db(table).where(primaryKey, this[primaryKey]).delete();
|
|
180
|
+
}
|
|
181
|
+
/** Merge mass-assignable attributes into the model (cast, not saved). */
|
|
182
|
+
fill(attributes) {
|
|
183
|
+
const ctor = this.ctor();
|
|
184
|
+
Object.assign(this, applyCasts(ctor.filterFillable(attributes), ctor.casts, castGet));
|
|
185
|
+
return this;
|
|
186
|
+
}
|
|
187
|
+
/** Force-assign attributes, bypassing mass-assignment guarding (still cast). */
|
|
188
|
+
forceFill(attributes) {
|
|
189
|
+
const ctor = this.ctor();
|
|
190
|
+
Object.assign(this, applyCasts(attributes, ctor.casts, castGet));
|
|
191
|
+
return this;
|
|
192
|
+
}
|
|
193
|
+
toJSON() {
|
|
194
|
+
const data = applyCasts({ ...this }, this.ctor().casts, castGet);
|
|
195
|
+
const relations = relationStore.get(this);
|
|
196
|
+
if (relations) {
|
|
197
|
+
for (const [name, value] of Object.entries(relations))
|
|
198
|
+
data[name] = serialize(value);
|
|
199
|
+
}
|
|
200
|
+
return data;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notifications — send a message to a recipient over one or more channels
|
|
3
|
+
* (mail, database, or your own), inline or through the queue. This is where the
|
|
4
|
+
* mail and queue layers compose: a notification declares *what* to say and
|
|
5
|
+
* *which channels* carry it, and each channel decides *how*.
|
|
6
|
+
*
|
|
7
|
+
* class InvoicePaid extends Notification {
|
|
8
|
+
* constructor(private amount: number) { super(); }
|
|
9
|
+
* via() { return ["mail", "database"]; }
|
|
10
|
+
* toMail() { return { subject: "Payment received", text: `Thanks for $${this.amount}.` }; }
|
|
11
|
+
* toArray() { return { amount: this.amount }; }
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* await notify(user, new InvoicePaid(4200)); // user.email routes the mail
|
|
15
|
+
*
|
|
16
|
+
* Set `shouldQueue = true` on a notification to deliver it from a queued job
|
|
17
|
+
* instead of inline. Channels are pluggable, so `array` (for tests) or a custom
|
|
18
|
+
* push-to-provider channel slot in the same way transports and drivers do.
|
|
19
|
+
*/
|
|
20
|
+
/** A recipient. Anything with routing info — often a `User` model. */
|
|
21
|
+
export interface Notifiable {
|
|
22
|
+
/** Return the address/id for a channel (e.g. an email). Falls back to `email`/`id`. */
|
|
23
|
+
routeNotificationFor?(channel: string): string | number | undefined;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
/** What a notification hands the mail channel. */
|
|
27
|
+
export interface MailContent {
|
|
28
|
+
subject: string;
|
|
29
|
+
text?: string;
|
|
30
|
+
html?: string;
|
|
31
|
+
from?: string;
|
|
32
|
+
/** Override the resolved recipient address. */
|
|
33
|
+
to?: string;
|
|
34
|
+
}
|
|
35
|
+
export declare abstract class Notification {
|
|
36
|
+
/** Deliver from a queued job instead of inline. */
|
|
37
|
+
shouldQueue: boolean;
|
|
38
|
+
/** Channels to deliver on. Default: mail. */
|
|
39
|
+
via(_notifiable: Notifiable): string[];
|
|
40
|
+
/** Build the mail-channel content. Required if `via` includes "mail". */
|
|
41
|
+
toMail?(notifiable: Notifiable): MailContent;
|
|
42
|
+
/** Build the payload stored/serialized by the database and array channels. */
|
|
43
|
+
toArray?(notifiable: Notifiable): Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
/** Resolve where a notifiable receives a given channel. */
|
|
46
|
+
export declare function routeFor(notifiable: Notifiable, channel: string): string | number | undefined;
|
|
47
|
+
/** The bridge that actually delivers a notification on one channel. */
|
|
48
|
+
export interface Channel {
|
|
49
|
+
send(notifiable: Notifiable, notification: Notification): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
/** Delivers via the mailer, using the notification's `toMail`. */
|
|
52
|
+
export declare class MailChannel implements Channel {
|
|
53
|
+
send(notifiable: Notifiable, notification: Notification): Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
/** Persists the notification's `toArray` payload to a table via the query builder. */
|
|
56
|
+
export declare class DatabaseChannel implements Channel {
|
|
57
|
+
private table;
|
|
58
|
+
constructor(table?: string);
|
|
59
|
+
send(notifiable: Notifiable, notification: Notification): Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
/** Collects deliveries in memory — for tests. */
|
|
62
|
+
export declare class ArrayChannel implements Channel {
|
|
63
|
+
readonly sent: {
|
|
64
|
+
notifiable: Notifiable;
|
|
65
|
+
notification: Notification;
|
|
66
|
+
}[];
|
|
67
|
+
send(notifiable: Notifiable, notification: Notification): Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
export declare class Notifier {
|
|
70
|
+
private channels;
|
|
71
|
+
/** Register (or replace) a channel by name. */
|
|
72
|
+
channel(name: string, channel: Channel): this;
|
|
73
|
+
private deliver;
|
|
74
|
+
/** Send a notification to one or many recipients. */
|
|
75
|
+
send(notifiables: Notifiable | Notifiable[], notification: Notification): Promise<void>;
|
|
76
|
+
}
|
|
77
|
+
/** Register the default notifier used by `notify()`. */
|
|
78
|
+
export declare function setNotifier(instance: Notifier): Notifier;
|
|
79
|
+
/** The default notifier instance (register channels on it). */
|
|
80
|
+
export declare function getNotifier(): Notifier;
|
|
81
|
+
/** Send a notification to one or many notifiables via the default notifier. */
|
|
82
|
+
export declare function notify(notifiables: Notifiable | Notifiable[], notification: Notification): Promise<void>;
|