@zerotal/orm 1.4.0 → 1.5.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/CHANGELOG.md +106 -0
- package/package.json +3 -3
- package/src/commands/DbSeedCommand.ts +15 -43
- package/src/commands/MigrateCommand.ts +46 -7
- package/src/commands/MigrateFreshCommand.ts +45 -2
- package/src/commands/MigrateRefreshCommand.ts +28 -0
- package/src/commands/_runSeeders.ts +71 -0
- package/src/commands/index.ts +1 -0
- package/src/conventions.ts +2 -1
- package/src/db/NPlusOneDetector.ts +93 -15
- package/src/db/QueryBuilder.ts +34 -3
- package/src/diagnostics/missingRelation.ts +187 -0
- package/src/diagnostics/runMigrationsEndpoint.ts +124 -0
- package/src/index.ts +3 -0
- package/src/model/BaseModel.ts +23 -22
- package/src/model/ModelQueryBuilder.ts +10 -10
- package/src/model/Observer.ts +2 -1
- package/src/model/OrmContext.ts +4 -3
- package/src/model/State.ts +4 -3
- package/src/model/decorators/_metadata.ts +19 -18
- package/src/model/decorators/_registerRelation.ts +2 -1
- package/src/model/decorators/column.ts +3 -2
- package/src/model/decorators/table.ts +3 -2
- package/src/model/hooks/HookRegistry.ts +9 -8
- package/src/model/relations/RelationRegistry.ts +3 -1
- package/src/observability.ts +2 -2
- package/src/provider/DatabaseProvider.ts +32 -3
- package/src/schema/Blueprint.ts +15 -2
- package/src/schema/ColumnDefinition.ts +35 -1
- package/src/schema/ModelInspector.ts +3 -2
- package/src/schema/Schema.ts +62 -2
- package/src/support/classRef.ts +23 -0
- package/src/support/identifiers.ts +5 -4
package/src/db/QueryBuilder.ts
CHANGED
|
@@ -216,6 +216,32 @@ function _getCachedTemplate(strings: string[]): TemplateStringsArray {
|
|
|
216
216
|
return tpl;
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Turn a JS value into something the driver can actually bind.
|
|
221
|
+
*
|
|
222
|
+
* Only dates need this, and they need it badly. A `Date` handed to Bun's SQLite
|
|
223
|
+
* driver as a bind parameter does not land: `update({ read_at: new Date() })`
|
|
224
|
+
* left the column NULL and **reported no error**, so a "mark all as read"
|
|
225
|
+
* feature shipped as a latent no-op that read correctly in the source. The
|
|
226
|
+
* asymmetry made it easy to write, too — `model.save()` applies casts, so the
|
|
227
|
+
* identical value through a model worked.
|
|
228
|
+
*
|
|
229
|
+
* The comparison path already learned this (see `ModelQueryBuilder._bindValue`:
|
|
230
|
+
* a `Date` in a `where` used to match zero rows). Doing it here covers every
|
|
231
|
+
* bind on every builder — `DB.table()` writes included — from one place, so the
|
|
232
|
+
* next path someone adds cannot reintroduce it.
|
|
233
|
+
*
|
|
234
|
+
* Dialect-aware because MySQL DATETIME rejects ISO 8601's `T`/`Z`; SQLite and
|
|
235
|
+
* PostgreSQL take it as-is.
|
|
236
|
+
*/
|
|
237
|
+
function _bindable(value: unknown, dialect: Dialect): unknown {
|
|
238
|
+
const date = value instanceof Carbon ? value.toDate() : value;
|
|
239
|
+
if (!(date instanceof Date)) return value;
|
|
240
|
+
return dialect === "mysql"
|
|
241
|
+
? date.toISOString().replace("T", " ").slice(0, 19)
|
|
242
|
+
: date.toISOString();
|
|
243
|
+
}
|
|
244
|
+
|
|
219
245
|
/**
|
|
220
246
|
* @internal Execute compiled segments on `conn` with prepared-template
|
|
221
247
|
* interning and QueryExecuted telemetry. Shared by `QueryBuilder._run` and
|
|
@@ -230,6 +256,7 @@ export async function _runSegments<T = Record<string, unknown>>(
|
|
|
230
256
|
const strings: string[] = [];
|
|
231
257
|
const values: unknown[] = [];
|
|
232
258
|
let current = "";
|
|
259
|
+
const dialect = dialectFor(conn);
|
|
233
260
|
|
|
234
261
|
for (const seg of segs) {
|
|
235
262
|
if (typeof seg === "string") {
|
|
@@ -237,7 +264,7 @@ export async function _runSegments<T = Record<string, unknown>>(
|
|
|
237
264
|
} else {
|
|
238
265
|
strings.push(current);
|
|
239
266
|
current = "";
|
|
240
|
-
values.push(seg.val);
|
|
267
|
+
values.push(_bindable(seg.val, dialect));
|
|
241
268
|
}
|
|
242
269
|
}
|
|
243
270
|
strings.push(current);
|
|
@@ -245,14 +272,18 @@ export async function _runSegments<T = Record<string, unknown>>(
|
|
|
245
272
|
const cacheKey = strings.join("\x00");
|
|
246
273
|
const tpl = _getCachedTemplate(strings);
|
|
247
274
|
const ctx = RequestContext.tryGet();
|
|
248
|
-
|
|
275
|
+
// Bindings go through too: without them the detector groups a six-month
|
|
276
|
+
// reporting loop — identical SQL, a different `period` each time — with a
|
|
277
|
+
// genuine per-row lookup, and sends you hunting for a relation to eager-load
|
|
278
|
+
// that does not exist.
|
|
279
|
+
if (trackNPlusOne) trackQuery(ctx, cacheKey, values);
|
|
249
280
|
|
|
250
281
|
const startMs = Date.now();
|
|
251
282
|
const rows = await conn<T>(tpl, ...values);
|
|
252
283
|
const durationMs = Date.now() - startMs;
|
|
253
284
|
FrameworkEvents.emit(
|
|
254
285
|
new QueryExecuted(
|
|
255
|
-
cacheKey.
|
|
286
|
+
cacheKey.replaceAll("\x00", "?"),
|
|
256
287
|
values,
|
|
257
288
|
startMs,
|
|
258
289
|
durationMs,
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "no such table: assets", answered.
|
|
3
|
+
*
|
|
4
|
+
* The message is exact and the stack is useless — every frame is inside the SQL
|
|
5
|
+
* driver, because that is where the failure surfaces, not where it comes from.
|
|
6
|
+
* The answer is almost always "you have migrations you have not run", and that
|
|
7
|
+
* lives here, one package away from the error page that needs it.
|
|
8
|
+
*
|
|
9
|
+
* The half that earns this feature is refusing to offer the button when it would
|
|
10
|
+
* not help. There are two situations and they need different answers: a table
|
|
11
|
+
* missing because a migration is pending, and a table missing because nobody ever
|
|
12
|
+
* wrote one. Running every pending migration in the second case changes nothing,
|
|
13
|
+
* leaves the developer where they started, and teaches them not to trust the
|
|
14
|
+
* panel.
|
|
15
|
+
*/
|
|
16
|
+
import type { ErrorDiagnosis } from "@zerotal/core";
|
|
17
|
+
import { loadMigrations } from "../commands/_loadMigrations.ts";
|
|
18
|
+
import { MigrationRunner } from "../schema/MigrationRunner.ts";
|
|
19
|
+
import type { MigrationEntry } from "../schema/MigrationRunner.ts";
|
|
20
|
+
import { _getConnection } from "../db/DB.ts";
|
|
21
|
+
|
|
22
|
+
/** What the database said is missing. */
|
|
23
|
+
export interface MissingRelation {
|
|
24
|
+
kind: "table" | "column";
|
|
25
|
+
name: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Dialect error codes, checked before the message text. */
|
|
29
|
+
const CODES: Record<string, MissingRelation["kind"]> = {
|
|
30
|
+
// PostgreSQL SQLSTATE
|
|
31
|
+
"42P01": "table", // undefined_table
|
|
32
|
+
"42703": "column", // undefined_column
|
|
33
|
+
// MySQL
|
|
34
|
+
"1146": "table", // ER_NO_SUCH_TABLE
|
|
35
|
+
"1054": "column", // ER_BAD_FIELD_ERROR
|
|
36
|
+
ER_NO_SUCH_TABLE: "table",
|
|
37
|
+
ER_BAD_FIELD_ERROR: "column",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Message shapes, as a fallback.
|
|
42
|
+
*
|
|
43
|
+
* SQLite has no error code worth branching on — everything arrives as
|
|
44
|
+
* `SQLITE_ERROR` — so its two messages are matched directly. Postgres and MySQL
|
|
45
|
+
* are matched by code above; their text is included because a driver that wraps
|
|
46
|
+
* the error can drop the code while keeping the message.
|
|
47
|
+
*/
|
|
48
|
+
const PATTERNS: Array<{ re: RegExp; kind: MissingRelation["kind"] }> = [
|
|
49
|
+
// Each captures the whole identifier — quotes, schema qualifier and all — and
|
|
50
|
+
// leaves the tidying to `bareName`. Capturing only the last segment needs a
|
|
51
|
+
// lazy qualifier prefix, and getting that subtly wrong is how `app.assets`
|
|
52
|
+
// came back as an empty name.
|
|
53
|
+
{ re: /no such table:\s*([\w".`]+)/i, kind: "table" },
|
|
54
|
+
{ re: /no such column:\s*([\w".`]+)/i, kind: "column" },
|
|
55
|
+
{ re: /relation\s+([\w".]+)\s+does not exist/i, kind: "table" },
|
|
56
|
+
{ re: /column\s+([\w".]+)\s+does not exist/i, kind: "column" },
|
|
57
|
+
{ re: /table\s+'([\w.]+)'\s+doesn'?t exist/i, kind: "table" },
|
|
58
|
+
{ re: /unknown column\s+'([\w.]+)'/i, kind: "column" },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/** Strip a qualifier and quoting: `"public"."assets"` → `assets`. */
|
|
62
|
+
function bareName(raw: string): string {
|
|
63
|
+
const parts = raw.replace(/["`']/g, "").split(".");
|
|
64
|
+
return parts[parts.length - 1] ?? raw;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether this error is a database complaining about something that is not there.
|
|
69
|
+
*
|
|
70
|
+
* Returns `null` for everything else, including every other database error — a
|
|
71
|
+
* diagnoser that recognises errors it does not own is worse than none.
|
|
72
|
+
*/
|
|
73
|
+
export function detectMissingRelation(error: Error): MissingRelation | null {
|
|
74
|
+
const message = error.message ?? "";
|
|
75
|
+
|
|
76
|
+
// Code first: it is unambiguous where it exists, and message text is localised
|
|
77
|
+
// on some MySQL builds.
|
|
78
|
+
const code = (error as { code?: string | number; errno?: number }).code;
|
|
79
|
+
const errno = (error as { errno?: number }).errno;
|
|
80
|
+
const kindFromCode = CODES[String(code)] ?? CODES[String(errno)];
|
|
81
|
+
|
|
82
|
+
for (const { re, kind } of PATTERNS) {
|
|
83
|
+
const match = re.exec(message);
|
|
84
|
+
if (match?.[1]) return { kind: kindFromCode ?? kind, name: bareName(match[1]) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A recognised code with an unrecognised message still tells us the kind, but
|
|
88
|
+
// not the name — worth reporting, because "some table is missing" plus the
|
|
89
|
+
// pending list is still the answer.
|
|
90
|
+
if (kindFromCode) return { kind: kindFromCode, name: "" };
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Migrations that exist on disk and have not run. */
|
|
95
|
+
export async function pendingMigrations(): Promise<string[]> {
|
|
96
|
+
const records = await loadMigrations();
|
|
97
|
+
if (records.length === 0) return [];
|
|
98
|
+
const entries: MigrationEntry[] = records.map((r) => ({ name: r.name, migration: r.instance }));
|
|
99
|
+
const runner = new MigrationRunner({ connection: _getConnection() });
|
|
100
|
+
const statuses = await runner.status(entries);
|
|
101
|
+
return statuses.filter((s) => !s.ran).map((s) => s.name);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Does any migration on disk so much as mention this name?
|
|
106
|
+
*
|
|
107
|
+
* A weak signal used only to sharpen the message in the nothing-pending case:
|
|
108
|
+
* "no migration mentions `assets`" is a better sentence than anything generic,
|
|
109
|
+
* and it is usually right, because a migration that creates a table names it.
|
|
110
|
+
*/
|
|
111
|
+
async function anyMigrationMentions(name: string): Promise<boolean> {
|
|
112
|
+
if (name === "") return false;
|
|
113
|
+
const glob = new Bun.Glob("database/migrations/*.ts");
|
|
114
|
+
for await (const file of glob.scan({ cwd: process.cwd() })) {
|
|
115
|
+
try {
|
|
116
|
+
const source = await Bun.file(file).text();
|
|
117
|
+
if (source.includes(name)) return true;
|
|
118
|
+
} catch {
|
|
119
|
+
// Unreadable file — treat as no evidence rather than failing the diagnosis.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build the diagnosis, given a token minter for the action.
|
|
127
|
+
*
|
|
128
|
+
* `mintToken` is passed in rather than imported so this stays testable without a
|
|
129
|
+
* running server, and so the endpoint owns its own token lifetime.
|
|
130
|
+
*/
|
|
131
|
+
export async function diagnoseMissingRelation(
|
|
132
|
+
error: Error,
|
|
133
|
+
options: { endpoint: string; mintToken: () => string },
|
|
134
|
+
): Promise<ErrorDiagnosis | null> {
|
|
135
|
+
const missing = detectMissingRelation(error);
|
|
136
|
+
if (!missing) return null;
|
|
137
|
+
|
|
138
|
+
const subject = missing.name === "" ? `A ${missing.kind}` : `${missing.kind} \`${missing.name}\``;
|
|
139
|
+
|
|
140
|
+
let pending: string[];
|
|
141
|
+
try {
|
|
142
|
+
pending = await pendingMigrations();
|
|
143
|
+
} catch {
|
|
144
|
+
// No connection, no migrations directory, a driver that cannot answer — the
|
|
145
|
+
// detection still stands, so say what is missing without guessing why.
|
|
146
|
+
return {
|
|
147
|
+
title: `${subject} does not exist.`,
|
|
148
|
+
detail:
|
|
149
|
+
"The migration state could not be read, so this cannot say whether a pending " +
|
|
150
|
+
"migration would create it. Run `bun zt migrate:status` to see where things stand.",
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (pending.length > 0) {
|
|
155
|
+
return {
|
|
156
|
+
title: `${subject} does not exist, and ${pending.length} migration${
|
|
157
|
+
pending.length === 1 ? " has" : "s have"
|
|
158
|
+
} not run.`,
|
|
159
|
+
detail:
|
|
160
|
+
"Running them is very likely the fix. This runs the same migrations " +
|
|
161
|
+
"`bun zt migrate` would, in the same order, against the same connection — " +
|
|
162
|
+
"and it is available here only because the app is in development.",
|
|
163
|
+
items: pending,
|
|
164
|
+
action: {
|
|
165
|
+
label: `Run ${pending.length} migration${pending.length === 1 ? "" : "s"}`,
|
|
166
|
+
url: options.endpoint,
|
|
167
|
+
token: options.mintToken(),
|
|
168
|
+
pendingLabel: "Migrating…",
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Nothing pending. Deliberately no button: it would run nothing and change
|
|
174
|
+
// nothing, and the developer would be back here having been told to try.
|
|
175
|
+
const mentioned = await anyMigrationMentions(missing.name);
|
|
176
|
+
return {
|
|
177
|
+
title: `${subject} does not exist, and every migration has already run.`,
|
|
178
|
+
detail: mentioned
|
|
179
|
+
? `A migration does mention \`${missing.name}\`, so this is more likely a rollback ` +
|
|
180
|
+
`that left the schema behind, or a migration that did not create what its name ` +
|
|
181
|
+
`suggests. \`bun zt migrate:status\` shows what ran, and \`migrate:refresh\` ` +
|
|
182
|
+
`rebuilds from scratch — it will destroy the data in this database.`
|
|
183
|
+
: `No migration in \`database/migrations\` mentions \`${missing.name}\`, which usually ` +
|
|
184
|
+
`means the migration that would create it was never written. \`bun zt make:migration\` ` +
|
|
185
|
+
`scaffolds one.`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The endpoint behind the error page's "Run migrations" button.
|
|
3
|
+
*
|
|
4
|
+
* This mutates a database in response to a request originating from a page that
|
|
5
|
+
* was rendered by a GET, which is a shape worth being paranoid about: a dev
|
|
6
|
+
* server on `localhost:3000` is reachable by any site the developer has open in
|
|
7
|
+
* another tab, and "run every pending migration" is not something a random page
|
|
8
|
+
* should be able to trigger.
|
|
9
|
+
*
|
|
10
|
+
* So it carries three independent guards, and each is checked here rather than
|
|
11
|
+
* inferred from the fact that the overlay is dev-only:
|
|
12
|
+
*
|
|
13
|
+
* 1. **`devSurfacesEnabled()`**, decided at request time. Note this is not
|
|
14
|
+
* `!isProdLike(...)`: `isProdLike("")` is false, so an unset `APP_ENV` would
|
|
15
|
+
* have *passed* that check. This one fails closed — only an explicitly
|
|
16
|
+
* non-production environment, or a process the dev orchestrator supervises,
|
|
17
|
+
* qualifies. It also reads the right thing: `setAppEnv()` overwrites
|
|
18
|
+
* `APP_ENV` with a runtime mode before boot, so reading it directly is wrong.
|
|
19
|
+
* 2. **A single-use token**, minted per error page and spent on first use. This
|
|
20
|
+
* is what a cross-origin caller cannot obtain: it would have to read the
|
|
21
|
+
* page, and the same-origin policy stops it.
|
|
22
|
+
* 3. **The origin guard**, the same one the raw Flow endpoints use — because
|
|
23
|
+
* this is registered as a raw route and so sits outside the CSRF middleware.
|
|
24
|
+
*
|
|
25
|
+
* The route is registered only when dev surfaces are enabled, so in production it
|
|
26
|
+
* does not exist at all. Guard 1 is the belt to that braces.
|
|
27
|
+
*/
|
|
28
|
+
import { Router, devSurfacesEnabled } from "@zerotal/core";
|
|
29
|
+
import { isAllowedOrigin } from "@zerotal/core/http";
|
|
30
|
+
import { loadMigrations } from "../commands/_loadMigrations.ts";
|
|
31
|
+
import { MigrationRunner } from "../schema/MigrationRunner.ts";
|
|
32
|
+
import type { MigrationEntry } from "../schema/MigrationRunner.ts";
|
|
33
|
+
import { _getConnection } from "../db/DB.ts";
|
|
34
|
+
|
|
35
|
+
/** Path the button posts to. */
|
|
36
|
+
export const RUN_MIGRATIONS_PATH = "/__zerotal/run-migrations";
|
|
37
|
+
/** Header carrying the single-use token. */
|
|
38
|
+
const TOKEN_HEADER = "X-Zerotal-Diagnosis-Token";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Outstanding tokens.
|
|
42
|
+
*
|
|
43
|
+
* In memory and per process, which is right: a token is only ever handed to the
|
|
44
|
+
* page this process just rendered, and a restart invalidating them is correct
|
|
45
|
+
* rather than inconvenient — the page is stale by then anyway.
|
|
46
|
+
*/
|
|
47
|
+
const _tokens = new Set<string>();
|
|
48
|
+
/** Tokens outlive one render but not a session; a bounded set cannot grow without limit. */
|
|
49
|
+
const MAX_TOKENS = 32;
|
|
50
|
+
|
|
51
|
+
/** Mint a token for one error page. @internal */
|
|
52
|
+
export function _mintDiagnosisToken(): string {
|
|
53
|
+
// The oldest token is dropped rather than letting a long dev session
|
|
54
|
+
// accumulate them. Insertion order is iteration order for a Set.
|
|
55
|
+
if (_tokens.size >= MAX_TOKENS) {
|
|
56
|
+
const oldest = _tokens.values().next().value;
|
|
57
|
+
if (oldest !== undefined) _tokens.delete(oldest);
|
|
58
|
+
}
|
|
59
|
+
const token = crypto.randomUUID();
|
|
60
|
+
_tokens.add(token);
|
|
61
|
+
return token;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Spend a token. Returns false when it was never minted, or was already used. @internal */
|
|
65
|
+
export function _spendDiagnosisToken(token: string | null): boolean {
|
|
66
|
+
if (!token) return false;
|
|
67
|
+
return _tokens.delete(token);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Forget every outstanding token. Tests. @internal */
|
|
71
|
+
export function _resetDiagnosisTokens(): void {
|
|
72
|
+
_tokens.clear();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Run every pending migration, and report what ran.
|
|
77
|
+
*
|
|
78
|
+
* Separated from the route so a test can drive it without a server.
|
|
79
|
+
*/
|
|
80
|
+
export async function _runPendingMigrations(): Promise<string[]> {
|
|
81
|
+
const records = await loadMigrations();
|
|
82
|
+
const entries: MigrationEntry[] = records.map((r) => ({ name: r.name, migration: r.instance }));
|
|
83
|
+
const runner = new MigrationRunner({ connection: _getConnection() });
|
|
84
|
+
return runner.run(entries);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Register the endpoint, unless this is production.
|
|
89
|
+
*
|
|
90
|
+
* Called from `DatabaseProvider.onRegister()`.
|
|
91
|
+
*/
|
|
92
|
+
export function registerRunMigrationsEndpoint(allowedOrigins: () => string[]): void {
|
|
93
|
+
if (!devSurfacesEnabled()) return;
|
|
94
|
+
|
|
95
|
+
Router.raw("POST", RUN_MIGRATIONS_PATH, async (req: Request): Promise<Response> => {
|
|
96
|
+
// Guard 1 — re-checked at request time, not inherited from registration.
|
|
97
|
+
if (!devSurfacesEnabled()) {
|
|
98
|
+
return new Response("Not available.", { status: 404 });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Guard 2 — a raw route bypasses the middleware pipeline, so CSRF protection
|
|
102
|
+
// does not apply and this is the check standing in for it.
|
|
103
|
+
if (!isAllowedOrigin(req, allowedOrigins())) {
|
|
104
|
+
return new Response("Forbidden origin.", { status: 403 });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Guard 3 — single use. A caller that cannot read the error page cannot have
|
|
108
|
+
// this, and replaying a captured one does not work twice.
|
|
109
|
+
if (!_spendDiagnosisToken(req.headers.get(TOKEN_HEADER))) {
|
|
110
|
+
return new Response("Invalid or already-used token.", { status: 403 });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const ran = await _runPendingMigrations();
|
|
115
|
+
if (ran.length === 0) return new Response("Nothing to run.", { status: 200 });
|
|
116
|
+
return new Response(`Ran ${ran.length}: ${ran.join(", ")}`, { status: 200 });
|
|
117
|
+
} catch (error) {
|
|
118
|
+
// The migration itself failed — which is a real answer, and more useful
|
|
119
|
+
// than a generic 500. It goes back as text so the panel can show it.
|
|
120
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
121
|
+
return new Response(`Migration failed: ${message}`, { status: 500 });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -75,6 +75,9 @@ export { Model, BaseModel } from "./model/BaseModel.ts";
|
|
|
75
75
|
// Mixin authoring types. Compose them onto a model with the `Model.using(...)` static —
|
|
76
76
|
// `class User extends Model.using(Authenticatable, Roles)`.
|
|
77
77
|
export type { Constructor, Mixin, Compose } from "./model/mixins.ts";
|
|
78
|
+
// The type every metadata registry keys on — a model class rather than an instance.
|
|
79
|
+
// Packages that register columns or relations from outside the ORM take it as a parameter.
|
|
80
|
+
export type { ClassRef } from "./support/classRef.ts";
|
|
78
81
|
// State-machine behaviour is an opt-in mixin — compose with `Model.using(State)`.
|
|
79
82
|
export { State } from "./model/State.ts";
|
|
80
83
|
// Soft deletes are opt-in — compose with `Model.using(SoftDeletes)`.
|
package/src/model/BaseModel.ts
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
import { TransactionContext } from "../db/TransactionContext.ts";
|
|
47
47
|
import type { InsertPayload, UpdatePayload, FillablePayload } from "./payload.ts";
|
|
48
48
|
import type { WhereOperator, OrderDirection } from "../db/types.ts";
|
|
49
|
+
import type { ClassRef } from "../support/classRef.ts";
|
|
49
50
|
|
|
50
51
|
let _dialect: "sqlite" | "postgres" | "mysql" = "sqlite";
|
|
51
52
|
|
|
@@ -233,13 +234,13 @@ type StringCast =
|
|
|
233
234
|
| `decimal:${number}`;
|
|
234
235
|
type CastOption = ColumnOptions["cast"];
|
|
235
236
|
|
|
236
|
-
function getCasts(ctor:
|
|
237
|
+
function getCasts(ctor: ClassRef): Record<string, CastOption> {
|
|
237
238
|
const merged: Record<string, CastOption> = {};
|
|
238
|
-
const chain:
|
|
239
|
-
let current:
|
|
239
|
+
const chain: ClassRef[] = [];
|
|
240
|
+
let current: ClassRef | null = ctor;
|
|
240
241
|
while (current && current !== Function.prototype) {
|
|
241
242
|
chain.push(current);
|
|
242
|
-
current = Object.getPrototypeOf(current) as
|
|
243
|
+
current = Object.getPrototypeOf(current) as ClassRef | null;
|
|
243
244
|
}
|
|
244
245
|
chain.reverse();
|
|
245
246
|
// `static encryptable` first, so an explicit cast on the same column still wins —
|
|
@@ -426,7 +427,7 @@ type ModelCtor<T extends BaseModel> = typeof BaseModel & { new (): T };
|
|
|
426
427
|
* (`Roles(Permissions(Base))`), where each relation lives on a different
|
|
427
428
|
* class in the chain.
|
|
428
429
|
*/
|
|
429
|
-
function relNames(ctor:
|
|
430
|
+
function relNames(ctor: ClassRef): Set<string> {
|
|
430
431
|
return new Set(relationsFor(ctor).keys());
|
|
431
432
|
}
|
|
432
433
|
|
|
@@ -463,7 +464,7 @@ function* ownDataEntries(
|
|
|
463
464
|
* (rare edge case: a model with zero @column decorators) so callers can fall back
|
|
464
465
|
* to the old unrestricted behaviour.
|
|
465
466
|
*/
|
|
466
|
-
function _allColumnKeys(cls:
|
|
467
|
+
function _allColumnKeys(cls: ClassRef): Set<string> | null {
|
|
467
468
|
const cols = columnsFor(cls);
|
|
468
469
|
return cols ? new Set(cols.keys()) : null;
|
|
469
470
|
}
|
|
@@ -1524,8 +1525,8 @@ export class BaseModel {
|
|
|
1524
1525
|
const ModelClass = this as unknown as typeof BaseModel;
|
|
1525
1526
|
const conn = _resolveConn(ModelClass);
|
|
1526
1527
|
const dialect = dialectFor(conn as unknown as object);
|
|
1527
|
-
const casts = getCasts(ModelClass as unknown as
|
|
1528
|
-
const colReg = columnsFor(ModelClass as unknown as
|
|
1528
|
+
const casts = getCasts(ModelClass as unknown as ClassRef);
|
|
1529
|
+
const colReg = columnsFor(ModelClass as unknown as ClassRef);
|
|
1529
1530
|
const useTs = ModelClass.timestamps;
|
|
1530
1531
|
|
|
1531
1532
|
const rows: Record<string, unknown>[] = _writeDialect.run(dialect, () => {
|
|
@@ -1610,8 +1611,8 @@ export class BaseModel {
|
|
|
1610
1611
|
): Promise<void> {
|
|
1611
1612
|
const conn = _resolveConn(this);
|
|
1612
1613
|
const dialect = dialectFor(conn as unknown as object);
|
|
1613
|
-
const casts = getCasts(this as unknown as
|
|
1614
|
-
const colReg = columnsFor(this as unknown as
|
|
1614
|
+
const casts = getCasts(this as unknown as ClassRef);
|
|
1615
|
+
const colReg = columnsFor(this as unknown as ClassRef);
|
|
1615
1616
|
|
|
1616
1617
|
const row: Record<string, unknown> = _writeDialect.run(dialect, () => {
|
|
1617
1618
|
const r: Record<string, unknown> = {};
|
|
@@ -1697,8 +1698,8 @@ export class BaseModel {
|
|
|
1697
1698
|
const ModelClass = this.constructor as typeof BaseModel;
|
|
1698
1699
|
const conn = _resolveConn(ModelClass);
|
|
1699
1700
|
const dialect = dialectFor(conn as unknown as object);
|
|
1700
|
-
const rels = relNames(ModelClass as unknown as
|
|
1701
|
-
const casts = getCasts(ModelClass as unknown as
|
|
1701
|
+
const rels = relNames(ModelClass as unknown as ClassRef);
|
|
1702
|
+
const casts = getCasts(ModelClass as unknown as ClassRef);
|
|
1702
1703
|
|
|
1703
1704
|
await HookRegistry.run(ModelClass, "beforeSave", this);
|
|
1704
1705
|
|
|
@@ -1730,8 +1731,8 @@ export class BaseModel {
|
|
|
1730
1731
|
}
|
|
1731
1732
|
}
|
|
1732
1733
|
|
|
1733
|
-
const colReg = columnsFor(ModelClass as unknown as
|
|
1734
|
-
const colKeys = _allColumnKeys(ModelClass as unknown as
|
|
1734
|
+
const colReg = columnsFor(ModelClass as unknown as ClassRef);
|
|
1735
|
+
const colKeys = _allColumnKeys(ModelClass as unknown as ClassRef);
|
|
1735
1736
|
|
|
1736
1737
|
if (!this._exists) {
|
|
1737
1738
|
// ── INSERT ──
|
|
@@ -1995,8 +1996,8 @@ export class BaseModel {
|
|
|
1995
1996
|
*/
|
|
1996
1997
|
replicate(except?: string[]): this {
|
|
1997
1998
|
const ModelClass = this.constructor as typeof BaseModel;
|
|
1998
|
-
const rels = relNames(ModelClass as unknown as
|
|
1999
|
-
const colKeys = _allColumnKeys(ModelClass as unknown as
|
|
1999
|
+
const rels = relNames(ModelClass as unknown as ClassRef);
|
|
2000
|
+
const colKeys = _allColumnKeys(ModelClass as unknown as ClassRef);
|
|
2000
2001
|
const skip = new Set<string>([...SYSTEM_KEYS, ...(except ?? [])]);
|
|
2001
2002
|
const inst = new (this.constructor as new () => this)();
|
|
2002
2003
|
for (const [k, v] of ownDataEntries(this, skip, rels, colKeys)) {
|
|
@@ -2240,7 +2241,7 @@ export class BaseModel {
|
|
|
2240
2241
|
* @category Relationships
|
|
2241
2242
|
*/
|
|
2242
2243
|
associate(relation: string, model: BaseModel): this {
|
|
2243
|
-
const meta = relationsFor(this.constructor).get(relation);
|
|
2244
|
+
const meta = relationsFor(this.constructor as ClassRef).get(relation);
|
|
2244
2245
|
if (!meta || meta.type !== "belongsTo") {
|
|
2245
2246
|
throw new Error(
|
|
2246
2247
|
`associate(): "${relation}" is not a belongsTo relation on ${this.constructor.name}`,
|
|
@@ -2267,7 +2268,7 @@ export class BaseModel {
|
|
|
2267
2268
|
* @category Relationships
|
|
2268
2269
|
*/
|
|
2269
2270
|
dissociate(relation: string): this {
|
|
2270
|
-
const meta = relationsFor(this.constructor).get(relation);
|
|
2271
|
+
const meta = relationsFor(this.constructor as ClassRef).get(relation);
|
|
2271
2272
|
if (!meta || meta.type !== "belongsTo") {
|
|
2272
2273
|
throw new Error(
|
|
2273
2274
|
`dissociate(): "${relation}" is not a belongsTo relation on ${this.constructor.name}`,
|
|
@@ -2307,8 +2308,8 @@ export class BaseModel {
|
|
|
2307
2308
|
*/
|
|
2308
2309
|
$dirty(): Record<string, unknown> {
|
|
2309
2310
|
const ModelClass = this.constructor as typeof BaseModel;
|
|
2310
|
-
const rels = relNames(ModelClass as unknown as
|
|
2311
|
-
const colKeys = _allColumnKeys(ModelClass as unknown as
|
|
2311
|
+
const rels = relNames(ModelClass as unknown as ClassRef);
|
|
2312
|
+
const colKeys = _allColumnKeys(ModelClass as unknown as ClassRef);
|
|
2312
2313
|
const out: Record<string, unknown> = {};
|
|
2313
2314
|
for (const [key, val] of ownDataEntries(this, SYSTEM_KEYS, rels, colKeys)) {
|
|
2314
2315
|
if (val !== this._original[key] || this._forcedDirty.has(key)) {
|
|
@@ -2499,8 +2500,8 @@ function _createLazyPivotProxy(
|
|
|
2499
2500
|
function _applyRow(inst: BaseModel, row: Record<string, unknown>): void {
|
|
2500
2501
|
const self = inst as unknown as Record<string, unknown>;
|
|
2501
2502
|
const orig: Record<string, unknown> = {};
|
|
2502
|
-
const
|
|
2503
|
-
const
|
|
2503
|
+
const ModelClass = inst.constructor as typeof BaseModel;
|
|
2504
|
+
const ctor: ClassRef = ModelClass;
|
|
2504
2505
|
const colReg = columnsFor(ctor);
|
|
2505
2506
|
installReactiveAccessors(inst); // json/array reactiveCasts accessors (registered at decoration)
|
|
2506
2507
|
const casts = getCasts(ctor);
|
|
@@ -22,11 +22,12 @@ import type { BaseModel } from "./BaseModel.ts";
|
|
|
22
22
|
import { type ColumnOptions } from "./decorators/column.ts";
|
|
23
23
|
import { columnsFor, relationsFor } from "./decorators/_metadata.ts";
|
|
24
24
|
import { currentOrmContext } from "./OrmContext.ts";
|
|
25
|
+
import type { ClassRef } from "../support/classRef.ts";
|
|
25
26
|
|
|
26
27
|
type StringCast = "datetime" | "array" | "json" | "date" | "boolean" | "integer" | "float";
|
|
27
28
|
type CastOption = ColumnOptions["cast"];
|
|
28
29
|
|
|
29
|
-
function _getCasts(ctor:
|
|
30
|
+
function _getCasts(ctor: ClassRef): Record<string, CastOption> {
|
|
30
31
|
const merged: Record<string, CastOption> = {};
|
|
31
32
|
const colReg = columnsFor(ctor);
|
|
32
33
|
// Mirrors getCasts() in BaseModel: `static encryptable` resolves to casts, and an
|
|
@@ -274,9 +275,9 @@ function _createPivotCollection<T extends BaseModel>(
|
|
|
274
275
|
// BaseModel as a type only — no cycle.
|
|
275
276
|
|
|
276
277
|
export type GlobalScopeCallback = (qb: ModelQueryBuilder<BaseModel>) => void;
|
|
277
|
-
export function _globalScopeRegistry(): Map<
|
|
278
|
+
export function _globalScopeRegistry(): Map<ClassRef, Map<string, GlobalScopeCallback>> {
|
|
278
279
|
return currentOrmContext().globalScopes as unknown as Map<
|
|
279
|
-
|
|
280
|
+
ClassRef,
|
|
280
281
|
Map<string, GlobalScopeCallback>
|
|
281
282
|
>;
|
|
282
283
|
}
|
|
@@ -1142,15 +1143,14 @@ export class ModelQueryBuilder<M extends BaseModel> extends QueryBuilder {
|
|
|
1142
1143
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1143
1144
|
withScopes(callback: (scopes: any) => void): this {
|
|
1144
1145
|
const ModelClass = this._ModelClass;
|
|
1145
|
-
const self = this;
|
|
1146
1146
|
const proxy = new Proxy({} as Record<string, (...args: unknown[]) => void>, {
|
|
1147
|
-
get(_target, prop: string | symbol) {
|
|
1147
|
+
get: (_target, prop: string | symbol) => {
|
|
1148
1148
|
return (...args: unknown[]) => {
|
|
1149
1149
|
const fn = (ModelClass as unknown as Record<string | symbol, unknown>)[prop];
|
|
1150
1150
|
if (typeof fn !== "function") return;
|
|
1151
1151
|
const result = fn(...args) as { apply?: (q: unknown) => void } | null | undefined;
|
|
1152
1152
|
if (result != null && typeof result.apply === "function") {
|
|
1153
|
-
result.apply(
|
|
1153
|
+
result.apply(this);
|
|
1154
1154
|
}
|
|
1155
1155
|
};
|
|
1156
1156
|
},
|
|
@@ -1249,8 +1249,8 @@ export class ModelQueryBuilder<M extends BaseModel> extends QueryBuilder {
|
|
|
1249
1249
|
|
|
1250
1250
|
const rawKey = column.split(".").pop() ?? column;
|
|
1251
1251
|
const camelKey = rawKey.includes("_") ? _toCamel(rawKey) : rawKey;
|
|
1252
|
-
const casts = _getCasts(this._ModelClass as unknown as
|
|
1253
|
-
const colMeta = columnsFor(this._ModelClass as unknown as
|
|
1252
|
+
const casts = _getCasts(this._ModelClass as unknown as ClassRef);
|
|
1253
|
+
const colMeta = columnsFor(this._ModelClass as unknown as ClassRef)?.get(camelKey);
|
|
1254
1254
|
const castOpt = casts[rawKey] ?? casts[camelKey] ?? colMeta?.cast;
|
|
1255
1255
|
const colType = colMeta?.type;
|
|
1256
1256
|
|
|
@@ -1324,9 +1324,9 @@ export class ModelQueryBuilder<M extends BaseModel> extends QueryBuilder {
|
|
|
1324
1324
|
if (spec.children.length > 0 && related.length > 0) {
|
|
1325
1325
|
if (meta.type === "morphTo") {
|
|
1326
1326
|
// Mixed related classes — group by constructor and recurse per group.
|
|
1327
|
-
const groups = new Map<
|
|
1327
|
+
const groups = new Map<ClassRef, BaseModel[]>();
|
|
1328
1328
|
for (const r of related) {
|
|
1329
|
-
const ctor = r.constructor as
|
|
1329
|
+
const ctor = r.constructor as ClassRef;
|
|
1330
1330
|
if (!groups.has(ctor)) groups.set(ctor, []);
|
|
1331
1331
|
groups.get(ctor)!.push(r);
|
|
1332
1332
|
}
|
package/src/model/Observer.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { HookRegistry, type HookName } from "./hooks/HookRegistry.ts";
|
|
2
|
+
import type { ClassRef } from "../support/classRef.ts";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Observer interface — implement any subset of lifecycle methods to react to a
|
|
@@ -61,7 +62,7 @@ const _methodToHook: Record<keyof ModelObserver, HookName> = {
|
|
|
61
62
|
* @param ObserverClass - An observer class (zero-arg constructor) implementing any subset of {@link ModelObserver}.
|
|
62
63
|
* @internal
|
|
63
64
|
*/
|
|
64
|
-
export function registerObserver<T>(ModelClass:
|
|
65
|
+
export function registerObserver<T>(ModelClass: ClassRef, ObserverClass: ObserverClass<T>): void {
|
|
65
66
|
const instance = new ObserverClass();
|
|
66
67
|
|
|
67
68
|
for (const [method, hook] of Object.entries(_methodToHook) as [keyof ModelObserver, HookName][]) {
|
package/src/model/OrmContext.ts
CHANGED
|
@@ -25,11 +25,11 @@ export class OrmContext {
|
|
|
25
25
|
/** Connections registered by name, selectable via `static connection`. */
|
|
26
26
|
namedConnections = new Map<string, SQLInstance>();
|
|
27
27
|
/** Per-model `onTransition` callbacks, keyed by target state (see the `State` mixin). */
|
|
28
|
-
transitionCallbacks = new Map<
|
|
28
|
+
transitionCallbacks = new Map<ClassRef, Map<string, unknown[]>>();
|
|
29
29
|
/** Per-model registered global query scopes. */
|
|
30
|
-
globalScopes = new Map<
|
|
30
|
+
globalScopes = new Map<ClassRef, Map<string, unknown>>();
|
|
31
31
|
/** Per-model lifecycle hooks. */
|
|
32
|
-
hooks = new Map<
|
|
32
|
+
hooks = new Map<ClassRef, Map<string, unknown[]>>();
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
let _ctx = new OrmContext();
|
|
@@ -58,6 +58,7 @@ export function resetOrmContext(): void {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
import { registerAppScope } from "@zerotal/core";
|
|
61
|
+
import type { ClassRef } from "../support/classRef.ts";
|
|
61
62
|
|
|
62
63
|
let _appScopeRegistered = false;
|
|
63
64
|
if (!_appScopeRegistered) {
|
package/src/model/State.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { StateError } from "../errors/index.ts";
|
|
23
23
|
import { currentOrmContext } from "./OrmContext.ts";
|
|
24
24
|
import type { Constructor } from "./mixins.ts";
|
|
25
|
+
import type { ClassRef } from "../support/classRef.ts";
|
|
25
26
|
|
|
26
27
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
27
28
|
|
|
@@ -87,7 +88,7 @@ export type TransitionCallback<T> = (
|
|
|
87
88
|
// ── Callback registry (execution-scoped on the OrmContext) ────────────────────
|
|
88
89
|
|
|
89
90
|
/** @internal Register or retrieve transition callbacks for a model class. */
|
|
90
|
-
function _getCallbacks(ModelClass:
|
|
91
|
+
function _getCallbacks(ModelClass: ClassRef, state: string): TransitionCallback<unknown>[] {
|
|
91
92
|
const reg = currentOrmContext().transitionCallbacks;
|
|
92
93
|
if (!reg.has(ModelClass)) reg.set(ModelClass, new Map());
|
|
93
94
|
const map = reg.get(ModelClass)!;
|
|
@@ -169,7 +170,7 @@ export function State<TBase extends Constructor>(Base: TBase) {
|
|
|
169
170
|
toState: string,
|
|
170
171
|
callback: TransitionCallback<T>,
|
|
171
172
|
): void {
|
|
172
|
-
_getCallbacks(this as unknown as
|
|
173
|
+
_getCallbacks(this as unknown as ClassRef, toState).push(
|
|
173
174
|
callback as TransitionCallback<unknown>,
|
|
174
175
|
);
|
|
175
176
|
}
|
|
@@ -260,7 +261,7 @@ export function State<TBase extends Constructor>(Base: TBase) {
|
|
|
260
261
|
await (this as unknown as { save(): Promise<unknown> }).save();
|
|
261
262
|
|
|
262
263
|
// Fire registered transition callbacks.
|
|
263
|
-
const map = currentOrmContext().transitionCallbacks.get(this.constructor as
|
|
264
|
+
const map = currentOrmContext().transitionCallbacks.get(this.constructor as ClassRef);
|
|
264
265
|
const meta = { from: currentState, to: newState };
|
|
265
266
|
const toFns = (map?.get(newState) ?? []) as TransitionCallback<unknown>[];
|
|
266
267
|
const anyFns = (map?.get("*") ?? []) as TransitionCallback<unknown>[];
|