@c9up/atlas 0.1.19 → 0.2.1

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 (250) hide show
  1. package/README.md +63 -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 +66 -7
  8. package/dist/AtlasProvider.d.ts.map +1 -1
  9. package/dist/AtlasProvider.js +205 -39
  10. package/dist/AtlasProvider.js.map +1 -1
  11. package/dist/BaseEntity.d.ts +181 -5
  12. package/dist/BaseEntity.d.ts.map +1 -1
  13. package/dist/BaseEntity.js +339 -33
  14. package/dist/BaseEntity.js.map +1 -1
  15. package/dist/BaseModel.d.ts +127 -0
  16. package/dist/BaseModel.d.ts.map +1 -0
  17. package/dist/BaseModel.js +250 -0
  18. package/dist/BaseModel.js.map +1 -0
  19. package/dist/BaseRepository.d.ts +81 -15
  20. package/dist/BaseRepository.d.ts.map +1 -1
  21. package/dist/BaseRepository.js +1433 -387
  22. package/dist/BaseRepository.js.map +1 -1
  23. package/dist/ConnectionManager.d.ts +106 -0
  24. package/dist/ConnectionManager.d.ts.map +1 -0
  25. package/dist/ConnectionManager.js +228 -0
  26. package/dist/ConnectionManager.js.map +1 -0
  27. package/dist/ModelQuery.d.ts +577 -41
  28. package/dist/ModelQuery.d.ts.map +1 -1
  29. package/dist/ModelQuery.js +2188 -291
  30. package/dist/ModelQuery.js.map +1 -1
  31. package/dist/Transaction.d.ts +71 -1
  32. package/dist/Transaction.d.ts.map +1 -1
  33. package/dist/Transaction.js +138 -24
  34. package/dist/Transaction.js.map +1 -1
  35. package/dist/adapters/NapiDbAdapter.d.ts +44 -4
  36. package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
  37. package/dist/adapters/NapiDbAdapter.js +131 -13
  38. package/dist/adapters/NapiDbAdapter.js.map +1 -1
  39. package/dist/console/contract.d.ts +62 -0
  40. package/dist/console/contract.d.ts.map +1 -0
  41. package/dist/console/contract.js +38 -0
  42. package/dist/console/contract.js.map +1 -0
  43. package/dist/console/factoryCommands.d.ts +23 -0
  44. package/dist/console/factoryCommands.d.ts.map +1 -0
  45. package/dist/console/factoryCommands.js +62 -0
  46. package/dist/console/factoryCommands.js.map +1 -0
  47. package/dist/console/migrationCommands.d.ts +78 -0
  48. package/dist/console/migrationCommands.d.ts.map +1 -0
  49. package/dist/console/migrationCommands.js +327 -0
  50. package/dist/console/migrationCommands.js.map +1 -0
  51. package/dist/console/schemaCheckCommand.d.ts +11 -17
  52. package/dist/console/schemaCheckCommand.d.ts.map +1 -1
  53. package/dist/console/schemaCheckCommand.js +21 -15
  54. package/dist/console/schemaCheckCommand.js.map +1 -1
  55. package/dist/console/schemaDumpCommand.d.ts +30 -0
  56. package/dist/console/schemaDumpCommand.d.ts.map +1 -0
  57. package/dist/console/schemaDumpCommand.js +69 -0
  58. package/dist/console/schemaDumpCommand.js.map +1 -0
  59. package/dist/console/schemaGenerateCommand.d.ts +100 -0
  60. package/dist/console/schemaGenerateCommand.d.ts.map +1 -0
  61. package/dist/console/schemaGenerateCommand.js +246 -0
  62. package/dist/console/schemaGenerateCommand.js.map +1 -0
  63. package/dist/console/seederCommands.d.ts +46 -0
  64. package/dist/console/seederCommands.d.ts.map +1 -0
  65. package/dist/console/seederCommands.js +136 -0
  66. package/dist/console/seederCommands.js.map +1 -0
  67. package/dist/decorators/entity.d.ts +50 -8
  68. package/dist/decorators/entity.d.ts.map +1 -1
  69. package/dist/decorators/entity.js +42 -2
  70. package/dist/decorators/entity.js.map +1 -1
  71. package/dist/decorators/hooks.d.ts +3 -3
  72. package/dist/decorators/hooks.d.ts.map +1 -1
  73. package/dist/decorators/hooks.js.map +1 -1
  74. package/dist/events.d.ts +69 -0
  75. package/dist/events.d.ts.map +1 -0
  76. package/dist/events.js +82 -0
  77. package/dist/events.js.map +1 -0
  78. package/dist/factories.d.ts +8 -0
  79. package/dist/factories.d.ts.map +1 -0
  80. package/dist/factories.js +8 -0
  81. package/dist/factories.js.map +1 -0
  82. package/dist/index.d.ts +15 -5
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +14 -3
  85. package/dist/index.js.map +1 -1
  86. package/dist/lucid-schema.d.ts +15 -0
  87. package/dist/lucid-schema.d.ts.map +1 -0
  88. package/dist/lucid-schema.js +16 -0
  89. package/dist/lucid-schema.js.map +1 -0
  90. package/dist/metadata-keys.d.ts +3 -2
  91. package/dist/metadata-keys.d.ts.map +1 -1
  92. package/dist/naming/NamingStrategy.d.ts +7 -0
  93. package/dist/naming/NamingStrategy.d.ts.map +1 -1
  94. package/dist/naming/NamingStrategy.js +16 -0
  95. package/dist/naming/NamingStrategy.js.map +1 -1
  96. package/dist/orm.d.ts +14 -0
  97. package/dist/orm.d.ts.map +1 -0
  98. package/dist/orm.js +14 -0
  99. package/dist/orm.js.map +1 -0
  100. package/dist/query/DatabaseQueryBuilder.d.ts +557 -0
  101. package/dist/query/DatabaseQueryBuilder.d.ts.map +1 -0
  102. package/dist/query/DatabaseQueryBuilder.js +1798 -0
  103. package/dist/query/DatabaseQueryBuilder.js.map +1 -0
  104. package/dist/query/DmlBuilder.d.ts +62 -0
  105. package/dist/query/DmlBuilder.d.ts.map +1 -0
  106. package/dist/query/DmlBuilder.js +84 -0
  107. package/dist/query/DmlBuilder.js.map +1 -0
  108. package/dist/query/QueryBuilder.d.ts +5 -0
  109. package/dist/query/QueryBuilder.d.ts.map +1 -1
  110. package/dist/query/QueryBuilder.js +7 -0
  111. package/dist/query/QueryBuilder.js.map +1 -1
  112. package/dist/query/RawQueryBuilder.d.ts +47 -0
  113. package/dist/query/RawQueryBuilder.d.ts.map +1 -0
  114. package/dist/query/RawQueryBuilder.js +138 -0
  115. package/dist/query/RawQueryBuilder.js.map +1 -0
  116. package/dist/query/interpolate.d.ts +45 -0
  117. package/dist/query/interpolate.d.ts.map +1 -0
  118. package/dist/query/interpolate.js +51 -0
  119. package/dist/query/interpolate.js.map +1 -0
  120. package/dist/query/native.d.ts +8 -0
  121. package/dist/query/native.d.ts.map +1 -1
  122. package/dist/query/native.js +14 -0
  123. package/dist/query/native.js.map +1 -1
  124. package/dist/query/operators.d.ts +4 -0
  125. package/dist/query/operators.d.ts.map +1 -0
  126. package/dist/query/operators.js +27 -0
  127. package/dist/query/operators.js.map +1 -0
  128. package/dist/schema/Migration.d.ts +50 -3
  129. package/dist/schema/Migration.d.ts.map +1 -1
  130. package/dist/schema/Migration.js +60 -24
  131. package/dist/schema/Migration.js.map +1 -1
  132. package/dist/schema/MigrationRunner.d.ts +95 -34
  133. package/dist/schema/MigrationRunner.d.ts.map +1 -1
  134. package/dist/schema/MigrationRunner.js +523 -68
  135. package/dist/schema/MigrationRunner.js.map +1 -1
  136. package/dist/schema/Schema.d.ts +114 -0
  137. package/dist/schema/Schema.d.ts.map +1 -1
  138. package/dist/schema/Schema.js +272 -3
  139. package/dist/schema/Schema.js.map +1 -1
  140. package/dist/schema/SchemaDumper.d.ts +121 -0
  141. package/dist/schema/SchemaDumper.d.ts.map +1 -0
  142. package/dist/schema/SchemaDumper.js +365 -0
  143. package/dist/schema/SchemaDumper.js.map +1 -0
  144. package/dist/schema/Seeder.d.ts +13 -0
  145. package/dist/schema/Seeder.d.ts.map +1 -1
  146. package/dist/schema/Seeder.js +28 -5
  147. package/dist/schema/Seeder.js.map +1 -1
  148. package/dist/schema/TableBuilder.d.ts +274 -16
  149. package/dist/schema/TableBuilder.d.ts.map +1 -1
  150. package/dist/schema/TableBuilder.js +656 -52
  151. package/dist/schema/TableBuilder.js.map +1 -1
  152. package/dist/schema/catalog.d.ts +71 -0
  153. package/dist/schema/catalog.d.ts.map +1 -0
  154. package/dist/schema/catalog.js +158 -0
  155. package/dist/schema/catalog.js.map +1 -0
  156. package/dist/schema/introspect.d.ts +1 -1
  157. package/dist/schema/introspect.d.ts.map +1 -1
  158. package/dist/schema/introspect.js +26 -11
  159. package/dist/schema/introspect.js.map +1 -1
  160. package/dist/schema/types.d.ts +150 -1
  161. package/dist/schema/types.d.ts.map +1 -1
  162. package/dist/schema/types.js +11 -0
  163. package/dist/schema/types.js.map +1 -1
  164. package/dist/seeders.d.ts +7 -0
  165. package/dist/seeders.d.ts.map +1 -0
  166. package/dist/seeders.js +7 -0
  167. package/dist/seeders.js.map +1 -0
  168. package/dist/services/db.d.ts +122 -5
  169. package/dist/services/db.d.ts.map +1 -1
  170. package/dist/services/db.js +187 -14
  171. package/dist/services/db.js.map +1 -1
  172. package/dist/testing/DatabaseCleanup.d.ts +33 -7
  173. package/dist/testing/DatabaseCleanup.d.ts.map +1 -1
  174. package/dist/testing/DatabaseCleanup.js +54 -24
  175. package/dist/testing/DatabaseCleanup.js.map +1 -1
  176. package/dist/testing/DbAssertions.d.ts +48 -0
  177. package/dist/testing/DbAssertions.d.ts.map +1 -0
  178. package/dist/testing/DbAssertions.js +80 -0
  179. package/dist/testing/DbAssertions.js.map +1 -0
  180. package/dist/testing/Factory.d.ts +166 -15
  181. package/dist/testing/Factory.d.ts.map +1 -1
  182. package/dist/testing/Factory.js +497 -23
  183. package/dist/testing/Factory.js.map +1 -1
  184. package/dist/testing/TestUtils.d.ts +49 -0
  185. package/dist/testing/TestUtils.d.ts.map +1 -0
  186. package/dist/testing/TestUtils.js +79 -0
  187. package/dist/testing/TestUtils.js.map +1 -0
  188. package/dist/testing/index.d.ts +3 -1
  189. package/dist/testing/index.d.ts.map +1 -1
  190. package/dist/testing/index.js +3 -1
  191. package/dist/testing/index.js.map +1 -1
  192. package/dist/types/relations.d.ts +31 -0
  193. package/dist/types/relations.d.ts.map +1 -0
  194. package/dist/types/relations.js +20 -0
  195. package/dist/types/relations.js.map +1 -0
  196. package/index.darwin-arm64.node +0 -0
  197. package/index.darwin-x64.node +0 -0
  198. package/index.linux-arm64-gnu.node +0 -0
  199. package/index.linux-x64-gnu.node +0 -0
  200. package/index.win32-x64-msvc.node +0 -0
  201. package/package.json +32 -2
  202. package/scripts/guard-publish.mjs +15 -0
  203. package/src/AtlasProvider.ts +305 -52
  204. package/src/BaseEntity.ts +460 -41
  205. package/src/BaseModel.ts +461 -0
  206. package/src/BaseRepository.ts +1710 -421
  207. package/src/ConnectionManager.ts +298 -0
  208. package/src/ModelQuery.ts +2998 -335
  209. package/src/Transaction.ts +227 -30
  210. package/src/adapters/NapiDbAdapter.ts +224 -17
  211. package/src/console/contract.ts +96 -0
  212. package/src/console/factoryCommands.ts +73 -0
  213. package/src/console/migrationCommands.ts +448 -0
  214. package/src/console/schemaCheckCommand.ts +26 -22
  215. package/src/console/schemaDumpCommand.ts +96 -0
  216. package/src/console/schemaGenerateCommand.ts +380 -0
  217. package/src/console/seederCommands.ts +174 -0
  218. package/src/decorators/entity.ts +71 -8
  219. package/src/decorators/hooks.ts +5 -3
  220. package/src/events.ts +117 -0
  221. package/src/factories.ts +10 -0
  222. package/src/index.ts +69 -3
  223. package/src/lucid-schema.ts +22 -0
  224. package/src/metadata-keys.ts +3 -2
  225. package/src/naming/NamingStrategy.ts +23 -0
  226. package/src/orm.ts +13 -0
  227. package/src/query/DatabaseQueryBuilder.ts +2724 -0
  228. package/src/query/DmlBuilder.ts +131 -0
  229. package/src/query/QueryBuilder.ts +8 -0
  230. package/src/query/RawQueryBuilder.ts +173 -0
  231. package/src/query/interpolate.ts +73 -0
  232. package/src/query/native.ts +29 -0
  233. package/src/query/operators.ts +27 -0
  234. package/src/schema/Migration.ts +71 -3
  235. package/src/schema/MigrationRunner.ts +676 -69
  236. package/src/schema/Schema.ts +359 -3
  237. package/src/schema/SchemaDumper.ts +518 -0
  238. package/src/schema/Seeder.ts +47 -10
  239. package/src/schema/TableBuilder.ts +783 -52
  240. package/src/schema/catalog.ts +236 -0
  241. package/src/schema/introspect.ts +26 -9
  242. package/src/schema/types.ts +137 -2
  243. package/src/seeders.ts +16 -0
  244. package/src/services/db.ts +361 -21
  245. package/src/testing/DatabaseCleanup.ts +78 -25
  246. package/src/testing/DbAssertions.ts +127 -0
  247. package/src/testing/Factory.ts +837 -40
  248. package/src/testing/TestUtils.ts +123 -0
  249. package/src/testing/index.ts +12 -1
  250. package/src/types/relations.ts +43 -0
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,
@@ -102,18 +107,67 @@ export interface HasManyRelationProxy
102
107
  readonly type: "hasMany";
103
108
  }
104
109
 
105
- /** `@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
+ */
106
119
  export interface BelongsToRelationProxy extends BulkRelationProxy {
107
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>;
108
125
  /** Set `parent.<fk> = model.<ownerKey>` and save the parent. Rejects null/undefined. */
109
126
  associate(model: BaseEntity): Promise<void>;
110
127
  /** Clear the FK and save the parent. */
111
128
  dissociate(): Promise<void>;
112
129
  }
113
130
 
114
- /** `@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
+ */
115
140
  export interface ManyToManyRelationProxy extends BulkRelationProxy {
116
141
  readonly type: "manyToMany";
142
+ /**
143
+ * Persist a new related row AND its pivot link, optionally with pivot column
144
+ * values (Adonis Lucid `create(values, pivotAttributes)`).
145
+ */
146
+ create(
147
+ data: Record<string, unknown>,
148
+ pivotAttributes?: Record<string, unknown>,
149
+ ): Promise<BaseEntity>;
150
+ /** Persist an existing related instance + pivot link, with optional pivot values. */
151
+ save(
152
+ related: BaseEntity,
153
+ pivotAttributes?: Record<string, unknown>,
154
+ ): Promise<void>;
155
+ /** Bulk {@link create}; `pivotAttributes[i]` applies to `rows[i]`. */
156
+ createMany(
157
+ rows: Array<Record<string, unknown>>,
158
+ pivotAttributes?: Array<Record<string, unknown>>,
159
+ ): Promise<BaseEntity[]>;
160
+ /** Bulk {@link save}; `pivotAttributes[i]` applies to `related[i]`. */
161
+ saveMany(
162
+ related: BaseEntity[],
163
+ pivotAttributes?: Array<Record<string, unknown>>,
164
+ ): Promise<BaseEntity[]>;
165
+ /**
166
+ * A query builder on the PIVOT table itself, scoped to this parent (Adonis
167
+ * Lucid `pivotQuery`) — read/update/delete pivot rows directly, beyond
168
+ * attach/detach/sync. Returns the connection-level query builder.
169
+ */
170
+ pivotQuery(): import("./query/DatabaseQueryBuilder.js").DatabaseQueryBuilder;
117
171
  /** Insert pivot rows. Accepts `id[]` or `{ id: extras }`. */
118
172
  attach(
119
173
  ids: Array<string | number> | Record<string, Record<string, unknown>>,
@@ -130,11 +184,33 @@ export interface ManyToManyRelationProxy extends BulkRelationProxy {
130
184
  ): Promise<void>;
131
185
  }
132
186
 
187
+ /**
188
+ * `@HasOneThrough` / `@HasManyThrough` — READ-ONLY two-hop relations. Lucid does
189
+ * NOT expose persistence on a through relation (verified against the Lucid docs):
190
+ * you persist via the intermediate model. `query()` traverses the through table;
191
+ * create/save/createMany/saveMany throw at runtime and are typed `Promise<never>`
192
+ * so a caller who narrows to a through relation gets a compile-time signal too.
193
+ *
194
+ * `@HasManyThrough` is Lucid parity. `@HasOneThrough` is an atlas addition —
195
+ * Lucid has no such relation (checked against adonisjs/lucid `develop`:
196
+ * `src/orm/relations/` holds belongs_to, has_many, has_many_through, has_one and
197
+ * many_to_many, and no hasOneThrough appears in its types). It is the same
198
+ * two-hop traversal returning a single row instead of an array.
199
+ */
200
+ export interface HasManyThroughRelationProxy extends BulkRelationProxy {
201
+ readonly type: "hasOneThrough" | "hasManyThrough";
202
+ create(data: Record<string, unknown>): Promise<never>;
203
+ save(related: BaseEntity): Promise<never>;
204
+ createMany(rows: Array<Record<string, unknown>>): Promise<never>;
205
+ saveMany(related: BaseEntity[]): Promise<never>;
206
+ }
207
+
133
208
  export type RelationProxy =
134
209
  | HasOneRelationProxy
135
210
  | HasManyRelationProxy
136
211
  | BelongsToRelationProxy
137
- | ManyToManyRelationProxy;
212
+ | ManyToManyRelationProxy
213
+ | HasManyThroughRelationProxy;
138
214
 
139
215
  export type { ColumnSerializeConfig };
140
216
 
@@ -142,7 +218,21 @@ export type { ColumnSerializeConfig };
142
218
  * Internal reserved keys on BaseEntity that must never be treated as database
143
219
  * columns or serialized as data. Used by dirty tracking and by `toJSON`.
144
220
  */
145
- const INTERNAL_KEYS = new Set<string>(["$extras", "$original"]);
221
+ const INTERNAL_KEYS = new Set<string>(["$extras", "$original", "$sideloaded"]);
222
+
223
+ /**
224
+ * Structural (cross-realm-safe) check for a date value exposing `toISO()` — a
225
+ * Chronos `DateTime` and any compatible instance from a duplicated package copy.
226
+ * Used by dirty-tracking to compare date columns by instant, not by reference.
227
+ */
228
+ function hasToISO(v: unknown): v is { toISO(): string } {
229
+ return (
230
+ typeof v === "object" &&
231
+ v !== null &&
232
+ "toISO" in v &&
233
+ typeof v.toISO === "function"
234
+ );
235
+ }
146
236
 
147
237
  export class BaseEntity {
148
238
  /** Index signature — entities have dynamic column properties set by hydrate/create. */
@@ -160,6 +250,14 @@ export class BaseEntity {
160
250
  */
161
251
  $extras: Record<string, unknown> = {};
162
252
 
253
+ /**
254
+ * Sideloaded data — arbitrary context attached to the instance (AdonisJS Lucid
255
+ * `$sideloaded`), e.g. the current tenant/user threaded through from a query.
256
+ * Never a column, never dirty-tracked, never serialized. Set it manually;
257
+ * query-level `.sideload()` auto-propagation to hydrated results is not wired.
258
+ */
259
+ $sideloaded: Record<string, unknown> = {};
260
+
163
261
  /**
164
262
  * Snapshot of the column values at the moment this entity was hydrated from
165
263
  * the database. Used by dirty tracking (`isDirty`, `$dirty`). Populated by
@@ -170,6 +268,89 @@ export class BaseEntity {
170
268
  */
171
269
  $original: Record<string, unknown> = {};
172
270
 
271
+ // — Lifecycle state (AdonisJS Lucid parity). Kept in `#`-private fields so they
272
+ // never leak into `Object.keys(this)` / `$dirty` / `$original` / `toJSON`.
273
+ #persisted = false;
274
+ #deleted = false;
275
+ #local = true;
276
+ #forceUpdate = false;
277
+
278
+ /**
279
+ * Force the next `save()` to run an UPDATE even when nothing is dirty — e.g. to
280
+ * fire DB triggers or re-persist the current state (AdonisJS Lucid
281
+ * `enableForceUpdate`). The flag is consumed by that save. Chainable.
282
+ */
283
+ enableForceUpdate(): this {
284
+ this.#forceUpdate = true;
285
+ return this;
286
+ }
287
+
288
+ /** @internal Read-and-clear the force-update flag — called by `BaseRepository`. */
289
+ $consumeForceUpdate(): boolean {
290
+ const forced = this.#forceUpdate;
291
+ this.#forceUpdate = false;
292
+ return forced;
293
+ }
294
+
295
+ /**
296
+ * `true` once the row exists in the database — after `save()`/`create()`
297
+ * inserts it or a fetch hydrates it. AdonisJS Lucid `$isPersisted`.
298
+ */
299
+ get $isPersisted(): boolean {
300
+ return this.#persisted;
301
+ }
302
+
303
+ /** Inverse of {@link $isPersisted} — a never-persisted instance. Lucid `$isNew`. */
304
+ get $isNew(): boolean {
305
+ return !this.#persisted;
306
+ }
307
+
308
+ /**
309
+ * `true` when the instance originated in memory (`new Model()` / `create()`),
310
+ * `false` when it was fetched from the database. Lucid `$isLocal`.
311
+ */
312
+ get $isLocal(): boolean {
313
+ return this.#local;
314
+ }
315
+
316
+ /**
317
+ * `true` once `delete()` has removed the row; the instance must not be saved
318
+ * again. Lucid `$isDeleted`.
319
+ */
320
+ get $isDeleted(): boolean {
321
+ return this.#deleted;
322
+ }
323
+
324
+ /** The primary-key column's current value. Lucid `$primaryKeyValue`. */
325
+ get $primaryKeyValue(): unknown {
326
+ const pk = getPrimaryKey(this.constructor as new () => BaseEntity);
327
+ return pk === undefined ? undefined : this[pk];
328
+ }
329
+
330
+ /** @internal Repository marks the instance deleted after DELETE. */
331
+ markAsDeleted(): void {
332
+ this.#deleted = true;
333
+ }
334
+
335
+ /** @internal Repository flags a DB-originated instance (`$isLocal = false`). */
336
+ markAsFromDatabase(): void {
337
+ this.#local = false;
338
+ }
339
+
340
+ /**
341
+ * @internal Revert a fresh INSERT that was rolled back — the row never
342
+ * persisted, so the instance must report `$isNew` again. Named safety
343
+ * deviation from Lucid (which keeps `$isPersisted` after rollback): without
344
+ * this, a later `parent.related('x').create(...)` reads `$isPersisted === true`,
345
+ * skips re-saving the parent, and writes a child with a foreign key pointing at
346
+ * a phantom row. Only used for instances that were provably not persisted
347
+ * before the failed batch.
348
+ */
349
+ markAsNotPersisted(): void {
350
+ this.#persisted = false;
351
+ this.$original = {};
352
+ }
353
+
173
354
  /** Set a property dynamically (used by hydrate/create). */
174
355
  setProp(key: string, value: unknown): void {
175
356
  this[key] = value;
@@ -220,6 +401,7 @@ export class BaseEntity {
220
401
  snapshot[key] = this[key];
221
402
  }
222
403
  this.$original = snapshot;
404
+ this.#persisted = true;
223
405
  }
224
406
 
225
407
  /**
@@ -255,6 +437,12 @@ export class BaseEntity {
255
437
  if (current instanceof Date && original instanceof Date) {
256
438
  return current.getTime() === original.getTime();
257
439
  }
440
+ // Chronos DateTime (or any `toISO()`-bearing value): compare by instant, not
441
+ // reference, so re-wrapping the same moment (x = DateTime.from(x), toUTC(), a
442
+ // driver rebuilding it) doesn't spuriously flag the column dirty.
443
+ if (hasToISO(current) && hasToISO(original)) {
444
+ return current.toISO() === original.toISO();
445
+ }
258
446
  return Object.is(current, original);
259
447
  }
260
448
 
@@ -267,6 +455,14 @@ export class BaseEntity {
267
455
  return !this.#columnEqualsOriginal(field);
268
456
  }
269
457
 
458
+ /**
459
+ * `true` when the instance has unsaved changes since it was hydrated/persisted
460
+ * (AdonisJS Lucid `$isDirty` getter). Equivalent to `isDirty()` with no args.
461
+ */
462
+ get $isDirty(): boolean {
463
+ return Object.keys(this.$dirty).length > 0;
464
+ }
465
+
270
466
  /**
271
467
  * Revert all dirty columns back to their `$original` values.
272
468
  *
@@ -374,6 +570,18 @@ export class BaseEntity {
374
570
  return this;
375
571
  }
376
572
 
573
+ /**
574
+ * Like {@link load} but a no-op when the relation is already populated on this
575
+ * instance (AdonisJS Lucid `loadOnce`). Chainable.
576
+ */
577
+ async loadOnce(
578
+ relationName: string,
579
+ callback?: (q: unknown) => void,
580
+ ): Promise<this> {
581
+ if (this[relationName] !== undefined) return this;
582
+ return this.load(relationName, callback);
583
+ }
584
+
377
585
  /**
378
586
  * Return a relation proxy bound to this instance. The proxy exposes
379
587
  * `create` / `createMany` / `save` / `saveMany` that auto-set the FK.
@@ -397,7 +605,7 @@ export class BaseEntity {
397
605
  *
398
606
  * @implements Story 30.7
399
607
  */
400
- fill(payload: Record<string, unknown>): this {
608
+ fill(payload: Record<string, unknown>, allowExtraProperties = false): this {
401
609
  const ctor = this.constructor as typeof BaseEntity & {
402
610
  fillable?: string[];
403
611
  guarded?: string[];
@@ -407,6 +615,7 @@ export class BaseEntity {
407
615
  `${ctor.name}: cannot declare both 'fillable' and 'guarded'`,
408
616
  );
409
617
  }
618
+ const known = this.#knownColumnKeys();
410
619
  const allowed = (key: string): boolean => {
411
620
  if (ctor.fillable) return ctor.fillable.includes(key);
412
621
  if (ctor.guarded) return !ctor.guarded.includes(key);
@@ -430,18 +639,76 @@ export class BaseEntity {
430
639
  }
431
640
  }
432
641
  for (const [k, v] of Object.entries(payload)) {
642
+ // Mass-assignment (fillable/guarded) is the more specific gate — it wins.
433
643
  if (!allowed(k)) throw new MassAssignmentError(ctor.name, k);
644
+ // Otherwise reject keys that aren't declared columns at all (Lucid
645
+ // strict), unless the caller opts into dropping extras.
646
+ if (!known.has(k)) {
647
+ if (allowExtraProperties) continue;
648
+ throw new AtlasError(
649
+ "E_EXTRA_PROPERTIES",
650
+ `Cannot fill '${k}' on ${ctor.name}: it is not a declared column.`,
651
+ {
652
+ hint: "Declare it with @Column, or pass allowExtraProperties=true to ignore extra keys.",
653
+ },
654
+ );
655
+ }
434
656
  this[k] = v;
435
657
  }
436
658
  return this;
437
659
  }
438
660
 
661
+ /**
662
+ * The set of keys `fill`/`merge` treat as declared attributes: `@Column`
663
+ * property keys, the primary key, and any names the user listed in
664
+ * `static fillable` / `static guarded` (which reference real columns).
665
+ * Anything outside this set is an "extra property" rejected unless
666
+ * `allowExtraProperties` is passed.
667
+ */
668
+ #knownColumnKeys(): Set<string> {
669
+ const ctor = this.constructor as typeof BaseEntity & {
670
+ fillable?: string[];
671
+ guarded?: string[];
672
+ };
673
+ const keys = new Set(getColumnMetadata(ctor).map((c) => c.propertyKey));
674
+ const pk = getPrimaryKey(ctor);
675
+ if (pk) keys.add(pk);
676
+ for (const k of ctor.fillable ?? []) keys.add(k);
677
+ for (const k of ctor.guarded ?? []) keys.add(k);
678
+ return keys;
679
+ }
680
+
681
+ /**
682
+ * Throw `MassAssignmentError` if `key` is blocked by the class's static
683
+ * `fillable` allowlist / `guarded` denylist — the SAME rule `fill()`/`merge()`
684
+ * enforce. Repositories call this in `create`/`createMany`/`updateOrCreate` so
685
+ * those paths cannot bypass mass-assignment protection (a `guarded` column
686
+ * like `role`/`isAdmin` must not be settable from a plain payload).
687
+ */
688
+ assertMassAssignable(key: string): void {
689
+ const ctor = this.constructor as typeof BaseEntity & {
690
+ fillable?: string[];
691
+ guarded?: string[];
692
+ };
693
+ if (ctor.fillable && ctor.guarded) {
694
+ throw new Error(
695
+ `${ctor.name}: cannot declare both 'fillable' and 'guarded'`,
696
+ );
697
+ }
698
+ const allowed = ctor.fillable
699
+ ? ctor.fillable.includes(key)
700
+ : ctor.guarded
701
+ ? !ctor.guarded.includes(key)
702
+ : true;
703
+ if (!allowed) throw new MassAssignmentError(ctor.name, key);
704
+ }
705
+
439
706
  /**
440
707
  * Patch the entity with a payload, only touching the provided keys. Same
441
708
  * allowlist/blocklist rules as `fill` but preserves fields not present in
442
709
  * the payload.
443
710
  */
444
- merge(payload: Record<string, unknown>): this {
711
+ merge(payload: Record<string, unknown>, allowExtraProperties = false): this {
445
712
  const ctor = this.constructor as typeof BaseEntity & {
446
713
  fillable?: string[];
447
714
  guarded?: string[];
@@ -451,6 +718,7 @@ export class BaseEntity {
451
718
  `${ctor.name}: cannot declare both 'fillable' and 'guarded'`,
452
719
  );
453
720
  }
721
+ const known = this.#knownColumnKeys();
454
722
  const allowed = (key: string): boolean => {
455
723
  if (ctor.fillable) return ctor.fillable.includes(key);
456
724
  if (ctor.guarded) return !ctor.guarded.includes(key);
@@ -460,6 +728,16 @@ export class BaseEntity {
460
728
  if (!allowed(k)) {
461
729
  throw new MassAssignmentError(ctor.name, k);
462
730
  }
731
+ if (!known.has(k)) {
732
+ if (allowExtraProperties) continue;
733
+ throw new AtlasError(
734
+ "E_EXTRA_PROPERTIES",
735
+ `Cannot merge '${k}' on ${ctor.name}: it is not a declared column.`,
736
+ {
737
+ hint: "Declare it with @Column, or pass allowExtraProperties=true to ignore extra keys.",
738
+ },
739
+ );
740
+ }
463
741
  this[k] = v;
464
742
  }
465
743
  return this;
@@ -480,6 +758,27 @@ export class BaseEntity {
480
758
  this.#domainEvents = [];
481
759
  }
482
760
 
761
+ /**
762
+ * Number of queued domain events — snapshot this BEFORE a transactional write
763
+ * so a rollback can drop only the events that write added (see
764
+ * {@link restoreDomainEventsTo}), preserving any the caller queued earlier.
765
+ */
766
+ domainEventCount(): number {
767
+ return this.#domainEvents.length;
768
+ }
769
+
770
+ /**
771
+ * Truncate the queued domain events back to a floor captured before a
772
+ * transactional write. On rollback this drops the tx-added events while
773
+ * KEEPING pre-existing ones (which describe work outside the rolled-back
774
+ * transaction). Events are append-only (`push`), so the first `n` are the
775
+ * pre-existing ones. A floor past the current length is a no-op.
776
+ */
777
+ restoreDomainEventsTo(n: number): void {
778
+ if (n < this.#domainEvents.length)
779
+ this.#domainEvents.length = Math.max(0, n);
780
+ }
781
+
483
782
  /** Get and clear accumulated domain events atomically. */
484
783
  flushDomainEvents(): DomainEvent[] {
485
784
  const events = [...this.#domainEvents];
@@ -501,60 +800,180 @@ export class BaseEntity {
501
800
  * @implements Story 32.4
502
801
  */
503
802
  toJSON(): Record<string, unknown> {
803
+ const ctor = this.constructor as typeof BaseEntity & {
804
+ serializeExtras?:
805
+ | boolean
806
+ | ((extras: Record<string, unknown>) => Record<string, unknown>);
807
+ };
808
+ const result: Record<string, unknown> = {
809
+ ...this.serializeAttributes(),
810
+ ...this.serializeRelations(),
811
+ ...this.serializeComputed(),
812
+ };
813
+
814
+ // $extras (aggregates / pivot values) are serialized only when the model
815
+ // opts in via `static serializeExtras = true` — AdonisJS Lucid parity
816
+ // (default OFF), so internal aggregates never leak into API JSON by default.
817
+ if (!ctor.serializeExtras) return result;
818
+ const extras =
819
+ typeof ctor.serializeExtras === "function"
820
+ ? ctor.serializeExtras(this.$extras)
821
+ : this.$extras;
822
+ return { ...result, ...extras };
823
+ }
824
+
825
+ /**
826
+ * Effective hidden/visible sets — class-level `static hidden`/`static visible`
827
+ * allowlists layered with per-instance `makeHidden`/`makeVisible` overrides.
828
+ */
829
+ #visibility(): { hidden: Set<string>; visible: Set<string> | null } {
504
830
  const ctor = this.constructor as typeof BaseEntity & {
505
831
  hidden?: readonly string[];
506
832
  visible?: readonly string[];
507
- serializeExtras?: boolean;
508
833
  };
509
834
  const hidden = new Set(ctor.hidden ?? []);
835
+ // makeHidden adds, makeVisible force-shows.
836
+ for (const f of this.#hiddenOverride ?? []) hidden.add(f);
837
+ for (const f of this.#visibleOverride ?? []) hidden.delete(f);
510
838
  const visible =
511
839
  ctor.visible && ctor.visible.length > 0 ? new Set(ctor.visible) : null;
840
+ return { hidden, visible };
841
+ }
512
842
 
843
+ #isVisible(
844
+ key: string,
845
+ hidden: Set<string>,
846
+ visible: Set<string> | null,
847
+ ): boolean {
848
+ if (visible && !visible.has(key) && !this.#visibleOverride?.has(key))
849
+ return false;
850
+ return !hidden.has(key);
851
+ }
852
+
853
+ /**
854
+ * Serialize the regular `@column` attributes only — respecting hidden/visible
855
+ * allowlists and per-column `serializeAs`/`serialize` overrides. Override this
856
+ * to customize attribute serialization (AdonisJS Lucid `serializeAttributes`).
857
+ */
858
+ protected serializeAttributes(): Record<string, unknown> {
859
+ const ctor = this.constructor as typeof BaseEntity;
513
860
  const serializeConfig = getColumnSerializeConfig(ctor);
514
- // Relation `serializeAs` overrides — a preloaded relation is a plain
515
- // property on the instance, so it shows up in `Object.keys` below. Without
516
- // this map the `serializeAs` declared on @HasOne/@HasMany/etc. was silently
517
- // ignored (the relation always serialized under its property name).
518
- const relByKey = new Map(
519
- getRelationMetadata(ctor).map((r) => [r.propertyKey, r]),
861
+ const relKeys = new Set(
862
+ getRelationMetadata(ctor).map((r) => r.propertyKey),
520
863
  );
864
+ // Only DECLARED @column.date/dateTime columns get ISO'd — a business value
865
+ // object that happens to expose toISO() on a non-date column is left intact.
866
+ const dateCols = getDateColumnConfig(ctor);
867
+ const { hidden, visible } = this.#visibility();
521
868
  const result: Record<string, unknown> = {};
522
-
523
- // Regular columns (respecting hidden/visible + serialize overrides)
524
869
  for (const key of Object.keys(this)) {
525
870
  if (INTERNAL_KEYS.has(key)) continue;
526
- if (visible && !visible.has(key)) continue;
527
- if (hidden.has(key)) continue;
528
-
529
- // Preloaded relation: honour its `serializeAs` (rename, or `null` hides
530
- // it from the JSON). The nested entity serializes via its own toJSON.
531
- const rel = relByKey.get(key);
532
- if (rel) {
533
- if (rel.serializeAs === null) continue;
534
- result[rel.serializeAs ?? key] = this[key];
535
- continue;
536
- }
537
-
871
+ if (relKeys.has(key)) continue; // handled by serializeRelations
872
+ if (!this.#isVisible(key, hidden, visible)) continue;
538
873
  const cfg = serializeConfig[key];
539
874
  if (cfg?.serializeAs === null) continue; // explicit hide
540
-
541
875
  const outKey = cfg?.serializeAs ?? key;
542
876
  const rawValue = this[key];
543
- result[outKey] = cfg?.serialize ? cfg.serialize(rawValue) : rawValue;
877
+ // A @column.dateTime value is a Chronos DateTime; serialize it to an ISO
878
+ // string (AdonisJS Lucid serializes date columns to ISO), unless an
879
+ // explicit @Column({ serialize }) override takes over.
880
+ result[outKey] = cfg?.serialize
881
+ ? // Adonis Lucid signature: (value, attribute, model).
882
+ cfg.serialize(rawValue, key, this)
883
+ : dateCols[key] && hasToISO(rawValue)
884
+ ? rawValue.toISO()
885
+ : rawValue;
544
886
  }
887
+ return result;
888
+ }
545
889
 
546
- // Computed getters (@computed on the prototype)
547
- const computed = getComputedProperties(ctor);
548
- for (const prop of computed) {
549
- if (visible && !visible.has(prop)) continue;
550
- if (hidden.has(prop)) continue;
551
- result[prop] = (this as Record<string, unknown>)[prop];
890
+ /**
891
+ * Serialize preloaded relations only — honouring each relation's `serializeAs`
892
+ * (rename, or `null` to hide). Nested entities serialize via their own
893
+ * `toJSON`. Override to customize (AdonisJS Lucid `serializeRelations`).
894
+ */
895
+ protected serializeRelations(): Record<string, unknown> {
896
+ const ctor = this.constructor as typeof BaseEntity;
897
+ const relByKey = new Map(
898
+ getRelationMetadata(ctor).map((r) => [r.propertyKey, r]),
899
+ );
900
+ const { hidden, visible } = this.#visibility();
901
+ const result: Record<string, unknown> = {};
902
+ for (const key of Object.keys(this)) {
903
+ if (!this.#isVisible(key, hidden, visible)) continue;
904
+ const rel = relByKey.get(key);
905
+ if (!rel) continue;
906
+ if (rel.serializeAs === null) continue;
907
+ result[rel.serializeAs ?? key] = this[key];
552
908
  }
909
+ return result;
910
+ }
553
911
 
554
- // $extras (aggregates / pivot values) are serialized only when the model
555
- // opts in via `static serializeExtras = true` AdonisJS Lucid parity
556
- // (default OFF), so internal aggregates never leak into API JSON by default.
557
- return ctor.serializeExtras ? { ...result, ...this.$extras } : result;
912
+ /**
913
+ * Serialize `@computed` getters only. Override to customize
914
+ * (AdonisJS Lucid `serializeComputed`).
915
+ */
916
+ protected serializeComputed(): Record<string, unknown> {
917
+ const ctor = this.constructor as typeof BaseEntity;
918
+ const { hidden, visible } = this.#visibility();
919
+ const result: Record<string, unknown> = {};
920
+ for (const prop of getComputedProperties(ctor)) {
921
+ if (!this.#isVisible(prop, hidden, visible)) continue;
922
+ result[prop] = this[prop];
923
+ }
924
+ return result;
925
+ }
926
+
927
+ // Per-instance serialization visibility.
928
+ //
929
+ // NOT Lucid parity, despite what these used to claim: `makeHidden` /
930
+ // `makeVisible` and the `static hidden` / `static visible` allowlists do not
931
+ // exist in Lucid (checked against adonisjs/lucid `develop` — LucidRow and
932
+ // LucidModel declare neither). Lucid hides a column with
933
+ // `@column({ serializeAs: null })`, which atlas also supports. This is an
934
+ // atlas addition of Eloquent lineage, kept because per-instance visibility
935
+ // is genuinely useful; it is a named deviation, not a Lucid feature.
936
+ #hiddenOverride?: Set<string>;
937
+ #visibleOverride?: Set<string>;
938
+
939
+ /**
940
+ * Hide these fields when serializing THIS instance, on top of the class-level
941
+ * `static hidden`. Chainable.
942
+ *
943
+ * An atlas addition, not Lucid — see the `#hiddenOverride` note. Lucid's
944
+ * equivalent is the static `@column({ serializeAs: null })`, which atlas
945
+ * supports too; this is the per-instance form Lucid has no answer for.
946
+ */
947
+ makeHidden(...fields: string[]): this {
948
+ this.#hiddenOverride ??= new Set();
949
+ for (const f of fields) this.#hiddenOverride.add(f);
950
+ return this;
951
+ }
952
+
953
+ /**
954
+ * Force these fields visible when serializing THIS instance, overriding the
955
+ * class-level `static hidden`/`visible`. Chainable.
956
+ *
957
+ * An atlas addition, not Lucid — see {@link makeHidden}.
958
+ */
959
+ makeVisible(...fields: string[]): this {
960
+ this.#visibleOverride ??= new Set();
961
+ for (const f of fields) this.#visibleOverride.add(f);
962
+ return this;
963
+ }
964
+
965
+ /**
966
+ * Plain object of the raw columns + preloaded relations + `$extras`, WITHOUT
967
+ * any serialization transform (no `hidden`/`visible`, no `serializeAs`, no
968
+ * per-column `serialize`) — AdonisJS Lucid `toObject()`.
969
+ */
970
+ toObject(): Record<string, unknown> {
971
+ const out: Record<string, unknown> = {};
972
+ for (const key of Object.keys(this)) {
973
+ if (INTERNAL_KEYS.has(key)) continue;
974
+ out[key] = this[key];
975
+ }
976
+ return { ...out, ...this.$extras };
558
977
  }
559
978
 
560
979
  /**