@c9up/atlas 0.1.18 → 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;
@@ -155,6 +155,15 @@ const POSTGRES_CAST_TYPES = new Set([
155
155
  "int",
156
156
  "bigint",
157
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",
158
167
  ]);
159
168
 
160
169
  /**
@@ -386,6 +395,14 @@ export class BaseRepository<T extends BaseEntity> {
386
395
  // ─── Finders ──────────────────────────────────────────────
387
396
 
388
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
+ }
389
406
  // Route through the query builder so the read hooks (beforeFind/afterFind)
390
407
  // fire and a `beforeFind` hook can mutate the query — the previous direct
391
408
  // `#compileSelect` fast path bypassed every read hook silently.
@@ -407,10 +424,40 @@ export class BaseRepository<T extends BaseEntity> {
407
424
  return this.query().where(column, value).first();
408
425
  }
409
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
+
410
456
  async all(): Promise<T[]> {
411
457
  // Through the builder so beforeFetch/afterFetch fire. The builder applies
412
458
  // the soft-delete scope by default, exactly like the old fast path.
413
- return this.query().exec();
459
+ // Ordered PK desc for AdonisJS Lucid `all()` parity (newest first).
460
+ return this.query().orderBy(this.#primaryKey, "desc").exec();
414
461
  }
415
462
 
416
463
  async allWithTrashed(): Promise<T[]> {
@@ -672,9 +719,14 @@ export class BaseRepository<T extends BaseEntity> {
672
719
  search: Record<string, unknown>,
673
720
  defaults: Record<string, unknown> = {},
674
721
  ): Promise<T> {
675
- const existing = await this.#findBySearch(search);
676
- if (existing) return existing;
677
- 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
+ });
678
730
  }
679
731
 
680
732
  /** Find a row or build an in-memory instance without persisting. */
@@ -692,23 +744,32 @@ export class BaseRepository<T extends BaseEntity> {
692
744
  return e;
693
745
  }
694
746
 
695
- /** Atomic find-or-update-or-insert. */
747
+ /** Atomic find-or-update-or-insert (AdonisJS Lucid parity — locked + transactional). */
696
748
  async updateOrCreate(
697
749
  search: Record<string, unknown>,
698
750
  values: Record<string, unknown>,
699
751
  ): Promise<T> {
700
- const existing = await this.#findBySearch(search);
701
- if (existing) {
702
- for (const [k, v] of Object.entries(values)) existing.setProp(k, v);
703
- await this.save(existing);
704
- return existing;
705
- }
706
- 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
+ });
707
762
  }
708
763
 
709
- async #findBySearch(search: Record<string, unknown>): Promise<T | null> {
764
+ async #findBySearch(
765
+ search: Record<string, unknown>,
766
+ lock = false,
767
+ ): Promise<T | null> {
710
768
  let q = this.query();
711
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();
712
773
  return q.first();
713
774
  }
714
775
 
@@ -1307,7 +1368,8 @@ export class BaseRepository<T extends BaseEntity> {
1307
1368
  [fkProp]: parentIdValue,
1308
1369
  });
1309
1370
 
1310
- // 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).
1311
1373
  const hasOps = {
1312
1374
  async create(data: Record<string, unknown>) {
1313
1375
  return relatedRepo.create(injectFk(data));
@@ -1315,6 +1377,27 @@ export class BaseRepository<T extends BaseEntity> {
1315
1377
  async createMany(rows: Array<Record<string, unknown>>) {
1316
1378
  return relatedRepo.createMany(rows.map(injectFk));
1317
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
+ },
1318
1401
  async save(related: BaseEntity) {
1319
1402
  related.setProp(fkCol, parentIdValue);
1320
1403
  related.setProp(fkProp, parentIdValue);
@@ -1654,9 +1737,42 @@ export class BaseRepository<T extends BaseEntity> {
1654
1737
  }
1655
1738
  };
1656
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
+ };
1657
1773
  const proxy: ManyToManyRelationProxy = {
1658
1774
  type: "manyToMany",
1659
- ...hasOps,
1775
+ ...m2mOps,
1660
1776
  query: scopedQuery,
1661
1777
  attach,
1662
1778
  detach,
@@ -1682,6 +1798,8 @@ export class BaseRepository<T extends BaseEntity> {
1682
1798
  type: "hasOne",
1683
1799
  create: hasOps.create,
1684
1800
  save: hasOps.save,
1801
+ firstOrCreate: hasOps.firstOrCreate,
1802
+ updateOrCreate: hasOps.updateOrCreate,
1685
1803
  createMany: () => reject("createMany"),
1686
1804
  saveMany: () => reject("saveMany"),
1687
1805
  query: scopedQuery,