@zerotal/orm 1.3.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 +154 -0
- package/package.json +3 -3
- package/src/casts/encrypted.ts +168 -0
- 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 +8 -0
- package/src/model/BaseModel.ts +89 -30
- package/src/model/ModelQueryBuilder.ts +28 -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 +20 -3
- 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 +33 -5
- package/src/schema/Schema.ts +62 -2
- package/src/support/classRef.ts +23 -0
- package/src/support/identifiers.ts +5 -4
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Seeder } from "../seeding/Seeder.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What running the app's seeders came to.
|
|
5
|
+
*
|
|
6
|
+
* Seeding is reported rather than thrown because two commands consume it and
|
|
7
|
+
* they want different things from a failure: `db:seed` has nothing else to do
|
|
8
|
+
* and simply reports, while `migrate:fresh --seed` has already rebuilt the
|
|
9
|
+
* schema by the time seeding runs and must not present that work as undone.
|
|
10
|
+
*/
|
|
11
|
+
export type SeedOutcome =
|
|
12
|
+
| { status: "seeded" }
|
|
13
|
+
/** No seeder file exists. `path` is where one was looked for. */
|
|
14
|
+
| { status: "missing"; path: string }
|
|
15
|
+
/** A seeder file exists but does not export what it should. */
|
|
16
|
+
| { status: "invalid"; message: string }
|
|
17
|
+
/** The seeder ran and threw. */
|
|
18
|
+
| { status: "failed"; message: string };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Run the application's database seeders.
|
|
22
|
+
*
|
|
23
|
+
* Prefers the class-based `database/seeders/DatabaseSeeder.ts`, falling back to
|
|
24
|
+
* a legacy `database/seeders/index.ts` exporting a default async function.
|
|
25
|
+
*
|
|
26
|
+
* @param cwd Project root to resolve `database/seeders/` against.
|
|
27
|
+
*
|
|
28
|
+
* @internal
|
|
29
|
+
*/
|
|
30
|
+
export async function runSeeders(cwd: string = process.cwd()): Promise<SeedOutcome> {
|
|
31
|
+
const seederPath = `${cwd}/database/seeders/DatabaseSeeder.ts`;
|
|
32
|
+
|
|
33
|
+
if (!(await Bun.file(seederPath).exists())) {
|
|
34
|
+
const legacyPath = `${cwd}/database/seeders/index.ts`;
|
|
35
|
+
if (!(await Bun.file(legacyPath).exists())) {
|
|
36
|
+
return { status: "missing", path: seederPath };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const module = (await import(legacyPath)) as { default?: () => Promise<void> };
|
|
41
|
+
const seed = module.default;
|
|
42
|
+
if (!seed) {
|
|
43
|
+
return { status: "invalid", message: "Seeder index must export a default async function." };
|
|
44
|
+
}
|
|
45
|
+
await seed();
|
|
46
|
+
return { status: "seeded" };
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return { status: "failed", message: error instanceof Error ? error.message : String(error) };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const module = (await import(seederPath)) as {
|
|
54
|
+
DatabaseSeeder?: new () => Seeder;
|
|
55
|
+
default?: new () => Seeder;
|
|
56
|
+
};
|
|
57
|
+
const SeederClass = module.DatabaseSeeder ?? module.default;
|
|
58
|
+
|
|
59
|
+
if (!SeederClass) {
|
|
60
|
+
return {
|
|
61
|
+
status: "invalid",
|
|
62
|
+
message: "DatabaseSeeder not found as a named or default export.",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await new SeederClass().run();
|
|
67
|
+
return { status: "seeded" };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return { status: "failed", message: error instanceof Error ? error.message : String(error) };
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/commands/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ export { MigrateCommand } from "./MigrateCommand.ts";
|
|
|
22
22
|
export { MigrateRollbackCommand } from "./MigrateRollbackCommand.ts";
|
|
23
23
|
export { MigrateStatusCommand } from "./MigrateStatusCommand.ts";
|
|
24
24
|
export { MigrateFreshCommand } from "./MigrateFreshCommand.ts";
|
|
25
|
+
export { MigrateRefreshCommand } from "./MigrateRefreshCommand.ts";
|
|
25
26
|
export { MakeMigrationCommand } from "./MakeMigrationCommand.ts";
|
|
26
27
|
export { MigrateGenerateCommand } from "./MigrateGenerateCommand.ts";
|
|
27
28
|
export { MakeModelCommand } from "./MakeModelCommand.ts";
|
package/src/conventions.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { tableNameFor } from "@zerotal/core";
|
|
|
3
3
|
import { BaseModel } from "./model/BaseModel.ts";
|
|
4
4
|
import { registerModel, modelByName } from "./model/decorators/_metadata.ts";
|
|
5
5
|
import { frameworkLog } from "@zerotal/core/logger";
|
|
6
|
+
import type { ClassRef } from "./support/classRef.ts";
|
|
6
7
|
|
|
7
8
|
function isModelClass(v: unknown): boolean {
|
|
8
9
|
return (
|
|
@@ -26,7 +27,7 @@ export const modelsConcern: ConcernDescriptor = {
|
|
|
26
27
|
for (const exported of Object.values(mod)) {
|
|
27
28
|
if (!isModelClass(exported)) continue;
|
|
28
29
|
const Model = exported as unknown as { name: string; table?: string };
|
|
29
|
-
registerModel(Model as unknown as
|
|
30
|
+
registerModel(Model as unknown as ClassRef);
|
|
30
31
|
// Convention table name — explicit @table / static table always wins.
|
|
31
32
|
if (!Model.table) Model.table = tableNameFor(Model.name);
|
|
32
33
|
}
|
|
@@ -25,15 +25,36 @@ import { NPlusOneDetected } from "../events.ts";
|
|
|
25
25
|
export class NPlusOneError extends ZerotalError {
|
|
26
26
|
readonly fingerprint: string;
|
|
27
27
|
readonly count: number;
|
|
28
|
+
/**
|
|
29
|
+
* How many distinct argument tuples the shape ran with.
|
|
30
|
+
*
|
|
31
|
+
* The difference between the two diagnoses. `1` means the same query with the
|
|
32
|
+
* same arguments ran N times — nothing to eager-load, the answer is to ask
|
|
33
|
+
* once. Anything higher is the classic per-row lookup.
|
|
34
|
+
*/
|
|
35
|
+
readonly distinctArgs: number;
|
|
36
|
+
|
|
37
|
+
constructor(fingerprint: string, count: number, distinctArgs = 0) {
|
|
38
|
+
const sql = fingerprint.replaceAll("\x00", "?");
|
|
39
|
+
// Sending someone to look for a relation to eager-load, when the query is
|
|
40
|
+
// the *same* one repeated with the *same* arguments, wastes the time the
|
|
41
|
+
// warning was supposed to save. Say which of the two this is.
|
|
42
|
+
const diagnosis =
|
|
43
|
+
distinctArgs === 1
|
|
44
|
+
? `with the same arguments every time. That is not a per-row lookup — nothing to\n` +
|
|
45
|
+
`eager-load — it is the same answer fetched repeatedly.\n\n` +
|
|
46
|
+
`Fix: ask once per request.\n` +
|
|
47
|
+
` const rows = await RequestContext.remember('key', () => …);\n`
|
|
48
|
+
: `with ${distinctArgs > 0 ? `${distinctArgs} different argument sets` : "varying arguments"}. ` +
|
|
49
|
+
`This is the classic N+1 access pattern.\n\n` +
|
|
50
|
+
`Fix: load the relation eagerly using .with('relation') on your query,\n` +
|
|
51
|
+
`call await model.load('relation') before the loop, or collapse the loop\n` +
|
|
52
|
+
`into a single .whereIn(...).\n`;
|
|
28
53
|
|
|
29
|
-
constructor(fingerprint: string, count: number) {
|
|
30
|
-
const sql = fingerprint.replace(/\x00/g, "?");
|
|
31
54
|
super(
|
|
32
55
|
`NPlusOneError: The query\n\n` +
|
|
33
56
|
` ${sql}\n\n` +
|
|
34
|
-
`was executed ${count} times in a single request
|
|
35
|
-
`Fix: load the relation eagerly using .with('relation') on your query,\n` +
|
|
36
|
-
`or call await model.load('relation') before the loop.\n\n` +
|
|
57
|
+
`was executed ${count} times in a single request, ${diagnosis}\n` +
|
|
37
58
|
`To suppress for a specific table or pattern:\n` +
|
|
38
59
|
` DB.allowNPlusOne('table_name') // permanent\n` +
|
|
39
60
|
` DB.allowNPlusOne('table_name', { once: true }) // this request only\n\n` +
|
|
@@ -43,13 +64,30 @@ export class NPlusOneError extends ZerotalError {
|
|
|
43
64
|
);
|
|
44
65
|
this.fingerprint = fingerprint;
|
|
45
66
|
this.count = count;
|
|
67
|
+
this.distinctArgs = distinctArgs;
|
|
46
68
|
}
|
|
47
69
|
}
|
|
48
70
|
|
|
49
71
|
// ── State ─────────────────────────────────────────────────────────────────────
|
|
50
72
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
73
|
+
/** What one query shape did during one request. */
|
|
74
|
+
interface ShapeStats {
|
|
75
|
+
count: number;
|
|
76
|
+
/**
|
|
77
|
+
* Distinct argument tuples seen, capped.
|
|
78
|
+
*
|
|
79
|
+
* Capped because a genuine 500-iteration loop would otherwise hold 500 keys
|
|
80
|
+
* for the life of the request to answer a question — "is this one argument or
|
|
81
|
+
* many?" — that a handful already settles.
|
|
82
|
+
*/
|
|
83
|
+
args: Set<string>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** How many distinct argument tuples to remember per shape before giving up counting. */
|
|
87
|
+
const _ARG_SAMPLE_CAP = 32;
|
|
88
|
+
|
|
89
|
+
/** Per-request query-shape stats. Keyed by the HttpContext object. */
|
|
90
|
+
const _counts = new WeakMap<object, Map<string, ShapeStats>>();
|
|
53
91
|
|
|
54
92
|
/** Once-per-request suppressions. Cleared automatically when the context is GC'd. */
|
|
55
93
|
const _onceSuppressed = new WeakMap<object, Set<string>>();
|
|
@@ -126,8 +164,18 @@ export function _resetNPlusOne(): void {
|
|
|
126
164
|
|
|
127
165
|
// ── Core tracking ─────────────────────────────────────────────────────────────
|
|
128
166
|
|
|
129
|
-
/**
|
|
130
|
-
|
|
167
|
+
/**
|
|
168
|
+
* @internal — called by QueryBuilder._run() on every query execution.
|
|
169
|
+
*
|
|
170
|
+
* @param values - The bound parameters. Optional so older call sites still
|
|
171
|
+
* compile; without them the detector cannot tell a per-row lookup from the
|
|
172
|
+
* same read repeated, and says so less precisely.
|
|
173
|
+
*/
|
|
174
|
+
export function trackQuery(
|
|
175
|
+
ctx: object | null | undefined,
|
|
176
|
+
fingerprint: string,
|
|
177
|
+
values?: readonly unknown[],
|
|
178
|
+
): void {
|
|
131
179
|
if (!ctx) return;
|
|
132
180
|
|
|
133
181
|
// Honour explicit opt-in or auto-enable in local/development only
|
|
@@ -158,15 +206,19 @@ export function trackQuery(ctx: object | null | undefined, fingerprint: string):
|
|
|
158
206
|
// Count this fingerprint for the current request
|
|
159
207
|
if (!_counts.has(ctx)) _counts.set(ctx, new Map());
|
|
160
208
|
const map = _counts.get(ctx)!;
|
|
161
|
-
const
|
|
162
|
-
|
|
209
|
+
const stats = map.get(fingerprint) ?? { count: 0, args: new Set<string>() };
|
|
210
|
+
stats.count++;
|
|
211
|
+
if (values !== undefined && stats.args.size < _ARG_SAMPLE_CAP) {
|
|
212
|
+
stats.args.add(_argKey(values));
|
|
213
|
+
}
|
|
214
|
+
map.set(fingerprint, stats);
|
|
163
215
|
|
|
164
|
-
if (count >= _threshold) {
|
|
216
|
+
if (stats.count >= _threshold) {
|
|
165
217
|
// Only fire once (at exactly the threshold), not on every subsequent hit
|
|
166
|
-
if (count > _threshold) return;
|
|
218
|
+
if (stats.count > _threshold) return;
|
|
167
219
|
|
|
168
|
-
const err = new NPlusOneError(fingerprint, count);
|
|
169
|
-
FrameworkEvents.emit(new NPlusOneDetected(fingerprint, count, ctx ?? undefined));
|
|
220
|
+
const err = new NPlusOneError(fingerprint, stats.count, stats.args.size);
|
|
221
|
+
FrameworkEvents.emit(new NPlusOneDetected(fingerprint, stats.count, ctx ?? undefined));
|
|
170
222
|
if (_mode === "throw") {
|
|
171
223
|
throw err;
|
|
172
224
|
} else {
|
|
@@ -174,3 +226,29 @@ export function trackQuery(ctx: object | null | undefined, fingerprint: string):
|
|
|
174
226
|
}
|
|
175
227
|
}
|
|
176
228
|
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A comparable key for one call's bound parameters.
|
|
232
|
+
*
|
|
233
|
+
* Only ever compared against other keys for the same SQL shape, so it needs to
|
|
234
|
+
* separate arguments rather than describe them. A value JSON cannot represent
|
|
235
|
+
* degrades to its `String()` form, which is enough to tell two calls apart.
|
|
236
|
+
*/
|
|
237
|
+
function _argKey(values: readonly unknown[]): string {
|
|
238
|
+
let key = "";
|
|
239
|
+
for (const value of values) {
|
|
240
|
+
if (value instanceof Date) key += `d${value.getTime()}|`;
|
|
241
|
+
else if (value === null || value === undefined) key += "∅|";
|
|
242
|
+
else if (typeof value === "object") key += `${_safeJson(value)}|`;
|
|
243
|
+
else key += `${String(value)}|`;
|
|
244
|
+
}
|
|
245
|
+
return key;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function _safeJson(value: object): string {
|
|
249
|
+
try {
|
|
250
|
+
return JSON.stringify(value) ?? "?";
|
|
251
|
+
} catch {
|
|
252
|
+
return "?";
|
|
253
|
+
}
|
|
254
|
+
}
|
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)`.
|
|
@@ -244,6 +247,11 @@ export type { DatabaseConfigShape } from "./config.ts";
|
|
|
244
247
|
// Casts
|
|
245
248
|
export { Cast, JsonCast, ArrayCast, json, objectOf, arrayOf } from "./casts/Cast.ts";
|
|
246
249
|
export type { CastContract, CastMapper, CastField } from "./casts/Cast.ts";
|
|
250
|
+
// Encrypted columns — `cast: "encrypted"` / `static encryptable`. The error is
|
|
251
|
+
// exported so an app can catch an unreadable row (a rotated APP_KEY) and say
|
|
252
|
+
// something useful instead of 500ing.
|
|
253
|
+
export { EncryptedColumnError, isEncryptedCast } from "./casts/encrypted.ts";
|
|
254
|
+
export type { EncryptedCastName } from "./casts/encrypted.ts";
|
|
247
255
|
|
|
248
256
|
// Framework events emitted by the ORM (subscribe via core's FrameworkEvents bus).
|
|
249
257
|
export {
|