@zerotal/orm 1.7.0 → 1.7.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/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,58 @@ follows the Zerotal monorepo's unified versioning.
|
|
|
6
6
|
|
|
7
7
|
**Maturity: `stable`**
|
|
8
8
|
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **A seeder that failed partway left its rows behind.** `Seeder.call()` has always wrapped
|
|
14
|
+
*composed* seeders in a transaction, so a `DatabaseSeeder` that delegates was atomic and one that
|
|
15
|
+
does its work inline — which is most of them — was not. A failure on the fourth table committed
|
|
16
|
+
the first three, so the obvious next move, running it again, died on a unique constraint, and the
|
|
17
|
+
only way out was `migrate:fresh`. Migrations became transactional in 1.7.0; this closes the
|
|
18
|
+
asymmetry.
|
|
19
|
+
|
|
20
|
+
`db:seed` now wraps the whole run. Nesting is safe — `DB.transaction` opens a `SAVEPOINT` when one
|
|
21
|
+
is already open, so an inner `call()` still rolls back independently. The wrapper is skipped when
|
|
22
|
+
no connection is bound, because a seeder is not obliged to touch the database and an app that has
|
|
23
|
+
not configured one should not fail to seed over a transaction it never needed.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **`DatabaseProvider` now runs in `worker`, so `zt queue:work` can boot.** It did not, and the
|
|
29
|
+
consequence was total rather than partial: `QueueProvider` _does_ run in `worker`, the
|
|
30
|
+
queue's own default driver is `sqlite`, and so the worker asked for a connection this
|
|
31
|
+
provider had not made and died on startup —
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
error: [Zerotal ORM] No database connection. Is DatabaseProvider registered?
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
— while it plainly was registered.
|
|
38
|
+
|
|
39
|
+
It was never only the queue. Nine providers run in `worker` — notifications, audit, media,
|
|
40
|
+
tenancy, scheduler among them — and a job exists to do work with models. `AuthProvider` and
|
|
41
|
+
`SessionProvider` are absent from `worker` correctly, having neither a request nor a
|
|
42
|
+
session; the ORM being absent was an oversight, dating to 1.0.2.
|
|
43
|
+
|
|
44
|
+
Found building the first cookbook app, whose first queued job could not run.
|
|
45
|
+
|
|
46
|
+
- **Relation keys now accept the JS spelling, like every other identifier.** The convention is
|
|
47
|
+
camelCase in the application and snake_case in the database, converted on the way through —
|
|
48
|
+
and relation keys were the one place it did not happen. `@hasMany(() => Issue, { foreignKey:
|
|
49
|
+
"projectId" })` type-checked and then emitted `no such column: issues.projectId`, with the
|
|
50
|
+
error naming a column rather than the relation that produced it.
|
|
51
|
+
|
|
52
|
+
`_relationSubquery()` builds its subquery with a plain `QueryBuilder`, and the `_column()`
|
|
53
|
+
hook that converts is an override on `ModelQueryBuilder`, so nothing was converting these.
|
|
54
|
+
The keys are now converted where they are qualified, which covers `withCount`, `withSum`,
|
|
55
|
+
`has`/`whereHas` and `withExists` for `hasMany`, `belongsTo`, `manyToMany` and the morph
|
|
56
|
+
relations. Both spellings resolve to the column, so apps already passing `project_id` are
|
|
57
|
+
unaffected.
|
|
58
|
+
|
|
59
|
+
Found building the first cookbook app, where a project list would not count its issues.
|
|
60
|
+
|
|
9
61
|
## [1.7.0] — 2026-08-16
|
|
10
62
|
|
|
11
63
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/orm",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"typecheck": "tsc --noEmit"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@zerotal/core": "1.7.
|
|
35
|
-
"@zerotal/validator": "1.7.
|
|
34
|
+
"@zerotal/core": "1.7.2",
|
|
35
|
+
"@zerotal/validator": "1.7.2"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"typescript": "^5.8.0"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Seeder } from "../seeding/Seeder.ts";
|
|
2
|
+
import { DB, _getDbConnectionOverride } from "../db/DB.ts";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* What running the app's seeders came to.
|
|
@@ -27,6 +28,30 @@ export type SeedOutcome =
|
|
|
27
28
|
*
|
|
28
29
|
* @internal
|
|
29
30
|
*/
|
|
31
|
+
/**
|
|
32
|
+
* Run `body` inside a transaction when there is a database to have one on.
|
|
33
|
+
*
|
|
34
|
+
* A seeder is not obliged to touch the database — it may write fixtures to disk,
|
|
35
|
+
* prime a cache, or call an API — and an app that has not bound a connection
|
|
36
|
+
* should not fail to seed because of a transaction it never needed. So the
|
|
37
|
+
* wrapper is conditional: with a connection, the whole seed is atomic; without,
|
|
38
|
+
* `body` runs exactly as it used to.
|
|
39
|
+
*/
|
|
40
|
+
async function _inTransaction(body: () => Promise<void>): Promise<void> {
|
|
41
|
+
let connected = _getDbConnectionOverride() !== null;
|
|
42
|
+
if (!connected) {
|
|
43
|
+
// No override — ask the container, which throws when nothing is bound.
|
|
44
|
+
try {
|
|
45
|
+
const { _getConnection } = await import("../db/DB.ts");
|
|
46
|
+
connected = _getConnection() !== undefined;
|
|
47
|
+
} catch {
|
|
48
|
+
connected = false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!connected) return body();
|
|
52
|
+
await DB.transaction(body);
|
|
53
|
+
}
|
|
54
|
+
|
|
30
55
|
export async function runSeeders(cwd: string = process.cwd()): Promise<SeedOutcome> {
|
|
31
56
|
const seederPath = `${cwd}/database/seeders/DatabaseSeeder.ts`;
|
|
32
57
|
|
|
@@ -42,7 +67,9 @@ export async function runSeeders(cwd: string = process.cwd()): Promise<SeedOutco
|
|
|
42
67
|
if (!seed) {
|
|
43
68
|
return { status: "invalid", message: "Seeder index must export a default async function." };
|
|
44
69
|
}
|
|
45
|
-
await
|
|
70
|
+
await _inTransaction(async () => {
|
|
71
|
+
await seed();
|
|
72
|
+
});
|
|
46
73
|
return { status: "seeded" };
|
|
47
74
|
} catch (error) {
|
|
48
75
|
return { status: "failed", message: error instanceof Error ? error.message : String(error) };
|
|
@@ -63,7 +90,20 @@ export async function runSeeders(cwd: string = process.cwd()): Promise<SeedOutco
|
|
|
63
90
|
};
|
|
64
91
|
}
|
|
65
92
|
|
|
66
|
-
|
|
93
|
+
// One transaction around the whole seed.
|
|
94
|
+
//
|
|
95
|
+
// `Seeder.call()` has always wrapped *composed* seeders, so a DatabaseSeeder
|
|
96
|
+
// that delegates was atomic and one that does its work inline — which is
|
|
97
|
+
// most of them — was not. A failure halfway left its rows committed, so the
|
|
98
|
+
// obvious next move, running it again, died on a unique constraint and the
|
|
99
|
+
// only way out was `migrate:fresh`. Migrations became transactional in
|
|
100
|
+
// 1.7.0; this closes the asymmetry.
|
|
101
|
+
//
|
|
102
|
+
// Nesting is safe: `DB.transaction` opens a SAVEPOINT when one is already
|
|
103
|
+
// open, so an inner `call()` still rolls back independently.
|
|
104
|
+
await _inTransaction(async () => {
|
|
105
|
+
await new SeederClass().run();
|
|
106
|
+
});
|
|
67
107
|
return { status: "seeded" };
|
|
68
108
|
} catch (error) {
|
|
69
109
|
return { status: "failed", message: error instanceof Error ? error.message : String(error) };
|
|
@@ -813,26 +813,36 @@ export class ModelQueryBuilder<M extends BaseModel> extends QueryBuilder {
|
|
|
813
813
|
);
|
|
814
814
|
}
|
|
815
815
|
|
|
816
|
+
// Relation keys carry the *JS* spelling — `@hasMany(Issue, { foreignKey:
|
|
817
|
+
// "projectId" })` — because that is the convention everywhere else: camelCase
|
|
818
|
+
// in the application, snake_case in the database, converted on the way
|
|
819
|
+
// through. `_column()` does that conversion, but it is an override on
|
|
820
|
+
// *this* class and the subquery below is a plain `QueryBuilder`, so nothing
|
|
821
|
+
// was converting these. The result was a correct-looking decorator emitting
|
|
822
|
+
// `no such column: issues.projectId`, with the error naming a column rather
|
|
823
|
+
// than the relation that produced it.
|
|
824
|
+
const col = (table: string, key: string): string => `${table}.${_toSnakeColumn(key)}`;
|
|
825
|
+
|
|
816
826
|
if (meta.type === "manyToMany") {
|
|
817
827
|
const relTable = Related.table;
|
|
818
828
|
sub = new QueryBuilder(meta.pivotTable!, this._sql)
|
|
819
829
|
.join(
|
|
820
830
|
relTable,
|
|
821
|
-
|
|
831
|
+
col(relTable, Related.primaryKey),
|
|
822
832
|
"=",
|
|
823
|
-
|
|
833
|
+
col(meta.pivotTable!, meta.pivotRelatedKey!),
|
|
824
834
|
)
|
|
825
|
-
.whereColumn(
|
|
835
|
+
.whereColumn(col(meta.pivotTable!, meta.pivotForeignKey!), col(main, meta.localKey!));
|
|
826
836
|
if (Related.softDeletes) sub.whereNull(`${relTable}.deleted_at`);
|
|
827
837
|
} else {
|
|
828
838
|
const relTable = Related.table;
|
|
829
839
|
sub = new QueryBuilder(relTable, this._sql);
|
|
830
840
|
if (meta.type === "belongsTo") {
|
|
831
|
-
sub.whereColumn(
|
|
841
|
+
sub.whereColumn(col(relTable, meta.localKey!), col(main, meta.foreignKey!));
|
|
832
842
|
} else {
|
|
833
|
-
sub.whereColumn(
|
|
843
|
+
sub.whereColumn(col(relTable, meta.foreignKey!), col(main, meta.localKey!));
|
|
834
844
|
if (meta.type === "morphMany" || meta.type === "morphOne") {
|
|
835
|
-
sub.where(
|
|
845
|
+
sub.where(col(relTable, meta.morphTypeColumn!), this._ModelClass.name);
|
|
836
846
|
}
|
|
837
847
|
}
|
|
838
848
|
if (Related.softDeletes) sub.whereNull(`${relTable}.deleted_at`);
|
|
@@ -51,8 +51,22 @@ declare module "@zerotal/core" {
|
|
|
51
51
|
*/
|
|
52
52
|
export class DatabaseProvider extends ServiceProvider {
|
|
53
53
|
static override provides = ["db"] as const;
|
|
54
|
+
/**
|
|
55
|
+
* Every mode, `worker` included.
|
|
56
|
+
*
|
|
57
|
+
* `worker` was missing until 1.7.1, which meant `bun zt queue:work` could not
|
|
58
|
+
* boot at all: the queue's own default driver is `sqlite`, `QueueProvider`
|
|
59
|
+
* does run in `worker`, and it asks for a connection this provider had not
|
|
60
|
+
* made — so the worker died on startup with "No database connection. Is
|
|
61
|
+
* DatabaseProvider registered?" while it plainly was.
|
|
62
|
+
*
|
|
63
|
+
* It was never only the queue. Nine providers run in `worker` — notifications,
|
|
64
|
+
* audit, media, tenancy, scheduler among them — and a job exists to do work
|
|
65
|
+
* with models. A worker without a database is a worker that cannot do the
|
|
66
|
+
* thing workers are for.
|
|
67
|
+
*/
|
|
54
68
|
// Use an explicit mutable array type to satisfy ServiceProvider's static property constraint.
|
|
55
|
-
static override environments: AppEnvironment[] = ["web", "console", "test", "repl"];
|
|
69
|
+
static override environments: AppEnvironment[] = ["web", "console", "worker", "test", "repl"];
|
|
56
70
|
|
|
57
71
|
private _disposeObservability: (() => void) | undefined = undefined;
|
|
58
72
|
|