@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
package/src/config.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { deepMerge } from "@zerotal/core";
|
|
2
|
+
import type { ConfigValidator, ConfigIssue } from "@zerotal/core/config";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shape of the `database` config namespace produced by {@link DatabaseConfig}.
|
|
6
|
+
* Consumed by {@link DatabaseProvider} to build the connection, read replicas,
|
|
7
|
+
* pool, and auto-sync behaviour.
|
|
8
|
+
*/
|
|
9
|
+
export interface DatabaseConfigShape {
|
|
10
|
+
/** Database driver. Default: 'sqlite' */
|
|
11
|
+
driver: "sqlite" | "postgres" | "mysql";
|
|
12
|
+
/** Connection URL. Default: './database/db.sqlite' */
|
|
13
|
+
url: string;
|
|
14
|
+
/**
|
|
15
|
+
* Read-replica connection URLs.
|
|
16
|
+
*
|
|
17
|
+
* When one or more replicas are configured, the ORM automatically routes
|
|
18
|
+
* SELECT / WITH / EXPLAIN queries to replicas (round-robin) and all
|
|
19
|
+
* mutating queries (INSERT, UPDATE, DELETE, DDL) plus transactions to the
|
|
20
|
+
* primary. No code changes are required in controllers or models.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* replicas: [
|
|
24
|
+
* env('REPLICA_1_URL'),
|
|
25
|
+
* env('REPLICA_2_URL'),
|
|
26
|
+
* ]
|
|
27
|
+
*/
|
|
28
|
+
replicas?: string[];
|
|
29
|
+
/**
|
|
30
|
+
* Connection pool options (PostgreSQL and MySQL only).
|
|
31
|
+
* Bun.sql manages the pool automatically - these tune its behaviour.
|
|
32
|
+
*/
|
|
33
|
+
pool?: {
|
|
34
|
+
/** Maximum number of connections in the pool. Default: 10 */
|
|
35
|
+
max?: number;
|
|
36
|
+
/** Seconds an idle connection is kept before being closed. Default: 30 */
|
|
37
|
+
idleTimeout?: number;
|
|
38
|
+
};
|
|
39
|
+
/** SQLite-specific options */
|
|
40
|
+
sqlite: {
|
|
41
|
+
/** Path to the SQLite file. Use ':memory:' for in-memory database. */
|
|
42
|
+
path: string;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Auto-sync the schema to your models at boot (TypeORM-style). Opt-in, and
|
|
46
|
+
* hard-off in production regardless of this value.
|
|
47
|
+
*
|
|
48
|
+
* - `false` (default): never sync; use generated migrations.
|
|
49
|
+
* - `true`: additive sync - create missing tables, add missing columns. Never drops.
|
|
50
|
+
* - `{ enabled, disruptive }`: set `disruptive: true` to also DROP columns that no
|
|
51
|
+
* model declares anymore (destroys their data - local/test only).
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* synchronize: env("APP_ENV") !== "production" // additive
|
|
55
|
+
* synchronize: { enabled: true, disruptive: false } // explicit, additive
|
|
56
|
+
* synchronize: { enabled: true, disruptive: true } // also drops removed columns
|
|
57
|
+
*/
|
|
58
|
+
synchronize?: boolean | { enabled: boolean; disruptive?: boolean };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const defaults: DatabaseConfigShape = {
|
|
62
|
+
driver: "sqlite",
|
|
63
|
+
url: "./database/db.sqlite",
|
|
64
|
+
sqlite: { path: "./database/db.sqlite" },
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Create a typed database configuration object with defaults.
|
|
69
|
+
*
|
|
70
|
+
* IMPORTANT: For SQLite, do NOT use a 'sqlite://' protocol prefix.
|
|
71
|
+
* Bun's native SQLite driver expects a raw file path or ':memory:'.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* import { DatabaseConfig } from '@zerotal/orm';
|
|
75
|
+
* export default DatabaseConfig({
|
|
76
|
+
* driver: 'sqlite',
|
|
77
|
+
* url: env('DATABASE_URL', './database/db.sqlite'),
|
|
78
|
+
* });
|
|
79
|
+
*/
|
|
80
|
+
export function DatabaseConfig(options: Partial<DatabaseConfigShape> = {}): DatabaseConfigShape {
|
|
81
|
+
return deepMerge(defaults, options);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const DRIVERS = new Set<string>(["sqlite", "postgres", "mysql"]);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Validate the `database` config namespace at boot. Catches driver/URL protocol
|
|
88
|
+
* mismatches in any environment (they can never work), and flags
|
|
89
|
+
* production-specific hazards — an in-memory database that vanishes on restart,
|
|
90
|
+
* a `synchronize` flag that is silently ignored — as warnings. Registered by
|
|
91
|
+
* {@link DatabaseProvider} via `app.registerConfigValidator("database", …)`.
|
|
92
|
+
*/
|
|
93
|
+
export const validateDatabaseConfig: ConfigValidator = (value, { isProduction }) => {
|
|
94
|
+
const cfg = value as Partial<DatabaseConfigShape> | undefined;
|
|
95
|
+
const issues: ConfigIssue[] = [];
|
|
96
|
+
const driver = cfg?.driver ?? "sqlite";
|
|
97
|
+
const url = cfg?.url ?? "";
|
|
98
|
+
|
|
99
|
+
if (!DRIVERS.has(driver)) {
|
|
100
|
+
issues.push({
|
|
101
|
+
level: "error",
|
|
102
|
+
message: `database.driver "${driver}" is not a supported dialect — use "sqlite", "postgres", or "mysql".`,
|
|
103
|
+
});
|
|
104
|
+
return issues;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const looksPostgres = /^postgres(ql)?:\/\//.test(url);
|
|
108
|
+
const looksMysql = /^mysql2?:\/\//.test(url);
|
|
109
|
+
if (driver === "postgres" && url && !looksPostgres) {
|
|
110
|
+
issues.push({
|
|
111
|
+
level: "error",
|
|
112
|
+
message:
|
|
113
|
+
`database.driver is "postgres" but database.url does not start with postgres:// — ` +
|
|
114
|
+
`set DATABASE_URL to a PostgreSQL connection URL.`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
if (driver === "mysql" && url && !looksMysql) {
|
|
118
|
+
issues.push({
|
|
119
|
+
level: "error",
|
|
120
|
+
message:
|
|
121
|
+
`database.driver is "mysql" but database.url does not start with mysql:// — ` +
|
|
122
|
+
`set DATABASE_URL to a MySQL connection URL.`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (driver === "sqlite" && (looksPostgres || looksMysql)) {
|
|
126
|
+
issues.push({
|
|
127
|
+
level: "error",
|
|
128
|
+
message:
|
|
129
|
+
`database.driver is "sqlite" but database.url points at a network database — ` +
|
|
130
|
+
`set driver to match the URL, or point url at a file path.`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (isProduction) {
|
|
135
|
+
if (driver === "sqlite" && (url === ":memory:" || cfg?.sqlite?.path === ":memory:")) {
|
|
136
|
+
issues.push({
|
|
137
|
+
level: "warning",
|
|
138
|
+
message:
|
|
139
|
+
"database uses an in-memory SQLite store in production — every restart loses all data. " +
|
|
140
|
+
"Point database.url at a file path or a network database.",
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const sync = cfg?.synchronize;
|
|
144
|
+
if (sync === true || (typeof sync === "object" && sync !== null && sync.enabled)) {
|
|
145
|
+
issues.push({
|
|
146
|
+
level: "warning",
|
|
147
|
+
message:
|
|
148
|
+
"database.synchronize is enabled but auto-sync is hard-off in production — " +
|
|
149
|
+
"the flag does nothing here. Ship schema changes as migrations.",
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const replicas = cfg?.replicas ?? [];
|
|
155
|
+
if (replicas.length > 0) {
|
|
156
|
+
if (driver === "sqlite") {
|
|
157
|
+
issues.push({
|
|
158
|
+
level: "warning",
|
|
159
|
+
message:
|
|
160
|
+
"database.replicas is set with the sqlite driver — read/write splitting is for " +
|
|
161
|
+
"network databases; the replica list adds no redundancy to a local file.",
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (replicas.some((r) => typeof r !== "string" || r.length === 0)) {
|
|
165
|
+
issues.push({
|
|
166
|
+
level: "error",
|
|
167
|
+
message:
|
|
168
|
+
"database.replicas contains an empty entry — an unset environment variable is the " +
|
|
169
|
+
"usual culprit. Every replica must be a full connection URL.",
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return issues;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Register this package's config namespace for typed `config()` dot-paths.
|
|
178
|
+
declare module "@zerotal/core" {
|
|
179
|
+
interface ConfigRegistry {
|
|
180
|
+
database: DatabaseConfigShape;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ConcernDescriptor } from "@zerotal/core";
|
|
2
|
+
import { tableNameFor } from "@zerotal/core";
|
|
3
|
+
import { BaseModel } from "./model/BaseModel.ts";
|
|
4
|
+
import { registerModel, modelByName } from "./model/decorators/_metadata.ts";
|
|
5
|
+
import { frameworkLog } from "@zerotal/core/logger";
|
|
6
|
+
|
|
7
|
+
function isModelClass(v: unknown): boolean {
|
|
8
|
+
return (
|
|
9
|
+
typeof v === "function" &&
|
|
10
|
+
v !== BaseModel &&
|
|
11
|
+
(v as { prototype?: unknown }).prototype instanceof BaseModel
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `app/models/` — every `BaseModel` subclass is registered: columns drained, table name
|
|
17
|
+
* derived by convention (unless `@table`/`static table` set one), indexed by class name.
|
|
18
|
+
* Runs first (order 10) so observers/policies can resolve their target model by name.
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
export const modelsConcern: ConcernDescriptor = {
|
|
22
|
+
name: "models",
|
|
23
|
+
order: 10,
|
|
24
|
+
dir: "app/models",
|
|
25
|
+
register(mod) {
|
|
26
|
+
for (const exported of Object.values(mod)) {
|
|
27
|
+
if (!isModelClass(exported)) continue;
|
|
28
|
+
const Model = exported as unknown as { name: string; table?: string };
|
|
29
|
+
registerModel(Model as unknown as Function);
|
|
30
|
+
// Convention table name — explicit @table / static table always wins.
|
|
31
|
+
if (!Model.table) Model.table = tableNameFor(Model.name);
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `app/observers/` — `XObserver` is attached to model `X` (strip the `Observer` suffix and
|
|
38
|
+
* look it up in the model registry). Override with `static model = SomeModel`.
|
|
39
|
+
* @internal
|
|
40
|
+
*/
|
|
41
|
+
export const observersConcern: ConcernDescriptor = {
|
|
42
|
+
name: "observers",
|
|
43
|
+
order: 20,
|
|
44
|
+
dir: "app/observers",
|
|
45
|
+
register(mod) {
|
|
46
|
+
for (const exported of Object.values(mod)) {
|
|
47
|
+
if (typeof exported !== "function") continue;
|
|
48
|
+
const cls = exported as { name: string; model?: unknown };
|
|
49
|
+
if (cls.model === undefined && !/Observer$/.test(cls.name)) continue;
|
|
50
|
+
|
|
51
|
+
let model = cls.model as typeof BaseModel | undefined;
|
|
52
|
+
if (!model)
|
|
53
|
+
model = modelByName(cls.name.replace(/Observer$/, "")) as typeof BaseModel | undefined;
|
|
54
|
+
if (!model) {
|
|
55
|
+
frameworkLog("orm").warn(`Observer "${cls.name}": no matching model found; skipped`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
model.observe(exported as never);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The ORM's convention concerns, registered by {@link DatabaseProvider} in order.
|
|
65
|
+
* @internal
|
|
66
|
+
*/
|
|
67
|
+
export const ormConcerns: ConcernDescriptor[] = [modelsConcern, observersConcern];
|