@bunnykit/orm 0.1.0 → 0.1.2
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/README.md +53 -16
- package/dist/bin/bunny.js +231 -10
- package/dist/src/migration/Migrator.d.ts +3 -2
- package/dist/src/migration/Migrator.js +67 -24
- package/dist/src/typegen/TypeGenerator.d.ts +3 -0
- package/dist/src/typegen/TypeGenerator.js +71 -51
- package/dist/src/utils.d.ts +2 -0
- package/dist/src/utils.js +9 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Bunny
|
|
2
2
|
|
|
3
|
+
> **Bun-only package.** Install with:
|
|
4
|
+
>
|
|
5
|
+
> ```bash
|
|
6
|
+
> bun add @bunnykit/orm
|
|
7
|
+
> ```
|
|
8
|
+
>
|
|
9
|
+
> npm, yarn, pnpm, and Node.js runtime usage are not supported.
|
|
10
|
+
|
|
3
11
|
An **Eloquent-inspired ORM** built specifically for [Bun](https://bun.sh)'s native `bun:sql` client. Supports **SQLite**, **MySQL**, and **PostgreSQL** with full TypeScript typing, a chainable query builder, schema migrations, model observers, and polymorphic relations.
|
|
4
12
|
|
|
5
13
|
---
|
|
@@ -24,8 +32,6 @@ An **Eloquent-inspired ORM** built specifically for [Bun](https://bun.sh)'s nati
|
|
|
24
32
|
bun add @bunnykit/orm
|
|
25
33
|
```
|
|
26
34
|
|
|
27
|
-
> **Note:** This package is Bun-only. Install and run it with Bun >= 1.1; npm, yarn, pnpm, and Node.js runtime usage are not supported.
|
|
28
|
-
|
|
29
35
|
---
|
|
30
36
|
|
|
31
37
|
## Configuration
|
|
@@ -46,7 +52,15 @@ export default {
|
|
|
46
52
|
// username: "root",
|
|
47
53
|
// password: "secret",
|
|
48
54
|
},
|
|
49
|
-
migrationsPath: "./database/migrations",
|
|
55
|
+
migrationsPath: ["./database/migrations", "./database/tenant-migrations"],
|
|
56
|
+
modelsPath: ["./src/models", "./src/admin/models"],
|
|
57
|
+
// Optional legacy type output directory
|
|
58
|
+
typesOutDir: "./src/generated/model-types",
|
|
59
|
+
// Optional typegen overrides
|
|
60
|
+
typeDeclarationImportPrefix: "../models",
|
|
61
|
+
typeDeclarations: {
|
|
62
|
+
admin_users: { path: "../AdminAccount", className: "AdminAccount" },
|
|
63
|
+
},
|
|
50
64
|
};
|
|
51
65
|
```
|
|
52
66
|
|
|
@@ -54,7 +68,9 @@ Or use environment variables:
|
|
|
54
68
|
|
|
55
69
|
```bash
|
|
56
70
|
export DATABASE_URL="sqlite://app.db"
|
|
57
|
-
export MIGRATIONS_PATH="./database/migrations"
|
|
71
|
+
export MIGRATIONS_PATH="./database/migrations,./database/tenant-migrations"
|
|
72
|
+
export MODELS_PATH="./src/models,./src/admin/models"
|
|
73
|
+
export TYPES_OUT_DIR="./src/generated/model-types"
|
|
58
74
|
```
|
|
59
75
|
|
|
60
76
|
---
|
|
@@ -137,6 +153,16 @@ await user.save();
|
|
|
137
153
|
await user.delete();
|
|
138
154
|
```
|
|
139
155
|
|
|
156
|
+
### REPL
|
|
157
|
+
|
|
158
|
+
Start an interactive Bunny session with the ORM already loaded:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
bunny repl
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The REPL exposes `Model`, `Schema`, `Connection`, `db`, and a `Models` map. Any model files under `modelsPath` are loaded automatically and also registered by class name on the global scope. If no project config is present, it starts against an in-memory SQLite database so you can still experiment immediately.
|
|
165
|
+
|
|
140
166
|
---
|
|
141
167
|
|
|
142
168
|
## Schema Builder
|
|
@@ -683,6 +709,9 @@ ObserverRegistry.register(User, {
|
|
|
683
709
|
# Create a new migration file
|
|
684
710
|
bun run bunny migrate:make CreateUsersTable
|
|
685
711
|
|
|
712
|
+
# Create in a specific folder
|
|
713
|
+
bun run bunny migrate:make CreateUsersTable ./database/tenant-migrations
|
|
714
|
+
|
|
686
715
|
# Run all pending migrations
|
|
687
716
|
bun run bunny migrate
|
|
688
717
|
|
|
@@ -718,12 +747,14 @@ Migrations are tracked in a `migrations` table (auto-created on first run).
|
|
|
718
747
|
|
|
719
748
|
### Auto Type Generation
|
|
720
749
|
|
|
721
|
-
If you set `typesOutDir` in your config, types are **automatically regenerated** after every `migrate` and `migrate:rollback
|
|
750
|
+
If you set `typesOutDir` in your config, types are **automatically regenerated** after every `migrate` and `migrate:rollback`.
|
|
751
|
+
|
|
752
|
+
If you set `modelsPath`, Bunny writes declaration files into a `types/` folder next to each model root and regenerates those files after migrations too:
|
|
722
753
|
|
|
723
754
|
```bash
|
|
724
755
|
bun run bunny migrate
|
|
725
756
|
# → Migrated: 2026xxxx_create_users_table.ts
|
|
726
|
-
# → Regenerated types in ./src/
|
|
757
|
+
# → Regenerated types in ./src/models/types, ./src/admin/models/types
|
|
727
758
|
```
|
|
728
759
|
|
|
729
760
|
No extra step needed — your models stay in sync with the schema automatically.
|
|
@@ -798,6 +829,9 @@ user.email = "a@example.com"; // ✅ typed setter
|
|
|
798
829
|
# Generate into default directory (./generated/models)
|
|
799
830
|
bun run bunny types:generate
|
|
800
831
|
|
|
832
|
+
# Generate into model-local ./types folders when modelsPath is configured
|
|
833
|
+
bun run bunny types:generate
|
|
834
|
+
|
|
801
835
|
# Generate into a custom directory
|
|
802
836
|
bun run bunny types:generate ./src/generated
|
|
803
837
|
```
|
|
@@ -807,23 +841,26 @@ Or configure in `bunny.config.ts`:
|
|
|
807
841
|
```ts
|
|
808
842
|
export default {
|
|
809
843
|
connection: { url: "sqlite://app.db" },
|
|
810
|
-
migrationsPath: "./database/migrations",
|
|
811
|
-
|
|
844
|
+
migrationsPath: ["./database/migrations", "./database/tenant-migrations"],
|
|
845
|
+
modelsPath: ["./src/models", "./src/admin/models"],
|
|
846
|
+
// Optional legacy output directory, still supported when you do not want
|
|
847
|
+
// the generated files beside each model root:
|
|
848
|
+
typesOutDir: "./src/generated/model-types",
|
|
849
|
+
// Optional override for custom module resolution when using typesOutDir:
|
|
812
850
|
typeDeclarationImportPrefix: "../models",
|
|
813
|
-
// Optional overrides for non-conventional model names or paths:
|
|
814
851
|
typeDeclarations: {
|
|
815
|
-
admin_users: { path: "../
|
|
852
|
+
admin_users: { path: "../AdminAccount", className: "AdminAccount" },
|
|
816
853
|
},
|
|
817
854
|
};
|
|
818
855
|
```
|
|
819
856
|
|
|
820
|
-
With `
|
|
857
|
+
With `modelsPath`, Bunny conventionally maps tables to singular PascalCase model modules and writes the generated declarations into `types/` beside each model root:
|
|
821
858
|
|
|
822
859
|
| Table | Generated augmentation |
|
|
823
860
|
|-------|------------------------|
|
|
824
|
-
| `users` | `../
|
|
825
|
-
| `blog_posts` | `../
|
|
826
|
-
| `categories` | `../
|
|
861
|
+
| `users` | `../User` / `User` |
|
|
862
|
+
| `blog_posts` | `../BlogPost` / `BlogPost` |
|
|
863
|
+
| `categories` | `../Category` / `Category` |
|
|
827
864
|
|
|
828
865
|
Set `typeDeclarationSingularModels: false` if your model classes use plural names.
|
|
829
866
|
|
|
@@ -832,7 +869,7 @@ Set `typeDeclarationSingularModels: false` if your model classes use plural name
|
|
|
832
869
|
For each table, Bunny generates an `Attributes` interface. If you configure `typeDeclarations`, it also augments your real model class:
|
|
833
870
|
|
|
834
871
|
```ts
|
|
835
|
-
//
|
|
872
|
+
// src/models/types/users.d.ts
|
|
836
873
|
export interface UsersAttributes {
|
|
837
874
|
id: number;
|
|
838
875
|
name: string;
|
|
@@ -840,7 +877,7 @@ export interface UsersAttributes {
|
|
|
840
877
|
created_at: string;
|
|
841
878
|
}
|
|
842
879
|
|
|
843
|
-
declare module "../
|
|
880
|
+
declare module "../User" {
|
|
844
881
|
interface User extends UsersAttributes {}
|
|
845
882
|
}
|
|
846
883
|
```
|
package/dist/bin/bunny.js
CHANGED
|
@@ -4,8 +4,201 @@ import { Migrator } from "../src/migration/Migrator.js";
|
|
|
4
4
|
import { MigrationCreator } from "../src/migration/MigrationCreator.js";
|
|
5
5
|
import { TypeGenerator } from "../src/typegen/TypeGenerator.js";
|
|
6
6
|
import { existsSync } from "fs";
|
|
7
|
+
import { mkdir, rm, writeFile } from "fs/promises";
|
|
7
8
|
import { join } from "path";
|
|
8
|
-
|
|
9
|
+
import { normalizePathList } from "../src/utils.js";
|
|
10
|
+
function parseEnvPathSetting(value) {
|
|
11
|
+
if (!value)
|
|
12
|
+
return undefined;
|
|
13
|
+
const paths = value
|
|
14
|
+
.split(",")
|
|
15
|
+
.map((item) => item.trim())
|
|
16
|
+
.filter((item) => item.length > 0);
|
|
17
|
+
if (paths.length === 0)
|
|
18
|
+
return undefined;
|
|
19
|
+
return paths.length === 1 ? paths[0] : paths;
|
|
20
|
+
}
|
|
21
|
+
async function createReplBootstrap(config) {
|
|
22
|
+
const tmpRoot = process.env.BUNNY_REPL_TMPDIR || "/private/tmp";
|
|
23
|
+
const dir = join(tmpRoot, "bunny-repl");
|
|
24
|
+
await mkdir(dir, { recursive: true });
|
|
25
|
+
const bootstrapPath = join(dir, `bootstrap-${Date.now()}-${Math.random().toString(36).slice(2)}.ts`);
|
|
26
|
+
const modelRoots = normalizePathList(config.modelsPath || config.typeDeclarationModelsDir);
|
|
27
|
+
const source = `
|
|
28
|
+
import {
|
|
29
|
+
BelongsTo,
|
|
30
|
+
BelongsToMany,
|
|
31
|
+
Blueprint,
|
|
32
|
+
Builder,
|
|
33
|
+
Connection,
|
|
34
|
+
Grammar,
|
|
35
|
+
HasMany,
|
|
36
|
+
HasManyThrough,
|
|
37
|
+
HasOne,
|
|
38
|
+
HasOneThrough,
|
|
39
|
+
Migration,
|
|
40
|
+
MigrationCreator,
|
|
41
|
+
Migrator,
|
|
42
|
+
MorphMany,
|
|
43
|
+
MorphMap,
|
|
44
|
+
MorphOne,
|
|
45
|
+
MorphTo,
|
|
46
|
+
MorphToMany,
|
|
47
|
+
MySqlGrammar,
|
|
48
|
+
ObserverRegistry,
|
|
49
|
+
PostgresGrammar,
|
|
50
|
+
Schema,
|
|
51
|
+
SQLiteGrammar,
|
|
52
|
+
TypeGenerator,
|
|
53
|
+
TypeMapper,
|
|
54
|
+
Model
|
|
55
|
+
} from "@bunnykit/orm";
|
|
56
|
+
import { existsSync } from "fs";
|
|
57
|
+
import { readdir } from "fs/promises";
|
|
58
|
+
import { basename, extname, join, resolve } from "path";
|
|
59
|
+
import { pathToFileURL } from "url";
|
|
60
|
+
|
|
61
|
+
const connection = new Connection(${JSON.stringify(config.connection)});
|
|
62
|
+
Model.setConnection(connection);
|
|
63
|
+
Schema.setConnection(connection);
|
|
64
|
+
|
|
65
|
+
const modelRoots = ${JSON.stringify(modelRoots)};
|
|
66
|
+
|
|
67
|
+
async function walkFiles(dir) {
|
|
68
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
69
|
+
const files = [];
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
if (entry.name === "types") continue;
|
|
72
|
+
const fullPath = join(dir, entry.name);
|
|
73
|
+
if (entry.isDirectory()) {
|
|
74
|
+
files.push(...await walkFiles(fullPath));
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (!entry.isFile()) continue;
|
|
78
|
+
const name = entry.name;
|
|
79
|
+
if (name.endsWith(".d.ts") || name.endsWith(".test.ts") || name.endsWith(".spec.ts")) continue;
|
|
80
|
+
if (![".ts", ".js", ".mts", ".mjs", ".cts", ".cjs"].includes(extname(name))) continue;
|
|
81
|
+
files.push(fullPath);
|
|
82
|
+
}
|
|
83
|
+
return files;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function loadModels(roots) {
|
|
87
|
+
const loaded = {};
|
|
88
|
+
for (const root of roots) {
|
|
89
|
+
const resolvedRoot = resolve(process.cwd(), root);
|
|
90
|
+
if (!existsSync(resolvedRoot)) continue;
|
|
91
|
+
const files = await walkFiles(resolvedRoot);
|
|
92
|
+
for (const file of files.sort()) {
|
|
93
|
+
const mod = await import(pathToFileURL(file).href);
|
|
94
|
+
for (const [exportName, exported] of Object.entries(mod)) {
|
|
95
|
+
if (exportName === "default") continue;
|
|
96
|
+
if (typeof exported === "function" && exported.prototype instanceof Model) {
|
|
97
|
+
const modelName = exportName;
|
|
98
|
+
loaded[modelName] = exported;
|
|
99
|
+
globalThis[modelName] = exported;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (typeof mod.default === "function" && mod.default.prototype instanceof Model) {
|
|
103
|
+
const modelName = mod.default.name || basename(file, extname(file));
|
|
104
|
+
loaded[modelName] = mod.default;
|
|
105
|
+
globalThis[modelName] = mod.default;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
globalThis.Models = loaded;
|
|
110
|
+
return loaded;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const loadedModels = await loadModels(modelRoots);
|
|
114
|
+
|
|
115
|
+
Object.assign(globalThis, {
|
|
116
|
+
Connection,
|
|
117
|
+
Builder,
|
|
118
|
+
Blueprint,
|
|
119
|
+
Grammar,
|
|
120
|
+
SQLiteGrammar,
|
|
121
|
+
MySqlGrammar,
|
|
122
|
+
PostgresGrammar,
|
|
123
|
+
Model,
|
|
124
|
+
HasMany,
|
|
125
|
+
BelongsTo,
|
|
126
|
+
HasOne,
|
|
127
|
+
HasManyThrough,
|
|
128
|
+
HasOneThrough,
|
|
129
|
+
BelongsToMany,
|
|
130
|
+
MorphMap,
|
|
131
|
+
MorphTo,
|
|
132
|
+
MorphOne,
|
|
133
|
+
MorphMany,
|
|
134
|
+
MorphToMany,
|
|
135
|
+
ObserverRegistry,
|
|
136
|
+
Migration,
|
|
137
|
+
Migrator,
|
|
138
|
+
MigrationCreator,
|
|
139
|
+
TypeGenerator,
|
|
140
|
+
TypeMapper,
|
|
141
|
+
Schema,
|
|
142
|
+
db: connection,
|
|
143
|
+
connection,
|
|
144
|
+
Models: loadedModels,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
console.log(\`Bunny REPL ready. Loaded \${Object.keys(loadedModels).length} model classes from modelsPath.\`);
|
|
148
|
+
`;
|
|
149
|
+
await writeFile(bootstrapPath, source, "utf-8");
|
|
150
|
+
return bootstrapPath;
|
|
151
|
+
}
|
|
152
|
+
async function runRepl(config, replArgs) {
|
|
153
|
+
const bootstrapPath = await createReplBootstrap(config);
|
|
154
|
+
await mkdir("/private/tmp/bunny-repl-cache", { recursive: true });
|
|
155
|
+
const proc = Bun.spawn(["bun", "repl", ...replArgs], {
|
|
156
|
+
env: {
|
|
157
|
+
...process.env,
|
|
158
|
+
TMPDIR: "/private/tmp",
|
|
159
|
+
TEMP: "/private/tmp",
|
|
160
|
+
TMP: "/private/tmp",
|
|
161
|
+
BUN_RUNTIME_TRANSPILER_CACHE_PATH: "/private/tmp/bunny-repl-cache",
|
|
162
|
+
},
|
|
163
|
+
terminal: {
|
|
164
|
+
cols: process.stdout.columns || 80,
|
|
165
|
+
rows: process.stdout.rows || 24,
|
|
166
|
+
data(_terminal, data) {
|
|
167
|
+
process.stdout.write(data);
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
const stdin = process.stdin;
|
|
172
|
+
const terminal = proc.terminal;
|
|
173
|
+
const restoreRawMode = stdin.isTTY && typeof stdin.setRawMode === "function";
|
|
174
|
+
if (restoreRawMode) {
|
|
175
|
+
stdin.setRawMode(true);
|
|
176
|
+
}
|
|
177
|
+
stdin.resume();
|
|
178
|
+
const onData = (chunk) => {
|
|
179
|
+
terminal.write(chunk);
|
|
180
|
+
};
|
|
181
|
+
stdin.on("data", onData);
|
|
182
|
+
const cleanup = async () => {
|
|
183
|
+
stdin.off("data", onData);
|
|
184
|
+
if (restoreRawMode) {
|
|
185
|
+
stdin.setRawMode(false);
|
|
186
|
+
}
|
|
187
|
+
terminal.close();
|
|
188
|
+
await rm(bootstrapPath, { force: true });
|
|
189
|
+
};
|
|
190
|
+
process.once("SIGINT", () => {
|
|
191
|
+
terminal.close();
|
|
192
|
+
});
|
|
193
|
+
process.once("SIGTERM", () => {
|
|
194
|
+
terminal.close();
|
|
195
|
+
});
|
|
196
|
+
terminal.write(`.load ${bootstrapPath}\n`);
|
|
197
|
+
const exitCode = await proc.exited;
|
|
198
|
+
await cleanup();
|
|
199
|
+
return exitCode;
|
|
200
|
+
}
|
|
201
|
+
async function loadConfig(allowFallback = false) {
|
|
9
202
|
const configPath = join(process.cwd(), "bunny.config.ts");
|
|
10
203
|
if (existsSync(configPath)) {
|
|
11
204
|
const mod = await import(configPath);
|
|
@@ -21,7 +214,8 @@ async function loadConfig() {
|
|
|
21
214
|
if (url) {
|
|
22
215
|
return {
|
|
23
216
|
connection: { url },
|
|
24
|
-
migrationsPath: process.env.MIGRATIONS_PATH || "./database/migrations",
|
|
217
|
+
migrationsPath: parseEnvPathSetting(process.env.MIGRATIONS_PATH) || "./database/migrations",
|
|
218
|
+
modelsPath: parseEnvPathSetting(process.env.MODELS_PATH),
|
|
25
219
|
};
|
|
26
220
|
}
|
|
27
221
|
const driver = process.env.DB_CONNECTION;
|
|
@@ -36,7 +230,15 @@ async function loadConfig() {
|
|
|
36
230
|
password: process.env.DB_PASSWORD,
|
|
37
231
|
filename: process.env.DB_DATABASE,
|
|
38
232
|
},
|
|
39
|
-
migrationsPath: process.env.MIGRATIONS_PATH || "./database/migrations",
|
|
233
|
+
migrationsPath: parseEnvPathSetting(process.env.MIGRATIONS_PATH) || "./database/migrations",
|
|
234
|
+
modelsPath: parseEnvPathSetting(process.env.MODELS_PATH),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
if (allowFallback) {
|
|
238
|
+
return {
|
|
239
|
+
connection: { url: "sqlite://:memory:" },
|
|
240
|
+
migrationsPath: parseEnvPathSetting(process.env.MIGRATIONS_PATH) || "./database/migrations",
|
|
241
|
+
modelsPath: parseEnvPathSetting(process.env.MODELS_PATH),
|
|
40
242
|
};
|
|
41
243
|
}
|
|
42
244
|
throw new Error("No database configuration found. Create bunny.config.ts or set DATABASE_URL / DB_CONNECTION environment variables.");
|
|
@@ -47,41 +249,58 @@ async function main() {
|
|
|
47
249
|
if (command === "migrate:make") {
|
|
48
250
|
const name = args[1];
|
|
49
251
|
if (!name) {
|
|
50
|
-
console.error("Usage: bun run bunny migrate:make <name>");
|
|
252
|
+
console.error("Usage: bun run bunny migrate:make <name> [directory]");
|
|
51
253
|
process.exit(1);
|
|
52
254
|
}
|
|
53
255
|
const config = await loadConfig();
|
|
54
256
|
const creator = new MigrationCreator();
|
|
55
|
-
const
|
|
257
|
+
const migrationRoots = normalizePathList(config.migrationsPath);
|
|
258
|
+
const targetPath = args[2] || migrationRoots[0] || "./database/migrations";
|
|
259
|
+
const path = await creator.create(name, targetPath);
|
|
56
260
|
console.log(`Created migration: ${path}`);
|
|
57
261
|
return;
|
|
58
262
|
}
|
|
59
263
|
if (command === "types:generate") {
|
|
60
264
|
const config = await loadConfig();
|
|
61
265
|
const connection = new Connection(config.connection);
|
|
62
|
-
const
|
|
266
|
+
const modelRoots = normalizePathList(config.modelsPath || config.typeDeclarationModelsDir);
|
|
267
|
+
const explicitOutDir = args[1];
|
|
268
|
+
const useModelTypesFolder = !explicitOutDir && !config.typesOutDir && modelRoots.length > 0;
|
|
269
|
+
const outDir = explicitOutDir || config.typesOutDir || (useModelTypesFolder ? join(modelRoots[0], "types") : "./generated/models");
|
|
63
270
|
const generator = new TypeGenerator(connection, {
|
|
64
271
|
outDir,
|
|
65
272
|
stubs: config.typeStubs,
|
|
66
273
|
declarations: !config.typeStubs,
|
|
67
274
|
modelDeclarations: config.typeDeclarations,
|
|
68
|
-
modelDirectory:
|
|
275
|
+
modelDirectory: !useModelTypesFolder ? modelRoots[0] : undefined,
|
|
276
|
+
modelDirectories: useModelTypesFolder ? modelRoots : undefined,
|
|
69
277
|
modelImportPrefix: config.typeDeclarationImportPrefix,
|
|
70
278
|
singularModels: config.typeDeclarationSingularModels,
|
|
279
|
+
declarationDirName: "types",
|
|
71
280
|
});
|
|
72
281
|
await generator.generate();
|
|
73
|
-
|
|
282
|
+
const outputLabel = useModelTypesFolder ? modelRoots.map((root) => join(root, "types")).join(", ") : outDir;
|
|
283
|
+
console.log(`Generated model type declarations in ${outputLabel}`);
|
|
74
284
|
return;
|
|
75
285
|
}
|
|
286
|
+
if (command === "repl") {
|
|
287
|
+
const config = await loadConfig(true);
|
|
288
|
+
const replArgs = args.slice(1);
|
|
289
|
+
const exitCode = await runRepl(config, replArgs);
|
|
290
|
+
process.exit(exitCode);
|
|
291
|
+
}
|
|
76
292
|
const config = await loadConfig();
|
|
77
293
|
const connection = new Connection(config.connection);
|
|
294
|
+
const modelRoots = normalizePathList(config.modelsPath || config.typeDeclarationModelsDir);
|
|
78
295
|
const migrator = new Migrator(connection, config.migrationsPath, config.typesOutDir, {
|
|
79
296
|
declarations: !config.typeStubs,
|
|
80
297
|
stubs: config.typeStubs,
|
|
81
298
|
modelDeclarations: config.typeDeclarations,
|
|
82
|
-
modelDirectory:
|
|
299
|
+
modelDirectory: modelRoots[0],
|
|
300
|
+
modelDirectories: modelRoots.length > 1 ? modelRoots : undefined,
|
|
83
301
|
modelImportPrefix: config.typeDeclarationImportPrefix,
|
|
84
302
|
singularModels: config.typeDeclarationSingularModels,
|
|
303
|
+
declarationDirName: "types",
|
|
85
304
|
});
|
|
86
305
|
if (command === "migrate") {
|
|
87
306
|
await migrator.run();
|
|
@@ -96,10 +315,12 @@ async function main() {
|
|
|
96
315
|
else {
|
|
97
316
|
console.log("Usage:");
|
|
98
317
|
console.log(" bun run bunny migrate Run pending migrations");
|
|
99
|
-
console.log(" bun run bunny migrate:make <name>
|
|
318
|
+
console.log(" bun run bunny migrate:make <name> [dir] Create a new migration");
|
|
100
319
|
console.log(" bun run bunny migrate:rollback Rollback the last batch");
|
|
101
320
|
console.log(" bun run bunny migrate:status Show migration status");
|
|
102
321
|
console.log(" bun run bunny types:generate [dir] Generate model type declarations from DB schema");
|
|
322
|
+
console.log(" bun run bunny repl Start a Bunny REPL with Model, Schema, and db loaded");
|
|
323
|
+
console.log(" Falls back to in-memory SQLite when no config is present");
|
|
103
324
|
}
|
|
104
325
|
}
|
|
105
326
|
main().catch((err) => {
|
|
@@ -5,9 +5,9 @@ export declare class Migrator {
|
|
|
5
5
|
private path;
|
|
6
6
|
private typesOutDir?;
|
|
7
7
|
private typeGeneratorOptions;
|
|
8
|
-
constructor(connection: Connection, path: string, typesOutDir?: string | undefined, typeGeneratorOptions?: Omit<TypeGeneratorOptions, "outDir">);
|
|
8
|
+
constructor(connection: Connection, path: string | string[], typesOutDir?: string | undefined, typeGeneratorOptions?: Omit<TypeGeneratorOptions, "outDir">);
|
|
9
|
+
private getPaths;
|
|
9
10
|
private ensureMigrationsTable;
|
|
10
|
-
private getRan;
|
|
11
11
|
private getLastBatchNumber;
|
|
12
12
|
private getMigrationFiles;
|
|
13
13
|
run(): Promise<void>;
|
|
@@ -18,4 +18,5 @@ export declare class Migrator {
|
|
|
18
18
|
status: string;
|
|
19
19
|
}[]>;
|
|
20
20
|
private resolve;
|
|
21
|
+
private getRan;
|
|
21
22
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
1
2
|
import { readdir } from "fs/promises";
|
|
2
|
-
import { resolve } from "path";
|
|
3
|
+
import { basename, join, relative, resolve } from "path";
|
|
3
4
|
import { Schema } from "../schema/Schema.js";
|
|
4
5
|
import { Builder } from "../query/Builder.js";
|
|
5
6
|
import { TypeGenerator } from "../typegen/TypeGenerator.js";
|
|
7
|
+
import { normalizePathList, toPosixPath } from "../utils.js";
|
|
6
8
|
export class Migrator {
|
|
7
9
|
connection;
|
|
8
10
|
path;
|
|
@@ -15,6 +17,9 @@ export class Migrator {
|
|
|
15
17
|
this.typeGeneratorOptions = typeGeneratorOptions;
|
|
16
18
|
Schema.setConnection(connection);
|
|
17
19
|
}
|
|
20
|
+
getPaths() {
|
|
21
|
+
return normalizePathList(this.path);
|
|
22
|
+
}
|
|
18
23
|
async ensureMigrationsTable() {
|
|
19
24
|
const exists = await Schema.hasTable("migrations");
|
|
20
25
|
if (!exists) {
|
|
@@ -25,13 +30,6 @@ export class Migrator {
|
|
|
25
30
|
});
|
|
26
31
|
}
|
|
27
32
|
}
|
|
28
|
-
async getRan() {
|
|
29
|
-
await this.ensureMigrationsTable();
|
|
30
|
-
const results = await new Builder(this.connection, "migrations")
|
|
31
|
-
.orderBy("id", "asc")
|
|
32
|
-
.get();
|
|
33
|
-
return results.map((row) => row.migration);
|
|
34
|
-
}
|
|
35
33
|
async getLastBatchNumber() {
|
|
36
34
|
const result = await new Builder(this.connection, "migrations")
|
|
37
35
|
.select("MAX(batch) as batch")
|
|
@@ -39,15 +37,28 @@ export class Migrator {
|
|
|
39
37
|
return result?.batch || 0;
|
|
40
38
|
}
|
|
41
39
|
async getMigrationFiles() {
|
|
42
|
-
const files =
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
const files = [];
|
|
41
|
+
for (const path of this.getPaths()) {
|
|
42
|
+
if (!existsSync(path))
|
|
43
|
+
continue;
|
|
44
|
+
const entries = await readdir(path);
|
|
45
|
+
for (const fileName of entries) {
|
|
46
|
+
if (!fileName.endsWith(".ts") && !fileName.endsWith(".js"))
|
|
47
|
+
continue;
|
|
48
|
+
const fullPath = resolve(path, fileName);
|
|
49
|
+
files.push({
|
|
50
|
+
id: toPosixPath(relative(process.cwd(), fullPath)),
|
|
51
|
+
fileName,
|
|
52
|
+
fullPath,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return files.sort((a, b) => a.fileName.localeCompare(b.fileName) || a.id.localeCompare(b.id));
|
|
46
57
|
}
|
|
47
58
|
async run() {
|
|
48
59
|
const ran = await this.getRan();
|
|
49
60
|
const files = await this.getMigrationFiles();
|
|
50
|
-
const pending = files.filter((f) => !ran.
|
|
61
|
+
const pending = files.filter((f) => !ran.has(f.id) && !ran.has(f.fileName));
|
|
51
62
|
if (pending.length === 0) {
|
|
52
63
|
console.log("Nothing to migrate.");
|
|
53
64
|
return;
|
|
@@ -56,14 +67,14 @@ export class Migrator {
|
|
|
56
67
|
await this.connection.beginTransaction();
|
|
57
68
|
try {
|
|
58
69
|
for (const file of pending) {
|
|
59
|
-
const migration = await this.resolve(file);
|
|
60
|
-
console.log(`Migrating: ${file}`);
|
|
70
|
+
const migration = await this.resolve(file.id);
|
|
71
|
+
console.log(`Migrating: ${file.id}`);
|
|
61
72
|
await migration.up();
|
|
62
73
|
await new Builder(this.connection, "migrations").insert({
|
|
63
|
-
migration: file,
|
|
74
|
+
migration: file.id,
|
|
64
75
|
batch,
|
|
65
76
|
});
|
|
66
|
-
console.log(`Migrated: ${file}`);
|
|
77
|
+
console.log(`Migrated: ${file.id}`);
|
|
67
78
|
}
|
|
68
79
|
await this.connection.commit();
|
|
69
80
|
await this.generateTypesIfNeeded();
|
|
@@ -107,31 +118,63 @@ export class Migrator {
|
|
|
107
118
|
}
|
|
108
119
|
}
|
|
109
120
|
async generateTypesIfNeeded() {
|
|
110
|
-
|
|
121
|
+
const modelDirectories = normalizePathList(this.typeGeneratorOptions.modelDirectories || this.typeGeneratorOptions.modelDirectory);
|
|
122
|
+
if (!this.typesOutDir && modelDirectories.length === 0)
|
|
111
123
|
return;
|
|
124
|
+
const outDir = this.typesOutDir || join(modelDirectories[0], this.typeGeneratorOptions.declarationDirName || "types");
|
|
112
125
|
const generator = new TypeGenerator(this.connection, {
|
|
113
126
|
declarations: true,
|
|
114
127
|
...this.typeGeneratorOptions,
|
|
115
|
-
outDir
|
|
128
|
+
outDir,
|
|
116
129
|
});
|
|
117
130
|
await generator.generate();
|
|
118
|
-
|
|
131
|
+
const label = this.typesOutDir || modelDirectories.map((dir) => join(dir, this.typeGeneratorOptions.declarationDirName || "types")).join(", ");
|
|
132
|
+
console.log(`Regenerated types in ${label}`);
|
|
119
133
|
}
|
|
120
134
|
async status() {
|
|
121
135
|
const ran = await this.getRan();
|
|
122
136
|
const files = await this.getMigrationFiles();
|
|
123
137
|
return files.map((file) => ({
|
|
124
|
-
migration: file,
|
|
125
|
-
status: ran.
|
|
138
|
+
migration: file.id,
|
|
139
|
+
status: ran.has(file.id) || ran.has(file.fileName) ? "Ran" : "Pending",
|
|
126
140
|
}));
|
|
127
141
|
}
|
|
128
142
|
async resolve(file) {
|
|
129
|
-
const
|
|
130
|
-
const
|
|
143
|
+
const normalized = toPosixPath(file);
|
|
144
|
+
const candidates = new Set();
|
|
145
|
+
if (normalized.includes("/")) {
|
|
146
|
+
candidates.add(resolve(process.cwd(), normalized));
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
for (const path of this.getPaths()) {
|
|
150
|
+
candidates.add(resolve(path, normalized));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const matches = [...candidates].filter((candidate) => existsSync(candidate));
|
|
154
|
+
if (matches.length === 0) {
|
|
155
|
+
throw new Error(`Migration ${file} could not be found in the configured migration paths.`);
|
|
156
|
+
}
|
|
157
|
+
if (matches.length > 1) {
|
|
158
|
+
throw new Error(`Migration ${file} is ambiguous across multiple migration paths.`);
|
|
159
|
+
}
|
|
160
|
+
const module = await import(matches[0]);
|
|
131
161
|
const MigrationClass = module.default || Object.values(module)[0];
|
|
132
162
|
if (!MigrationClass) {
|
|
133
163
|
throw new Error(`Migration ${file} does not export a class.`);
|
|
134
164
|
}
|
|
135
165
|
return new MigrationClass();
|
|
136
166
|
}
|
|
167
|
+
async getRan() {
|
|
168
|
+
await this.ensureMigrationsTable();
|
|
169
|
+
const results = await new Builder(this.connection, "migrations")
|
|
170
|
+
.orderBy("id", "asc")
|
|
171
|
+
.get();
|
|
172
|
+
const ran = new Set();
|
|
173
|
+
for (const row of results) {
|
|
174
|
+
const migration = toPosixPath(String(row.migration));
|
|
175
|
+
ran.add(migration);
|
|
176
|
+
ran.add(basename(migration));
|
|
177
|
+
}
|
|
178
|
+
return ran;
|
|
179
|
+
}
|
|
137
180
|
}
|
|
@@ -9,8 +9,10 @@ export interface TypeGeneratorOptions {
|
|
|
9
9
|
declarations?: boolean;
|
|
10
10
|
modelDeclarations?: Record<string, string | ModelDeclaration>;
|
|
11
11
|
modelDirectory?: string;
|
|
12
|
+
modelDirectories?: string[];
|
|
12
13
|
modelImportPrefix?: string;
|
|
13
14
|
singularModels?: boolean;
|
|
15
|
+
declarationDirName?: string;
|
|
14
16
|
}
|
|
15
17
|
export declare class TypeGenerator {
|
|
16
18
|
private connection;
|
|
@@ -23,6 +25,7 @@ export declare class TypeGenerator {
|
|
|
23
25
|
private singularizeTable;
|
|
24
26
|
private singularizeWord;
|
|
25
27
|
private getTables;
|
|
28
|
+
private getDeclarationTargets;
|
|
26
29
|
private getCurrentDatabase;
|
|
27
30
|
private getColumns;
|
|
28
31
|
private toClassName;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdir, writeFile } from "fs/promises";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { TypeMapper } from "./TypeMapper.js";
|
|
4
|
-
import { snakeCase } from "../utils.js";
|
|
4
|
+
import { normalizePathList, snakeCase } from "../utils.js";
|
|
5
5
|
export class TypeGenerator {
|
|
6
6
|
connection;
|
|
7
7
|
options;
|
|
@@ -10,71 +10,75 @@ export class TypeGenerator {
|
|
|
10
10
|
this.options = options;
|
|
11
11
|
}
|
|
12
12
|
async generate() {
|
|
13
|
-
await mkdir(this.options.outDir, { recursive: true });
|
|
14
13
|
const tables = await this.getTables();
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
lines.push("");
|
|
32
|
-
const modelDeclaration = this.getModelDeclaration(table, className);
|
|
33
|
-
if (declarationOnly && modelDeclaration) {
|
|
34
|
-
lines.push(`declare module "${modelDeclaration.path}" {`);
|
|
35
|
-
lines.push(` interface ${modelDeclaration.className} extends ${interfaceName} {}`);
|
|
36
|
-
lines.push("}");
|
|
37
|
-
lines.push("");
|
|
38
|
-
}
|
|
39
|
-
if (!declarationOnly && this.options.stubs) {
|
|
40
|
-
lines.push(`export class ${className}Base extends Model<${interfaceName}> {`);
|
|
41
|
-
lines.push(` static table = "${table}";`);
|
|
42
|
-
lines.push("");
|
|
14
|
+
const declarationOnly = this.options.declarations ?? !this.options.stubs;
|
|
15
|
+
const targets = declarationOnly
|
|
16
|
+
? this.getDeclarationTargets()
|
|
17
|
+
: [{ outDir: this.options.outDir }];
|
|
18
|
+
for (const target of targets) {
|
|
19
|
+
await mkdir(target.outDir, { recursive: true });
|
|
20
|
+
for (const table of tables) {
|
|
21
|
+
const columns = await this.getColumns(table);
|
|
22
|
+
const className = this.toClassName(table);
|
|
23
|
+
const interfaceName = `${className}Attributes`;
|
|
24
|
+
const lines = [];
|
|
25
|
+
if (!declarationOnly) {
|
|
26
|
+
lines.push(`import { Model } from "@bunnykit/orm";`);
|
|
27
|
+
lines.push("");
|
|
28
|
+
}
|
|
29
|
+
lines.push(`export interface ${interfaceName} {`);
|
|
43
30
|
for (const col of columns) {
|
|
44
31
|
const tsType = TypeMapper.sqlToTsType(col.type, col.nullable);
|
|
45
|
-
lines.push(`
|
|
46
|
-
lines.push(` return this.getAttribute("${col.name}");`);
|
|
47
|
-
lines.push(` }`);
|
|
48
|
-
lines.push(` set ${col.name}(value: ${tsType}) {`);
|
|
49
|
-
lines.push(` this.setAttribute("${col.name}", value);`);
|
|
50
|
-
lines.push(` }`);
|
|
51
|
-
lines.push("");
|
|
32
|
+
lines.push(` ${col.name}${col.nullable ? "?" : ""}: ${tsType};`);
|
|
52
33
|
}
|
|
53
34
|
lines.push("}");
|
|
35
|
+
lines.push("");
|
|
36
|
+
const modelDeclaration = this.getModelDeclaration(table, className, target.modelImportPrefix);
|
|
37
|
+
if (declarationOnly && modelDeclaration) {
|
|
38
|
+
lines.push(`declare module "${modelDeclaration.path}" {`);
|
|
39
|
+
lines.push(` interface ${modelDeclaration.className} extends ${interfaceName} {}`);
|
|
40
|
+
lines.push("}");
|
|
41
|
+
lines.push("");
|
|
42
|
+
}
|
|
43
|
+
if (!declarationOnly && this.options.stubs) {
|
|
44
|
+
lines.push(`export class ${className}Base extends Model<${interfaceName}> {`);
|
|
45
|
+
lines.push(` static table = "${table}";`);
|
|
46
|
+
lines.push("");
|
|
47
|
+
for (const col of columns) {
|
|
48
|
+
const tsType = TypeMapper.sqlToTsType(col.type, col.nullable);
|
|
49
|
+
lines.push(` get ${col.name}(): ${tsType} {`);
|
|
50
|
+
lines.push(` return this.getAttribute("${col.name}");`);
|
|
51
|
+
lines.push(` }`);
|
|
52
|
+
lines.push(` set ${col.name}(value: ${tsType}) {`);
|
|
53
|
+
lines.push(` this.setAttribute("${col.name}", value);`);
|
|
54
|
+
lines.push(` }`);
|
|
55
|
+
lines.push("");
|
|
56
|
+
}
|
|
57
|
+
lines.push("}");
|
|
58
|
+
}
|
|
59
|
+
const fileName = `${snakeCase(className)}.${declarationOnly ? "d.ts" : "ts"}`;
|
|
60
|
+
const filePath = join(target.outDir, fileName);
|
|
61
|
+
await writeFile(filePath, lines.join("\n") + "\n", "utf-8");
|
|
54
62
|
}
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
const indexLines = tables.map((table) => {
|
|
64
|
+
const className = this.toClassName(table);
|
|
65
|
+
const fileName = snakeCase(className);
|
|
66
|
+
return `export * from "./${fileName}";`;
|
|
67
|
+
});
|
|
68
|
+
await writeFile(join(target.outDir, `index.${declarationOnly ? "d.ts" : "ts"}`), indexLines.join("\n") + "\n", "utf-8");
|
|
58
69
|
}
|
|
59
|
-
const declarationOnly = this.options.declarations ?? !this.options.stubs;
|
|
60
|
-
const indexLines = tables.map((table) => {
|
|
61
|
-
const className = this.toClassName(table);
|
|
62
|
-
const fileName = snakeCase(className);
|
|
63
|
-
return `export * from "./${fileName}";`;
|
|
64
|
-
});
|
|
65
|
-
await writeFile(join(this.options.outDir, `index.${declarationOnly ? "d.ts" : "ts"}`), indexLines.join("\n") + "\n", "utf-8");
|
|
66
70
|
}
|
|
67
|
-
getModelDeclaration(table, fallbackClassName) {
|
|
71
|
+
getModelDeclaration(table, fallbackClassName, modelImportPrefix) {
|
|
68
72
|
const declaration = this.options.modelDeclarations?.[table];
|
|
69
73
|
if (!declaration)
|
|
70
|
-
return this.getConventionModelDeclaration(table);
|
|
74
|
+
return this.getConventionModelDeclaration(table, modelImportPrefix);
|
|
71
75
|
if (typeof declaration === "string") {
|
|
72
76
|
return { path: declaration, className: this.toModelClassName(table, fallbackClassName) };
|
|
73
77
|
}
|
|
74
78
|
return { path: declaration.path, className: declaration.className || this.toModelClassName(table, fallbackClassName) };
|
|
75
79
|
}
|
|
76
|
-
getConventionModelDeclaration(table) {
|
|
77
|
-
const prefix = this.options.modelImportPrefix || this.options.modelDirectory;
|
|
80
|
+
getConventionModelDeclaration(table, modelImportPrefix) {
|
|
81
|
+
const prefix = modelImportPrefix || this.options.modelImportPrefix || this.options.modelDirectory;
|
|
78
82
|
if (!prefix)
|
|
79
83
|
return null;
|
|
80
84
|
const className = this.toModelClassName(table);
|
|
@@ -129,6 +133,22 @@ export class TypeGenerator {
|
|
|
129
133
|
return rows.map((r) => r.table_name);
|
|
130
134
|
}
|
|
131
135
|
}
|
|
136
|
+
getDeclarationTargets() {
|
|
137
|
+
const modelDirectories = normalizePathList(this.options.modelDirectories || this.options.modelDirectory);
|
|
138
|
+
if (modelDirectories.length === 0) {
|
|
139
|
+
return [
|
|
140
|
+
{
|
|
141
|
+
outDir: this.options.outDir,
|
|
142
|
+
modelImportPrefix: this.options.modelImportPrefix || this.options.modelDirectory || "",
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
const declarationDirName = this.options.declarationDirName || "types";
|
|
147
|
+
return modelDirectories.map((dir) => ({
|
|
148
|
+
outDir: join(dir, declarationDirName),
|
|
149
|
+
modelImportPrefix: this.options.modelImportPrefix || "..",
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
132
152
|
async getCurrentDatabase() {
|
|
133
153
|
const rows = await this.connection.query("SELECT DATABASE() as db");
|
|
134
154
|
return rows[0]?.db || "";
|
package/dist/src/utils.d.ts
CHANGED
package/dist/src/utils.js
CHANGED
|
@@ -4,3 +4,12 @@ export function snakeCase(str) {
|
|
|
4
4
|
.toLowerCase()
|
|
5
5
|
.replace(/^_/, "");
|
|
6
6
|
}
|
|
7
|
+
export function normalizePathList(value) {
|
|
8
|
+
if (!value)
|
|
9
|
+
return [];
|
|
10
|
+
const values = Array.isArray(value) ? value : [value];
|
|
11
|
+
return values.map((item) => item.trim()).filter((item) => item.length > 0);
|
|
12
|
+
}
|
|
13
|
+
export function toPosixPath(value) {
|
|
14
|
+
return value.replace(/\\/g, "/");
|
|
15
|
+
}
|
package/package.json
CHANGED