@zerotal/orm 1.8.1 → 1.10.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/api-surface.md +34 -220
  3. package/package.json +3 -3
  4. package/src/commands/DbBackupCommand.ts +143 -0
  5. package/src/commands/_backup.ts +356 -0
  6. package/src/config.ts +10 -8
  7. package/src/db/DB.ts +27 -2
  8. package/src/db/NPlusOneDetector.ts +2 -2
  9. package/src/db/ReadWriteRouter.ts +2 -0
  10. package/src/db/dialects/MysqlDialect.ts +2 -0
  11. package/src/db/dialects/PostgresDialect.ts +2 -0
  12. package/src/db/dialects/SqliteDialect.ts +2 -0
  13. package/src/db/dialects/index.ts +2 -0
  14. package/src/db/dialects/types.ts +12 -2
  15. package/src/db/resolver.ts +4 -0
  16. package/src/db/types.ts +5 -5
  17. package/src/diagnostics/pendingMigrationsCheck.ts +60 -0
  18. package/src/implicitBinding.ts +1 -0
  19. package/src/model/BaseModel.ts +6 -0
  20. package/src/model/ModelQueryBuilder.ts +1 -0
  21. package/src/model/OrmContext.ts +14 -2
  22. package/src/model/decorators/belongsTo.ts +2 -2
  23. package/src/model/decorators/column.ts +8 -7
  24. package/src/model/decorators/hasMany.ts +1 -1
  25. package/src/model/decorators/hasManyThrough.ts +2 -2
  26. package/src/model/decorators/hasOne.ts +1 -1
  27. package/src/model/decorators/manyToMany.ts +4 -4
  28. package/src/model/decorators/morphMany.ts +1 -1
  29. package/src/model/decorators/morphOne.ts +1 -1
  30. package/src/model/decorators/morphTo.ts +2 -2
  31. package/src/model/decorators/morphToMany.ts +3 -3
  32. package/src/model/decorators/morphedByMany.ts +3 -3
  33. package/src/model/decorators/table.ts +2 -2
  34. package/src/model/hooks/HookRegistry.ts +2 -0
  35. package/src/observability.ts +2 -0
  36. package/src/provider/DatabaseProvider.ts +17 -1
  37. package/src/schema/MigrationCodegen.ts +2 -0
  38. package/src/schema/ModelInspector.ts +4 -0
  39. package/src/schema/SchemaDiffer.ts +6 -0
  40. package/src/schema/SchemaInspector.ts +4 -0
  41. package/src/schema/autoMigrate.ts +15 -3
@@ -107,6 +107,8 @@ HookRegistry.onAfterRun = (ModelClass, hook, model): void => {
107
107
  /**
108
108
  * Register a named connection that models can select via `static connection`.
109
109
  * Stored on the current OrmContext (execution-scoped), not a global.
110
+ *
111
+ * @internal
110
112
  */
111
113
  export function registerModelConnection(name: string, conn: SQLInstance, dialect?: Dialect): void {
112
114
  currentOrmContext().namedConnections.set(name, conn);
@@ -159,6 +161,8 @@ export function _getModelConnection(): SQLInstance {
159
161
  * multi-database strategy: it returns the active tenant's connection so every model
160
162
  * query transparently hits the right database. Returns `null` to defer to the normal
161
163
  * resolution. Defaults to a no-op, so it changes nothing until something registers it.
164
+ *
165
+ * @internal
162
166
  */
163
167
  export type ContextConnectionResolver = (ModelClass?: typeof BaseModel) => SQLInstance | null;
164
168
  let _contextConnectionResolver: ContextConnectionResolver | null = null;
@@ -167,6 +171,8 @@ let _contextConnectionResolver: ContextConnectionResolver | null = null;
167
171
  * Register a context-aware connection resolver (pass `null` to clear). Consulted by
168
172
  * `_resolveConn` after explicit transactions and `static connection` named bindings,
169
173
  * but before the default connection — so transactions and pinned connections still win.
174
+ *
175
+ * @internal
170
176
  */
171
177
  export function registerConnectionResolver(fn: ContextConnectionResolver | null): void {
172
178
  _contextConnectionResolver = fn;
@@ -275,6 +275,7 @@ function _createPivotCollection<T extends BaseModel>(
275
275
  // BaseModel as a type only — no cycle.
276
276
 
277
277
  export type GlobalScopeCallback = (qb: ModelQueryBuilder<BaseModel>) => void;
278
+ /** @internal */
278
279
  export function _globalScopeRegistry(): Map<ClassRef, Map<string, GlobalScopeCallback>> {
279
280
  return currentOrmContext().globalScopes as unknown as Map<
280
281
  ClassRef,
@@ -18,6 +18,8 @@ import type { SQLInstance } from "../db/sql-types.ts";
18
18
  * } finally {
19
19
  * useOrmContext(prev);
20
20
  * }
21
+ *
22
+ * @internal
21
23
  */
22
24
  export class OrmContext {
23
25
  /** Explicit connection override that wins over the resolved default (used by tests / `withDatabase`). */
@@ -34,7 +36,11 @@ export class OrmContext {
34
36
 
35
37
  let _ctx = new OrmContext();
36
38
 
37
- /** Return the currently active {@link OrmContext}. */
39
+ /**
40
+ * Return the currently active {@link OrmContext}.
41
+ *
42
+ * @internal
43
+ */
38
44
  export function currentOrmContext(): OrmContext {
39
45
  return _ctx;
40
46
  }
@@ -45,6 +51,8 @@ export function currentOrmContext(): OrmContext {
45
51
  *
46
52
  * @param ctx The context to activate (defaults to a new, empty context).
47
53
  * @returns The context that was active before this call.
54
+ *
55
+ * @internal
48
56
  */
49
57
  export function useOrmContext(ctx: OrmContext = new OrmContext()): OrmContext {
50
58
  const prev = _ctx;
@@ -52,7 +60,11 @@ export function useOrmContext(ctx: OrmContext = new OrmContext()): OrmContext {
52
60
  return prev;
53
61
  }
54
62
 
55
- /** Replace the active context with a fresh, empty {@link OrmContext} (test/teardown reset). */
63
+ /**
64
+ * Replace the active context with a fresh, empty {@link OrmContext} (test/teardown reset).
65
+ *
66
+ * @internal
67
+ */
56
68
  export function resetOrmContext(): void {
57
69
  _ctx = new OrmContext();
58
70
  }
@@ -5,9 +5,9 @@ export interface BelongsToOptions {
5
5
  /** FK column on this (child) model pointing to the related row (e.g. `user_id`). */
6
6
  foreignKey: string;
7
7
  /** Key on the related model the FK references. Defaults to `'id'`. */
8
- localKey?: string;
8
+ localKey?: string | undefined;
9
9
  /** Return a default (unsaved) related model instead of null when absent. */
10
- withDefault?: boolean | Record<string, unknown> | ((model: unknown) => void);
10
+ withDefault?: boolean | Record<string, unknown> | ((model: unknown) => void) | undefined;
11
11
  }
12
12
 
13
13
  /**
@@ -82,11 +82,11 @@ export interface ColumnOptions {
82
82
  *
83
83
  * @default "string"
84
84
  */
85
- type?: "string" | "text" | "number" | "boolean" | "datetime" | "json";
85
+ type?: "string" | "text" | "number" | "boolean" | "datetime" | "json" | undefined;
86
86
  /** Mark this column as the table's primary key. */
87
- primary?: boolean;
87
+ primary?: boolean | undefined;
88
88
  /** Allow SQL `NULL` for this column. */
89
- nullable?: boolean;
89
+ nullable?: boolean | undefined;
90
90
  /** Default value applied when none is provided. */
91
91
  default?: unknown;
92
92
  /**
@@ -96,9 +96,9 @@ export interface ColumnOptions {
96
96
  * usually a correctness property (a webhook idempotency key, an invoice number),
97
97
  * and `migrate:generate` can only emit constraints it can see declared.
98
98
  */
99
- unique?: boolean;
99
+ unique?: boolean | undefined;
100
100
  /** Add a plain (non-unique) index on this column. */
101
- index?: boolean;
101
+ index?: boolean | undefined;
102
102
  /**
103
103
  * Shorthand cast types automatically serialize/deserialize the column value.
104
104
  * Can also be a custom object with `get`/`set` functions for full control.
@@ -134,9 +134,10 @@ export interface ColumnOptions {
134
134
  get?: (dbValue: unknown) => unknown;
135
135
  set?: (jsValue: unknown) => unknown;
136
136
  }
137
- | CastContract<unknown>;
137
+ | CastContract<unknown>
138
+ | undefined;
138
139
  /** Enum object (e.g. the imported TS enum) used alongside cast: 'enum'. */
139
- enumValues?: Record<string, string | number>;
140
+ enumValues?: Record<string, string | number> | undefined;
140
141
  }
141
142
 
142
143
  /**
@@ -5,7 +5,7 @@ export interface HasManyOptions {
5
5
  /** FK column on the related table pointing back to the parent (e.g. `user_id`). */
6
6
  foreignKey: string;
7
7
  /** Parent local key the FK references. Defaults to `'id'`. */
8
- localKey?: string;
8
+ localKey?: string | undefined;
9
9
  }
10
10
 
11
11
  /**
@@ -6,9 +6,9 @@ export interface HasManyThroughOptions {
6
6
  /** FK on the related table referencing the through model (e.g. 'user_id' on posts). */
7
7
  secondKey: string;
8
8
  /** Parent local key the firstKey references. Default 'id'. */
9
- localKey?: string;
9
+ localKey?: string | undefined;
10
10
  /** Through local key the secondKey references. Default 'id'. */
11
- throughLocalKey?: string;
11
+ throughLocalKey?: string | undefined;
12
12
  }
13
13
 
14
14
  /**
@@ -5,7 +5,7 @@ export interface HasOneOptions {
5
5
  /** FK column on the related table pointing back to the parent (e.g. `user_id`). */
6
6
  foreignKey: string;
7
7
  /** Parent local key the FK references. Defaults to `'id'`. */
8
- localKey?: string;
8
+ localKey?: string | undefined;
9
9
  }
10
10
 
11
11
  /**
@@ -9,13 +9,13 @@ export interface ManyToManyOptions {
9
9
  /** Pivot column referencing the related model (e.g. `role_id`). */
10
10
  pivotRelatedKey: string;
11
11
  /** Parent local key the pivot FK references. Defaults to `'id'`. */
12
- localKey?: string;
12
+ localKey?: string | undefined;
13
13
  /** Related local key the pivot relatedKey references. Defaults to `'id'`. */
14
- relatedKey?: string;
14
+ relatedKey?: string | undefined;
15
15
  /** Extra pivot columns to hydrate onto each related model's `pivot` bag. */
16
- withPivot?: string[];
16
+ withPivot?: string[] | undefined;
17
17
  /** Maintain created_at / updated_at on the pivot table during attach/sync. */
18
- withTimestamps?: boolean;
18
+ withTimestamps?: boolean | undefined;
19
19
  }
20
20
 
21
21
  /**
@@ -7,7 +7,7 @@ export interface MorphManyOptions {
7
7
  */
8
8
  morphName: string;
9
9
  /** Override the local key. Defaults to 'id'. */
10
- localKey?: string;
10
+ localKey?: string | undefined;
11
11
  }
12
12
 
13
13
  /**
@@ -7,7 +7,7 @@ export interface MorphOneOptions {
7
7
  */
8
8
  morphName: string;
9
9
  /** Override the local key. Defaults to 'id'. */
10
- localKey?: string;
10
+ localKey?: string | undefined;
11
11
  }
12
12
 
13
13
  /**
@@ -7,12 +7,12 @@ export interface MorphToOptions {
7
7
  * Override the _type column name. Defaults to `<propertyName>_type`.
8
8
  * e.g. property `commentable` → `commentable_type`.
9
9
  */
10
- morphTypeColumn?: string;
10
+ morphTypeColumn?: string | undefined;
11
11
  /**
12
12
  * Override the _id column name. Defaults to `<propertyName>_id`.
13
13
  * e.g. property `commentable` → `commentable_id`.
14
14
  */
15
- morphForeignKey?: string;
15
+ morphForeignKey?: string | undefined;
16
16
  }
17
17
 
18
18
  function _toSnake(s: string): string {
@@ -6,11 +6,11 @@ export interface MorphToManyOptions {
6
6
  /** FK on the pivot table referencing the related model (e.g. 'tag_id'). */
7
7
  relatedPivotKey: string;
8
8
  /** Pivot table name. Defaults to `{morphName}s`. */
9
- pivotTable?: string;
9
+ pivotTable?: string | undefined;
10
10
  /** Extra pivot columns to hydrate. */
11
- withPivot?: string[];
11
+ withPivot?: string[] | undefined;
12
12
  /** Maintain pivot timestamps. */
13
- withTimestamps?: boolean;
13
+ withTimestamps?: boolean | undefined;
14
14
  }
15
15
 
16
16
  /**
@@ -6,9 +6,9 @@ export interface MorphedByManyOptions {
6
6
  /** FK on the pivot referencing the parent model (e.g. 'tag_id'). */
7
7
  parentPivotKey: string;
8
8
  /** Pivot table name. Defaults to `{morphName}s`. */
9
- pivotTable?: string;
10
- withPivot?: string[];
11
- withTimestamps?: boolean;
9
+ pivotTable?: string | undefined;
10
+ withPivot?: string[] | undefined;
11
+ withTimestamps?: boolean | undefined;
12
12
  }
13
13
 
14
14
  /**
@@ -11,9 +11,9 @@ import type { ClassRef } from "../../support/classRef.ts";
11
11
  */
12
12
  export interface TableOptions {
13
13
  /** Enable / disable automatic timestamp columns. @default true */
14
- timestamps?: boolean;
14
+ timestamps?: boolean | undefined;
15
15
  /** Override the primary key column name. @default "id" */
16
- primaryKey?: string;
16
+ primaryKey?: string | undefined;
17
17
  }
18
18
 
19
19
  /**
@@ -36,6 +36,8 @@ const _suppressCtx = new AsyncLocalStorage<true>();
36
36
  /**
37
37
  * Run `fn` with all model hooks and observers silenced.
38
38
  * Used internally by Factory when `dispatchEvents()` has NOT been called.
39
+ *
40
+ * @internal
39
41
  */
40
42
  export function _suppressHooks<T>(fn: () => Promise<T>): Promise<T> {
41
43
  return _suppressCtx.run(true, fn);
@@ -85,6 +85,8 @@ function _ctxPath(ctx: object): string {
85
85
  /**
86
86
  * Subscribe the ORM's events to every installed observer. Returns a disposer that
87
87
  * removes every subscription; call it from the ORM provider's `onStopping()`.
88
+ *
89
+ * @internal
88
90
  */
89
91
  export function installOrmObservability(app: Application): () => void {
90
92
  const unsubs: Array<() => void> = [];
@@ -1,6 +1,6 @@
1
1
  import type { SQLInstance } from "../db/sql-types.ts";
2
2
  import { ServiceProvider, registerErrorDiagnoser, isProdLike, deployEnv } from "@zerotal/core";
3
- import type { AppEnvironment } from "@zerotal/core";
3
+ import type { AppEnvironment, DoctorCheck } from "@zerotal/core";
4
4
  import type { ConfigManager } from "@zerotal/core/config";
5
5
  import { SQL } from "bun";
6
6
  import { DB, _getConnection } from "../db/DB.ts";
@@ -18,6 +18,7 @@ import { autoMigrateConcern } from "../schema/autoMigrate.ts";
18
18
  import { registerImplicitBinding } from "../implicitBinding.ts";
19
19
  import { installOrmObservability } from "../observability.ts";
20
20
  import { diagnoseMissingRelation } from "../diagnostics/missingRelation.ts";
21
+ import { pendingMigrationsCheck } from "../diagnostics/pendingMigrationsCheck.ts";
21
22
  import {
22
23
  registerRunMigrationsEndpoint,
23
24
  RUN_MIGRATIONS_PATH,
@@ -207,6 +208,15 @@ export class DatabaseProvider extends ServiceProvider {
207
208
  }
208
209
  }
209
210
 
211
+ /**
212
+ * The overlay answers "why did this fail" after something already has. This asks
213
+ * the same question before anything breaks, which is the cheaper moment to hear
214
+ * it — `doctor` already boots the app and already holds the connection.
215
+ */
216
+ override doctorChecks(): DoctorCheck[] {
217
+ return [pendingMigrationsCheck];
218
+ }
219
+
210
220
  override async onBooted(): Promise<void> {
211
221
  // Forward the ORM's framework events to whatever observers are installed.
212
222
  this._disposeObservability = installOrmObservability(this.app);
@@ -256,6 +266,12 @@ export class DatabaseProvider extends ServiceProvider {
256
266
  runner.registerLazy("db:seed", () =>
257
267
  import("../commands/DbSeedCommand.ts").then((m) => m.DbSeedCommand),
258
268
  );
269
+ // The counterpart to every command above it. `migrate`, `migrate:fresh` and
270
+ // `db:seed` all assume the file will still be there; this is the one that
271
+ // makes that assumption survivable.
272
+ runner.registerLazy("db:backup", () =>
273
+ import("../commands/DbBackupCommand.ts").then((m) => m.DbBackupCommand),
274
+ );
259
275
  runner.registerLazy("make:seeder", () =>
260
276
  import("../commands/MakeSeederCommand.ts").then((m) => m.MakeSeederCommand),
261
277
  );
@@ -111,6 +111,8 @@ function alterTableBlocks(newColumns: NewColumn[]): string {
111
111
  * - Creates new tables with `Schema.create()`
112
112
  * - Adds new columns with `Schema.table()` (ALTER TABLE ADD COLUMN)
113
113
  * - Drops created tables in `down()` (column additions are left for manual rollback)
114
+ *
115
+ * @internal
114
116
  */
115
117
  export function generateMigrationContent(className: string, diff: DiffResult): string {
116
118
  const upParts: string[] = [];
@@ -7,6 +7,7 @@ import type { ClassRef } from "../support/classRef.ts";
7
7
 
8
8
  // ── Model schema descriptor ───────────────────────────────────────────────────
9
9
 
10
+ /** @internal */
10
11
  export interface ModelColumn {
11
12
  name: string;
12
13
  type: ColumnOptions["type"]; // 'string' | 'text' | 'number' | 'boolean' | 'datetime' | 'json'
@@ -19,6 +20,7 @@ export interface ModelColumn {
19
20
  index?: boolean;
20
21
  }
21
22
 
23
+ /** @internal */
22
24
  export interface ModelSchema {
23
25
  table: string;
24
26
  primaryKey: string;
@@ -109,6 +111,8 @@ function encryptedColumns(
109
111
  * by child classes, matching normal TypeScript class semantics.
110
112
  *
111
113
  * Used by `migrate:generate` to compare model intent against the live DB.
114
+ *
115
+ * @internal
112
116
  */
113
117
  export const ModelInspector = {
114
118
  /**
@@ -3,20 +3,24 @@ import { columnDbName, type ModelSchema, type ModelColumn } from "./ModelInspect
3
3
 
4
4
  // -- Diff result types ---------------------------------------------------------
5
5
 
6
+ /** @internal */
6
7
  export interface NewTable {
7
8
  schema: ModelSchema;
8
9
  }
9
10
 
11
+ /** @internal */
10
12
  export interface NewColumn {
11
13
  table: string;
12
14
  column: ModelColumn;
13
15
  }
14
16
 
17
+ /** @internal */
15
18
  export interface DroppedColumn {
16
19
  table: string;
17
20
  column: string;
18
21
  }
19
22
 
23
+ /** @internal */
20
24
  export interface DiffResult {
21
25
  newTables: NewTable[];
22
26
  newColumns: NewColumn[];
@@ -36,6 +40,8 @@ export interface DiffResult {
36
40
  *
37
41
  * Additive deltas (newTables, newColumns) are always safe to apply. Dropped
38
42
  * columns are reported separately and only applied by a disruptive synchronize.
43
+ *
44
+ * @internal
39
45
  */
40
46
  export const SchemaDiffer = {
41
47
  async diff(schemas: ModelSchema[]): Promise<DiffResult> {
@@ -3,6 +3,7 @@ import { _getDialect } from "../model/BaseModel.ts";
3
3
 
4
4
  // ── DB column descriptor (normalised across dialects) ─────────────────────────
5
5
 
6
+ /** @internal */
6
7
  export interface LiveColumn {
7
8
  name: string;
8
9
  rawType: string; // original SQL type string from the DB (uppercase)
@@ -10,6 +11,7 @@ export interface LiveColumn {
10
11
  primary: boolean;
11
12
  }
12
13
 
14
+ /** @internal */
13
15
  export interface LiveTable {
14
16
  name: string;
15
17
  columns: LiveColumn[];
@@ -38,6 +40,8 @@ async function queryParam<T>(sql: string, value: unknown): Promise<T[]> {
38
40
  /**
39
41
  * Queries the live database to enumerate tables and their column definitions.
40
42
  * Used by `migrate:generate` to compute what has changed since the last migration.
43
+ *
44
+ * @internal
41
45
  */
42
46
  export const SchemaInspector = {
43
47
  /** Return all user-defined table names in the current DB. */
@@ -41,14 +41,18 @@ function applyColumn(table: TableBuilder, col: ModelColumn): void {
41
41
  else if (col.index) table.index(dbName);
42
42
  }
43
43
 
44
- /** Options for {@link synchronizeSchema}. */
44
+ /**
45
+ * Options for {@link synchronizeSchema}.
46
+ *
47
+ * @internal
48
+ */
45
49
  export interface SynchronizeOptions {
46
50
  /**
47
51
  * When true, also DROP columns that exist in the database but are no longer
48
52
  * declared by any model. Off by default - additive changes are always safe,
49
53
  * drops can lose data.
50
54
  */
51
- disruptive?: boolean;
55
+ disruptive?: boolean | undefined;
52
56
  }
53
57
 
54
58
  /**
@@ -61,6 +65,8 @@ export interface SynchronizeOptions {
61
65
  *
62
66
  * Returns the diff that was applied (additive deltas are empty when already in sync;
63
67
  * `droppedColumns` is populated but only acted on when `disruptive` is set).
68
+ *
69
+ * @internal
64
70
  */
65
71
  export async function synchronizeSchema(options: SynchronizeOptions = {}): Promise<DiffResult> {
66
72
  const diff = await SchemaDiffer.diff(ModelInspector.all());
@@ -112,7 +118,11 @@ export async function synchronizeSchema(options: SynchronizeOptions = {}): Promi
112
118
  return diff;
113
119
  }
114
120
 
115
- /** Resolved, normalised form of the `database.synchronize` config value. */
121
+ /**
122
+ * Resolved, normalised form of the `database.synchronize` config value.
123
+ *
124
+ * @internal
125
+ */
116
126
  export interface ResolvedSyncOptions {
117
127
  enabled: boolean;
118
128
  disruptive: boolean;
@@ -124,6 +134,8 @@ export interface ResolvedSyncOptions {
124
134
  * false / undefined -> { enabled: false, disruptive: false }
125
135
  * true -> { enabled: true, disruptive: false } (additive)
126
136
  * { enabled, disruptive? } -> as given (enabled defaults true when the object is present)
137
+ *
138
+ * @internal
127
139
  */
128
140
  export function resolveSyncOptions(raw: unknown): ResolvedSyncOptions {
129
141
  if (raw === true) return { enabled: true, disruptive: false };