@c9up/atlas 0.1.18 → 0.2.0

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.
Files changed (127) hide show
  1. package/README.md +55 -14
  2. package/db.darwin-arm64.node +0 -0
  3. package/db.darwin-x64.node +0 -0
  4. package/db.linux-arm64-gnu.node +0 -0
  5. package/db.linux-x64-gnu.node +0 -0
  6. package/db.win32-x64-msvc.node +0 -0
  7. package/dist/AtlasProvider.d.ts +6 -0
  8. package/dist/AtlasProvider.d.ts.map +1 -1
  9. package/dist/AtlasProvider.js +2 -2
  10. package/dist/AtlasProvider.js.map +1 -1
  11. package/dist/BaseEntity.d.ts +171 -7
  12. package/dist/BaseEntity.d.ts.map +1 -1
  13. package/dist/BaseEntity.js +339 -31
  14. package/dist/BaseEntity.js.map +1 -1
  15. package/dist/BaseModel.d.ts +91 -0
  16. package/dist/BaseModel.d.ts.map +1 -0
  17. package/dist/BaseModel.js +193 -0
  18. package/dist/BaseModel.js.map +1 -0
  19. package/dist/BaseRepository.d.ts +77 -15
  20. package/dist/BaseRepository.d.ts.map +1 -1
  21. package/dist/BaseRepository.js +1423 -354
  22. package/dist/BaseRepository.js.map +1 -1
  23. package/dist/ModelQuery.d.ts +429 -11
  24. package/dist/ModelQuery.d.ts.map +1 -1
  25. package/dist/ModelQuery.js +1733 -145
  26. package/dist/ModelQuery.js.map +1 -1
  27. package/dist/Transaction.d.ts +17 -0
  28. package/dist/Transaction.d.ts.map +1 -1
  29. package/dist/Transaction.js +57 -5
  30. package/dist/Transaction.js.map +1 -1
  31. package/dist/adapters/NapiDbAdapter.d.ts +33 -4
  32. package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
  33. package/dist/adapters/NapiDbAdapter.js +101 -11
  34. package/dist/adapters/NapiDbAdapter.js.map +1 -1
  35. package/dist/console/migrationCommands.d.ts +48 -0
  36. package/dist/console/migrationCommands.d.ts.map +1 -0
  37. package/dist/console/migrationCommands.js +220 -0
  38. package/dist/console/migrationCommands.js.map +1 -0
  39. package/dist/decorators/entity.d.ts +37 -6
  40. package/dist/decorators/entity.d.ts.map +1 -1
  41. package/dist/decorators/entity.js +32 -2
  42. package/dist/decorators/entity.js.map +1 -1
  43. package/dist/events.d.ts +64 -0
  44. package/dist/events.d.ts.map +1 -0
  45. package/dist/events.js +82 -0
  46. package/dist/events.js.map +1 -0
  47. package/dist/index.d.ts +5 -1
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +3 -0
  50. package/dist/index.js.map +1 -1
  51. package/dist/metadata-keys.d.ts +3 -2
  52. package/dist/metadata-keys.d.ts.map +1 -1
  53. package/dist/naming/NamingStrategy.d.ts +7 -0
  54. package/dist/naming/NamingStrategy.d.ts.map +1 -1
  55. package/dist/naming/NamingStrategy.js +16 -0
  56. package/dist/naming/NamingStrategy.js.map +1 -1
  57. package/dist/schema/Migration.d.ts +26 -3
  58. package/dist/schema/Migration.d.ts.map +1 -1
  59. package/dist/schema/Migration.js +33 -24
  60. package/dist/schema/Migration.js.map +1 -1
  61. package/dist/schema/MigrationRunner.d.ts +43 -32
  62. package/dist/schema/MigrationRunner.d.ts.map +1 -1
  63. package/dist/schema/MigrationRunner.js +211 -26
  64. package/dist/schema/MigrationRunner.js.map +1 -1
  65. package/dist/schema/Schema.d.ts +57 -0
  66. package/dist/schema/Schema.d.ts.map +1 -1
  67. package/dist/schema/Schema.js +138 -3
  68. package/dist/schema/Schema.js.map +1 -1
  69. package/dist/schema/SchemaCheck.d.ts.map +1 -1
  70. package/dist/schema/SchemaCheck.js +3 -1
  71. package/dist/schema/SchemaCheck.js.map +1 -1
  72. package/dist/schema/TableBuilder.d.ts +247 -8
  73. package/dist/schema/TableBuilder.d.ts.map +1 -1
  74. package/dist/schema/TableBuilder.js +607 -41
  75. package/dist/schema/TableBuilder.js.map +1 -1
  76. package/dist/schema/catalog.d.ts +47 -0
  77. package/dist/schema/catalog.d.ts.map +1 -0
  78. package/dist/schema/catalog.js +111 -0
  79. package/dist/schema/catalog.js.map +1 -0
  80. package/dist/schema/introspect.js.map +1 -1
  81. package/dist/schema/types.d.ts +150 -1
  82. package/dist/schema/types.d.ts.map +1 -1
  83. package/dist/schema/types.js +11 -0
  84. package/dist/schema/types.js.map +1 -1
  85. package/dist/services/db.d.ts +6 -0
  86. package/dist/services/db.d.ts.map +1 -1
  87. package/dist/services/db.js +17 -0
  88. package/dist/services/db.js.map +1 -1
  89. package/dist/testing/DatabaseCleanup.d.ts +7 -4
  90. package/dist/testing/DatabaseCleanup.d.ts.map +1 -1
  91. package/dist/testing/DatabaseCleanup.js +21 -18
  92. package/dist/testing/DatabaseCleanup.js.map +1 -1
  93. package/dist/testing/Factory.d.ts +70 -5
  94. package/dist/testing/Factory.d.ts.map +1 -1
  95. package/dist/testing/Factory.js +209 -10
  96. package/dist/testing/Factory.js.map +1 -1
  97. package/index.darwin-arm64.node +0 -0
  98. package/index.darwin-x64.node +0 -0
  99. package/index.linux-arm64-gnu.node +0 -0
  100. package/index.linux-x64-gnu.node +0 -0
  101. package/index.win32-x64-msvc.node +0 -0
  102. package/package.json +4 -1
  103. package/scripts/guard-publish.mjs +15 -0
  104. package/src/AtlasProvider.ts +8 -1
  105. package/src/BaseEntity.ts +449 -40
  106. package/src/BaseModel.ts +324 -0
  107. package/src/BaseRepository.ts +1659 -371
  108. package/src/ModelQuery.ts +2290 -203
  109. package/src/Transaction.ts +68 -5
  110. package/src/adapters/NapiDbAdapter.ts +159 -10
  111. package/src/console/migrationCommands.ts +258 -0
  112. package/src/decorators/entity.ts +53 -6
  113. package/src/events.ts +112 -0
  114. package/src/index.ts +19 -0
  115. package/src/metadata-keys.ts +3 -2
  116. package/src/naming/NamingStrategy.ts +23 -0
  117. package/src/schema/Migration.ts +42 -3
  118. package/src/schema/MigrationRunner.ts +270 -27
  119. package/src/schema/Schema.ts +210 -3
  120. package/src/schema/SchemaCheck.ts +7 -2
  121. package/src/schema/TableBuilder.ts +735 -41
  122. package/src/schema/catalog.ts +166 -0
  123. package/src/schema/introspect.ts +3 -4
  124. package/src/schema/types.ts +137 -2
  125. package/src/services/db.ts +28 -0
  126. package/src/testing/DatabaseCleanup.ts +23 -22
  127. package/src/testing/Factory.ts +332 -15
package/src/BaseEntity.ts CHANGED
@@ -11,8 +11,13 @@
11
11
  * @implements FR29, FR35, stories 32.1 through 32.5
12
12
  */
13
13
 
14
- import { getRelationMetadata } from "./decorators/entity.js";
15
- import { MassAssignmentError } from "./errors.js";
14
+ import {
15
+ getColumnMetadata,
16
+ getDateColumnConfig,
17
+ getPrimaryKey,
18
+ getRelationMetadata,
19
+ } from "./decorators/entity.js";
20
+ import { AtlasError, MassAssignmentError } from "./errors.js";
16
21
  import {
17
22
  COLUMN_SERIALIZE_KEY,
18
23
  COMPUTED_KEY,
@@ -73,8 +78,22 @@ interface BulkRelationProxy extends BaseRelationProxy {
73
78
  saveMany(related: BaseEntity[]): Promise<BaseEntity[]>;
74
79
  }
75
80
 
81
+ /** Relation upsert helpers scoped to the parent's FK (AdonisJS hasOne/hasMany). */
82
+ interface RelationUpsertProxy {
83
+ firstOrCreate(
84
+ search: Record<string, unknown>,
85
+ defaults?: Record<string, unknown>,
86
+ ): Promise<BaseEntity>;
87
+ updateOrCreate(
88
+ search: Record<string, unknown>,
89
+ values: Record<string, unknown>,
90
+ ): Promise<BaseEntity>;
91
+ }
92
+
76
93
  /** `@HasOne` — single related row. `createMany`/`saveMany` are intentionally absent. */
77
- export interface HasOneRelationProxy extends BaseRelationProxy {
94
+ export interface HasOneRelationProxy
95
+ extends BaseRelationProxy,
96
+ RelationUpsertProxy {
78
97
  readonly type: "hasOne";
79
98
  /** Throws with a clear "not supported on @HasOne" — exposed as a typed no-op for symmetry. */
80
99
  createMany(rows: Array<Record<string, unknown>>): Promise<never>;
@@ -82,20 +101,42 @@ export interface HasOneRelationProxy extends BaseRelationProxy {
82
101
  }
83
102
 
84
103
  /** `@HasMany` — zero or more related rows with bulk write support. */
85
- export interface HasManyRelationProxy extends BulkRelationProxy {
104
+ export interface HasManyRelationProxy
105
+ extends BulkRelationProxy,
106
+ RelationUpsertProxy {
86
107
  readonly type: "hasMany";
87
108
  }
88
109
 
89
- /** `@BelongsTo` — set/clear the FK via `associate`/`dissociate`. */
110
+ /**
111
+ * `@BelongsTo` — the FK lives on THIS model, so the only writes are `associate`
112
+ * (link an owner) / `dissociate` (clear it). create/save/createMany/saveMany are
113
+ * NOT valid here (they'd inject the FK into the owner table and save this model
114
+ * before it has an owner): they throw at runtime and are typed `Promise<never>`
115
+ * so a caller who narrows to belongsTo gets a compile-time signal too — same
116
+ * pattern as `@HasOne`'s bulk methods. AdonisJS Lucid's belongsTo client exposes
117
+ * only associate/dissociate.
118
+ */
90
119
  export interface BelongsToRelationProxy extends BulkRelationProxy {
91
120
  readonly type: "belongsTo";
121
+ create(data: Record<string, unknown>): Promise<never>;
122
+ save(related: BaseEntity): Promise<never>;
123
+ createMany(rows: Array<Record<string, unknown>>): Promise<never>;
124
+ saveMany(related: BaseEntity[]): Promise<never>;
92
125
  /** Set `parent.<fk> = model.<ownerKey>` and save the parent. Rejects null/undefined. */
93
126
  associate(model: BaseEntity): Promise<void>;
94
127
  /** Clear the FK and save the parent. */
95
128
  dissociate(): Promise<void>;
96
129
  }
97
130
 
98
- /** `@ManyToMany` — full Lucid pivot API. */
131
+ /**
132
+ * `@ManyToMany` — the pivot write API.
133
+ *
134
+ * Not the *full* Lucid surface, despite what this used to say. Still missing:
135
+ * `pivotAttributes`/`performSync` on `save`/`saveMany`/`create`/`createMany`
136
+ * (they attach with empty extras), a public `updatePivot` (it exists but only
137
+ * `sync` calls it), `pivotQuery`, and an overridable `relatedKey` (the related
138
+ * primary key is assumed).
139
+ */
99
140
  export interface ManyToManyRelationProxy extends BulkRelationProxy {
100
141
  readonly type: "manyToMany";
101
142
  /** Insert pivot rows. Accepts `id[]` or `{ id: extras }`. */
@@ -114,11 +155,33 @@ export interface ManyToManyRelationProxy extends BulkRelationProxy {
114
155
  ): Promise<void>;
115
156
  }
116
157
 
158
+ /**
159
+ * `@HasOneThrough` / `@HasManyThrough` — READ-ONLY two-hop relations. Lucid does
160
+ * NOT expose persistence on a through relation (verified against the Lucid docs):
161
+ * you persist via the intermediate model. `query()` traverses the through table;
162
+ * create/save/createMany/saveMany throw at runtime and are typed `Promise<never>`
163
+ * so a caller who narrows to a through relation gets a compile-time signal too.
164
+ *
165
+ * `@HasManyThrough` is Lucid parity. `@HasOneThrough` is an atlas addition —
166
+ * Lucid has no such relation (checked against adonisjs/lucid `develop`:
167
+ * `src/orm/relations/` holds belongs_to, has_many, has_many_through, has_one and
168
+ * many_to_many, and no hasOneThrough appears in its types). It is the same
169
+ * two-hop traversal returning a single row instead of an array.
170
+ */
171
+ export interface HasManyThroughRelationProxy extends BulkRelationProxy {
172
+ readonly type: "hasOneThrough" | "hasManyThrough";
173
+ create(data: Record<string, unknown>): Promise<never>;
174
+ save(related: BaseEntity): Promise<never>;
175
+ createMany(rows: Array<Record<string, unknown>>): Promise<never>;
176
+ saveMany(related: BaseEntity[]): Promise<never>;
177
+ }
178
+
117
179
  export type RelationProxy =
118
180
  | HasOneRelationProxy
119
181
  | HasManyRelationProxy
120
182
  | BelongsToRelationProxy
121
- | ManyToManyRelationProxy;
183
+ | ManyToManyRelationProxy
184
+ | HasManyThroughRelationProxy;
122
185
 
123
186
  export type { ColumnSerializeConfig };
124
187
 
@@ -126,7 +189,21 @@ export type { ColumnSerializeConfig };
126
189
  * Internal reserved keys on BaseEntity that must never be treated as database
127
190
  * columns or serialized as data. Used by dirty tracking and by `toJSON`.
128
191
  */
129
- const INTERNAL_KEYS = new Set<string>(["$extras", "$original"]);
192
+ const INTERNAL_KEYS = new Set<string>(["$extras", "$original", "$sideloaded"]);
193
+
194
+ /**
195
+ * Structural (cross-realm-safe) check for a date value exposing `toISO()` — a
196
+ * Chronos `DateTime` and any compatible instance from a duplicated package copy.
197
+ * Used by dirty-tracking to compare date columns by instant, not by reference.
198
+ */
199
+ function hasToISO(v: unknown): v is { toISO(): string } {
200
+ return (
201
+ typeof v === "object" &&
202
+ v !== null &&
203
+ "toISO" in v &&
204
+ typeof v.toISO === "function"
205
+ );
206
+ }
130
207
 
131
208
  export class BaseEntity {
132
209
  /** Index signature — entities have dynamic column properties set by hydrate/create. */
@@ -144,6 +221,14 @@ export class BaseEntity {
144
221
  */
145
222
  $extras: Record<string, unknown> = {};
146
223
 
224
+ /**
225
+ * Sideloaded data — arbitrary context attached to the instance (AdonisJS Lucid
226
+ * `$sideloaded`), e.g. the current tenant/user threaded through from a query.
227
+ * Never a column, never dirty-tracked, never serialized. Set it manually;
228
+ * query-level `.sideload()` auto-propagation to hydrated results is not wired.
229
+ */
230
+ $sideloaded: Record<string, unknown> = {};
231
+
147
232
  /**
148
233
  * Snapshot of the column values at the moment this entity was hydrated from
149
234
  * the database. Used by dirty tracking (`isDirty`, `$dirty`). Populated by
@@ -154,6 +239,89 @@ export class BaseEntity {
154
239
  */
155
240
  $original: Record<string, unknown> = {};
156
241
 
242
+ // — Lifecycle state (AdonisJS Lucid parity). Kept in `#`-private fields so they
243
+ // never leak into `Object.keys(this)` / `$dirty` / `$original` / `toJSON`.
244
+ #persisted = false;
245
+ #deleted = false;
246
+ #local = true;
247
+ #forceUpdate = false;
248
+
249
+ /**
250
+ * Force the next `save()` to run an UPDATE even when nothing is dirty — e.g. to
251
+ * fire DB triggers or re-persist the current state (AdonisJS Lucid
252
+ * `enableForceUpdate`). The flag is consumed by that save. Chainable.
253
+ */
254
+ enableForceUpdate(): this {
255
+ this.#forceUpdate = true;
256
+ return this;
257
+ }
258
+
259
+ /** @internal Read-and-clear the force-update flag — called by `BaseRepository`. */
260
+ $consumeForceUpdate(): boolean {
261
+ const forced = this.#forceUpdate;
262
+ this.#forceUpdate = false;
263
+ return forced;
264
+ }
265
+
266
+ /**
267
+ * `true` once the row exists in the database — after `save()`/`create()`
268
+ * inserts it or a fetch hydrates it. AdonisJS Lucid `$isPersisted`.
269
+ */
270
+ get $isPersisted(): boolean {
271
+ return this.#persisted;
272
+ }
273
+
274
+ /** Inverse of {@link $isPersisted} — a never-persisted instance. Lucid `$isNew`. */
275
+ get $isNew(): boolean {
276
+ return !this.#persisted;
277
+ }
278
+
279
+ /**
280
+ * `true` when the instance originated in memory (`new Model()` / `create()`),
281
+ * `false` when it was fetched from the database. Lucid `$isLocal`.
282
+ */
283
+ get $isLocal(): boolean {
284
+ return this.#local;
285
+ }
286
+
287
+ /**
288
+ * `true` once `delete()` has removed the row; the instance must not be saved
289
+ * again. Lucid `$isDeleted`.
290
+ */
291
+ get $isDeleted(): boolean {
292
+ return this.#deleted;
293
+ }
294
+
295
+ /** The primary-key column's current value. Lucid `$primaryKeyValue`. */
296
+ get $primaryKeyValue(): unknown {
297
+ const pk = getPrimaryKey(this.constructor as new () => BaseEntity);
298
+ return pk === undefined ? undefined : this[pk];
299
+ }
300
+
301
+ /** @internal Repository marks the instance deleted after DELETE. */
302
+ markAsDeleted(): void {
303
+ this.#deleted = true;
304
+ }
305
+
306
+ /** @internal Repository flags a DB-originated instance (`$isLocal = false`). */
307
+ markAsFromDatabase(): void {
308
+ this.#local = false;
309
+ }
310
+
311
+ /**
312
+ * @internal Revert a fresh INSERT that was rolled back — the row never
313
+ * persisted, so the instance must report `$isNew` again. Named safety
314
+ * deviation from Lucid (which keeps `$isPersisted` after rollback): without
315
+ * this, a later `parent.related('x').create(...)` reads `$isPersisted === true`,
316
+ * skips re-saving the parent, and writes a child with a foreign key pointing at
317
+ * a phantom row. Only used for instances that were provably not persisted
318
+ * before the failed batch.
319
+ */
320
+ markAsNotPersisted(): void {
321
+ this.#persisted = false;
322
+ this.$original = {};
323
+ }
324
+
157
325
  /** Set a property dynamically (used by hydrate/create). */
158
326
  setProp(key: string, value: unknown): void {
159
327
  this[key] = value;
@@ -204,6 +372,7 @@ export class BaseEntity {
204
372
  snapshot[key] = this[key];
205
373
  }
206
374
  this.$original = snapshot;
375
+ this.#persisted = true;
207
376
  }
208
377
 
209
378
  /**
@@ -239,6 +408,12 @@ export class BaseEntity {
239
408
  if (current instanceof Date && original instanceof Date) {
240
409
  return current.getTime() === original.getTime();
241
410
  }
411
+ // Chronos DateTime (or any `toISO()`-bearing value): compare by instant, not
412
+ // reference, so re-wrapping the same moment (x = DateTime.from(x), toUTC(), a
413
+ // driver rebuilding it) doesn't spuriously flag the column dirty.
414
+ if (hasToISO(current) && hasToISO(original)) {
415
+ return current.toISO() === original.toISO();
416
+ }
242
417
  return Object.is(current, original);
243
418
  }
244
419
 
@@ -251,6 +426,14 @@ export class BaseEntity {
251
426
  return !this.#columnEqualsOriginal(field);
252
427
  }
253
428
 
429
+ /**
430
+ * `true` when the instance has unsaved changes since it was hydrated/persisted
431
+ * (AdonisJS Lucid `$isDirty` getter). Equivalent to `isDirty()` with no args.
432
+ */
433
+ get $isDirty(): boolean {
434
+ return Object.keys(this.$dirty).length > 0;
435
+ }
436
+
254
437
  /**
255
438
  * Revert all dirty columns back to their `$original` values.
256
439
  *
@@ -358,6 +541,18 @@ export class BaseEntity {
358
541
  return this;
359
542
  }
360
543
 
544
+ /**
545
+ * Like {@link load} but a no-op when the relation is already populated on this
546
+ * instance (AdonisJS Lucid `loadOnce`). Chainable.
547
+ */
548
+ async loadOnce(
549
+ relationName: string,
550
+ callback?: (q: unknown) => void,
551
+ ): Promise<this> {
552
+ if (this[relationName] !== undefined) return this;
553
+ return this.load(relationName, callback);
554
+ }
555
+
361
556
  /**
362
557
  * Return a relation proxy bound to this instance. The proxy exposes
363
558
  * `create` / `createMany` / `save` / `saveMany` that auto-set the FK.
@@ -381,7 +576,7 @@ export class BaseEntity {
381
576
  *
382
577
  * @implements Story 30.7
383
578
  */
384
- fill(payload: Record<string, unknown>): this {
579
+ fill(payload: Record<string, unknown>, allowExtraProperties = false): this {
385
580
  const ctor = this.constructor as typeof BaseEntity & {
386
581
  fillable?: string[];
387
582
  guarded?: string[];
@@ -391,6 +586,7 @@ export class BaseEntity {
391
586
  `${ctor.name}: cannot declare both 'fillable' and 'guarded'`,
392
587
  );
393
588
  }
589
+ const known = this.#knownColumnKeys();
394
590
  const allowed = (key: string): boolean => {
395
591
  if (ctor.fillable) return ctor.fillable.includes(key);
396
592
  if (ctor.guarded) return !ctor.guarded.includes(key);
@@ -414,18 +610,76 @@ export class BaseEntity {
414
610
  }
415
611
  }
416
612
  for (const [k, v] of Object.entries(payload)) {
613
+ // Mass-assignment (fillable/guarded) is the more specific gate — it wins.
417
614
  if (!allowed(k)) throw new MassAssignmentError(ctor.name, k);
615
+ // Otherwise reject keys that aren't declared columns at all (Lucid
616
+ // strict), unless the caller opts into dropping extras.
617
+ if (!known.has(k)) {
618
+ if (allowExtraProperties) continue;
619
+ throw new AtlasError(
620
+ "E_EXTRA_PROPERTIES",
621
+ `Cannot fill '${k}' on ${ctor.name}: it is not a declared column.`,
622
+ {
623
+ hint: "Declare it with @Column, or pass allowExtraProperties=true to ignore extra keys.",
624
+ },
625
+ );
626
+ }
418
627
  this[k] = v;
419
628
  }
420
629
  return this;
421
630
  }
422
631
 
632
+ /**
633
+ * The set of keys `fill`/`merge` treat as declared attributes: `@Column`
634
+ * property keys, the primary key, and any names the user listed in
635
+ * `static fillable` / `static guarded` (which reference real columns).
636
+ * Anything outside this set is an "extra property" rejected unless
637
+ * `allowExtraProperties` is passed.
638
+ */
639
+ #knownColumnKeys(): Set<string> {
640
+ const ctor = this.constructor as typeof BaseEntity & {
641
+ fillable?: string[];
642
+ guarded?: string[];
643
+ };
644
+ const keys = new Set(getColumnMetadata(ctor).map((c) => c.propertyKey));
645
+ const pk = getPrimaryKey(ctor);
646
+ if (pk) keys.add(pk);
647
+ for (const k of ctor.fillable ?? []) keys.add(k);
648
+ for (const k of ctor.guarded ?? []) keys.add(k);
649
+ return keys;
650
+ }
651
+
652
+ /**
653
+ * Throw `MassAssignmentError` if `key` is blocked by the class's static
654
+ * `fillable` allowlist / `guarded` denylist — the SAME rule `fill()`/`merge()`
655
+ * enforce. Repositories call this in `create`/`createMany`/`updateOrCreate` so
656
+ * those paths cannot bypass mass-assignment protection (a `guarded` column
657
+ * like `role`/`isAdmin` must not be settable from a plain payload).
658
+ */
659
+ assertMassAssignable(key: string): void {
660
+ const ctor = this.constructor as typeof BaseEntity & {
661
+ fillable?: string[];
662
+ guarded?: string[];
663
+ };
664
+ if (ctor.fillable && ctor.guarded) {
665
+ throw new Error(
666
+ `${ctor.name}: cannot declare both 'fillable' and 'guarded'`,
667
+ );
668
+ }
669
+ const allowed = ctor.fillable
670
+ ? ctor.fillable.includes(key)
671
+ : ctor.guarded
672
+ ? !ctor.guarded.includes(key)
673
+ : true;
674
+ if (!allowed) throw new MassAssignmentError(ctor.name, key);
675
+ }
676
+
423
677
  /**
424
678
  * Patch the entity with a payload, only touching the provided keys. Same
425
679
  * allowlist/blocklist rules as `fill` but preserves fields not present in
426
680
  * the payload.
427
681
  */
428
- merge(payload: Record<string, unknown>): this {
682
+ merge(payload: Record<string, unknown>, allowExtraProperties = false): this {
429
683
  const ctor = this.constructor as typeof BaseEntity & {
430
684
  fillable?: string[];
431
685
  guarded?: string[];
@@ -435,6 +689,7 @@ export class BaseEntity {
435
689
  `${ctor.name}: cannot declare both 'fillable' and 'guarded'`,
436
690
  );
437
691
  }
692
+ const known = this.#knownColumnKeys();
438
693
  const allowed = (key: string): boolean => {
439
694
  if (ctor.fillable) return ctor.fillable.includes(key);
440
695
  if (ctor.guarded) return !ctor.guarded.includes(key);
@@ -444,6 +699,16 @@ export class BaseEntity {
444
699
  if (!allowed(k)) {
445
700
  throw new MassAssignmentError(ctor.name, k);
446
701
  }
702
+ if (!known.has(k)) {
703
+ if (allowExtraProperties) continue;
704
+ throw new AtlasError(
705
+ "E_EXTRA_PROPERTIES",
706
+ `Cannot merge '${k}' on ${ctor.name}: it is not a declared column.`,
707
+ {
708
+ hint: "Declare it with @Column, or pass allowExtraProperties=true to ignore extra keys.",
709
+ },
710
+ );
711
+ }
447
712
  this[k] = v;
448
713
  }
449
714
  return this;
@@ -464,6 +729,27 @@ export class BaseEntity {
464
729
  this.#domainEvents = [];
465
730
  }
466
731
 
732
+ /**
733
+ * Number of queued domain events — snapshot this BEFORE a transactional write
734
+ * so a rollback can drop only the events that write added (see
735
+ * {@link restoreDomainEventsTo}), preserving any the caller queued earlier.
736
+ */
737
+ domainEventCount(): number {
738
+ return this.#domainEvents.length;
739
+ }
740
+
741
+ /**
742
+ * Truncate the queued domain events back to a floor captured before a
743
+ * transactional write. On rollback this drops the tx-added events while
744
+ * KEEPING pre-existing ones (which describe work outside the rolled-back
745
+ * transaction). Events are append-only (`push`), so the first `n` are the
746
+ * pre-existing ones. A floor past the current length is a no-op.
747
+ */
748
+ restoreDomainEventsTo(n: number): void {
749
+ if (n < this.#domainEvents.length)
750
+ this.#domainEvents.length = Math.max(0, n);
751
+ }
752
+
467
753
  /** Get and clear accumulated domain events atomically. */
468
754
  flushDomainEvents(): DomainEvent[] {
469
755
  const events = [...this.#domainEvents];
@@ -485,57 +771,180 @@ export class BaseEntity {
485
771
  * @implements Story 32.4
486
772
  */
487
773
  toJSON(): Record<string, unknown> {
774
+ const ctor = this.constructor as typeof BaseEntity & {
775
+ serializeExtras?:
776
+ | boolean
777
+ | ((extras: Record<string, unknown>) => Record<string, unknown>);
778
+ };
779
+ const result: Record<string, unknown> = {
780
+ ...this.serializeAttributes(),
781
+ ...this.serializeRelations(),
782
+ ...this.serializeComputed(),
783
+ };
784
+
785
+ // $extras (aggregates / pivot values) are serialized only when the model
786
+ // opts in via `static serializeExtras = true` — AdonisJS Lucid parity
787
+ // (default OFF), so internal aggregates never leak into API JSON by default.
788
+ if (!ctor.serializeExtras) return result;
789
+ const extras =
790
+ typeof ctor.serializeExtras === "function"
791
+ ? ctor.serializeExtras(this.$extras)
792
+ : this.$extras;
793
+ return { ...result, ...extras };
794
+ }
795
+
796
+ /**
797
+ * Effective hidden/visible sets — class-level `static hidden`/`static visible`
798
+ * allowlists layered with per-instance `makeHidden`/`makeVisible` overrides.
799
+ */
800
+ #visibility(): { hidden: Set<string>; visible: Set<string> | null } {
488
801
  const ctor = this.constructor as typeof BaseEntity & {
489
802
  hidden?: readonly string[];
490
803
  visible?: readonly string[];
491
804
  };
492
805
  const hidden = new Set(ctor.hidden ?? []);
806
+ // makeHidden adds, makeVisible force-shows.
807
+ for (const f of this.#hiddenOverride ?? []) hidden.add(f);
808
+ for (const f of this.#visibleOverride ?? []) hidden.delete(f);
493
809
  const visible =
494
810
  ctor.visible && ctor.visible.length > 0 ? new Set(ctor.visible) : null;
811
+ return { hidden, visible };
812
+ }
813
+
814
+ #isVisible(
815
+ key: string,
816
+ hidden: Set<string>,
817
+ visible: Set<string> | null,
818
+ ): boolean {
819
+ if (visible && !visible.has(key) && !this.#visibleOverride?.has(key))
820
+ return false;
821
+ return !hidden.has(key);
822
+ }
495
823
 
824
+ /**
825
+ * Serialize the regular `@column` attributes only — respecting hidden/visible
826
+ * allowlists and per-column `serializeAs`/`serialize` overrides. Override this
827
+ * to customize attribute serialization (AdonisJS Lucid `serializeAttributes`).
828
+ */
829
+ protected serializeAttributes(): Record<string, unknown> {
830
+ const ctor = this.constructor as typeof BaseEntity;
496
831
  const serializeConfig = getColumnSerializeConfig(ctor);
497
- // Relation `serializeAs` overrides — a preloaded relation is a plain
498
- // property on the instance, so it shows up in `Object.keys` below. Without
499
- // this map the `serializeAs` declared on @HasOne/@HasMany/etc. was silently
500
- // ignored (the relation always serialized under its property name).
501
- const relByKey = new Map(
502
- getRelationMetadata(ctor).map((r) => [r.propertyKey, r]),
832
+ const relKeys = new Set(
833
+ getRelationMetadata(ctor).map((r) => r.propertyKey),
503
834
  );
835
+ // Only DECLARED @column.date/dateTime columns get ISO'd — a business value
836
+ // object that happens to expose toISO() on a non-date column is left intact.
837
+ const dateCols = getDateColumnConfig(ctor);
838
+ const { hidden, visible } = this.#visibility();
504
839
  const result: Record<string, unknown> = {};
505
-
506
- // Regular columns (respecting hidden/visible + serialize overrides)
507
840
  for (const key of Object.keys(this)) {
508
841
  if (INTERNAL_KEYS.has(key)) continue;
509
- if (visible && !visible.has(key)) continue;
510
- if (hidden.has(key)) continue;
511
-
512
- // Preloaded relation: honour its `serializeAs` (rename, or `null` hides
513
- // it from the JSON). The nested entity serializes via its own toJSON.
514
- const rel = relByKey.get(key);
515
- if (rel) {
516
- if (rel.serializeAs === null) continue;
517
- result[rel.serializeAs ?? key] = this[key];
518
- continue;
519
- }
520
-
842
+ if (relKeys.has(key)) continue; // handled by serializeRelations
843
+ if (!this.#isVisible(key, hidden, visible)) continue;
521
844
  const cfg = serializeConfig[key];
522
845
  if (cfg?.serializeAs === null) continue; // explicit hide
523
-
524
846
  const outKey = cfg?.serializeAs ?? key;
525
847
  const rawValue = this[key];
526
- result[outKey] = cfg?.serialize ? cfg.serialize(rawValue) : rawValue;
848
+ // A @column.dateTime value is a Chronos DateTime; serialize it to an ISO
849
+ // string (AdonisJS Lucid serializes date columns to ISO), unless an
850
+ // explicit @Column({ serialize }) override takes over.
851
+ result[outKey] = cfg?.serialize
852
+ ? // Adonis Lucid signature: (value, attribute, model).
853
+ cfg.serialize(rawValue, key, this)
854
+ : dateCols[key] && hasToISO(rawValue)
855
+ ? rawValue.toISO()
856
+ : rawValue;
527
857
  }
858
+ return result;
859
+ }
528
860
 
529
- // Computed getters (@computed on the prototype)
530
- const computed = getComputedProperties(ctor);
531
- for (const prop of computed) {
532
- if (visible && !visible.has(prop)) continue;
533
- if (hidden.has(prop)) continue;
534
- result[prop] = (this as Record<string, unknown>)[prop];
861
+ /**
862
+ * Serialize preloaded relations only — honouring each relation's `serializeAs`
863
+ * (rename, or `null` to hide). Nested entities serialize via their own
864
+ * `toJSON`. Override to customize (AdonisJS Lucid `serializeRelations`).
865
+ */
866
+ protected serializeRelations(): Record<string, unknown> {
867
+ const ctor = this.constructor as typeof BaseEntity;
868
+ const relByKey = new Map(
869
+ getRelationMetadata(ctor).map((r) => [r.propertyKey, r]),
870
+ );
871
+ const { hidden, visible } = this.#visibility();
872
+ const result: Record<string, unknown> = {};
873
+ for (const key of Object.keys(this)) {
874
+ if (!this.#isVisible(key, hidden, visible)) continue;
875
+ const rel = relByKey.get(key);
876
+ if (!rel) continue;
877
+ if (rel.serializeAs === null) continue;
878
+ result[rel.serializeAs ?? key] = this[key];
879
+ }
880
+ return result;
881
+ }
882
+
883
+ /**
884
+ * Serialize `@computed` getters only. Override to customize
885
+ * (AdonisJS Lucid `serializeComputed`).
886
+ */
887
+ protected serializeComputed(): Record<string, unknown> {
888
+ const ctor = this.constructor as typeof BaseEntity;
889
+ const { hidden, visible } = this.#visibility();
890
+ const result: Record<string, unknown> = {};
891
+ for (const prop of getComputedProperties(ctor)) {
892
+ if (!this.#isVisible(prop, hidden, visible)) continue;
893
+ result[prop] = this[prop];
535
894
  }
895
+ return result;
896
+ }
897
+
898
+ // Per-instance serialization visibility.
899
+ //
900
+ // NOT Lucid parity, despite what these used to claim: `makeHidden` /
901
+ // `makeVisible` and the `static hidden` / `static visible` allowlists do not
902
+ // exist in Lucid (checked against adonisjs/lucid `develop` — LucidRow and
903
+ // LucidModel declare neither). Lucid hides a column with
904
+ // `@column({ serializeAs: null })`, which atlas also supports. This is an
905
+ // atlas addition of Eloquent lineage, kept because per-instance visibility
906
+ // is genuinely useful; it is a named deviation, not a Lucid feature.
907
+ #hiddenOverride?: Set<string>;
908
+ #visibleOverride?: Set<string>;
909
+
910
+ /**
911
+ * Hide these fields when serializing THIS instance, on top of the class-level
912
+ * `static hidden`. Chainable.
913
+ *
914
+ * An atlas addition, not Lucid — see the `#hiddenOverride` note. Lucid's
915
+ * equivalent is the static `@column({ serializeAs: null })`, which atlas
916
+ * supports too; this is the per-instance form Lucid has no answer for.
917
+ */
918
+ makeHidden(...fields: string[]): this {
919
+ this.#hiddenOverride ??= new Set();
920
+ for (const f of fields) this.#hiddenOverride.add(f);
921
+ return this;
922
+ }
536
923
 
537
- // $extras merged last — aggregates and pivot values show up alongside columns
538
- return { ...result, ...this.$extras };
924
+ /**
925
+ * Force these fields visible when serializing THIS instance, overriding the
926
+ * class-level `static hidden`/`visible`. Chainable.
927
+ *
928
+ * An atlas addition, not Lucid — see {@link makeHidden}.
929
+ */
930
+ makeVisible(...fields: string[]): this {
931
+ this.#visibleOverride ??= new Set();
932
+ for (const f of fields) this.#visibleOverride.add(f);
933
+ return this;
934
+ }
935
+
936
+ /**
937
+ * Plain object of the raw columns + preloaded relations + `$extras`, WITHOUT
938
+ * any serialization transform (no `hidden`/`visible`, no `serializeAs`, no
939
+ * per-column `serialize`) — AdonisJS Lucid `toObject()`.
940
+ */
941
+ toObject(): Record<string, unknown> {
942
+ const out: Record<string, unknown> = {};
943
+ for (const key of Object.keys(this)) {
944
+ if (INTERNAL_KEYS.has(key)) continue;
945
+ out[key] = this[key];
946
+ }
947
+ return { ...out, ...this.$extras };
539
948
  }
540
949
 
541
950
  /**