@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
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];