@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +170 -0
  4. package/package.json +58 -0
  5. package/src/casts/Cast.ts +200 -0
  6. package/src/commands/DbSeedCommand.ts +71 -0
  7. package/src/commands/MakeFactoryCommand.ts +59 -0
  8. package/src/commands/MakeMigrationCommand.ts +109 -0
  9. package/src/commands/MakeModelCommand.ts +83 -0
  10. package/src/commands/MakeSeederCommand.ts +50 -0
  11. package/src/commands/MigrateCommand.ts +60 -0
  12. package/src/commands/MigrateFreshCommand.ts +41 -0
  13. package/src/commands/MigrateGenerateCommand.ts +110 -0
  14. package/src/commands/MigrateRollbackCommand.ts +43 -0
  15. package/src/commands/MigrateStatusCommand.ts +49 -0
  16. package/src/commands/_loadMigrations.ts +34 -0
  17. package/src/commands/index.ts +30 -0
  18. package/src/config.ts +182 -0
  19. package/src/conventions.ts +67 -0
  20. package/src/db/DB.ts +486 -0
  21. package/src/db/NPlusOneDetector.ts +176 -0
  22. package/src/db/QueryBuilder.ts +2458 -0
  23. package/src/db/ReadWriteRouter.ts +96 -0
  24. package/src/db/TransactionContext.ts +13 -0
  25. package/src/db/dialects/MysqlDialect.ts +57 -0
  26. package/src/db/dialects/PostgresDialect.ts +55 -0
  27. package/src/db/dialects/SqliteDialect.ts +54 -0
  28. package/src/db/dialects/index.ts +25 -0
  29. package/src/db/dialects/types.ts +67 -0
  30. package/src/db/resolver.ts +30 -0
  31. package/src/db/sql-types.ts +12 -0
  32. package/src/db/types.ts +296 -0
  33. package/src/errors/MassAssignmentError.ts +25 -0
  34. package/src/errors/MigrationError.ts +18 -0
  35. package/src/errors/ModelNotFoundError.ts +21 -0
  36. package/src/errors/NPlusOneError.ts +6 -0
  37. package/src/errors/RelationNotLoadedError.ts +19 -0
  38. package/src/errors/StateError.ts +18 -0
  39. package/src/errors/TransactionError.ts +13 -0
  40. package/src/errors/UnsupportedDialectError.ts +18 -0
  41. package/src/errors/index.ts +7 -0
  42. package/src/events.ts +112 -0
  43. package/src/global.d.ts +17 -0
  44. package/src/implicitBinding.ts +73 -0
  45. package/src/index.ts +255 -0
  46. package/src/model/BaseModel.ts +2499 -0
  47. package/src/model/ModelQueryBuilder.ts +1808 -0
  48. package/src/model/Observer.ts +73 -0
  49. package/src/model/OrmContext.ts +71 -0
  50. package/src/model/ReactiveProxy.ts +53 -0
  51. package/src/model/SoftDeletes.ts +108 -0
  52. package/src/model/State.ts +290 -0
  53. package/src/model/decorators/_metadata.ts +211 -0
  54. package/src/model/decorators/_registerRelation.ts +20 -0
  55. package/src/model/decorators/belongsTo.ts +38 -0
  56. package/src/model/decorators/column.ts +278 -0
  57. package/src/model/decorators/hasMany.ts +34 -0
  58. package/src/model/decorators/hasManyThrough.ts +50 -0
  59. package/src/model/decorators/hasOne.ts +34 -0
  60. package/src/model/decorators/hasOneThrough.ts +40 -0
  61. package/src/model/decorators/manyToMany.ts +55 -0
  62. package/src/model/decorators/morphMany.ts +38 -0
  63. package/src/model/decorators/morphOne.ts +38 -0
  64. package/src/model/decorators/morphTo.ts +51 -0
  65. package/src/model/decorators/morphToMany.ts +49 -0
  66. package/src/model/decorators/morphedByMany.ts +46 -0
  67. package/src/model/decorators/table.ts +124 -0
  68. package/src/model/hooks/HookRegistry.ts +110 -0
  69. package/src/model/mixins.ts +536 -0
  70. package/src/model/payload.ts +114 -0
  71. package/src/model/relations/RelationRegistry.ts +184 -0
  72. package/src/observability.ts +210 -0
  73. package/src/provider/DatabaseProvider.ts +266 -0
  74. package/src/schema/Blueprint.ts +900 -0
  75. package/src/schema/ColumnDefinition.ts +517 -0
  76. package/src/schema/Migration.ts +34 -0
  77. package/src/schema/MigrationCodegen.ts +108 -0
  78. package/src/schema/MigrationRunner.ts +351 -0
  79. package/src/schema/ModelInspector.ts +133 -0
  80. package/src/schema/Schema.ts +140 -0
  81. package/src/schema/SchemaDiffer.ts +137 -0
  82. package/src/schema/SchemaInspector.ts +164 -0
  83. package/src/schema/__test_migrations__/001_create_test_table.ts +15 -0
  84. package/src/schema/autoMigrate.ts +154 -0
  85. package/src/schema/index.ts +28 -0
  86. package/src/seeding/Seeder.ts +46 -0
  87. package/src/support/identifiers.ts +62 -0
@@ -0,0 +1,96 @@
1
+ import type { SQLInstance } from "./sql-types.ts";
2
+ /**
3
+ * Transparent read/write routing for Bun SQL connections.
4
+ *
5
+ * `createReadWriteRouter()` returns a `SQLInstance`-compatible proxy that:
6
+ *
7
+ * - Routes **SELECT / WITH / EXPLAIN / PRAGMA / SHOW / DESCRIBE** → round-robin replica pool.
8
+ * - Routes **everything else** (INSERT, UPDATE, DELETE, DDL, transactions) → primary.
9
+ * - Delegates `begin()` (transactions) exclusively to the primary.
10
+ * - Closes all unique connections on `end()`.
11
+ *
12
+ * The returned value is structurally identical to `SQLInstance`, so it is a
13
+ * drop-in replacement everywhere a connection is expected — `DB.table()`,
14
+ * `BaseModel`, and `DB.raw()` all route automatically with no code changes.
15
+ *
16
+ * @param primary The writable primary/master connection.
17
+ * @param replicas One or more read-replica connections. Load-balanced round-robin.
18
+ * When empty, the primary is returned as-is (no overhead).
19
+ *
20
+ * @example
21
+ * const primary = new SQL(env('DATABASE_URL'));
22
+ * const replica = new SQL(env('REPLICA_URL'));
23
+ * const router = createReadWriteRouter(primary, [replica]);
24
+ *
25
+ * // Drop-in: pass router wherever you'd pass a SQL connection
26
+ * DB.table('users') // SELECT → replica, mutating → primary
27
+ */
28
+ export function createReadWriteRouter(primary: SQLInstance, replicas: SQLInstance[]): SQLInstance {
29
+ if (replicas.length === 0) return primary;
30
+
31
+ let _rr = 0;
32
+ const pool = replicas;
33
+
34
+ const handler: ProxyHandler<object> = {
35
+ // Intercepts the tagged-template function call: conn`SELECT ...`
36
+ apply(_target, _thisArg, args: unknown[]) {
37
+ const tpl = args[0] as TemplateStringsArray;
38
+ // Join ALL template fragments — locking clauses (FOR UPDATE / FOR SHARE)
39
+ // appear at the END of a SELECT, typically after binding placeholders,
40
+ // so classifying on the first fragment alone would miss them.
41
+ const sql = Array.isArray(tpl) ? tpl.join(" ") : "";
42
+ const conn = _isReadQuery(sql) ? pool[_rr++ % pool.length]! : primary;
43
+ return (conn as unknown as (...a: unknown[]) => unknown)(...args);
44
+ },
45
+
46
+ get(_target, prop: string | symbol) {
47
+ // Transactions always run on the primary
48
+ if (prop === "begin") {
49
+ return (primary as unknown as Record<string | symbol, unknown>)["begin"];
50
+ }
51
+
52
+ // On shutdown, close all unique connections
53
+ if (prop === "end") {
54
+ const unique = [...new Set([primary, ...pool])];
55
+ return () => Promise.all(unique.map((c) => c.end())).then(() => undefined);
56
+ }
57
+
58
+ // Escape hatch used by DB.onPrimary() to extract the underlying primary
59
+ if (prop === "__primary__") return primary;
60
+
61
+ // Delegate all other property access to the primary
62
+ return (primary as unknown as Record<string | symbol, unknown>)[prop];
63
+ },
64
+ };
65
+
66
+ // The proxy target must be a function so the `apply` trap fires on calls
67
+ return new Proxy(primary as unknown as object, handler) as unknown as SQLInstance;
68
+ }
69
+
70
+ /**
71
+ * Returns `true` for queries that are safe to run on a read replica.
72
+ * Strips leading block comments (`/* ... *\/`) and line comments (`-- ...`)
73
+ * before testing so that annotated SQL is classified correctly.
74
+ *
75
+ * Locking reads (`SELECT … FOR UPDATE / FOR SHARE / LOCK IN SHARE MODE`)
76
+ * are classified as writes — a row lock taken on a replica is useless, so
77
+ * they must reach the primary even outside a transaction.
78
+ *
79
+ * Anything not matched here is treated as a write and routed to the primary.
80
+ */
81
+ export function _isReadQuery(sql: string): boolean {
82
+ const stripped = sql
83
+ .replace(/\/\*[\s\S]*?\*\//g, "") // /* block comments */
84
+ .replace(/--[^\n]*/g, "") // -- line comments
85
+ .trimStart();
86
+ if (!/^(select|with\b|explain\b|pragma\b|show\b|describe\b)/i.test(stripped)) return false;
87
+ // Pessimistic-lock clauses defeat replication — route to the primary.
88
+ if (
89
+ /\bfor\s+(update|share|no\s+key\s+update|key\s+share)\b|\block\s+in\s+share\s+mode\b/i.test(
90
+ stripped,
91
+ )
92
+ ) {
93
+ return false;
94
+ }
95
+ return true;
96
+ }
@@ -0,0 +1,13 @@
1
+ import type { SQLInstance } from "./sql-types.ts";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+
4
+ /**
5
+ * AsyncLocalStorage that carries the active transaction connection through
6
+ * the call stack. Set by DB.transaction(); read by BaseModel and DB helpers.
7
+ *
8
+ * This is the correct propagation mechanism for transactions that originate
9
+ * outside a request context (console commands, scheduled jobs, seeders).
10
+ * For request-scoped transactions, ctx._transaction is also set for legacy
11
+ * callers, but TransactionContext is the authoritative source.
12
+ */
13
+ export const TransactionContext = new AsyncLocalStorage<SQLInstance>();
@@ -0,0 +1,57 @@
1
+ import type { DatePart, DialectQuery, SqlDialect } from "./types.ts";
2
+
3
+ /**
4
+ * MySQL strategy — INFORMATION_SCHEMA introspection, DAY()/MONTH()/YEAR()
5
+ * date parts, GET_LOCK()/RELEASE_LOCK() named advisory locks.
6
+ */
7
+ export class MysqlDialect implements SqlDialect {
8
+ readonly name = "mysql" as const;
9
+ readonly supportsAdvisoryLocks = true;
10
+
11
+ hasTableSql(table: string): DialectQuery {
12
+ return {
13
+ sql:
14
+ `SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES ` +
15
+ `WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`,
16
+ params: [table],
17
+ };
18
+ }
19
+
20
+ hasColumnSql(table: string, column: string): DialectQuery {
21
+ return {
22
+ sql:
23
+ `SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS ` +
24
+ `WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
25
+ params: [table, column],
26
+ };
27
+ }
28
+
29
+ dateExpr(part: DatePart, column: string): string {
30
+ switch (part) {
31
+ case "date":
32
+ return `DATE(${column})`;
33
+ case "time":
34
+ return `TIME(${column})`;
35
+ case "day":
36
+ return `DAY(${column})`;
37
+ case "month":
38
+ return `MONTH(${column})`;
39
+ case "year":
40
+ return `YEAR(${column})`;
41
+ }
42
+ }
43
+
44
+ // MySQL advisory locks are named — the numeric key maps to a namespaced
45
+ // string. Timeout -1 blocks until acquired (matching pg_advisory_lock).
46
+ autoIncrementColumn(column: string): string {
47
+ return `${column} INT AUTO_INCREMENT PRIMARY KEY`;
48
+ }
49
+
50
+ advisoryLockSql(key: number): DialectQuery {
51
+ return { sql: `SELECT GET_LOCK(?, -1)`, params: [`zerotal_lock_${key}`] };
52
+ }
53
+
54
+ advisoryUnlockSql(key: number): DialectQuery {
55
+ return { sql: `SELECT RELEASE_LOCK(?)`, params: [`zerotal_lock_${key}`] };
56
+ }
57
+ }
@@ -0,0 +1,55 @@
1
+ import type { DatePart, DialectQuery, SqlDialect } from "./types.ts";
2
+
3
+ /**
4
+ * PostgreSQL strategy — information_schema introspection, EXTRACT()/casts for
5
+ * date parts, pg_advisory_lock for advisory locks.
6
+ */
7
+ export class PostgresDialect implements SqlDialect {
8
+ readonly name = "postgres" as const;
9
+ readonly supportsAdvisoryLocks = true;
10
+
11
+ hasTableSql(table: string): DialectQuery {
12
+ return {
13
+ sql: `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = ?`,
14
+ params: [table],
15
+ };
16
+ }
17
+
18
+ hasColumnSql(table: string, column: string): DialectQuery {
19
+ return {
20
+ sql:
21
+ `SELECT column_name FROM information_schema.columns ` +
22
+ `WHERE table_schema = 'public' AND table_name = ? AND column_name = ?`,
23
+ params: [table, column],
24
+ };
25
+ }
26
+
27
+ dateExpr(part: DatePart, column: string): string {
28
+ switch (part) {
29
+ case "date":
30
+ return `CAST(${column} AS date)`;
31
+ case "time":
32
+ return `CAST(${column} AS time)`;
33
+ case "day":
34
+ return `CAST(EXTRACT(DAY FROM ${column}) AS integer)`;
35
+ case "month":
36
+ return `CAST(EXTRACT(MONTH FROM ${column}) AS integer)`;
37
+ case "year":
38
+ return `CAST(EXTRACT(YEAR FROM ${column}) AS integer)`;
39
+ }
40
+ }
41
+
42
+ autoIncrementColumn(column: string): string {
43
+ // GENERATED ALWAYS AS IDENTITY is the SQL-standard form serial has been soft-deprecated
44
+ // in favour of since PostgreSQL 10.
45
+ return `${column} INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY`;
46
+ }
47
+
48
+ advisoryLockSql(key: number): DialectQuery {
49
+ return { sql: `SELECT pg_advisory_lock(?)`, params: [key] };
50
+ }
51
+
52
+ advisoryUnlockSql(key: number): DialectQuery {
53
+ return { sql: `SELECT pg_advisory_unlock(?)`, params: [key] };
54
+ }
55
+ }
@@ -0,0 +1,54 @@
1
+ import type { DatePart, DialectQuery, SqlDialect } from "./types.ts";
2
+
3
+ /**
4
+ * SQLite strategy — sqlite_master / pragma_table_info introspection,
5
+ * strftime() date parts, no advisory-lock primitive.
6
+ */
7
+ export class SqliteDialect implements SqlDialect {
8
+ readonly name = "sqlite" as const;
9
+ readonly supportsAdvisoryLocks = false;
10
+
11
+ hasTableSql(table: string): DialectQuery {
12
+ return {
13
+ sql: `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`,
14
+ params: [table],
15
+ };
16
+ }
17
+
18
+ hasColumnSql(table: string, column: string): DialectQuery {
19
+ // pragma_table_info() is the table-valued form of PRAGMA table_info —
20
+ // unlike the PRAGMA it accepts bound parameters, so the table name never
21
+ // needs to be inlined into the SQL.
22
+ return {
23
+ sql: `SELECT name FROM pragma_table_info(?) WHERE name = ?`,
24
+ params: [table, column],
25
+ };
26
+ }
27
+
28
+ dateExpr(part: DatePart, column: string): string {
29
+ switch (part) {
30
+ case "date":
31
+ return `date(${column})`;
32
+ case "time":
33
+ return `time(${column})`;
34
+ case "day":
35
+ return `cast(strftime('%d', ${column}) as integer)`;
36
+ case "month":
37
+ return `cast(strftime('%m', ${column}) as integer)`;
38
+ case "year":
39
+ return `cast(strftime('%Y', ${column}) as integer)`;
40
+ }
41
+ }
42
+
43
+ autoIncrementColumn(column: string): string {
44
+ return `${column} INTEGER PRIMARY KEY AUTOINCREMENT`;
45
+ }
46
+
47
+ advisoryLockSql(): DialectQuery | null {
48
+ return null;
49
+ }
50
+
51
+ advisoryUnlockSql(): DialectQuery | null {
52
+ return null;
53
+ }
54
+ }
@@ -0,0 +1,25 @@
1
+ import { SqliteDialect } from "./SqliteDialect.ts";
2
+ import { PostgresDialect } from "./PostgresDialect.ts";
3
+ import { MysqlDialect } from "./MysqlDialect.ts";
4
+ import type { DialectName, SqlDialect } from "./types.ts";
5
+
6
+ const _dialects: Record<DialectName, SqlDialect> = {
7
+ sqlite: new SqliteDialect(),
8
+ postgres: new PostgresDialect(),
9
+ mysql: new MysqlDialect(),
10
+ };
11
+
12
+ /**
13
+ * Resolve the SQL strategy object for a dialect name.
14
+ *
15
+ * @example
16
+ * getDialect(dialectFor(conn)).dateExpr('day', 'created_at')
17
+ */
18
+ export function getDialect(name: DialectName): SqlDialect {
19
+ return _dialects[name];
20
+ }
21
+
22
+ export { SqliteDialect } from "./SqliteDialect.ts";
23
+ export { PostgresDialect } from "./PostgresDialect.ts";
24
+ export { MysqlDialect } from "./MysqlDialect.ts";
25
+ export type { SqlDialect, DialectName, DialectQuery, DatePart } from "./types.ts";
@@ -0,0 +1,67 @@
1
+ // ── Dialect strategy contract ─────────────────────────────────────────────────
2
+ //
3
+ // The ORM is dialect-light, but a handful of SQL constructs genuinely differ
4
+ // across engines: schema introspection (hasTable / hasColumn), date-part
5
+ // extraction (strftime vs EXTRACT vs DAY()/MONTH()/YEAR()) and advisory locks.
6
+ // Each engine implements this interface once; QueryBuilder, Schema and DB
7
+ // consult the active dialect instead of hard-coding SQLite syntax behind the
8
+ // multi-dialect facade.
9
+
10
+ /** Supported database engines. Mirrors `Dialect` in QueryBuilder.ts. */
11
+ export type DialectName = "sqlite" | "postgres" | "mysql";
12
+
13
+ /** A parameterised statement: `sql` uses `?` placeholders bound from `params`. */
14
+ export interface DialectQuery {
15
+ sql: string;
16
+ params: unknown[];
17
+ }
18
+
19
+ /** The date component extracted by whereDate/whereTime/whereDay/whereMonth/whereYear. */
20
+ export type DatePart = "date" | "time" | "day" | "month" | "year";
21
+
22
+ /**
23
+ * Per-engine SQL strategy.
24
+ *
25
+ * @example
26
+ * const d = getDialect("postgres");
27
+ * const { sql, params } = d.hasTableSql("users");
28
+ */
29
+ export interface SqlDialect {
30
+ readonly name: DialectName;
31
+
32
+ /** Introspection query returning at least one row when `table` exists. */
33
+ hasTableSql(table: string): DialectQuery;
34
+
35
+ /** Introspection query returning at least one row when `column` exists on `table`. */
36
+ hasColumnSql(table: string, column: string): DialectQuery;
37
+
38
+ /**
39
+ * SQL expression extracting a date part from `column`.
40
+ * The caller must validate `column` as a safe identifier first — the
41
+ * expression is interpolated verbatim.
42
+ */
43
+ dateExpr(part: DatePart, column: string): string;
44
+
45
+ /**
46
+ * The column definition for an auto-incrementing integer primary key.
47
+ *
48
+ * Every engine spells this differently and none of them accept SQLite's
49
+ * `INTEGER PRIMARY KEY AUTOINCREMENT`: PostgreSQL wants a serial/identity type, MySQL
50
+ * wants `AUTO_INCREMENT`. Hard-coding the SQLite form meant the *first* `migrate` against
51
+ * PostgreSQL died on a syntax error and MySQL on error 1064 — before any of the
52
+ * dialect-aware `hasTable`/`ALTER` handling downstream got a chance to matter.
53
+ *
54
+ * @param column - Already-validated column name.
55
+ * @returns The full column fragment, type and constraints included.
56
+ */
57
+ autoIncrementColumn(column: string): string;
58
+
59
+ /** Whether the engine supports application-level advisory locks. */
60
+ readonly supportsAdvisoryLocks: boolean;
61
+
62
+ /** Statement acquiring an advisory lock (blocking), or null when unsupported. */
63
+ advisoryLockSql(key: number): DialectQuery | null;
64
+
65
+ /** Statement releasing an advisory lock, or null when unsupported. */
66
+ advisoryUnlockSql(key: number): DialectQuery | null;
67
+ }
@@ -0,0 +1,30 @@
1
+ import type { SQLInstance } from "./sql-types.ts";
2
+
3
+ /**
4
+ * Decouples the ORM from the DI container: a function that returns the active
5
+ * base connection (or `undefined` if none is registered yet).
6
+ */
7
+ type ConnectionResolver = () => SQLInstance | undefined;
8
+
9
+ let _resolver: ConnectionResolver | null = null;
10
+
11
+ /**
12
+ * Register the callback used to look up the base connection from the container.
13
+ * Pass `null` to clear it. Set once by the `DatabaseProvider` at boot.
14
+ */
15
+ export function setConnectionResolver(fn: ConnectionResolver | null): void {
16
+ _resolver = fn;
17
+ }
18
+
19
+ /**
20
+ * Resolve the container's base connection via the registered resolver, swallowing
21
+ * any resolver error. Returns `undefined` when no resolver is set or it fails.
22
+ */
23
+ export function resolveContainerConnection(): SQLInstance | undefined {
24
+ if (!_resolver) return undefined;
25
+ try {
26
+ return _resolver();
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The shape of a Bun SQL connection as this ORM uses it: a callable tagged-template
3
+ * that runs a query, plus `begin()` for transactions and `end()` to close.
4
+ *
5
+ * Exported (rather than an ambient global) so consumers that pull this package's
6
+ * source into their type program resolve it through the import graph.
7
+ */
8
+ export interface SQLInstance {
9
+ <T = Record<string, unknown>>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T[]>;
10
+ begin<T>(fn: (tx: SQLInstance) => Promise<T>): Promise<T>;
11
+ end(): Promise<void>;
12
+ }