@c9up/atlas 0.1.17 → 0.1.19
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/db.darwin-arm64.node +0 -0
- package/db.darwin-x64.node +0 -0
- package/db.linux-arm64-gnu.node +0 -0
- package/db.linux-x64-gnu.node +0 -0
- package/db.win32-x64-msvc.node +0 -0
- package/dist/BaseEntity.d.ts +7 -2
- package/dist/BaseEntity.d.ts.map +1 -1
- package/dist/BaseEntity.js +4 -2
- package/dist/BaseEntity.js.map +1 -1
- package/dist/BaseRepository.d.ts +9 -3
- package/dist/BaseRepository.d.ts.map +1 -1
- package/dist/BaseRepository.js +123 -17
- package/dist/BaseRepository.js.map +1 -1
- package/dist/ModelQuery.d.ts +95 -0
- package/dist/ModelQuery.d.ts.map +1 -1
- package/dist/ModelQuery.js +386 -27
- package/dist/ModelQuery.js.map +1 -1
- package/dist/schema/SchemaCheck.d.ts.map +1 -1
- package/dist/schema/SchemaCheck.js +3 -1
- package/dist/schema/SchemaCheck.js.map +1 -1
- package/dist/schema/introspect.js.map +1 -1
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +2 -1
- package/src/BaseEntity.ts +23 -4
- package/src/BaseRepository.ts +143 -17
- package/src/ModelQuery.ts +503 -30
- package/src/schema/SchemaCheck.ts +7 -2
- package/src/schema/introspect.ts +3 -4
package/src/BaseRepository.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
|
+
import type { TransactionOptions } from "./adapters/NapiDbAdapter.js";
|
|
8
9
|
import type {
|
|
9
10
|
BaseEntity,
|
|
10
11
|
BelongsToRelationProxy,
|
|
@@ -27,9 +28,7 @@ import {
|
|
|
27
28
|
type PrimaryKeyGenerator,
|
|
28
29
|
} from "./decorators/entity.js";
|
|
29
30
|
import { fireHooks } from "./decorators/hooks.js";
|
|
30
|
-
import type { TransactionOptions } from "./adapters/NapiDbAdapter.js";
|
|
31
31
|
import { AtlasError, EntityNotFoundError } from "./errors.js";
|
|
32
|
-
import type { TransactionClient } from "./Transaction.js";
|
|
33
32
|
import { ModelQuery, runWithAtlasInternalBypass } from "./ModelQuery.js";
|
|
34
33
|
import {
|
|
35
34
|
type AtlasDialect,
|
|
@@ -38,6 +37,7 @@ import {
|
|
|
38
37
|
registerColumnCast,
|
|
39
38
|
registerTableCasts,
|
|
40
39
|
} from "./query/native.js";
|
|
40
|
+
import { type TransactionClient, transaction } from "./Transaction.js";
|
|
41
41
|
import { camelToSnake, snakeToCamel } from "./utils/casing.js";
|
|
42
42
|
|
|
43
43
|
type EntityConstructor<T extends BaseEntity> = new () => T;
|
|
@@ -147,6 +147,23 @@ const POSTGRES_CAST_TYPES = new Set([
|
|
|
147
147
|
"jsonb",
|
|
148
148
|
"numeric",
|
|
149
149
|
"decimal",
|
|
150
|
+
// Nullable integer columns (opt-in via `@Column({ type: 'integer' })`): a JS
|
|
151
|
+
// number binds as a real int, but a JS `null` binds as text, which Postgres
|
|
152
|
+
// won't coerce to int on assignment. Untyped `@Column()` int columns never
|
|
153
|
+
// reach here, so their plain-number bind is untouched.
|
|
154
|
+
"integer",
|
|
155
|
+
"int",
|
|
156
|
+
"bigint",
|
|
157
|
+
"smallint",
|
|
158
|
+
// Nullable boolean / float columns hit the same text-bound-NULL issue.
|
|
159
|
+
"boolean",
|
|
160
|
+
"bool",
|
|
161
|
+
"real",
|
|
162
|
+
"float4",
|
|
163
|
+
"double precision",
|
|
164
|
+
"double",
|
|
165
|
+
"float8",
|
|
166
|
+
"float",
|
|
150
167
|
]);
|
|
151
168
|
|
|
152
169
|
/**
|
|
@@ -378,6 +395,14 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
378
395
|
// ─── Finders ──────────────────────────────────────────────
|
|
379
396
|
|
|
380
397
|
async find(id: string | number | bigint): Promise<T | null> {
|
|
398
|
+
// AdonisJS Lucid throws on `undefined`/`null` rather than silently running
|
|
399
|
+
// `WHERE pk = NULL` (which matches nothing) — a common typo footgun.
|
|
400
|
+
if (id === undefined || id === null) {
|
|
401
|
+
throw new AtlasError(
|
|
402
|
+
"E_INVALID_FIND_VALUE",
|
|
403
|
+
`${this.#entityClass.name}.find() expects a value, received ${String(id)}.`,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
381
406
|
// Route through the query builder so the read hooks (beforeFind/afterFind)
|
|
382
407
|
// fire and a `beforeFind` hook can mutate the query — the previous direct
|
|
383
408
|
// `#compileSelect` fast path bypassed every read hook silently.
|
|
@@ -399,10 +424,40 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
399
424
|
return this.query().where(column, value).first();
|
|
400
425
|
}
|
|
401
426
|
|
|
427
|
+
/** Find by a column or throw `EntityNotFoundError` (AdonisJS `findByOrFail`). */
|
|
428
|
+
async findByOrFail(column: string, value: unknown): Promise<T> {
|
|
429
|
+
const entity = await this.findBy(column, value);
|
|
430
|
+
if (!entity) {
|
|
431
|
+
throw new EntityNotFoundError(this.#entityClass.name, {
|
|
432
|
+
[column]: value,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
return entity;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Find many rows by primary key (AdonisJS `findMany`), ordered PK desc. */
|
|
439
|
+
async findMany(ids: Array<string | number>): Promise<T[]> {
|
|
440
|
+
if (ids.length === 0) return [];
|
|
441
|
+
return this.query()
|
|
442
|
+
.whereIn(this.#primaryKey, ids)
|
|
443
|
+
.orderBy(this.#primaryKey, "desc")
|
|
444
|
+
.exec();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Find many rows by an arbitrary column (AdonisJS `findManyBy`). */
|
|
448
|
+
async findManyBy(
|
|
449
|
+
column: string,
|
|
450
|
+
values: Array<string | number>,
|
|
451
|
+
): Promise<T[]> {
|
|
452
|
+
if (values.length === 0) return [];
|
|
453
|
+
return this.query().whereIn(column, values).exec();
|
|
454
|
+
}
|
|
455
|
+
|
|
402
456
|
async all(): Promise<T[]> {
|
|
403
457
|
// Through the builder so beforeFetch/afterFetch fire. The builder applies
|
|
404
458
|
// the soft-delete scope by default, exactly like the old fast path.
|
|
405
|
-
|
|
459
|
+
// Ordered PK desc for AdonisJS Lucid `all()` parity (newest first).
|
|
460
|
+
return this.query().orderBy(this.#primaryKey, "desc").exec();
|
|
406
461
|
}
|
|
407
462
|
|
|
408
463
|
async allWithTrashed(): Promise<T[]> {
|
|
@@ -664,9 +719,14 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
664
719
|
search: Record<string, unknown>,
|
|
665
720
|
defaults: Record<string, unknown> = {},
|
|
666
721
|
): Promise<T> {
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
return this
|
|
722
|
+
// Atomic (AdonisJS Lucid parity): find-under-lock then create inside one
|
|
723
|
+
// transaction, so two concurrent callers can't both miss and both INSERT.
|
|
724
|
+
return transaction(this.#db, async (trx) => {
|
|
725
|
+
const repo = this.useTransaction(trx);
|
|
726
|
+
const existing = await repo.#findBySearch(search, true);
|
|
727
|
+
if (existing) return existing;
|
|
728
|
+
return repo.create({ ...search, ...defaults });
|
|
729
|
+
});
|
|
670
730
|
}
|
|
671
731
|
|
|
672
732
|
/** Find a row or build an in-memory instance without persisting. */
|
|
@@ -684,23 +744,32 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
684
744
|
return e;
|
|
685
745
|
}
|
|
686
746
|
|
|
687
|
-
/** Atomic find-or-update-or-insert. */
|
|
747
|
+
/** Atomic find-or-update-or-insert (AdonisJS Lucid parity — locked + transactional). */
|
|
688
748
|
async updateOrCreate(
|
|
689
749
|
search: Record<string, unknown>,
|
|
690
750
|
values: Record<string, unknown>,
|
|
691
751
|
): Promise<T> {
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
752
|
+
return transaction(this.#db, async (trx) => {
|
|
753
|
+
const repo = this.useTransaction(trx);
|
|
754
|
+
const existing = await repo.#findBySearch(search, true);
|
|
755
|
+
if (existing) {
|
|
756
|
+
for (const [k, v] of Object.entries(values)) existing.setProp(k, v);
|
|
757
|
+
await repo.save(existing);
|
|
758
|
+
return existing;
|
|
759
|
+
}
|
|
760
|
+
return repo.create({ ...search, ...values });
|
|
761
|
+
});
|
|
699
762
|
}
|
|
700
763
|
|
|
701
|
-
async #findBySearch(
|
|
764
|
+
async #findBySearch(
|
|
765
|
+
search: Record<string, unknown>,
|
|
766
|
+
lock = false,
|
|
767
|
+
): Promise<T | null> {
|
|
702
768
|
let q = this.query();
|
|
703
769
|
for (const [k, v] of Object.entries(search)) q = q.where(k, v);
|
|
770
|
+
// `forUpdate()` row-locks the matched row so a concurrent updateOrCreate
|
|
771
|
+
// serializes behind it (no-op on SQLite, which serializes writes anyway).
|
|
772
|
+
if (lock) q = q.forUpdate();
|
|
704
773
|
return q.first();
|
|
705
774
|
}
|
|
706
775
|
|
|
@@ -1299,7 +1368,8 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1299
1368
|
[fkProp]: parentIdValue,
|
|
1300
1369
|
});
|
|
1301
1370
|
|
|
1302
|
-
// Shared "has" proxy methods (create/createMany/save/saveMany
|
|
1371
|
+
// Shared "has" proxy methods (create/createMany/save/saveMany +
|
|
1372
|
+
// firstOrCreate/updateOrCreate scoped to this parent's FK).
|
|
1303
1373
|
const hasOps = {
|
|
1304
1374
|
async create(data: Record<string, unknown>) {
|
|
1305
1375
|
return relatedRepo.create(injectFk(data));
|
|
@@ -1307,6 +1377,27 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1307
1377
|
async createMany(rows: Array<Record<string, unknown>>) {
|
|
1308
1378
|
return relatedRepo.createMany(rows.map(injectFk));
|
|
1309
1379
|
},
|
|
1380
|
+
// Scope the search to the parent's FK column so the lookup only sees
|
|
1381
|
+
// this parent's rows; inject the FK into the created/updated row. The
|
|
1382
|
+
// related repo's firstOrCreate/updateOrCreate are atomic (txn + lock).
|
|
1383
|
+
async firstOrCreate(
|
|
1384
|
+
search: Record<string, unknown>,
|
|
1385
|
+
defaults: Record<string, unknown> = {},
|
|
1386
|
+
) {
|
|
1387
|
+
return relatedRepo.firstOrCreate(
|
|
1388
|
+
{ ...search, [fkCol]: parentIdValue },
|
|
1389
|
+
injectFk(defaults),
|
|
1390
|
+
);
|
|
1391
|
+
},
|
|
1392
|
+
async updateOrCreate(
|
|
1393
|
+
search: Record<string, unknown>,
|
|
1394
|
+
values: Record<string, unknown>,
|
|
1395
|
+
) {
|
|
1396
|
+
return relatedRepo.updateOrCreate(
|
|
1397
|
+
{ ...search, [fkCol]: parentIdValue },
|
|
1398
|
+
injectFk(values),
|
|
1399
|
+
);
|
|
1400
|
+
},
|
|
1310
1401
|
async save(related: BaseEntity) {
|
|
1311
1402
|
related.setProp(fkCol, parentIdValue);
|
|
1312
1403
|
related.setProp(fkProp, parentIdValue);
|
|
@@ -1646,9 +1737,42 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1646
1737
|
}
|
|
1647
1738
|
};
|
|
1648
1739
|
|
|
1740
|
+
// m2m create/save persist the related row THEN insert a pivot row —
|
|
1741
|
+
// NOT `hasOps.injectFk`, which would write a bogus `<parent>_id` column
|
|
1742
|
+
// onto the related table and never touch the pivot (silent corruption).
|
|
1743
|
+
const relatedPkProp = getPrimaryKey(relatedClass) ?? "id";
|
|
1744
|
+
const attachIds = (rows: BaseEntity[]): Promise<void> => {
|
|
1745
|
+
if (rows.length === 0) return Promise.resolve();
|
|
1746
|
+
const arg: Record<string, Record<string, unknown>> = {};
|
|
1747
|
+
for (const r of rows) arg[String(r[relatedPkProp])] = {};
|
|
1748
|
+
return attach(arg);
|
|
1749
|
+
};
|
|
1750
|
+
const m2mOps = {
|
|
1751
|
+
async create(data: Record<string, unknown>): Promise<BaseEntity> {
|
|
1752
|
+
const created = await relatedRepo.create(data);
|
|
1753
|
+
await attachIds([created]);
|
|
1754
|
+
return created;
|
|
1755
|
+
},
|
|
1756
|
+
async createMany(
|
|
1757
|
+
rows: Array<Record<string, unknown>>,
|
|
1758
|
+
): Promise<BaseEntity[]> {
|
|
1759
|
+
const created = await relatedRepo.createMany(rows);
|
|
1760
|
+
await attachIds(created);
|
|
1761
|
+
return created;
|
|
1762
|
+
},
|
|
1763
|
+
async save(related: BaseEntity): Promise<void> {
|
|
1764
|
+
await relatedRepo.save(related);
|
|
1765
|
+
await attachIds([related]);
|
|
1766
|
+
},
|
|
1767
|
+
async saveMany(related: BaseEntity[]): Promise<BaseEntity[]> {
|
|
1768
|
+
const saved = await relatedRepo.saveMany(related);
|
|
1769
|
+
await attachIds(saved);
|
|
1770
|
+
return saved;
|
|
1771
|
+
},
|
|
1772
|
+
};
|
|
1649
1773
|
const proxy: ManyToManyRelationProxy = {
|
|
1650
1774
|
type: "manyToMany",
|
|
1651
|
-
...
|
|
1775
|
+
...m2mOps,
|
|
1652
1776
|
query: scopedQuery,
|
|
1653
1777
|
attach,
|
|
1654
1778
|
detach,
|
|
@@ -1674,6 +1798,8 @@ export class BaseRepository<T extends BaseEntity> {
|
|
|
1674
1798
|
type: "hasOne",
|
|
1675
1799
|
create: hasOps.create,
|
|
1676
1800
|
save: hasOps.save,
|
|
1801
|
+
firstOrCreate: hasOps.firstOrCreate,
|
|
1802
|
+
updateOrCreate: hasOps.updateOrCreate,
|
|
1677
1803
|
createMany: () => reject("createMany"),
|
|
1678
1804
|
saveMany: () => reject("saveMany"),
|
|
1679
1805
|
query: scopedQuery,
|