@zerotal/orm 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +170 -0
  4. package/package.json +58 -0
  5. package/src/casts/Cast.ts +200 -0
  6. package/src/commands/DbSeedCommand.ts +71 -0
  7. package/src/commands/MakeFactoryCommand.ts +59 -0
  8. package/src/commands/MakeMigrationCommand.ts +109 -0
  9. package/src/commands/MakeModelCommand.ts +83 -0
  10. package/src/commands/MakeSeederCommand.ts +50 -0
  11. package/src/commands/MigrateCommand.ts +60 -0
  12. package/src/commands/MigrateFreshCommand.ts +41 -0
  13. package/src/commands/MigrateGenerateCommand.ts +110 -0
  14. package/src/commands/MigrateRollbackCommand.ts +43 -0
  15. package/src/commands/MigrateStatusCommand.ts +49 -0
  16. package/src/commands/_loadMigrations.ts +34 -0
  17. package/src/commands/index.ts +30 -0
  18. package/src/config.ts +182 -0
  19. package/src/conventions.ts +67 -0
  20. package/src/db/DB.ts +486 -0
  21. package/src/db/NPlusOneDetector.ts +176 -0
  22. package/src/db/QueryBuilder.ts +2458 -0
  23. package/src/db/ReadWriteRouter.ts +96 -0
  24. package/src/db/TransactionContext.ts +13 -0
  25. package/src/db/dialects/MysqlDialect.ts +57 -0
  26. package/src/db/dialects/PostgresDialect.ts +55 -0
  27. package/src/db/dialects/SqliteDialect.ts +54 -0
  28. package/src/db/dialects/index.ts +25 -0
  29. package/src/db/dialects/types.ts +67 -0
  30. package/src/db/resolver.ts +30 -0
  31. package/src/db/sql-types.ts +12 -0
  32. package/src/db/types.ts +296 -0
  33. package/src/errors/MassAssignmentError.ts +25 -0
  34. package/src/errors/MigrationError.ts +18 -0
  35. package/src/errors/ModelNotFoundError.ts +21 -0
  36. package/src/errors/NPlusOneError.ts +6 -0
  37. package/src/errors/RelationNotLoadedError.ts +19 -0
  38. package/src/errors/StateError.ts +18 -0
  39. package/src/errors/TransactionError.ts +13 -0
  40. package/src/errors/UnsupportedDialectError.ts +18 -0
  41. package/src/errors/index.ts +7 -0
  42. package/src/events.ts +112 -0
  43. package/src/global.d.ts +17 -0
  44. package/src/implicitBinding.ts +73 -0
  45. package/src/index.ts +255 -0
  46. package/src/model/BaseModel.ts +2499 -0
  47. package/src/model/ModelQueryBuilder.ts +1808 -0
  48. package/src/model/Observer.ts +73 -0
  49. package/src/model/OrmContext.ts +71 -0
  50. package/src/model/ReactiveProxy.ts +53 -0
  51. package/src/model/SoftDeletes.ts +108 -0
  52. package/src/model/State.ts +290 -0
  53. package/src/model/decorators/_metadata.ts +211 -0
  54. package/src/model/decorators/_registerRelation.ts +20 -0
  55. package/src/model/decorators/belongsTo.ts +38 -0
  56. package/src/model/decorators/column.ts +278 -0
  57. package/src/model/decorators/hasMany.ts +34 -0
  58. package/src/model/decorators/hasManyThrough.ts +50 -0
  59. package/src/model/decorators/hasOne.ts +34 -0
  60. package/src/model/decorators/hasOneThrough.ts +40 -0
  61. package/src/model/decorators/manyToMany.ts +55 -0
  62. package/src/model/decorators/morphMany.ts +38 -0
  63. package/src/model/decorators/morphOne.ts +38 -0
  64. package/src/model/decorators/morphTo.ts +51 -0
  65. package/src/model/decorators/morphToMany.ts +49 -0
  66. package/src/model/decorators/morphedByMany.ts +46 -0
  67. package/src/model/decorators/table.ts +124 -0
  68. package/src/model/hooks/HookRegistry.ts +110 -0
  69. package/src/model/mixins.ts +536 -0
  70. package/src/model/payload.ts +114 -0
  71. package/src/model/relations/RelationRegistry.ts +184 -0
  72. package/src/observability.ts +210 -0
  73. package/src/provider/DatabaseProvider.ts +266 -0
  74. package/src/schema/Blueprint.ts +900 -0
  75. package/src/schema/ColumnDefinition.ts +517 -0
  76. package/src/schema/Migration.ts +34 -0
  77. package/src/schema/MigrationCodegen.ts +108 -0
  78. package/src/schema/MigrationRunner.ts +351 -0
  79. package/src/schema/ModelInspector.ts +133 -0
  80. package/src/schema/Schema.ts +140 -0
  81. package/src/schema/SchemaDiffer.ts +137 -0
  82. package/src/schema/SchemaInspector.ts +164 -0
  83. package/src/schema/__test_migrations__/001_create_test_table.ts +15 -0
  84. package/src/schema/autoMigrate.ts +154 -0
  85. package/src/schema/index.ts +28 -0
  86. package/src/seeding/Seeder.ts +46 -0
  87. package/src/support/identifiers.ts +62 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog — @zerotal/orm
2
+
3
+ All notable changes to this package are documented here. The format is
4
+ based on [Keep a Changelog](https://keepachangelog.com/); this package
5
+ follows the Zerotal monorepo's unified versioning.
6
+
7
+ **Maturity: `stable`**
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [1.0.0] — 2026-08-05
12
+
13
+ _First public release._
14
+
15
+ ### Notes
16
+
17
+ - Conforms to the Zerotal package conventions (provider in `src/provider/`, PascalCase config factory, `ZerotalError`-based errors, test coverage).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zerotal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # @zerotal/orm
2
+
3
+ > Active Record ORM for Bun — models, migrations, and a fluent query builder on top of `Bun.sql`.
4
+
5
+ `@zerotal/orm` maps TypeScript classes to database tables: declare columns with decorators, define relationships, and read/write data through a chainable query builder. It supports SQLite, PostgreSQL, and MySQL with the same model code, plus migrations, soft deletes, eager loading, and pagination.
6
+
7
+ Part of the [Zerotal](../../README.md) framework. Requires **Bun ≥ 1.3.14**.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ bun add @zerotal/orm
13
+ ```
14
+
15
+ ## Setup
16
+
17
+ Register the provider in `bootstrap/providers.ts`:
18
+
19
+ ```ts
20
+ import { DatabaseProvider } from "@zerotal/orm";
21
+
22
+ export default [
23
+ // …your other providers
24
+ DatabaseProvider,
25
+ ];
26
+ ```
27
+
28
+ Configure a connection in `config/database.ts`:
29
+
30
+ ```ts
31
+ import { DatabaseConfig } from "@zerotal/orm";
32
+ import { env } from "@zerotal/core";
33
+
34
+ export default DatabaseConfig({
35
+ driver: env("DB_DRIVER", "sqlite"), // 'sqlite' | 'postgres' | 'mysql'
36
+ url: env("DATABASE_URL", "./database/db.sqlite"),
37
+ replicas: [], // optional read-replica URLs
38
+ });
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ### Define a model
44
+
45
+ ```ts
46
+ import { BaseModel, column, table, belongsTo, hasMany } from "@zerotal/orm";
47
+ import type { Columns } from "@zerotal/orm";
48
+
49
+ @(table("posts").withTimestamps().withSoftDeletes())
50
+ export class Post extends BaseModel {
51
+ static fillable: Columns<Post>[] = ["title", "body", "status", "userId"];
52
+
53
+ @column("string") title!: string;
54
+ @column("text") body!: string;
55
+ @column("string") status!: string;
56
+ @column("integer") userId!: number;
57
+
58
+ @belongsTo(() => User, { foreignKey: "userId" })
59
+ author!: User;
60
+
61
+ @hasMany(() => Comment, { foreignKey: "postId" })
62
+ comments!: Comment[];
63
+ }
64
+ ```
65
+
66
+ ### Query records
67
+
68
+ ```ts
69
+ const post = await Post.find(1); // or findOrFail(1) to throw
70
+ const published = await Post.query()
71
+ .where("status", "published")
72
+ .orderBy("created_at", "desc")
73
+ .get<Post>();
74
+
75
+ const created = await Post.create({ title: "Hello", body: "…", status: "draft" });
76
+
77
+ post.fill({ title: "Updated" });
78
+ await post.save();
79
+
80
+ await post.delete(); // soft delete (table has .withSoftDeletes())
81
+ await post.restore(); // un-delete
82
+ await post.forceDelete(); // permanent
83
+ ```
84
+
85
+ ### Paginate
86
+
87
+ ```ts
88
+ const page = await Post.query()
89
+ .where("status", "published")
90
+ .orderBy("created_at", "desc")
91
+ .paginate(15, Number(http.query("page", "1")));
92
+
93
+ page.data; // Post[] for this page
94
+ page.total; // total matching rows
95
+ page.lastPage; // number of pages
96
+ ```
97
+
98
+ ### Migrations
99
+
100
+ ```ts
101
+ import { Migration, Schema } from "@zerotal/orm";
102
+
103
+ export default class CreatePostsTable extends Migration {
104
+ async up(): Promise<void> {
105
+ await Schema.create("posts", (t) => {
106
+ t.increments("id");
107
+ t.integer("user_id").index();
108
+ t.string("title");
109
+ t.text("body");
110
+ t.softDeletes();
111
+ t.timestamps();
112
+ });
113
+ }
114
+
115
+ async down(): Promise<void> {
116
+ await Schema.drop("posts");
117
+ }
118
+ }
119
+ ```
120
+
121
+ Run them with `bun zt migrate` (`--fresh`, `migrate:rollback`, `migrate:status` also available).
122
+
123
+ ### Raw query builder
124
+
125
+ ```ts
126
+ import { DB } from "@zerotal/orm";
127
+
128
+ const rows = await DB.table("settings").where("key", "theme").first();
129
+ await DB.table("settings").upsert(
130
+ { key: "theme", value: "dark" },
131
+ { key: "theme" },
132
+ { value: "dark" },
133
+ );
134
+ ```
135
+
136
+ ## Exports
137
+
138
+ This package exposes two subpath entry points:
139
+
140
+ | Subpath | Contents |
141
+ | ------------- | --------------------------------------------------------------------------------- |
142
+ | `.` (default) | The full ORM runtime — see the table below. |
143
+ | `./commands` | CLI command classes used by the `zerotal` binary (`make:model`, `migrate`, etc.). |
144
+
145
+ Main exports from the default entry point:
146
+
147
+ - **Models** — `BaseModel` / `Model`, `ModelQueryBuilder`, `DB`, `QueryBuilder`
148
+ - **Decorators** — `column`, `table`, `belongsTo`, `hasMany`, `hasOne`, `manyToMany`, `morphTo`, `morphMany`, `morphOne`, `hasManyThrough`, `hasOneThrough`, `morphToMany`, `morphedByMany`
149
+ - **Schema / migrations** — `Schema`, `Blueprint`, `Migration`, `MigrationRunner`, `SchemaInspector`, `ModelInspector`, `SchemaDiffer`, `synchronizeSchema`
150
+ - **Seeding** — `Seeder`
151
+ - **Casts** — `Cast`, `JsonCast`, `ArrayCast`, `json`, `objectOf`, `arrayOf`
152
+ - **Hooks & observers** — `HookRegistry`, `ModelObserver`
153
+ - **N+1 detection** — `preventNPlusOne`, `allowNPlusOne`, `NPlusOneError`
154
+ - **Errors** — `ModelNotFoundError`, `RelationNotLoadedError`, `TransactionError`, `MigrationError`, `StateError`
155
+ - **Provider & config** — `DatabaseProvider`, `DatabaseConfig`
156
+ - **Types** — `Columns`, `InsertPayload`, `UpdatePayload`, `PaginateResult`, `CursorPaginateResult`, and more
157
+
158
+ ## Documentation
159
+
160
+ - [ORM overview](../../docs/orm/index.md)
161
+ - [Queries](../../docs/orm/queries.md)
162
+ - [Relationships](../../docs/orm/relationships.md)
163
+ - [Casts & Mutators](../../docs/orm/casts.md)
164
+ - [Lifecycle & Events](../../docs/orm/lifecycle.md)
165
+ - [Serialization](../../docs/orm/serialization.md)
166
+ - [Factories](../../docs/orm/factories.md)
167
+ - [Query Builder](../../docs/query-builder.md)
168
+ - [Migrations](../../docs/migrations.md)
169
+ - [Pagination](../../docs/pagination.md)
170
+ - [Seeding](../../docs/seeding.md)
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@zerotal/orm",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "maturity": "stable",
6
+ "private": false,
7
+ "type": "module",
8
+ "main": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "exports": {
11
+ ".": "./src/index.ts",
12
+ "./commands": "./src/commands/index.ts"
13
+ },
14
+ "files": [
15
+ "CHANGELOG.md",
16
+ "src",
17
+ "!src/**/*.test.ts",
18
+ "!src/**/*.test.tsx",
19
+ "!src/**/*.spec.ts",
20
+ "!src/**/__fixtures__/**"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "engines": {
26
+ "bun": ">=1.3.14"
27
+ },
28
+ "scripts": {
29
+ "test": "bun test",
30
+ "typecheck": "tsc --noEmit"
31
+ },
32
+ "dependencies": {
33
+ "@zerotal/core": "1.0.0",
34
+ "@zerotal/validator": "1.0.0"
35
+ },
36
+ "devDependencies": {
37
+ "typescript": "^5.8.0"
38
+ },
39
+ "description": "Active Record ORM for Zerotal built on Bun.sql — models, relations, query builder, migrations, and schema.",
40
+ "keywords": [
41
+ "zerotal",
42
+ "bun",
43
+ "typescript",
44
+ "framework",
45
+ "orm",
46
+ "active-record",
47
+ "sql",
48
+ "database",
49
+ "migrations"
50
+ ],
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/zerotaldev/zerotal.git",
54
+ "directory": "packages/orm"
55
+ },
56
+ "homepage": "https://github.com/zerotaldev/zerotal/tree/main/packages/orm#readme",
57
+ "bugs": "https://github.com/zerotaldev/zerotal/issues"
58
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Column casts — convert a stored DB value to a rich model value on read, and
3
+ * back on write. The framework already accepts any `{ get, set }` object as a
4
+ * cast; `Cast` is the ergonomic, reusable base for custom ones (put yours in
5
+ * `app/casts/`), plus built-in JSON/array casters.
6
+ *
7
+ * @example
8
+ * // app/casts/MoneyCast.ts
9
+ * export class MoneyCast extends Cast<number> {
10
+ * get(db: unknown) { return Number(db) / 100; } // cents → dollars
11
+ * set(v: number) { return Math.round(v * 100); }
12
+ * }
13
+ *
14
+ * // model
15
+ * @column({ cast: new MoneyCast() }) price!: number;
16
+ * @column({ cast: arrayOf(Address) }) addresses!: Address[];
17
+ * @column({ cast: json<Settings>() }) settings!: Settings;
18
+ */
19
+
20
+ /**
21
+ * A reusable column caster: DB value ⇄ model value.
22
+ *
23
+ * Any object with these two methods can be passed to `@column({ cast })`; the
24
+ * ORM calls `get` when hydrating an attribute from the database and `set` when
25
+ * writing it back.
26
+ *
27
+ * @typeParam T - The model-side (deserialized) value type.
28
+ * @category Casts
29
+ */
30
+ export interface CastContract<T = unknown> {
31
+ /** DB value → model value (on read). */
32
+ get(dbValue: unknown): T;
33
+ /** Model value → DB value (on write). */
34
+ set(value: T): unknown;
35
+ }
36
+
37
+ /**
38
+ * Base class for custom casts. Extend it, implement `get`/`set`, and reference an
39
+ * instance from a column: `@column({ cast: new MoneyCast() })`.
40
+ *
41
+ * @typeParam T - The model-side (deserialized) value type.
42
+ * @category Casts
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * // app/casts/MoneyCast.ts
47
+ * export class MoneyCast extends Cast<number> {
48
+ * get(db: unknown) { return Number(db) / 100; } // cents → dollars
49
+ * set(v: number) { return Math.round(v * 100); }
50
+ * }
51
+ * ```
52
+ */
53
+ export abstract class Cast<T = unknown> implements CastContract<T> {
54
+ abstract get(dbValue: unknown): T;
55
+ abstract set(value: T): unknown;
56
+ }
57
+
58
+ /** A class constructor or mapper function used to hydrate a JSON value. */
59
+ export type CastMapper<T> = ((raw: unknown) => T) | (new (...args: never[]) => T);
60
+
61
+ function _parse(v: unknown): unknown {
62
+ if (v === null || v === undefined) return v;
63
+ if (typeof v === "string") {
64
+ try {
65
+ return JSON.parse(v);
66
+ } catch {
67
+ return v;
68
+ }
69
+ }
70
+ return v;
71
+ }
72
+
73
+ function _isClass(fn: unknown): boolean {
74
+ return typeof fn === "function" && /^class[\s{]/.test(Function.prototype.toString.call(fn));
75
+ }
76
+
77
+ function _hydrate<T>(mapper: CastMapper<T> | undefined, raw: unknown): T {
78
+ if (mapper === undefined || raw === null || raw === undefined) return raw as T;
79
+ if (_isClass(mapper)) {
80
+ const C = mapper as unknown as { fromJSON?: (r: unknown) => T; prototype: object };
81
+ if (typeof C.fromJSON === "function") return C.fromJSON(raw);
82
+ // Hydrate without invoking the constructor (data/value objects).
83
+ return Object.assign(Object.create(C.prototype) as object, raw) as T;
84
+ }
85
+ return (mapper as (raw: unknown) => T)(raw);
86
+ }
87
+
88
+ /** A sub-field discovered on a cast's mapper class — consumed by the admin UI. */
89
+ export interface CastField {
90
+ name: string;
91
+ label: string;
92
+ type: string;
93
+ }
94
+
95
+ function _titleField(s: string): string {
96
+ return s
97
+ .replace(/[_-]+/g, " ")
98
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
99
+ .replace(/\b\w/g, (c) => c.toUpperCase())
100
+ .trim();
101
+ }
102
+
103
+ function _widgetForValue(v: unknown): string {
104
+ if (typeof v === "boolean") return "toggle";
105
+ if (typeof v === "number") return "number";
106
+ return "text";
107
+ }
108
+
109
+ /** Introspect a mapper class into a field list (keys + inferred types from defaults). */
110
+ function _introspectFields(mapper: unknown): CastField[] {
111
+ if (!_isClass(mapper)) return [];
112
+ let inst: Record<string, unknown>;
113
+ try {
114
+ inst = new (mapper as new () => Record<string, unknown>)();
115
+ } catch {
116
+ return [];
117
+ }
118
+ return Object.entries(inst)
119
+ .filter(([, v]) => typeof v !== "function")
120
+ .map(([k, v]) => ({ name: k, label: _titleField(k), type: _widgetForValue(v) }));
121
+ }
122
+
123
+ /**
124
+ * Cast a JSON column to a typed object, optionally hydrated into a class.
125
+ * On read the stored JSON is parsed (and mapped via the constructor's `fromJSON`
126
+ * or prototype hydration when a class mapper is given); on write it is stringified.
127
+ * Usually created via the {@link json} / {@link objectOf} helpers.
128
+ * @category Casts
129
+ */
130
+ export class JsonCast<T = unknown> extends Cast<T | null> {
131
+ /** Structural hint for the admin UI: a single nested object. */
132
+ readonly shape = "object" as const;
133
+ constructor(private readonly mapper?: CastMapper<T>) {
134
+ super();
135
+ }
136
+ /** Sub-fields derived from the mapper class (empty for plain JSON). */
137
+ fields(): CastField[] {
138
+ return _introspectFields(this.mapper);
139
+ }
140
+ get(dbValue: unknown): T | null {
141
+ const v = _parse(dbValue);
142
+ return v === null || v === undefined ? (v as null) : _hydrate(this.mapper, v);
143
+ }
144
+ set(value: T | null): unknown {
145
+ return value === null || value === undefined ? null : JSON.stringify(value);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Cast a JSON column to an array of typed values, optionally hydrated. Non-array
151
+ * stored values read back as `[]`; on write the array is stringified. Usually
152
+ * created via the {@link arrayOf} helper.
153
+ * @category Casts
154
+ */
155
+ export class ArrayCast<T = unknown> extends Cast<T[]> {
156
+ /** Structural hint for the admin UI: a repeating list of nested objects. */
157
+ readonly shape = "array" as const;
158
+ constructor(private readonly mapper?: CastMapper<T>) {
159
+ super();
160
+ }
161
+ /** Sub-fields of each element, derived from the mapper class. */
162
+ fields(): CastField[] {
163
+ return _introspectFields(this.mapper);
164
+ }
165
+ get(dbValue: unknown): T[] {
166
+ const v = _parse(dbValue);
167
+ if (!Array.isArray(v)) return [];
168
+ return v.map((x) => _hydrate(this.mapper, x));
169
+ }
170
+ set(value: T[]): unknown {
171
+ return JSON.stringify(value ?? []);
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Typed JSON object cast: `@column({ cast: json<Settings>() })`.
177
+ * @param mapper - Optional class or function to hydrate the parsed value into.
178
+ * @category Casts
179
+ */
180
+ export function json<T = unknown>(mapper?: CastMapper<T>): JsonCast<T> {
181
+ return new JsonCast<T>(mapper);
182
+ }
183
+
184
+ /**
185
+ * Alias of {@link json}, reads nicely with a class: `objectOf(Address)`.
186
+ * @param mapper - Optional class or function to hydrate the parsed value into.
187
+ * @category Casts
188
+ */
189
+ export function objectOf<T = unknown>(mapper?: CastMapper<T>): JsonCast<T> {
190
+ return new JsonCast<T>(mapper);
191
+ }
192
+
193
+ /**
194
+ * Typed JSON list cast: `@column({ cast: arrayOf(Address) })`.
195
+ * @param mapper - Optional class or function to hydrate each element into.
196
+ * @category Casts
197
+ */
198
+ export function arrayOf<T = unknown>(mapper?: CastMapper<T>): ArrayCast<T> {
199
+ return new ArrayCast<T>(mapper);
200
+ }
@@ -0,0 +1,71 @@
1
+ import { Command } from "@zerotal/core";
2
+ import type { Seeder } from "../seeding/Seeder.ts";
3
+
4
+ /**
5
+ * Runs the application's database seeders (`bun zt db:seed`).
6
+ *
7
+ * Imports `database/seeders/DatabaseSeeder.ts` and invokes its `run()` method,
8
+ * falling back to a legacy `database/seeders/index.ts` default-export function
9
+ * when the class-based seeder is absent.
10
+ *
11
+ * @example
12
+ * ```bash
13
+ * bun zt db:seed
14
+ * ```
15
+ *
16
+ * @category Seeding
17
+ */
18
+ export class DbSeedCommand extends Command {
19
+ static commandName = "db:seed";
20
+ static description = "Run database seeders from database/seeders/";
21
+ static needsApp = true;
22
+
23
+ async run(): Promise<void> {
24
+ const cwd = process.cwd();
25
+ const seederPath = `${cwd}/database/seeders/DatabaseSeeder.ts`;
26
+
27
+ const file = Bun.file(seederPath);
28
+ if (!(await file.exists())) {
29
+ // Fall back to legacy index.ts seeder format
30
+ const legacyPath = `${cwd}/database/seeders/index.ts`;
31
+ if (await Bun.file(legacyPath).exists()) {
32
+ try {
33
+ const mod = await import(legacyPath);
34
+ const seed = mod.default as (() => Promise<void>) | undefined;
35
+ if (!seed) {
36
+ this.error("Seeder index must export a default async function.");
37
+ return;
38
+ }
39
+ await seed();
40
+ this.info("Database seeded.");
41
+ } catch (err) {
42
+ const msg = err instanceof Error ? err.message : String(err);
43
+ this.error(`Failed to run seeders: ${msg}`);
44
+ }
45
+ return;
46
+ }
47
+
48
+ this.error(`Seeder not found: ${seederPath}`);
49
+ this.dim("Create it with: bun zerotal.ts make:seeder DatabaseSeeder");
50
+ return;
51
+ }
52
+
53
+ try {
54
+ const mod = await import(seederPath);
55
+ const SeederClass = (mod.DatabaseSeeder ?? mod.default) as (new () => Seeder) | undefined;
56
+
57
+ if (!SeederClass) {
58
+ this.error("DatabaseSeeder not found as a named or default export.");
59
+ return;
60
+ }
61
+
62
+ this.section("Database Seeding");
63
+ const seeder = new SeederClass();
64
+ await seeder.run();
65
+ this.info("Database seeded successfully.");
66
+ } catch (err) {
67
+ const msg = err instanceof Error ? err.message : String(err);
68
+ this.error(`Failed to run seeders: ${msg}`);
69
+ }
70
+ }
71
+ }
@@ -0,0 +1,59 @@
1
+ import { Command, Str } from "@zerotal/core";
2
+
3
+ /**
4
+ * Scaffolds a new model factory (`bun zt make:factory`).
5
+ *
6
+ * Writes `database/factories/<Model>Factory.ts` defining a `Factory` for the
7
+ * named model, ready to be imported in tests to build and persist records.
8
+ *
9
+ * @example
10
+ * ```bash
11
+ * bun zt make:factory User
12
+ * ```
13
+ *
14
+ * @category Scaffolding (make:*)
15
+ */
16
+ export class MakeFactoryCommand extends Command {
17
+ static commandName = "make:factory";
18
+ static description = "Create a new model factory";
19
+ static needsApp = false;
20
+ static args = [{ name: "model", required: true, description: "Model name (e.g. User)" }];
21
+
22
+ async run(): Promise<void> {
23
+ const model = this.args["model"]!;
24
+ const path = `database/factories/${model}Factory.ts`;
25
+
26
+ if (await Bun.file(path).exists()) {
27
+ this.error(`File already exists: ${path}`);
28
+ return;
29
+ }
30
+
31
+ await Bun.write(path, factoryStub(model));
32
+ this.info(`Created: ${path}`);
33
+ this.dim(
34
+ `Import in tests: import { ${model}Factory } from '../database/factories/${model}Factory.ts';`,
35
+ );
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Returns the source text of a factory file for the given model name.
41
+ */
42
+ export function factoryStub(model: string): string {
43
+ const modelVar = Str.lcfirst(model);
44
+ return `import { Factory } from '@zerotal/testing';
45
+ import { ${model} } from '../../app/models/${model}.ts';
46
+
47
+ export const ${model}Factory = Factory.define(${model}, (fake) => ({
48
+ // Define default attributes here. Use fake for generated values:
49
+ // name: fake.string(10),
50
+ // email: fake.email(),
51
+ }));
52
+
53
+ // Usage in tests:
54
+ // const ${modelVar} = await ${model}Factory.create();
55
+ // const ${modelVar}s = await ${model}Factory.createMany(5);
56
+ // const draft = ${model}Factory.make({ published: false });
57
+ // const post = await PostFactory.for(${modelVar}).create();
58
+ `;
59
+ }