@zerotal/orm 1.0.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 +17 -0
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/package.json +58 -0
- package/src/casts/Cast.ts +200 -0
- package/src/commands/DbSeedCommand.ts +71 -0
- package/src/commands/MakeFactoryCommand.ts +59 -0
- package/src/commands/MakeMigrationCommand.ts +109 -0
- package/src/commands/MakeModelCommand.ts +83 -0
- package/src/commands/MakeSeederCommand.ts +50 -0
- package/src/commands/MigrateCommand.ts +60 -0
- package/src/commands/MigrateFreshCommand.ts +41 -0
- package/src/commands/MigrateGenerateCommand.ts +110 -0
- package/src/commands/MigrateRollbackCommand.ts +43 -0
- package/src/commands/MigrateStatusCommand.ts +49 -0
- package/src/commands/_loadMigrations.ts +34 -0
- package/src/commands/index.ts +30 -0
- package/src/config.ts +182 -0
- package/src/conventions.ts +67 -0
- package/src/db/DB.ts +486 -0
- package/src/db/NPlusOneDetector.ts +176 -0
- package/src/db/QueryBuilder.ts +2458 -0
- package/src/db/ReadWriteRouter.ts +96 -0
- package/src/db/TransactionContext.ts +13 -0
- package/src/db/dialects/MysqlDialect.ts +57 -0
- package/src/db/dialects/PostgresDialect.ts +55 -0
- package/src/db/dialects/SqliteDialect.ts +54 -0
- package/src/db/dialects/index.ts +25 -0
- package/src/db/dialects/types.ts +67 -0
- package/src/db/resolver.ts +30 -0
- package/src/db/sql-types.ts +12 -0
- package/src/db/types.ts +296 -0
- package/src/errors/MassAssignmentError.ts +25 -0
- package/src/errors/MigrationError.ts +18 -0
- package/src/errors/ModelNotFoundError.ts +21 -0
- package/src/errors/NPlusOneError.ts +6 -0
- package/src/errors/RelationNotLoadedError.ts +19 -0
- package/src/errors/StateError.ts +18 -0
- package/src/errors/TransactionError.ts +13 -0
- package/src/errors/UnsupportedDialectError.ts +18 -0
- package/src/errors/index.ts +7 -0
- package/src/events.ts +112 -0
- package/src/global.d.ts +17 -0
- package/src/implicitBinding.ts +73 -0
- package/src/index.ts +255 -0
- package/src/model/BaseModel.ts +2499 -0
- package/src/model/ModelQueryBuilder.ts +1808 -0
- package/src/model/Observer.ts +73 -0
- package/src/model/OrmContext.ts +71 -0
- package/src/model/ReactiveProxy.ts +53 -0
- package/src/model/SoftDeletes.ts +108 -0
- package/src/model/State.ts +290 -0
- package/src/model/decorators/_metadata.ts +211 -0
- package/src/model/decorators/_registerRelation.ts +20 -0
- package/src/model/decorators/belongsTo.ts +38 -0
- package/src/model/decorators/column.ts +278 -0
- package/src/model/decorators/hasMany.ts +34 -0
- package/src/model/decorators/hasManyThrough.ts +50 -0
- package/src/model/decorators/hasOne.ts +34 -0
- package/src/model/decorators/hasOneThrough.ts +40 -0
- package/src/model/decorators/manyToMany.ts +55 -0
- package/src/model/decorators/morphMany.ts +38 -0
- package/src/model/decorators/morphOne.ts +38 -0
- package/src/model/decorators/morphTo.ts +51 -0
- package/src/model/decorators/morphToMany.ts +49 -0
- package/src/model/decorators/morphedByMany.ts +46 -0
- package/src/model/decorators/table.ts +124 -0
- package/src/model/hooks/HookRegistry.ts +110 -0
- package/src/model/mixins.ts +536 -0
- package/src/model/payload.ts +114 -0
- package/src/model/relations/RelationRegistry.ts +184 -0
- package/src/observability.ts +210 -0
- package/src/provider/DatabaseProvider.ts +266 -0
- package/src/schema/Blueprint.ts +900 -0
- package/src/schema/ColumnDefinition.ts +517 -0
- package/src/schema/Migration.ts +34 -0
- package/src/schema/MigrationCodegen.ts +108 -0
- package/src/schema/MigrationRunner.ts +351 -0
- package/src/schema/ModelInspector.ts +133 -0
- package/src/schema/Schema.ts +140 -0
- package/src/schema/SchemaDiffer.ts +137 -0
- package/src/schema/SchemaInspector.ts +164 -0
- package/src/schema/__test_migrations__/001_create_test_table.ts +15 -0
- package/src/schema/autoMigrate.ts +154 -0
- package/src/schema/index.ts +28 -0
- package/src/seeding/Seeder.ts +46 -0
- package/src/support/identifiers.ts +62 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { SchemaInspector } from "./SchemaInspector.ts";
|
|
2
|
+
import { columnDbName, type ModelSchema, type ModelColumn } from "./ModelInspector.ts";
|
|
3
|
+
|
|
4
|
+
// -- Diff result types ---------------------------------------------------------
|
|
5
|
+
|
|
6
|
+
export interface NewTable {
|
|
7
|
+
schema: ModelSchema;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface NewColumn {
|
|
11
|
+
table: string;
|
|
12
|
+
column: ModelColumn;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DroppedColumn {
|
|
16
|
+
table: string;
|
|
17
|
+
column: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DiffResult {
|
|
21
|
+
newTables: NewTable[];
|
|
22
|
+
newColumns: NewColumn[];
|
|
23
|
+
/**
|
|
24
|
+
* Columns that exist in the live table but are no longer declared by the model.
|
|
25
|
+
* Only ever acted on by a *disruptive* `synchronize` - additive sync and
|
|
26
|
+
* `migrate:generate` ignore these to avoid accidental data loss.
|
|
27
|
+
*/
|
|
28
|
+
droppedColumns: DroppedColumn[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// -- SchemaDiffer --------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Compares a set of model schemas against the live database and returns
|
|
35
|
+
* the deltas: tables that need to be created and columns that need to be added.
|
|
36
|
+
*
|
|
37
|
+
* Additive deltas (newTables, newColumns) are always safe to apply. Dropped
|
|
38
|
+
* columns are reported separately and only applied by a disruptive synchronize.
|
|
39
|
+
*/
|
|
40
|
+
export const SchemaDiffer = {
|
|
41
|
+
async diff(schemas: ModelSchema[]): Promise<DiffResult> {
|
|
42
|
+
const newTables: NewTable[] = [];
|
|
43
|
+
const newColumns: NewColumn[] = [];
|
|
44
|
+
const droppedColumns: DroppedColumn[] = [];
|
|
45
|
+
|
|
46
|
+
for (const schema of schemas) {
|
|
47
|
+
const live = await SchemaInspector.describe(schema.table);
|
|
48
|
+
|
|
49
|
+
if (live === null) {
|
|
50
|
+
// Entire table is new - generate a CREATE TABLE migration.
|
|
51
|
+
newTables.push({ schema });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Table exists - find columns that the model declares but the DB doesn't have.
|
|
56
|
+
const liveNames = new Set(live.columns.map((c) => c.name));
|
|
57
|
+
|
|
58
|
+
// The full set of columns the model expects to exist: declared columns plus
|
|
59
|
+
// the framework-managed ones (primary key, timestamps, soft-deletes). Any live
|
|
60
|
+
// column outside this set is a candidate drop (disruptive sync only). Compare in
|
|
61
|
+
// snake_case (the DB convention) so camelCase model props match their real columns.
|
|
62
|
+
const expected = new Set<string>(schema.columns.map((c) => columnDbName(c.name)));
|
|
63
|
+
if (schema.primaryKey) expected.add(columnDbName(schema.primaryKey));
|
|
64
|
+
if (schema.timestamps) {
|
|
65
|
+
expected.add("created_at");
|
|
66
|
+
expected.add("updated_at");
|
|
67
|
+
}
|
|
68
|
+
if (schema.softDeletes) expected.add("deleted_at");
|
|
69
|
+
|
|
70
|
+
for (const col of live.columns) {
|
|
71
|
+
// Never drop the primary key, and never drop a still-declared column.
|
|
72
|
+
if (col.primary || expected.has(col.name)) continue;
|
|
73
|
+
droppedColumns.push({ table: schema.table, column: col.name });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const col of schema.columns) {
|
|
77
|
+
// The model column carries its raw (camelCase) name; the DB has it snake_cased.
|
|
78
|
+
if (!liveNames.has(columnDbName(col.name))) {
|
|
79
|
+
newColumns.push({ table: schema.table, column: col });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check timestamp columns that Blueprint.timestamps() would add.
|
|
84
|
+
if (schema.timestamps) {
|
|
85
|
+
if (!liveNames.has("created_at")) {
|
|
86
|
+
newColumns.push({
|
|
87
|
+
table: schema.table,
|
|
88
|
+
column: {
|
|
89
|
+
name: "created_at",
|
|
90
|
+
type: "datetime",
|
|
91
|
+
nullable: true,
|
|
92
|
+
primary: false,
|
|
93
|
+
default: undefined,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (!liveNames.has("updated_at")) {
|
|
98
|
+
newColumns.push({
|
|
99
|
+
table: schema.table,
|
|
100
|
+
column: {
|
|
101
|
+
name: "updated_at",
|
|
102
|
+
type: "datetime",
|
|
103
|
+
nullable: true,
|
|
104
|
+
primary: false,
|
|
105
|
+
default: undefined,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check soft-delete column that Blueprint.softDeletes() would add.
|
|
112
|
+
if (schema.softDeletes && !liveNames.has("deleted_at")) {
|
|
113
|
+
newColumns.push({
|
|
114
|
+
table: schema.table,
|
|
115
|
+
column: {
|
|
116
|
+
name: "deleted_at",
|
|
117
|
+
type: "datetime",
|
|
118
|
+
nullable: true,
|
|
119
|
+
primary: false,
|
|
120
|
+
default: undefined,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { newTables, newColumns, droppedColumns };
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* True when there is nothing *additive* to generate. Dropped columns are
|
|
131
|
+
* deliberately excluded: `migrate:generate` never emits drops, so a table with
|
|
132
|
+
* only dropped columns is still "in sync" from the generator's perspective.
|
|
133
|
+
*/
|
|
134
|
+
isEmpty(diff: DiffResult): boolean {
|
|
135
|
+
return diff.newTables.length === 0 && diff.newColumns.length === 0;
|
|
136
|
+
},
|
|
137
|
+
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { _getDbConnection } from "../db/DB.ts";
|
|
2
|
+
import { _getDialect } from "../model/BaseModel.ts";
|
|
3
|
+
|
|
4
|
+
// ── DB column descriptor (normalised across dialects) ─────────────────────────
|
|
5
|
+
|
|
6
|
+
export interface LiveColumn {
|
|
7
|
+
name: string;
|
|
8
|
+
rawType: string; // original SQL type string from the DB (uppercase)
|
|
9
|
+
nullable: boolean;
|
|
10
|
+
primary: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface LiveTable {
|
|
14
|
+
name: string;
|
|
15
|
+
columns: LiveColumn[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── Internal helpers ──────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/** Execute a no-parameter SQL query and return typed rows. */
|
|
21
|
+
async function queryRaw<T>(sql: string): Promise<T[]> {
|
|
22
|
+
const conn = _getDbConnection();
|
|
23
|
+
const strings = [sql];
|
|
24
|
+
const tpl = Object.assign(strings, { raw: strings }) as TemplateStringsArray;
|
|
25
|
+
return conn<T>(tpl);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Execute a query with exactly one bound parameter and return typed rows. */
|
|
29
|
+
async function queryParam<T>(sql: string, value: unknown): Promise<T[]> {
|
|
30
|
+
const conn = _getDbConnection();
|
|
31
|
+
const parts = sql.split("?");
|
|
32
|
+
const tpl = Object.assign(parts, { raw: parts }) as TemplateStringsArray;
|
|
33
|
+
return conn<T>(tpl, value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── SchemaInspector ───────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Queries the live database to enumerate tables and their column definitions.
|
|
40
|
+
* Used by `migrate:generate` to compute what has changed since the last migration.
|
|
41
|
+
*/
|
|
42
|
+
export const SchemaInspector = {
|
|
43
|
+
/** Return all user-defined table names in the current DB. */
|
|
44
|
+
async tables(): Promise<string[]> {
|
|
45
|
+
const dialect = _getDialect();
|
|
46
|
+
|
|
47
|
+
if (dialect === "postgres") {
|
|
48
|
+
const rows = await queryRaw<{ table_name: string }>(
|
|
49
|
+
`SELECT table_name FROM information_schema.tables
|
|
50
|
+
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
|
51
|
+
ORDER BY table_name`,
|
|
52
|
+
);
|
|
53
|
+
return rows.map((r) => r.table_name);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (dialect === "mysql") {
|
|
57
|
+
const rows = await queryRaw<{ table_name: string }>(
|
|
58
|
+
`SELECT TABLE_NAME AS table_name FROM INFORMATION_SCHEMA.TABLES
|
|
59
|
+
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'
|
|
60
|
+
ORDER BY TABLE_NAME`,
|
|
61
|
+
);
|
|
62
|
+
return rows.map((r) => r.table_name);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// SQLite
|
|
66
|
+
const rows = await queryRaw<{ name: string }>(
|
|
67
|
+
`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name`,
|
|
68
|
+
);
|
|
69
|
+
return rows.map((r) => r.name);
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Return column details for a single table, or null if the table doesn't exist.
|
|
74
|
+
* Table name is sanitised before use — safe against injection even for SQLite PRAGMAs.
|
|
75
|
+
*/
|
|
76
|
+
async columns(table: string): Promise<LiveColumn[] | null> {
|
|
77
|
+
const dialect = _getDialect();
|
|
78
|
+
const safeName = table.replace(/[^a-zA-Z0-9_]/g, "");
|
|
79
|
+
|
|
80
|
+
if (dialect === "postgres") {
|
|
81
|
+
const exists = await queryParam<{ exists: boolean }>(
|
|
82
|
+
`SELECT EXISTS(
|
|
83
|
+
SELECT 1 FROM information_schema.tables
|
|
84
|
+
WHERE table_schema = 'public' AND table_name = ?
|
|
85
|
+
) AS exists`,
|
|
86
|
+
table,
|
|
87
|
+
);
|
|
88
|
+
if (!exists[0]?.exists) return null;
|
|
89
|
+
|
|
90
|
+
const rows = await queryParam<{
|
|
91
|
+
column_name: string;
|
|
92
|
+
data_type: string;
|
|
93
|
+
is_nullable: string;
|
|
94
|
+
}>(
|
|
95
|
+
`SELECT column_name, data_type, is_nullable
|
|
96
|
+
FROM information_schema.columns
|
|
97
|
+
WHERE table_schema = 'public' AND table_name = ?
|
|
98
|
+
ORDER BY ordinal_position`,
|
|
99
|
+
table,
|
|
100
|
+
);
|
|
101
|
+
const pks = await queryParam<{ column_name: string }>(
|
|
102
|
+
`SELECT kcu.column_name
|
|
103
|
+
FROM information_schema.table_constraints tc
|
|
104
|
+
JOIN information_schema.key_column_usage kcu
|
|
105
|
+
USING (constraint_name, table_schema, table_name)
|
|
106
|
+
WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_name = ?`,
|
|
107
|
+
table,
|
|
108
|
+
);
|
|
109
|
+
const pkSet = new Set(pks.map((r) => r.column_name));
|
|
110
|
+
return rows.map((r) => ({
|
|
111
|
+
name: r.column_name,
|
|
112
|
+
rawType: r.data_type.toUpperCase(),
|
|
113
|
+
nullable: r.is_nullable === "YES",
|
|
114
|
+
primary: pkSet.has(r.column_name),
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (dialect === "mysql") {
|
|
119
|
+
const rows = await queryParam<{
|
|
120
|
+
COLUMN_NAME: string;
|
|
121
|
+
DATA_TYPE: string;
|
|
122
|
+
IS_NULLABLE: string;
|
|
123
|
+
COLUMN_KEY: string;
|
|
124
|
+
}>(
|
|
125
|
+
`SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_KEY
|
|
126
|
+
FROM INFORMATION_SCHEMA.COLUMNS
|
|
127
|
+
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
|
|
128
|
+
ORDER BY ORDINAL_POSITION`,
|
|
129
|
+
table,
|
|
130
|
+
);
|
|
131
|
+
if (rows.length === 0) return null;
|
|
132
|
+
return rows.map((r) => ({
|
|
133
|
+
name: r.COLUMN_NAME,
|
|
134
|
+
rawType: r.DATA_TYPE.toUpperCase(),
|
|
135
|
+
nullable: r.IS_NULLABLE === "YES",
|
|
136
|
+
primary: r.COLUMN_KEY === "PRI",
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// SQLite — PRAGMA table_info (table name must be inlined, not parameterised)
|
|
141
|
+
const tableExists = await queryParam<{ name: string }>(
|
|
142
|
+
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`,
|
|
143
|
+
safeName,
|
|
144
|
+
);
|
|
145
|
+
if (tableExists.length === 0) return null;
|
|
146
|
+
|
|
147
|
+
const rows = await queryRaw<{ name: string; type: string; notnull: number; pk: number }>(
|
|
148
|
+
`PRAGMA table_info(${safeName})`,
|
|
149
|
+
);
|
|
150
|
+
return rows.map((r) => ({
|
|
151
|
+
name: r.name,
|
|
152
|
+
rawType: (r.type ?? "TEXT").toUpperCase(),
|
|
153
|
+
nullable: r.notnull === 0 && r.pk === 0,
|
|
154
|
+
primary: r.pk > 0,
|
|
155
|
+
}));
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
/** Return a fully described LiveTable, or null if the table doesn't exist. */
|
|
159
|
+
async describe(table: string): Promise<LiveTable | null> {
|
|
160
|
+
const columns = await SchemaInspector.columns(table);
|
|
161
|
+
if (columns === null) return null;
|
|
162
|
+
return { name: table, columns };
|
|
163
|
+
},
|
|
164
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Migration } from '../Migration.ts';
|
|
2
|
+
import { Schema } from '../Schema.ts';
|
|
3
|
+
|
|
4
|
+
export default class CreateTestTable extends Migration {
|
|
5
|
+
async up(): Promise<void> {
|
|
6
|
+
await Schema.create('test_from_dir', (table) => {
|
|
7
|
+
table.increments('id');
|
|
8
|
+
table.string('label');
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async down(): Promise<void> {
|
|
13
|
+
await Schema.dropIfExists('test_from_dir');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { ConcernDescriptor } from "@zerotal/core";
|
|
2
|
+
import type { ConfigManager } from "@zerotal/core/config";
|
|
3
|
+
import { ModelInspector, columnDbName } from "./ModelInspector.ts";
|
|
4
|
+
import { SchemaDiffer, type DiffResult } from "./SchemaDiffer.ts";
|
|
5
|
+
import { Schema } from "./Schema.ts";
|
|
6
|
+
import type { ModelColumn } from "./ModelInspector.ts";
|
|
7
|
+
|
|
8
|
+
// Mirrors MigrationCodegen's mapping so synchronize() produces the same schema as
|
|
9
|
+
// `migrate:generate` would - but applies it directly instead of writing a migration file.
|
|
10
|
+
const BLUEPRINT_METHOD: Record<string, string> = {
|
|
11
|
+
string: "string",
|
|
12
|
+
number: "integer",
|
|
13
|
+
boolean: "boolean",
|
|
14
|
+
datetime: "dateTime",
|
|
15
|
+
json: "json",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type ColumnBuilder = { nullable(): ColumnBuilder; default(v: unknown): ColumnBuilder };
|
|
19
|
+
type TableBuilder = Record<string, (name: string) => ColumnBuilder> & {
|
|
20
|
+
increments(name: string): unknown;
|
|
21
|
+
timestamps(): void;
|
|
22
|
+
softDeletes(): void;
|
|
23
|
+
dropColumn(...names: string[]): unknown;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function applyColumn(table: TableBuilder, col: ModelColumn): void {
|
|
27
|
+
const method = BLUEPRINT_METHOD[col.type ?? "string"] ?? "string";
|
|
28
|
+
// Models declare columns in camelCase; the ORM reads/writes snake_case — emit snake_case
|
|
29
|
+
// so synchronize produces columns the runtime can actually read (e.g. two_factor_secret).
|
|
30
|
+
const builder = table[method]!(columnDbName(col.name));
|
|
31
|
+
if (col.nullable) builder.nullable();
|
|
32
|
+
if (col.default !== undefined) builder.default(col.default);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Options for {@link synchronizeSchema}. */
|
|
36
|
+
export interface SynchronizeOptions {
|
|
37
|
+
/**
|
|
38
|
+
* When true, also DROP columns that exist in the database but are no longer
|
|
39
|
+
* declared by any model. Off by default - additive changes are always safe,
|
|
40
|
+
* drops can lose data.
|
|
41
|
+
*/
|
|
42
|
+
disruptive?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Schema sync (TypeORM-style `synchronize`): create missing tables and add missing columns
|
|
47
|
+
* to match the registered models. By default it is additive only and never drops or alters
|
|
48
|
+
* existing columns.
|
|
49
|
+
*
|
|
50
|
+
* Pass `{ disruptive: true }` to also drop columns that no model declares anymore - an
|
|
51
|
+
* explicit opt-in, since dropping a column destroys its data.
|
|
52
|
+
*
|
|
53
|
+
* Returns the diff that was applied (additive deltas are empty when already in sync;
|
|
54
|
+
* `droppedColumns` is populated but only acted on when `disruptive` is set).
|
|
55
|
+
*/
|
|
56
|
+
export async function synchronizeSchema(options: SynchronizeOptions = {}): Promise<DiffResult> {
|
|
57
|
+
const diff = await SchemaDiffer.diff(ModelInspector.all());
|
|
58
|
+
|
|
59
|
+
for (const { schema } of diff.newTables) {
|
|
60
|
+
await Schema.createIfNotExists(schema.table, (t) => {
|
|
61
|
+
const table = t as unknown as TableBuilder;
|
|
62
|
+
table.increments(columnDbName(schema.primaryKey));
|
|
63
|
+
for (const col of schema.columns) {
|
|
64
|
+
if (col.primary) continue; // increments() covers the PK
|
|
65
|
+
applyColumn(table, col);
|
|
66
|
+
}
|
|
67
|
+
if (schema.timestamps) table.timestamps();
|
|
68
|
+
if (schema.softDeletes) table.softDeletes();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const byTable = new Map<string, ModelColumn[]>();
|
|
73
|
+
for (const nc of diff.newColumns) {
|
|
74
|
+
const bucket = byTable.get(nc.table) ?? [];
|
|
75
|
+
bucket.push(nc.column);
|
|
76
|
+
byTable.set(nc.table, bucket);
|
|
77
|
+
}
|
|
78
|
+
for (const [table, cols] of byTable) {
|
|
79
|
+
await Schema.table(table, (t) => {
|
|
80
|
+
const tb = t as unknown as TableBuilder;
|
|
81
|
+
for (const col of cols) applyColumn(tb, col);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Disruptive phase: drop columns the models no longer declare. Opt-in only.
|
|
86
|
+
if (options.disruptive && diff.droppedColumns.length > 0) {
|
|
87
|
+
const dropsByTable = new Map<string, string[]>();
|
|
88
|
+
for (const dc of diff.droppedColumns) {
|
|
89
|
+
const bucket = dropsByTable.get(dc.table) ?? [];
|
|
90
|
+
bucket.push(dc.column);
|
|
91
|
+
dropsByTable.set(dc.table, bucket);
|
|
92
|
+
}
|
|
93
|
+
for (const [table, names] of dropsByTable) {
|
|
94
|
+
console.warn(
|
|
95
|
+
`[Zerotal] synchronize (disruptive): dropping ${table}.{${names.join(", ")}} - data will be lost.`,
|
|
96
|
+
);
|
|
97
|
+
await Schema.table(table, (t) => {
|
|
98
|
+
(t as unknown as TableBuilder).dropColumn(...names);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return diff;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Resolved, normalised form of the `database.synchronize` config value. */
|
|
107
|
+
export interface ResolvedSyncOptions {
|
|
108
|
+
enabled: boolean;
|
|
109
|
+
disruptive: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Normalise the polymorphic `database.synchronize` config into `{ enabled, disruptive }`.
|
|
114
|
+
*
|
|
115
|
+
* false / undefined -> { enabled: false, disruptive: false }
|
|
116
|
+
* true -> { enabled: true, disruptive: false } (additive)
|
|
117
|
+
* { enabled, disruptive? } -> as given (enabled defaults true when the object is present)
|
|
118
|
+
*/
|
|
119
|
+
export function resolveSyncOptions(raw: unknown): ResolvedSyncOptions {
|
|
120
|
+
if (raw === true) return { enabled: true, disruptive: false };
|
|
121
|
+
if (raw && typeof raw === "object") {
|
|
122
|
+
const o = raw as { enabled?: boolean; disruptive?: boolean };
|
|
123
|
+
const enabled = o.enabled !== false;
|
|
124
|
+
// Disruptive only matters when enabled — force it off otherwise.
|
|
125
|
+
return { enabled, disruptive: enabled && o.disruptive === true };
|
|
126
|
+
}
|
|
127
|
+
return { enabled: false, disruptive: false };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One-shot convention (runs after `models`, order 100). Applies `synchronizeSchema()` only when
|
|
132
|
+
* `database.synchronize` is enabled - strictly opt-in (like TypeORM), and HARD-OFF in
|
|
133
|
+
* `production` regardless (use `migrate` with generated files there).
|
|
134
|
+
*
|
|
135
|
+
* `synchronize` accepts `true` (additive) or `{ enabled, disruptive }` to also drop columns
|
|
136
|
+
* no model declares anymore.
|
|
137
|
+
*/
|
|
138
|
+
export const autoMigrateConcern: ConcernDescriptor = {
|
|
139
|
+
name: "auto-migrate",
|
|
140
|
+
order: 100,
|
|
141
|
+
async run(ctx) {
|
|
142
|
+
// Hard-off in production. `ctx.env` is the runtime mode (web/console/…), so the
|
|
143
|
+
// deployment name is read from APP_ENV directly.
|
|
144
|
+
if (Bun.env.APP_ENV === "production") return;
|
|
145
|
+
const config = ctx.resolve<ConfigManager>("config");
|
|
146
|
+
const { enabled, disruptive } = resolveSyncOptions(config?.get("database.synchronize"));
|
|
147
|
+
if (!enabled) return;
|
|
148
|
+
try {
|
|
149
|
+
await synchronizeSchema({ disruptive });
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.error("[Zerotal] auto-migrate (synchronize) failed:", err);
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module schema
|
|
3
|
+
*
|
|
4
|
+
* Internal barrel for the schema-builder and migration subsystem: the
|
|
5
|
+
* {@link Schema} facade, the {@link Blueprint} table builder and its
|
|
6
|
+
* {@link ColumnBuilder}/{@link ForeignKeyBuilder} column API, the
|
|
7
|
+
* {@link Migration} base class and {@link MigrationRunner}, plus the introspection
|
|
8
|
+
* and diffing helpers ({@link SchemaInspector}, {@link ModelInspector},
|
|
9
|
+
* {@link SchemaDiffer}, {@link generateMigrationContent}) that power
|
|
10
|
+
* `migrate:generate` and `synchronize`.
|
|
11
|
+
*
|
|
12
|
+
* This is not a public package entry point — its members are re-exported from the
|
|
13
|
+
* package root (`@zerotal/orm`), which is where consumers should import them.
|
|
14
|
+
*/
|
|
15
|
+
export { Blueprint } from "./Blueprint.ts";
|
|
16
|
+
export { ColumnBuilder, ForeignIdColumnBuilder, ForeignKeyBuilder } from "./ColumnDefinition.ts";
|
|
17
|
+
export type { FKAction } from "./ColumnDefinition.ts";
|
|
18
|
+
export { Schema } from "./Schema.ts";
|
|
19
|
+
export { Migration } from "./Migration.ts";
|
|
20
|
+
export { MigrationRunner } from "./MigrationRunner.ts";
|
|
21
|
+
export type { MigrationEntry, MigrationRecord, MigrationStatus } from "./MigrationRunner.ts";
|
|
22
|
+
export { SchemaInspector } from "./SchemaInspector.ts";
|
|
23
|
+
export type { LiveColumn, LiveTable } from "./SchemaInspector.ts";
|
|
24
|
+
export { ModelInspector } from "./ModelInspector.ts";
|
|
25
|
+
export type { ModelColumn, ModelSchema } from "./ModelInspector.ts";
|
|
26
|
+
export { SchemaDiffer } from "./SchemaDiffer.ts";
|
|
27
|
+
export type { DiffResult, NewTable, NewColumn } from "./SchemaDiffer.ts";
|
|
28
|
+
export { generateMigrationContent } from "./MigrationCodegen.ts";
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { DB } from "../db/DB.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Base class for database seeders. Extend it and implement {@link run} to populate
|
|
5
|
+
* your database; use {@link call} to compose other seeders. Run seeders with the
|
|
6
|
+
* `db:seed` command.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* // database/seeders/DatabaseSeeder.ts
|
|
11
|
+
* export class DatabaseSeeder extends Seeder {
|
|
12
|
+
* async run(): Promise<void> {
|
|
13
|
+
* await this.call([UserSeeder, PostSeeder]);
|
|
14
|
+
* }
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* export class UserSeeder extends Seeder {
|
|
18
|
+
* async run(): Promise<void> {
|
|
19
|
+
* await User.factory().count(10).create();
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export abstract class Seeder {
|
|
25
|
+
/** Seed logic for this seeder. Implemented by subclasses. */
|
|
26
|
+
abstract run(): Promise<void>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run other seeder classes in sequence inside a single DB transaction.
|
|
30
|
+
* If any seeder throws, all changes are rolled back atomically.
|
|
31
|
+
* Order matters — seed dependencies before dependents (users before posts).
|
|
32
|
+
*
|
|
33
|
+
* @param seeders - Seeder classes to instantiate and run, in order.
|
|
34
|
+
* @returns Resolves once every seeder has run and the transaction commits.
|
|
35
|
+
*/
|
|
36
|
+
async call(seeders: (new () => Seeder)[]): Promise<void> {
|
|
37
|
+
const execute = async (): Promise<void> => {
|
|
38
|
+
for (const SeederClass of seeders) {
|
|
39
|
+
const instance = new SeederClass();
|
|
40
|
+
await instance.run();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
await DB.transaction(execute);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared identifier-case helpers — the ORM's one camel/snake implementation.
|
|
3
|
+
*
|
|
4
|
+
* Before this module existed there were five copies across three files, with
|
|
5
|
+
* two different camel regexes. The divergence was live: `_keysetValue` used
|
|
6
|
+
* `/_([a-z0-9])/g` while hydration used `/_([a-z])/g`, so for a column like
|
|
7
|
+
* `user_2fa` the keyset lookup asked for `user2fa` on an instance whose
|
|
8
|
+
* property was `user_2fa` — the cursor encoded `undefined` and keyset
|
|
9
|
+
* pagination restarted from the top.
|
|
10
|
+
*
|
|
11
|
+
* The letters-only regex is canonical **because hydration uses it**: every
|
|
12
|
+
* helper that reads a property off a hydrated instance must derive the same
|
|
13
|
+
* name hydration produced.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// DB schemas are static: the same names appear on every row. Caching the regex
|
|
17
|
+
// result turns thousands of executions into Map lookups after the first query.
|
|
18
|
+
const _camelCache = new Map<string, string>();
|
|
19
|
+
const _snakeCache = new Map<string, string>();
|
|
20
|
+
|
|
21
|
+
/** snake_case → camelCase, memoized. Matches BaseModel hydration exactly. */
|
|
22
|
+
export function toCamelKey(s: string): string {
|
|
23
|
+
let v = _camelCache.get(s);
|
|
24
|
+
if (v === undefined) {
|
|
25
|
+
v = s.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
26
|
+
_camelCache.set(s, v);
|
|
27
|
+
}
|
|
28
|
+
return v;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Convert a model property name to its database column name (camelCase →
|
|
33
|
+
* snake_case), mirroring the convention BaseModel uses on read/write. Raw
|
|
34
|
+
* expressions (anything beyond identifier/dot characters) and already-snake
|
|
35
|
+
* names pass through unchanged, so it is safe and idempotent to apply to any
|
|
36
|
+
* caller-supplied column — qualified `table.createdAt` resolves cleanly too.
|
|
37
|
+
*/
|
|
38
|
+
export function toSnakeColumn(s: string): string {
|
|
39
|
+
if (/[^\w.]/.test(s)) return s; // raw expression / function call — leave as-is
|
|
40
|
+
let v = _snakeCache.get(s);
|
|
41
|
+
if (v === undefined) {
|
|
42
|
+
v = s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
43
|
+
_snakeCache.set(s, v);
|
|
44
|
+
}
|
|
45
|
+
return v;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The constructor prototype chain of `ctor`, base-first — so a subclass's
|
|
50
|
+
* entries override its parents' when merged in order. Terminates on
|
|
51
|
+
* `Function.prototype` (not a falsy `.name`) so anonymous mixin classes are
|
|
52
|
+
* still visited. Shared by cast collection and global-scope merging.
|
|
53
|
+
*/
|
|
54
|
+
export function ctorChain(ctor: Function): Function[] {
|
|
55
|
+
const chain: Function[] = [];
|
|
56
|
+
let current: Function | null = ctor;
|
|
57
|
+
while (current && current !== Function.prototype) {
|
|
58
|
+
chain.unshift(current);
|
|
59
|
+
current = Object.getPrototypeOf(current) as Function | null;
|
|
60
|
+
}
|
|
61
|
+
return chain;
|
|
62
|
+
}
|