@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.
@@ -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
- return this.query().exec();
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
- const existing = await this.#findBySearch(search);
668
- if (existing) return existing;
669
- return this.create({ ...search, ...defaults });
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
- const existing = await this.#findBySearch(search);
693
- if (existing) {
694
- for (const [k, v] of Object.entries(values)) existing.setProp(k, v);
695
- await this.save(existing);
696
- return existing;
697
- }
698
- return this.create({ ...search, ...values });
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(search: Record<string, unknown>): Promise<T | null> {
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
- ...hasOps,
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,