@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,73 @@
1
+ import { HookRegistry, type HookName } from "./hooks/HookRegistry.ts";
2
+
3
+ /**
4
+ * Observer interface — implement any subset of lifecycle methods to react to a
5
+ * model's create / update / save / delete / retrieve events in one cohesive class.
6
+ *
7
+ * @remarks
8
+ * Each method name maps to an internal {@link HookName}: `creating`→`beforeCreate`,
9
+ * `created`→`afterCreate`, `updating`→`beforeUpdate`, `updated`→`afterUpdate`,
10
+ * `saving`→`beforeSave`, `saved`→`afterSave`, `deleting`→`beforeDelete`,
11
+ * `deleted`→`afterDelete`, and `retrieved`→`afterFind`. Methods may be async;
12
+ * a `before*` method that throws aborts the operation.
13
+ *
14
+ * @typeParam T - The model type the observer watches.
15
+ *
16
+ * @example
17
+ * export class UserObserver implements ModelObserver<User> {
18
+ * creating(user: User) { user.uuid = crypto.randomUUID(); }
19
+ * created(user: User) { Log.info('User created', { id: user.id }); }
20
+ * deleting(user: User) { Log.info('Deleting user', { id: user.id }); }
21
+ * }
22
+ *
23
+ * // Register once at boot (in a ServiceProvider)
24
+ * User.observe(UserObserver);
25
+ */
26
+ export interface ModelObserver<T = unknown> {
27
+ creating?(model: T): Promise<void> | void;
28
+ created?(model: T): Promise<void> | void;
29
+ updating?(model: T): Promise<void> | void;
30
+ updated?(model: T): Promise<void> | void;
31
+ saving?(model: T): Promise<void> | void;
32
+ saved?(model: T): Promise<void> | void;
33
+ deleting?(model: T): Promise<void> | void;
34
+ deleted?(model: T): Promise<void> | void;
35
+ retrieved?(model: T): Promise<void> | void;
36
+ }
37
+
38
+ type ObserverClass<T> = new () => ModelObserver<T>;
39
+
40
+ // Maps observer lifecycle method names to internal HookName
41
+ const _methodToHook: Record<keyof ModelObserver, HookName> = {
42
+ creating: "beforeCreate",
43
+ created: "afterCreate",
44
+ updating: "beforeUpdate",
45
+ updated: "afterUpdate",
46
+ saving: "beforeSave",
47
+ saved: "afterSave",
48
+ deleting: "beforeDelete",
49
+ deleted: "afterDelete",
50
+ retrieved: "afterFind",
51
+ };
52
+
53
+ /**
54
+ * Register an observer class for a model: instantiate it once and wire each
55
+ * implemented lifecycle method to its corresponding hook.
56
+ *
57
+ * Called by `BaseModel.observe(ObserverClass)`; app code normally uses that
58
+ * rather than calling this directly.
59
+ *
60
+ * @param ModelClass - The model constructor to observe.
61
+ * @param ObserverClass - An observer class (zero-arg constructor) implementing any subset of {@link ModelObserver}.
62
+ * @internal
63
+ */
64
+ export function registerObserver<T>(ModelClass: Function, ObserverClass: ObserverClass<T>): void {
65
+ const instance = new ObserverClass();
66
+
67
+ for (const [method, hook] of Object.entries(_methodToHook) as [keyof ModelObserver, HookName][]) {
68
+ const fn = instance[method] as ((m: T) => Promise<void> | void) | undefined;
69
+ if (typeof fn === "function") {
70
+ HookRegistry.register<T>(ModelClass, hook, fn.bind(instance));
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,71 @@
1
+ import type { SQLInstance } from "../db/sql-types.ts";
2
+
3
+ /**
4
+ * Execution-scoped container for all mutable ORM state that must NOT leak across
5
+ * requests or tenants — the override/named connections, per-model state-machine
6
+ * transition callbacks, registered global scopes, and lifecycle hooks.
7
+ *
8
+ * Keeping this state on a swappable context (rather than in module globals) lets
9
+ * each request or tenant boundary run with an isolated ORM view; the app scope
10
+ * integration below swaps a fresh `OrmContext` in per application scope.
11
+ *
12
+ * @example
13
+ * // Scope connection + registrations to a block, then restore:
14
+ * const prev = useOrmContext(new OrmContext());
15
+ * try {
16
+ * BaseModel.registerConnection("tenant", tenantConn);
17
+ * await doTenantWork();
18
+ * } finally {
19
+ * useOrmContext(prev);
20
+ * }
21
+ */
22
+ export class OrmContext {
23
+ /** Explicit connection override that wins over the resolved default (used by tests / `withDatabase`). */
24
+ overrideConnection: SQLInstance | null = null;
25
+ /** Connections registered by name, selectable via `static connection`. */
26
+ namedConnections = new Map<string, SQLInstance>();
27
+ /** Per-model `onTransition` callbacks, keyed by target state (see the `State` mixin). */
28
+ transitionCallbacks = new Map<Function, Map<string, unknown[]>>();
29
+ /** Per-model registered global query scopes. */
30
+ globalScopes = new Map<Function, Map<string, unknown>>();
31
+ /** Per-model lifecycle hooks. */
32
+ hooks = new Map<Function, Map<string, unknown[]>>();
33
+ }
34
+
35
+ let _ctx = new OrmContext();
36
+
37
+ /** Return the currently active {@link OrmContext}. */
38
+ export function currentOrmContext(): OrmContext {
39
+ return _ctx;
40
+ }
41
+
42
+ /**
43
+ * Install `ctx` (or a fresh {@link OrmContext}) as the active context and return
44
+ * the previous one, so callers can restore it afterwards.
45
+ *
46
+ * @param ctx The context to activate (defaults to a new, empty context).
47
+ * @returns The context that was active before this call.
48
+ */
49
+ export function useOrmContext(ctx: OrmContext = new OrmContext()): OrmContext {
50
+ const prev = _ctx;
51
+ _ctx = ctx;
52
+ return prev;
53
+ }
54
+
55
+ /** Replace the active context with a fresh, empty {@link OrmContext} (test/teardown reset). */
56
+ export function resetOrmContext(): void {
57
+ _ctx = new OrmContext();
58
+ }
59
+
60
+ import { registerAppScope } from "@zerotal/core";
61
+
62
+ let _appScopeRegistered = false;
63
+ if (!_appScopeRegistered) {
64
+ _appScopeRegistered = true;
65
+ registerAppScope(() => {
66
+ const prev = useOrmContext(new OrmContext());
67
+ return () => {
68
+ useOrmContext(prev);
69
+ };
70
+ });
71
+ }
@@ -0,0 +1,53 @@
1
+ import type { BaseModel } from "./BaseModel.ts";
2
+
3
+ type ProxyTarget = Record<string | symbol, unknown>;
4
+
5
+ const _proxyCache = new WeakMap<object, object>();
6
+
7
+ /**
8
+ * Wrap an object/array `target` in a deeply-reactive `Proxy` so that any nested
9
+ * mutation marks `model`'s `propertyKey` dirty (via {@link BaseModel.markDirty}),
10
+ * ensuring in-place edits of `json`/`array` cast columns are persisted on the
11
+ * next `save()`.
12
+ *
13
+ * Non-object values, `null`, and `Date` instances are returned unwrapped.
14
+ * Nested objects are proxied lazily on access, and proxies are cached per target
15
+ * (WeakMap) so repeated wrapping is cheap and identity-stable. Used by the
16
+ * ORM when a model has {@link BaseModel.reactiveCasts} enabled.
17
+ *
18
+ * @param model The owning model instance to flag dirty on mutation.
19
+ * @param propertyKey The column property the target belongs to.
20
+ * @param target The value to make reactive.
21
+ * @returns A reactive proxy of `target`, or `target` unchanged if not proxyable.
22
+ */
23
+ export function makeReactive<T>(model: BaseModel, propertyKey: string, target: T): T {
24
+ if (typeof target !== "object" || target === null || target instanceof Date) {
25
+ return target;
26
+ }
27
+
28
+ const cached = _proxyCache.get(target as object) as T | undefined;
29
+ if (cached) return cached;
30
+
31
+ const proxy = new Proxy(target as ProxyTarget, {
32
+ get(obj, prop, receiver) {
33
+ const value = Reflect.get(obj, prop, receiver);
34
+ if (typeof value === "object" && value !== null && !(value instanceof Date)) {
35
+ return makeReactive(model, propertyKey, value);
36
+ }
37
+ return value;
38
+ },
39
+ set(obj, prop, value, receiver) {
40
+ const result = Reflect.set(obj, prop, value, receiver);
41
+ model.markDirty(propertyKey as keyof BaseModel);
42
+ return result;
43
+ },
44
+ deleteProperty(obj, prop) {
45
+ const result = Reflect.deleteProperty(obj, prop);
46
+ model.markDirty(propertyKey as keyof BaseModel);
47
+ return result;
48
+ },
49
+ });
50
+
51
+ _proxyCache.set(target as object, proxy);
52
+ return proxy as unknown as T;
53
+ }
@@ -0,0 +1,108 @@
1
+ // ── SoftDeletes mixin ─────────────────────────────────────────────────────────
2
+ //
3
+ // Opt-in soft deletes. Compose it so only models that want them carry the API —
4
+ // `deletedAt`, `forceDelete()`, `restore()`, `trashed()`, and the `withTrashed()` /
5
+ // `onlyTrashed()` query scopes. A hard-delete model has none of these.
6
+ //
7
+ // import { BaseModelWith, SoftDeletes } from "@zerotal/orm";
8
+ //
9
+ // @table("posts")
10
+ // class Post extends BaseModelWith(SoftDeletes) {
11
+ // @column() title!: string;
12
+ // }
13
+ //
14
+ // await post.delete(); // sets deleted_at; row hidden from default queries
15
+ // await Post.withTrashed().get();
16
+ // await post.restore(); // deleted_at = NULL
17
+ // await post.forceDelete(); // permanent
18
+ //
19
+ // Setting `static softDeletes = true` is what the query engine and schema sync read
20
+ // (every query scopes `WHERE deleted_at IS NULL`, and the migrator provisions the
21
+ // `deleted_at` column) — the mixin flips it for you.
22
+
23
+ import { _resolveConn, type BaseModel } from "./BaseModel.ts";
24
+ import { ModelQueryBuilder } from "./ModelQueryBuilder.ts";
25
+ import { QueryBuilder } from "../db/QueryBuilder.ts";
26
+ import type { Constructor } from "./mixins.ts";
27
+
28
+ // Structural view of the concrete model class used by the static scopes.
29
+ interface SoftDeleteModelClass<T extends BaseModel> {
30
+ table: string;
31
+ primaryKey: string;
32
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
33
+ new (...args: any[]): T;
34
+ }
35
+
36
+ /**
37
+ * Mixin that adds opt-in soft deletes to a model. Compose it so only models that
38
+ * want them carry the API — `deletedAt`, {@link SoftDeletes.forceDelete},
39
+ * {@link SoftDeletes.restore}, {@link SoftDeletes.trashed}, and the
40
+ * {@link SoftDeletes.withTrashed} / {@link SoftDeletes.onlyTrashed} query scopes.
41
+ *
42
+ * The mixin flips `static softDeletes = true`, which is what the query engine and
43
+ * schema sync read: every default query scopes `WHERE deleted_at IS NULL`, the
44
+ * migrator provisions a `deleted_at` column, and `delete()` sets `deleted_at`
45
+ * instead of removing the row.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * @table("posts")
50
+ * class Post extends BaseModelWith(SoftDeletes) {
51
+ * @column() title!: string;
52
+ * }
53
+ *
54
+ * await post.delete(); // sets deleted_at; hidden from default queries
55
+ * await Post.withTrashed().get(); // includes soft-deleted rows
56
+ * await Post.onlyTrashed().get(); // only soft-deleted rows
57
+ * await post.restore(); // deleted_at = NULL
58
+ * await post.forceDelete(); // permanent DELETE
59
+ * ```
60
+ */
61
+ export function SoftDeletes<TBase extends Constructor>(Base: TBase) {
62
+ class SoftDeletes extends Base {
63
+ /** Engine switch — query scoping + schema `deleted_at` provisioning read this. */
64
+ static softDeletes = true;
65
+
66
+ /** When the row was soft-deleted, or null/undefined when live. */
67
+ declare deletedAt?: Date | null;
68
+
69
+ /** A query that INCLUDES soft-deleted rows (bypasses the default scope). */
70
+ static withTrashed<T extends BaseModel>(this: SoftDeleteModelClass<T>): ModelQueryBuilder<T> {
71
+ return new ModelQueryBuilder<T>(this.table, _resolveConn(this as never), this as never);
72
+ }
73
+
74
+ /** A query that returns ONLY soft-deleted rows. */
75
+ static onlyTrashed<T extends BaseModel>(this: SoftDeleteModelClass<T>): ModelQueryBuilder<T> {
76
+ const qb = new ModelQueryBuilder<T>(this.table, _resolveConn(this as never), this as never);
77
+ qb.whereNotNull("deleted_at");
78
+ return qb;
79
+ }
80
+
81
+ /** True when this record is currently soft-deleted. */
82
+ trashed(): boolean {
83
+ return (this as { deletedAt?: unknown }).deletedAt != null;
84
+ }
85
+
86
+ /** Permanently delete the row, bypassing soft delete. */
87
+ async forceDelete(): Promise<void> {
88
+ const M = this.constructor as unknown as { table: string; primaryKey: string };
89
+ await new QueryBuilder(M.table, _resolveConn(this.constructor as never))
90
+ .where(M.primaryKey, (this as { id?: unknown }).id)
91
+ .delete();
92
+ }
93
+
94
+ /**
95
+ * Restore a soft-deleted record by setting `deleted_at` back to NULL, so it
96
+ * reappears in normal queries.
97
+ */
98
+ async restore(): Promise<void> {
99
+ const M = this.constructor as unknown as { table: string; primaryKey: string };
100
+ await new QueryBuilder(M.table, _resolveConn(this.constructor as never))
101
+ .where(M.primaryKey, (this as { id?: unknown }).id)
102
+ .update({ deleted_at: null });
103
+ (this as { deletedAt?: unknown }).deletedAt = null;
104
+ }
105
+ }
106
+
107
+ return SoftDeletes;
108
+ }
@@ -0,0 +1,290 @@
1
+ // ── State machine mixin ───────────────────────────────────────────────────────
2
+ //
3
+ // Finite-state-machine behaviour as an opt-in mixin, so only models that declare a
4
+ // workflow carry the API — `transitionTo` / `forceState` / `onTransition` / the
5
+ // `states` + `stateField` statics never appear on models that don't use them.
6
+ //
7
+ // import { BaseModelWith } from "@zerotal/orm";
8
+ //
9
+ // const States = {
10
+ // pending: { canTransitionTo: ["active", "cancelled"] as const },
11
+ // active: { canTransitionTo: ["expired"] as const,
12
+ // guard: (s: Subscription) => { if (!s.stripeId) throw new StateError(...); } },
13
+ // expired: { canTransitionTo: [] as const },
14
+ // cancelled: { canTransitionTo: [] as const },
15
+ // } as const;
16
+ //
17
+ // class Subscription extends BaseModelWith(State) {
18
+ // static states = States;
19
+ // @column() status!: keyof typeof States;
20
+ // }
21
+
22
+ import { StateError } from "../errors/index.ts";
23
+ import { currentOrmContext } from "./OrmContext.ts";
24
+ import type { Constructor } from "./mixins.ts";
25
+
26
+ // ── Types ─────────────────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * Reject the in-flight transition with a human-readable reason. Throws a {@link StateError}
30
+ * pre-populated with the model name and the from/to states, so a guard doesn't have to
31
+ * construct the error itself:
32
+ *
33
+ * guard: async (order, reject) => {
34
+ * if (!order.paid) reject("Can't ship an unpaid order.");
35
+ * }
36
+ */
37
+ export type RejectTransition = (reason: string) => never;
38
+
39
+ /** Context a guard receives alongside the model: the reject helper and the from/to states. */
40
+ export interface TransitionContext {
41
+ /** Reject the transition with a reason — throws a StateError carrying the model + from/to. */
42
+ reject: RejectTransition;
43
+ /** The state being transitioned **from**. */
44
+ current: string;
45
+ /** The state being transitioned **to**. */
46
+ intended: string;
47
+ }
48
+
49
+ /**
50
+ * A guard that runs before a transition is committed. It may:
51
+ * - allow it (return `true`/`undefined`),
52
+ * - block it (return `false` — yields a generic StateError), or
53
+ * - reject it with a reason via `ctx.reject(reason)` (a StateError carrying that reason).
54
+ */
55
+ export type StateGuard<T> = (
56
+ model: T,
57
+ ctx: TransitionContext,
58
+ ) => boolean | void | Promise<boolean | void>;
59
+
60
+ /**
61
+ * Result of {@link State.transitionTo}: `[true]` on success, or `[false, StateError]` when the
62
+ * transition is illegal or a guard blocks it. Destructure and check — TypeScript narrows the
63
+ * error to `StateError` in the `!ok` branch:
64
+ *
65
+ * const [ok, err] = await order.transitionTo("shipped");
66
+ * if (!ok) return json({ error: err.message }, 422);
67
+ */
68
+ export type TransitionResult = [ok: true] | [ok: false, error: StateError];
69
+
70
+ /** One state's definition within a state machine. */
71
+ export interface StateDefinition<States extends string, T = unknown> {
72
+ canTransitionTo: readonly States[];
73
+ guard?: StateGuard<T>;
74
+ }
75
+
76
+ /** Full state schema — a plain object with `as const`. */
77
+ export type StateMachine<States extends string, T = unknown> = {
78
+ [K in States]: StateDefinition<States, T>;
79
+ };
80
+
81
+ /** Callback fired after a successful transition (from → to). */
82
+ export type TransitionCallback<T> = (
83
+ model: T,
84
+ meta: { from: string; to: string },
85
+ ) => Promise<void> | void;
86
+
87
+ // ── Callback registry (execution-scoped on the OrmContext) ────────────────────
88
+
89
+ /** @internal Register or retrieve transition callbacks for a model class. */
90
+ function _getCallbacks(ModelClass: Function, state: string): TransitionCallback<unknown>[] {
91
+ const reg = currentOrmContext().transitionCallbacks;
92
+ if (!reg.has(ModelClass)) reg.set(ModelClass, new Map());
93
+ const map = reg.get(ModelClass)!;
94
+ if (!map.has(state)) map.set(state, []);
95
+ return map.get(state)! as TransitionCallback<unknown>[];
96
+ }
97
+
98
+ /** @internal Clear all transition callbacks — used in tests. Prefer resetOrmContext(). */
99
+ export function _clearTransitionCallbacks(): void {
100
+ currentOrmContext().transitionCallbacks.clear();
101
+ }
102
+
103
+ // ── The mixin ─────────────────────────────────────────────────────────────────
104
+
105
+ interface StateModelClass {
106
+ // `any` (not `unknown`) for the model type so a subclass can type its guards with its own
107
+ // concrete model — `guard: (order: Order, reject) => …` — without a variance conflict.
108
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
109
+ states?: Record<string, StateDefinition<string, any>>;
110
+ stateField: string;
111
+ name: string;
112
+ }
113
+
114
+ /**
115
+ * Mixin that adds finite-state-machine behaviour to a model. Compose it so only
116
+ * models that declare a workflow carry the API — `transitionTo` / `forceState` /
117
+ * `onTransition` and the `states` + `stateField` statics never appear on models
118
+ * that don't use them.
119
+ *
120
+ * Declare the machine in `static states` (use `as const` for exact state typing)
121
+ * and, optionally, override `static stateField` when the state column isn't
122
+ * `status`. Each transition validates that the move is allowed, runs the target
123
+ * state's guard, persists via `save()`, then fires `onTransition` callbacks.
124
+ *
125
+ * @example
126
+ * ```ts
127
+ * const States = {
128
+ * pending: { canTransitionTo: ["active", "cancelled"] as const },
129
+ * active: { canTransitionTo: ["expired"] as const,
130
+ * guard: (s: Subscription) => { if (!s.stripeId) throw new StateError(...); } },
131
+ * expired: { canTransitionTo: [] as const },
132
+ * cancelled: { canTransitionTo: [] as const },
133
+ * } as const;
134
+ *
135
+ * class Subscription extends BaseModelWith(State) {
136
+ * static states = States;
137
+ * @column() status!: keyof typeof States;
138
+ * }
139
+ *
140
+ * const [ok, err] = await sub.transitionTo("active");
141
+ * if (!ok) return json({ error: err.message }, 422);
142
+ * ```
143
+ */
144
+ export function State<TBase extends Constructor>(Base: TBase) {
145
+ class State extends Base {
146
+ /** Column name that holds the state value. Override when it isn't `status`. */
147
+ static stateField = "status";
148
+
149
+ /**
150
+ * Finite-state-machine definition. Each key is a valid state; `canTransitionTo`
151
+ * lists the states reachable from it, and an optional `guard` runs before the
152
+ * target is entered (call `reject(reason)`, throw `StateError`, or return `false`
153
+ * to block). Use `as const` so TypeScript infers the exact state union.
154
+ */
155
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- see StateModelClass note
156
+ static states?: Record<string, StateDefinition<string, any>>;
157
+
158
+ /**
159
+ * Register a callback that fires after a successful `transitionTo()`. Pass `'*'`
160
+ * to listen on every transition.
161
+ *
162
+ * @example
163
+ * Subscription.onTransition("active", async (sub, { from }) => { ... });
164
+ * Subscription.onTransition("*", (sub, { from, to }) => { ... });
165
+ */
166
+ static onTransition<T>(
167
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
168
+ this: { new (...args: any[]): T },
169
+ toState: string,
170
+ callback: TransitionCallback<T>,
171
+ ): void {
172
+ _getCallbacks(this as unknown as Function, toState).push(
173
+ callback as TransitionCallback<unknown>,
174
+ );
175
+ }
176
+
177
+ /**
178
+ * Transition the model to a new state, enforcing the rules in `static states`:
179
+ * validates the transition is allowed, runs the target state's guard, updates
180
+ * the state column and `save()`s, then fires `onTransition` callbacks.
181
+ *
182
+ * Returns a result tuple — `[true]` on success, or `[false, StateError]` when the
183
+ * transition is illegal or a guard blocks it — so callers can branch without a
184
+ * try/catch. Unexpected errors (a failing `save()`, a throwing `onTransition`
185
+ * callback, or a non-StateError thrown by a guard) still propagate.
186
+ *
187
+ * @example
188
+ * const [ok, err] = await ticket.transitionTo("resolved");
189
+ * if (!ok) return json({ error: err.message }, 422);
190
+ */
191
+ async transitionTo(newState: string): Promise<TransitionResult> {
192
+ const ModelClass = this.constructor as unknown as StateModelClass;
193
+ const schema = ModelClass.states;
194
+ const self = this as unknown as Record<string, unknown>;
195
+ const field = ModelClass.stateField;
196
+ const currentState = String(self[field] ?? "");
197
+ const modelName = ModelClass.name;
198
+
199
+ if (!schema) {
200
+ return [
201
+ false,
202
+ new StateError(
203
+ modelName,
204
+ currentState,
205
+ newState,
206
+ `State machine not configured on ${modelName}. Add \`static states = { ... }\`.`,
207
+ ),
208
+ ];
209
+ }
210
+
211
+ const currentDef = schema[currentState];
212
+ if (!currentDef) {
213
+ return [
214
+ false,
215
+ new StateError(
216
+ modelName,
217
+ currentState,
218
+ newState,
219
+ `Unknown current state '${currentState}' on ${modelName}.`,
220
+ ),
221
+ ];
222
+ }
223
+
224
+ if (!(currentDef.canTransitionTo as readonly string[]).includes(newState)) {
225
+ return [false, new StateError(modelName, currentState, newState)];
226
+ }
227
+
228
+ // Run the target state's guard before touching the DB. A guard blocks the transition by
229
+ // returning `false` or calling `ctx.reject(reason)` (which throws a StateError we catch
230
+ // and surface in the tuple). Any *other* thrown error is a real fault and propagates.
231
+ const targetDef = schema[newState];
232
+ if (targetDef?.guard) {
233
+ const reject: RejectTransition = (reason: string): never => {
234
+ throw new StateError(modelName, currentState, newState, reason);
235
+ };
236
+ try {
237
+ const allowed = await (targetDef.guard as StateGuard<this>)(this, {
238
+ reject,
239
+ current: currentState,
240
+ intended: newState,
241
+ });
242
+ if (allowed === false) {
243
+ return [
244
+ false,
245
+ new StateError(
246
+ modelName,
247
+ currentState,
248
+ newState,
249
+ `Guard rejected transition from '${currentState}' to '${newState}' on ${modelName}.`,
250
+ ),
251
+ ];
252
+ }
253
+ } catch (error) {
254
+ if (error instanceof StateError) return [false, error];
255
+ throw error;
256
+ }
257
+ }
258
+
259
+ self[field] = newState;
260
+ await (this as unknown as { save(): Promise<unknown> }).save();
261
+
262
+ // Fire registered transition callbacks.
263
+ const map = currentOrmContext().transitionCallbacks.get(this.constructor as Function);
264
+ const meta = { from: currentState, to: newState };
265
+ const toFns = (map?.get(newState) ?? []) as TransitionCallback<unknown>[];
266
+ const anyFns = (map?.get("*") ?? []) as TransitionCallback<unknown>[];
267
+ for (const fn of [...toFns, ...anyFns]) await fn(this as unknown, meta);
268
+
269
+ return [true];
270
+ }
271
+
272
+ /**
273
+ * Forcefully set the state column to any value, bypassing guards and
274
+ * `onTransition` callbacks. For **test factories** and **seeders** only —
275
+ * throws when `APP_ENV` is `production`.
276
+ *
277
+ * @throws {Error} when called with `APP_ENV=production`.
278
+ */
279
+ async forceState(state: string): Promise<this> {
280
+ if (Bun.env["APP_ENV"] === "production") {
281
+ throw new Error("forceState() cannot be called in production (APP_ENV=production).");
282
+ }
283
+ const field = (this.constructor as unknown as StateModelClass).stateField;
284
+ (this as unknown as Record<string, unknown>)[field] = state;
285
+ return (this as unknown as { save(): Promise<unknown> }).save() as Promise<this>;
286
+ }
287
+ }
288
+
289
+ return State;
290
+ }