@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,211 @@
1
+ // ── Decorator registration core (Bun 1.3.x standard-decorator workaround) ─────
2
+ //
3
+ // Bun 1.3.x mis-compiles standard TC39 field decorators: deferred work is corrupted
4
+ // and shared across every class defined in the same file. Specifically —
5
+ // • field initializers and field `addInitializer` callbacks are cross-wired (a class
6
+ // runs another class's initializers, and names captured in their closures resolve
7
+ // to the LAST class defined in the file);
8
+ // • `context.metadata` is a single object shared by every class in the file;
9
+ // • `Symbol.metadata` is never assigned to the class.
10
+ //
11
+ // The ONE thing Bun compiles correctly is the decorator BODY: it runs synchronously at
12
+ // class-definition time with the correct `context.name`, but without a reference to the
13
+ // class. So each field/relation decorator captures its name+config in the body and
14
+ // ENQUEUES a registration closure. The `@table` class decorator runs synchronously right
15
+ // after a class's members — and it DOES receive the class — so it drains the queue into
16
+ // the concrete class. Result: every model anchors its columns/relations via `@table`.
17
+
18
+ import { relationRegistry } from "../relations/RelationRegistry.ts";
19
+ import type { RelationMetadata } from "../relations/RelationRegistry.ts";
20
+ import type { ColumnOptions } from "./column.ts";
21
+
22
+ /**
23
+ * Per-class OWN column definitions. Readers walk the prototype chain to merge inherited.
24
+ * @internal
25
+ */
26
+ export const columnRegistry = new Map<Function, Map<string, ColumnOptions>>();
27
+
28
+ // ── Definition-time queue ─────────────────────────────────────────────────────
29
+
30
+ interface PendingMember {
31
+ /** Field name (captured correctly in the decorator body). */
32
+ name: string;
33
+ apply: (ctor: Function) => void;
34
+ }
35
+ let _pending: PendingMember[] = [];
36
+
37
+ /**
38
+ * Enqueue a member registration from a decorator body (name captured correctly there).
39
+ * @internal
40
+ */
41
+ export function enqueueMember(name: string, apply: (ctor: Function) => void): void {
42
+ _pending.push({ name, apply });
43
+ }
44
+
45
+ /**
46
+ * Drain queued member registrations into `ctor`. Called by the `@table` class decorator,
47
+ * which runs synchronously immediately after the class's member decorators — so the queue
48
+ * contains exactly that class's members and nothing else.
49
+ * @internal
50
+ */
51
+ export function drainPendingMembers(ctor: Function): void {
52
+ if (_pending.length === 0) return;
53
+ const batch = _pending;
54
+ _pending = [];
55
+ for (const e of batch) e.apply(ctor);
56
+ registerModelName(ctor);
57
+ }
58
+
59
+ // ── Per-class registration (invoked from drained closures) ────────────────────
60
+
61
+ /**
62
+ * Record a column definition for `ctor`, and mirror any `cast` onto the class's own
63
+ * `static casts` map (seeded from the parent so a subclass extends rather than mutates it).
64
+ * @internal
65
+ */
66
+ export function registerColumn(ctor: Function, name: string, options: ColumnOptions): void {
67
+ let m = columnRegistry.get(ctor);
68
+ if (!m) {
69
+ m = new Map();
70
+ columnRegistry.set(ctor, m);
71
+ }
72
+ m.set(name, options);
73
+
74
+ if (options.cast) {
75
+ const c = ctor as { casts?: Record<string, ColumnOptions["cast"]> };
76
+ // Give the class its OWN casts object (seeded from the parent) the first time we add
77
+ // to it, so a subclass extends rather than mutates the parent's casts.
78
+ if (!Object.prototype.hasOwnProperty.call(ctor, "casts")) {
79
+ const parent = (
80
+ Object.getPrototypeOf(ctor) as { casts?: Record<string, ColumnOptions["cast"]> }
81
+ )?.casts;
82
+ c.casts = { ...(parent ?? {}) };
83
+ }
84
+ c.casts![name] = options.cast;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Record relation metadata for `ctor`. Invoked from a drained decorator closure.
90
+ * @internal
91
+ */
92
+ export function registerRelation(ctor: Function, name: string, meta: RelationMetadata): void {
93
+ let m = relationRegistry.get(ctor);
94
+ if (!m) {
95
+ m = new Map();
96
+ relationRegistry.set(ctor, m);
97
+ }
98
+ m.set(name, meta);
99
+ }
100
+
101
+ // ── Convention registration (used by the auto-discovery loader) ───────────────
102
+
103
+ const _registeredModels = new WeakSet<Function>();
104
+ /**
105
+ * class name → model class, for observer/policy association by name.
106
+ * @internal
107
+ */
108
+ export const modelsByName = new Map<string, Function>();
109
+
110
+ /**
111
+ * Look up a model class by its (unqualified) class name.
112
+ * @internal
113
+ */
114
+ export function modelByName(name: string): Function | undefined {
115
+ return modelsByName.get(name);
116
+ }
117
+
118
+ /** Index a model class under its name. Called by @table's drain and by registerModel(). */
119
+ function registerModelName(ctor: Function): void {
120
+ const name = (ctor as { name?: string }).name;
121
+ if (name) modelsByName.set(name, ctor);
122
+ }
123
+
124
+ /**
125
+ * Register a model class discovered by convention — the loader calls this for every class
126
+ * in `app/models/`, so `@table` is not required:
127
+ * - drains the class's buffered @column/@relation entries by matching a probe instance's
128
+ * own field names (Bun's deferred-decorator bug prevents binding name→class at definition
129
+ * without an anchor; the loader IS the anchor, and the probe reveals which buffered
130
+ * entries belong to this class — so convention models must use real fields, e.g.
131
+ * `@column() name!: string`, not `@column() declare name: string`);
132
+ * - indexes it under `modelsByName`.
133
+ *
134
+ * The convention table name is applied separately by the loader's models concern (which has
135
+ * the inflector); explicit `@table("...")` / `static table` always wins. Idempotent and a
136
+ * safe no-op on already-`@table`'d models.
137
+ * @internal
138
+ */
139
+ export function registerModel(ctor: Function): void {
140
+ if (_registeredModels.has(ctor)) return;
141
+ _registeredModels.add(ctor);
142
+
143
+ if (_pending.length > 0) {
144
+ let own = new Set<string>();
145
+ try {
146
+ own = new Set(Object.keys(new (ctor as new () => object)()));
147
+ } catch {
148
+ /* not constructible without args — can't claim columns by probe */
149
+ }
150
+ if (own.size) {
151
+ // A class's field decorators run consecutively at definition, so its buffered
152
+ // entries form a contiguous run; claim from the first own-matching entry until the
153
+ // first entry that belongs to another class.
154
+ const start = _pending.findIndex((e) => own.has(e.name));
155
+ if (start !== -1) {
156
+ let end = start;
157
+ while (end < _pending.length && own.has(_pending[end]!.name)) end++;
158
+ const claimed = _pending.splice(start, end - start);
159
+ for (const e of claimed) e.apply(ctor);
160
+ }
161
+ }
162
+ }
163
+
164
+ registerModelName(ctor);
165
+ }
166
+
167
+ // ── Readers (prototype-chain merge; child wins) ───────────────────────────────
168
+
169
+ /**
170
+ * Merged column definitions (own + inherited) for a class, or null if none.
171
+ * @internal
172
+ */
173
+ export function columnsFor(ctor: Function): Map<string, ColumnOptions> | null {
174
+ const merged = new Map<string, ColumnOptions>();
175
+ let cls: Function | null = ctor;
176
+ while (cls && cls !== Function.prototype) {
177
+ const c = columnRegistry.get(cls);
178
+ if (c) for (const [k, v] of c) if (!merged.has(k)) merged.set(k, v);
179
+ cls = Object.getPrototypeOf(cls) as Function | null;
180
+ }
181
+ return merged.size ? merged : null;
182
+ }
183
+
184
+ /**
185
+ * Merged relation metadata (imperative mixins + @decorators, own + inherited).
186
+ * @internal
187
+ */
188
+ export function relationsFor(ctor: Function): Map<string, RelationMetadata> {
189
+ const merged = new Map<string, RelationMetadata>();
190
+ let cls: Function | null = ctor;
191
+ while (cls && cls !== Function.prototype) {
192
+ const r = relationRegistry.get(cls);
193
+ if (r) for (const [k, v] of r) if (!merged.has(k)) merged.set(k, v);
194
+ cls = Object.getPrototypeOf(cls) as Function | null;
195
+ }
196
+ return merged;
197
+ }
198
+
199
+ /**
200
+ * Names of reactive (json/array cast) columns for a class (own + inherited).
201
+ * @internal
202
+ */
203
+ export function reactiveColumnsFor(ctor: Function): string[] {
204
+ const cols = columnsFor(ctor);
205
+ if (!cols) return [];
206
+ const out: string[] = [];
207
+ for (const [name, opts] of cols) {
208
+ if (opts.cast === "json" || opts.cast === "array" || opts.type === "json") out.push(name);
209
+ }
210
+ return out;
211
+ }
@@ -0,0 +1,20 @@
1
+ import type { RelationMetadata } from "../relations/RelationRegistry.ts";
2
+ import { enqueueMember, registerRelation } from "./_metadata.ts";
3
+
4
+ /**
5
+ * The single plumbing for every relation field decorator (standard TC39 decorators).
6
+ *
7
+ * Registration is captured in the decorator BODY (which runs at definition time with the
8
+ * correct `context.name`) and ENQUEUED — we can't use a field initializer/addInitializer,
9
+ * which Bun 1.3.x cross-wires across classes in a file. The `@table` class decorator
10
+ * drains the queue into the concrete class, invoking the relation FACTORY (`metaFor`)
11
+ * with that class so morph/through values depending on the class name resolve correctly.
12
+ */
13
+ export function makeRelationDecorator(
14
+ metaFor: (ctor: Function, field: string) => RelationMetadata,
15
+ ) {
16
+ return function (_value: unknown, context: ClassFieldDecoratorContext): void {
17
+ const name = String(context.name);
18
+ enqueueMember(name, (ctor) => registerRelation(ctor, name, metaFor(ctor, name)));
19
+ };
20
+ }
@@ -0,0 +1,38 @@
1
+ import { makeRelationDecorator } from "./_registerRelation.ts";
2
+
3
+ /** Options for {@link belongsTo}. */
4
+ export interface BelongsToOptions {
5
+ /** FK column on this (child) model pointing to the related row (e.g. `user_id`). */
6
+ foreignKey: string;
7
+ /** Key on the related model the FK references. Defaults to `'id'`. */
8
+ localKey?: string;
9
+ /** Return a default (unsaved) related model instead of null when absent. */
10
+ withDefault?: boolean | Record<string, unknown> | ((model: unknown) => void);
11
+ }
12
+
13
+ /**
14
+ * Declare the inverse of a one-to-many / one-to-one: this model belongs to a
15
+ * single related row referenced by a foreign key on this model's table (e.g. a
16
+ * `Post` belongs to a `User`).
17
+ *
18
+ * @param related - Lazy factory returning the related model class.
19
+ * @param options - Foreign/local key configuration, plus optional `withDefault`.
20
+ * @category Relationships
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * class Post extends BaseModel {
25
+ * \@belongsTo(() => User, { foreignKey: 'user_id' })
26
+ * author!: BelongsTo<User>;
27
+ * }
28
+ * ```
29
+ */
30
+ export function belongsTo(related: () => unknown, options: BelongsToOptions) {
31
+ return makeRelationDecorator(() => ({
32
+ type: "belongsTo" as const,
33
+ related,
34
+ foreignKey: options.foreignKey,
35
+ localKey: options.localKey ?? "id",
36
+ ...(options.withDefault !== undefined ? { withDefault: options.withDefault } : {}),
37
+ }));
38
+ }
@@ -0,0 +1,278 @@
1
+ import { makeReactive } from "../ReactiveProxy.ts";
2
+ import type { CastContract } from "../../casts/Cast.ts";
3
+ import {
4
+ enqueueMember,
5
+ registerColumn,
6
+ columnRegistry,
7
+ columnsFor,
8
+ reactiveColumnsFor,
9
+ } from "./_metadata.ts";
10
+
11
+ // Re-exported here for the public API — populated at class-definition time via @table.
12
+ export { columnRegistry };
13
+
14
+ // ── Column type definitions ───────────────────────────────────────────────────
15
+
16
+ /**
17
+ * String shorthand accepted by `@column("...")`.
18
+ * Each value maps to a fully-resolved `ColumnOptions` object.
19
+ *
20
+ * | Shorthand | Equivalent options |
21
+ * |-------------|--------------------------------------------------|
22
+ * | `"string"` | `{ type: "string" }` |
23
+ * | `"text"` | `{ type: "string" }` (large text, alias) |
24
+ * | `"integer"` | `{ type: "number", cast: "integer" }` |
25
+ * | `"number"` | `{ type: "number" }` |
26
+ * | `"float"` | `{ type: "number", cast: "float" }` |
27
+ * | `"boolean"` | `{ type: "boolean", cast: "boolean" }` |
28
+ * | `"datetime"`| `{ type: "datetime", cast: "datetime" }` |
29
+ * | `"date"` | `{ type: "datetime", cast: "date" }` |
30
+ * | `"json"` | `{ type: "json", cast: "json" }` |
31
+ * | `"array"` | `{ type: "json", cast: "array" }` |
32
+ */
33
+ export type ColumnShorthand =
34
+ | "string"
35
+ | "text"
36
+ | "integer"
37
+ | "number"
38
+ | "float"
39
+ | "boolean"
40
+ | "datetime"
41
+ | "date"
42
+ | "json"
43
+ | "array";
44
+
45
+ /**
46
+ * Full option object accepted by `@column({ ... })`.
47
+ *
48
+ * Every string shorthand ({@link ColumnShorthand}) resolves to one of these; use
49
+ * the object form directly when you need `nullable`, `default`, a custom `cast`,
50
+ * or to mark a `primary` key.
51
+ *
52
+ * @remarks
53
+ * The registered `type` drives schema generation / auto-migration, while `cast`
54
+ * drives runtime serialization of the attribute value (see the field docs below).
55
+ * A `cast` is also mirrored onto the model class's `static casts` map at
56
+ * registration time.
57
+ *
58
+ * @example
59
+ * ```ts
60
+ * @column({ type: "string", nullable: true, default: "guest" })
61
+ * nickname?: string | null;
62
+ *
63
+ * @column({ type: "json", cast: "array", default: [] })
64
+ * tags!: string[];
65
+ *
66
+ * @column({ cast: new MoneyCast() })
67
+ * price!: number;
68
+ * ```
69
+ */
70
+ export interface ColumnOptions {
71
+ /** Logical storage type; drives schema generation and auto-migration. @default "string" */
72
+ type?: "string" | "number" | "boolean" | "datetime" | "json";
73
+ /** Mark this column as the table's primary key. */
74
+ primary?: boolean;
75
+ /** Allow SQL `NULL` for this column. */
76
+ nullable?: boolean;
77
+ /** Default value applied when none is provided. */
78
+ default?: unknown;
79
+ /**
80
+ * Shorthand cast types automatically serialize/deserialize the column value.
81
+ * Can also be a custom object with `get`/`set` functions for full control.
82
+ *
83
+ * - 'datetime' — Carbon on read, ISO string on write (recommended for dates)
84
+ * - 'date' — native Date on read, ISO string on write
85
+ * - 'array' / 'json' — JSON.parse on read, JSON.stringify on write
86
+ * - 'boolean' — coerces 0/1 integers; writes 0 or 1
87
+ * - 'integer' — parseInt on both read and write
88
+ * - 'float' — parseFloat on both read and write
89
+ * - 'enum' — pass-through; pairs with `enumValues` for TS enum columns
90
+ */
91
+ cast?:
92
+ | "datetime"
93
+ | "array"
94
+ | "json"
95
+ | "date"
96
+ | "boolean"
97
+ | "integer"
98
+ | "float"
99
+ | "enum"
100
+ | "immutable_datetime"
101
+ | `decimal:${number}`
102
+ | {
103
+ get?: (dbValue: unknown) => unknown;
104
+ set?: (jsValue: unknown) => unknown;
105
+ }
106
+ | CastContract<unknown>;
107
+ /** Enum object (e.g. the imported TS enum) used alongside cast: 'enum'. */
108
+ enumValues?: Record<string, string | number>;
109
+ }
110
+
111
+ /**
112
+ * Map each ColumnShorthand to its resolved ColumnOptions.
113
+ */
114
+ const SHORTHAND_MAP: Record<ColumnShorthand, ColumnOptions> = {
115
+ string: { type: "string" },
116
+ text: { type: "string" },
117
+ integer: { type: "number", cast: "integer" },
118
+ number: { type: "number" },
119
+ float: { type: "number", cast: "float" },
120
+ boolean: { type: "boolean", cast: "boolean" },
121
+ datetime: { type: "datetime", cast: "datetime" },
122
+ date: { type: "datetime", cast: "date" },
123
+ json: { type: "json", cast: "json" },
124
+ array: { type: "json", cast: "array" },
125
+ };
126
+
127
+ function resolveOptions(arg?: ColumnShorthand | ColumnOptions): ColumnOptions {
128
+ if (arg === undefined) return { type: "string" };
129
+ if (typeof arg === "string") {
130
+ const a = arg as string;
131
+ if (a.startsWith("decimal:"))
132
+ return { type: "number", cast: a as ColumnOptions["cast"] } as ColumnOptions;
133
+ if (a === "immutable_datetime")
134
+ return {
135
+ type: "datetime",
136
+ cast: "immutable_datetime" as ColumnOptions["cast"],
137
+ } as ColumnOptions;
138
+ return SHORTHAND_MAP[a as ColumnShorthand] ?? { type: "string" };
139
+ }
140
+ return arg;
141
+ }
142
+
143
+ // ── Reactivity ────────────────────────────────────────────────────────────────
144
+
145
+ function shouldReactive(options: ColumnOptions): boolean {
146
+ return options.cast === "json" || options.cast === "array" || options.type === "json";
147
+ }
148
+
149
+ function wrapReactive(
150
+ instance: unknown,
151
+ key: string,
152
+ options: ColumnOptions,
153
+ value: unknown,
154
+ ): unknown {
155
+ if (!shouldReactive(options)) return value;
156
+ const ctor = (instance as { constructor?: { reactiveCasts?: boolean } }).constructor;
157
+ if (!ctor || !ctor.reactiveCasts) return value;
158
+ return makeReactive(instance as any, key, value);
159
+ }
160
+
161
+ function defineReactiveProperty(
162
+ instance: unknown,
163
+ key: string,
164
+ options: ColumnOptions,
165
+ initialValue?: unknown,
166
+ ): void {
167
+ if (!shouldReactive(options)) return;
168
+
169
+ const privateKey = `_zerotal_${key}`;
170
+ const self = instance as Record<string, unknown>;
171
+ const seed = initialValue !== undefined ? initialValue : self[key];
172
+ self[privateKey] = wrapReactive(instance, key, options, seed);
173
+
174
+ Object.defineProperty(instance, key, {
175
+ get() {
176
+ return (this as Record<string, unknown>)[privateKey];
177
+ },
178
+ set(value: unknown) {
179
+ const next = wrapReactive(this, key, options, value);
180
+ (this as Record<string, unknown>)[privateKey] = next;
181
+ if ((this as { _exists?: boolean })._exists) {
182
+ (this as { markDirty: (k: string) => void }).markDirty(key);
183
+ }
184
+ },
185
+ enumerable: true,
186
+ configurable: true,
187
+ });
188
+ }
189
+
190
+ // ── @column() overloads ───────────────────────────────────────────────────────
191
+
192
+ /** Standard TC39 class-field decorator returned by {@link column}. */
193
+ type ColumnDecorator = (value: undefined, context: ClassFieldDecoratorContext) => void;
194
+
195
+ /**
196
+ * Map a model property to a database column.
197
+ *
198
+ * @remarks
199
+ * Applied to a field of a `BaseModel` subclass, `@column` registers that property
200
+ * in the ORM's column metadata (`columnRegistry`), so it participates in
201
+ * hydration, dirty-tracking, persistence, and schema generation / auto-migration.
202
+ *
203
+ * The argument may be omitted (defaults to a `"string"` column), given as a
204
+ * {@link ColumnShorthand} string, or given as a full {@link ColumnOptions} object.
205
+ * Options control the storage `type`, `primary` key, `nullable`, `default`, and —
206
+ * most importantly — the `cast` that serializes the value between DB and model
207
+ * (`json`/`array`, `datetime`/`date`, `boolean`, `integer`/`float`, `decimal:n`,
208
+ * `enum`, or a custom `{ get, set }` / {@link CastContract}). A declared `cast` is
209
+ * also mirrored onto the class's `static casts` map.
210
+ *
211
+ * Timestamp and primary-key conventions themselves are configured on the class via
212
+ * `@table` (see {@link table}); `@column` only maps individual fields.
213
+ *
214
+ * Registration is anchored at class-definition time by the `@table` decorator (a
215
+ * Bun 1.3.x standard-decorator workaround), so a model that declares columns must
216
+ * also carry `@table` — or be auto-discovered from `app/models/`.
217
+ *
218
+ * @param arg - A {@link ColumnShorthand} string, a {@link ColumnOptions} object, or nothing (defaults to `"string"`).
219
+ * @returns A class-field decorator that registers the property as a column.
220
+ *
221
+ * @example
222
+ * ```ts
223
+ * @table("users")
224
+ * export class User extends BaseModel {
225
+ * @column({ primary: true }) id!: number;
226
+ *
227
+ * // No args — defaults to a string column
228
+ * @column() name!: string;
229
+ *
230
+ * // String shorthand
231
+ * @column("integer") age!: number;
232
+ * @column("datetime") createdAt!: Carbon;
233
+ *
234
+ * // Cast a JSON column to/from an array (reactive when `static reactiveCasts`)
235
+ * @column({ type: "json", cast: "array", default: [] }) roles!: string[];
236
+ *
237
+ * // Nullable with a custom cast object
238
+ * @column({ nullable: true, cast: { get: (v) => v && new URL(String(v)), set: (u) => u?.href } })
239
+ * website?: URL | null;
240
+ * }
241
+ * ```
242
+ */
243
+ export function column(): ColumnDecorator;
244
+ export function column(type: ColumnShorthand): ColumnDecorator;
245
+ export function column(options: ColumnOptions): ColumnDecorator;
246
+ export function column(arg?: ColumnShorthand | ColumnOptions): ColumnDecorator {
247
+ const options = resolveOptions(arg);
248
+ // The decorator BODY runs synchronously at definition time with the correct
249
+ // `context.name` (the only thing Bun 1.3.x compiles reliably for field decorators).
250
+ // We can't defer to a field initializer or addInitializer — Bun cross-wires those
251
+ // across classes. Instead capture name+options here and enqueue the registration; the
252
+ // `@table` class decorator drains the queue into the concrete class (see _metadata.ts).
253
+ return function (_value: undefined, context: ClassFieldDecoratorContext): void {
254
+ const name = String(context.name);
255
+ enqueueMember(name, (ctor) => registerColumn(ctor, name, options));
256
+ };
257
+ }
258
+
259
+ /**
260
+ * Install reactive accessors (json/array `reactiveCasts` columns) on a fresh instance.
261
+ * Called from the model's fill()/hydrate paths — registration now happens at decoration
262
+ * time, so the per-instance reactive setup is done here rather than in a field initializer.
263
+ * Idempotent.
264
+ *
265
+ * @param instance - The freshly constructed / hydrated model instance to wire.
266
+ * @internal
267
+ */
268
+ export function installReactiveAccessors(instance: object): void {
269
+ const reactive = reactiveColumnsFor(instance.constructor as Function);
270
+ if (!reactive.length) return;
271
+ const cols = columnsFor(instance.constructor as Function);
272
+ for (const name of reactive) {
273
+ const desc = Object.getOwnPropertyDescriptor(instance, name);
274
+ if (desc && typeof desc.get === "function") continue; // already installed
275
+ const opts = cols?.get(name);
276
+ if (opts) defineReactiveProperty(instance, name, opts);
277
+ }
278
+ }
@@ -0,0 +1,34 @@
1
+ import { makeRelationDecorator } from "./_registerRelation.ts";
2
+
3
+ /** Options for {@link hasMany}. */
4
+ export interface HasManyOptions {
5
+ /** FK column on the related table pointing back to the parent (e.g. `user_id`). */
6
+ foreignKey: string;
7
+ /** Parent local key the FK references. Defaults to `'id'`. */
8
+ localKey?: string;
9
+ }
10
+
11
+ /**
12
+ * Declare a one-to-many relation: the parent owns many related rows, matched by a
13
+ * foreign key on the related table (e.g. a `User` has many `Post`s).
14
+ *
15
+ * @param related - Lazy factory returning the related model class.
16
+ * @param options - Foreign/local key configuration.
17
+ * @category Relationships
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * class User extends BaseModel {
22
+ * \@hasMany(() => Post, { foreignKey: 'user_id' })
23
+ * posts!: HasMany<Post>;
24
+ * }
25
+ * ```
26
+ */
27
+ export function hasMany(related: () => unknown, options: HasManyOptions) {
28
+ return makeRelationDecorator(() => ({
29
+ type: "hasMany" as const,
30
+ related,
31
+ foreignKey: options.foreignKey,
32
+ localKey: options.localKey ?? "id",
33
+ }));
34
+ }
@@ -0,0 +1,50 @@
1
+ import { makeRelationDecorator } from "./_registerRelation.ts";
2
+
3
+ export interface HasManyThroughOptions {
4
+ /** FK on the through table referencing the parent (e.g. 'country_id' on users). */
5
+ firstKey: string;
6
+ /** FK on the related table referencing the through model (e.g. 'user_id' on posts). */
7
+ secondKey: string;
8
+ /** Parent local key the firstKey references. Default 'id'. */
9
+ localKey?: string;
10
+ /** Through local key the secondKey references. Default 'id'. */
11
+ throughLocalKey?: string;
12
+ }
13
+
14
+ /**
15
+ * Declare a has-many-through relation: reach the related rows across one
16
+ * intermediate ("through") model — e.g. `Country` —(users.country_id)→ `User`
17
+ * —(posts.user_id)→ `Post`, so a country has many posts through its users.
18
+ *
19
+ * @remarks Not supported by {@link has} / {@link whereHas}; use eager
20
+ * {@link ModelQueryBuilder.with | with()} to load it.
21
+ *
22
+ * @param related - Lazy factory returning the far/related model class.
23
+ * @param through - Lazy factory returning the intermediate model class.
24
+ * @param options - First/second key configuration across the two hops.
25
+ * @category Relationships
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * class Country extends BaseModel {
30
+ * \@hasManyThrough(() => Post, () => User, { firstKey: 'country_id', secondKey: 'user_id' })
31
+ * posts!: HasMany<Post>;
32
+ * }
33
+ * ```
34
+ */
35
+ export function hasManyThrough(
36
+ related: () => unknown,
37
+ through: () => unknown,
38
+ options: HasManyThroughOptions,
39
+ ) {
40
+ return makeRelationDecorator(() => ({
41
+ type: "hasManyThrough" as const,
42
+ related,
43
+ through,
44
+ firstKey: options.firstKey,
45
+ secondKey: options.secondKey,
46
+ foreignKey: options.secondKey,
47
+ localKey: options.localKey ?? "id",
48
+ throughLocalKey: options.throughLocalKey ?? "id",
49
+ }));
50
+ }
@@ -0,0 +1,34 @@
1
+ import { makeRelationDecorator } from "./_registerRelation.ts";
2
+
3
+ /** Options for {@link hasOne}. */
4
+ export interface HasOneOptions {
5
+ /** FK column on the related table pointing back to the parent (e.g. `user_id`). */
6
+ foreignKey: string;
7
+ /** Parent local key the FK references. Defaults to `'id'`. */
8
+ localKey?: string;
9
+ }
10
+
11
+ /**
12
+ * Declare a one-to-one relation: the parent owns a single related row, matched by
13
+ * a foreign key on the related table (e.g. a `User` has one `Profile`).
14
+ *
15
+ * @param related - Lazy factory returning the related model class.
16
+ * @param options - Foreign/local key configuration.
17
+ * @category Relationships
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * class User extends BaseModel {
22
+ * \@hasOne(() => Profile, { foreignKey: 'user_id' })
23
+ * profile!: HasOne<Profile>;
24
+ * }
25
+ * ```
26
+ */
27
+ export function hasOne(related: () => unknown, options: HasOneOptions) {
28
+ return makeRelationDecorator(() => ({
29
+ type: "hasOne" as const,
30
+ related,
31
+ foreignKey: options.foreignKey,
32
+ localKey: options.localKey ?? "id",
33
+ }));
34
+ }