@zerotal/orm 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/package.json +58 -0
- package/src/casts/Cast.ts +200 -0
- package/src/commands/DbSeedCommand.ts +71 -0
- package/src/commands/MakeFactoryCommand.ts +59 -0
- package/src/commands/MakeMigrationCommand.ts +109 -0
- package/src/commands/MakeModelCommand.ts +83 -0
- package/src/commands/MakeSeederCommand.ts +50 -0
- package/src/commands/MigrateCommand.ts +60 -0
- package/src/commands/MigrateFreshCommand.ts +41 -0
- package/src/commands/MigrateGenerateCommand.ts +110 -0
- package/src/commands/MigrateRollbackCommand.ts +43 -0
- package/src/commands/MigrateStatusCommand.ts +49 -0
- package/src/commands/_loadMigrations.ts +34 -0
- package/src/commands/index.ts +30 -0
- package/src/config.ts +182 -0
- package/src/conventions.ts +67 -0
- package/src/db/DB.ts +486 -0
- package/src/db/NPlusOneDetector.ts +176 -0
- package/src/db/QueryBuilder.ts +2458 -0
- package/src/db/ReadWriteRouter.ts +96 -0
- package/src/db/TransactionContext.ts +13 -0
- package/src/db/dialects/MysqlDialect.ts +57 -0
- package/src/db/dialects/PostgresDialect.ts +55 -0
- package/src/db/dialects/SqliteDialect.ts +54 -0
- package/src/db/dialects/index.ts +25 -0
- package/src/db/dialects/types.ts +67 -0
- package/src/db/resolver.ts +30 -0
- package/src/db/sql-types.ts +12 -0
- package/src/db/types.ts +296 -0
- package/src/errors/MassAssignmentError.ts +25 -0
- package/src/errors/MigrationError.ts +18 -0
- package/src/errors/ModelNotFoundError.ts +21 -0
- package/src/errors/NPlusOneError.ts +6 -0
- package/src/errors/RelationNotLoadedError.ts +19 -0
- package/src/errors/StateError.ts +18 -0
- package/src/errors/TransactionError.ts +13 -0
- package/src/errors/UnsupportedDialectError.ts +18 -0
- package/src/errors/index.ts +7 -0
- package/src/events.ts +112 -0
- package/src/global.d.ts +17 -0
- package/src/implicitBinding.ts +73 -0
- package/src/index.ts +255 -0
- package/src/model/BaseModel.ts +2499 -0
- package/src/model/ModelQueryBuilder.ts +1808 -0
- package/src/model/Observer.ts +73 -0
- package/src/model/OrmContext.ts +71 -0
- package/src/model/ReactiveProxy.ts +53 -0
- package/src/model/SoftDeletes.ts +108 -0
- package/src/model/State.ts +290 -0
- package/src/model/decorators/_metadata.ts +211 -0
- package/src/model/decorators/_registerRelation.ts +20 -0
- package/src/model/decorators/belongsTo.ts +38 -0
- package/src/model/decorators/column.ts +278 -0
- package/src/model/decorators/hasMany.ts +34 -0
- package/src/model/decorators/hasManyThrough.ts +50 -0
- package/src/model/decorators/hasOne.ts +34 -0
- package/src/model/decorators/hasOneThrough.ts +40 -0
- package/src/model/decorators/manyToMany.ts +55 -0
- package/src/model/decorators/morphMany.ts +38 -0
- package/src/model/decorators/morphOne.ts +38 -0
- package/src/model/decorators/morphTo.ts +51 -0
- package/src/model/decorators/morphToMany.ts +49 -0
- package/src/model/decorators/morphedByMany.ts +46 -0
- package/src/model/decorators/table.ts +124 -0
- package/src/model/hooks/HookRegistry.ts +110 -0
- package/src/model/mixins.ts +536 -0
- package/src/model/payload.ts +114 -0
- package/src/model/relations/RelationRegistry.ts +184 -0
- package/src/observability.ts +210 -0
- package/src/provider/DatabaseProvider.ts +266 -0
- package/src/schema/Blueprint.ts +900 -0
- package/src/schema/ColumnDefinition.ts +517 -0
- package/src/schema/Migration.ts +34 -0
- package/src/schema/MigrationCodegen.ts +108 -0
- package/src/schema/MigrationRunner.ts +351 -0
- package/src/schema/ModelInspector.ts +133 -0
- package/src/schema/Schema.ts +140 -0
- package/src/schema/SchemaDiffer.ts +137 -0
- package/src/schema/SchemaInspector.ts +164 -0
- package/src/schema/__test_migrations__/001_create_test_table.ts +15 -0
- package/src/schema/autoMigrate.ts +154 -0
- package/src/schema/index.ts +28 -0
- package/src/seeding/Seeder.ts +46 -0
- package/src/support/identifiers.ts +62 -0
|
@@ -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
|
+
}
|