@c9up/atlas 0.1.19 → 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.
- package/README.md +55 -14
- package/db.darwin-arm64.node +0 -0
- package/db.darwin-x64.node +0 -0
- package/db.linux-arm64-gnu.node +0 -0
- package/db.linux-x64-gnu.node +0 -0
- package/db.win32-x64-msvc.node +0 -0
- package/dist/AtlasProvider.d.ts +6 -0
- package/dist/AtlasProvider.d.ts.map +1 -1
- package/dist/AtlasProvider.js +2 -2
- package/dist/AtlasProvider.js.map +1 -1
- package/dist/BaseEntity.d.ts +164 -5
- package/dist/BaseEntity.d.ts.map +1 -1
- package/dist/BaseEntity.js +339 -33
- package/dist/BaseEntity.js.map +1 -1
- package/dist/BaseModel.d.ts +91 -0
- package/dist/BaseModel.d.ts.map +1 -0
- package/dist/BaseModel.js +193 -0
- package/dist/BaseModel.js.map +1 -0
- package/dist/BaseRepository.d.ts +71 -15
- package/dist/BaseRepository.d.ts.map +1 -1
- package/dist/BaseRepository.js +1355 -384
- package/dist/BaseRepository.js.map +1 -1
- package/dist/ModelQuery.d.ts +341 -18
- package/dist/ModelQuery.d.ts.map +1 -1
- package/dist/ModelQuery.js +1406 -177
- package/dist/ModelQuery.js.map +1 -1
- package/dist/Transaction.d.ts +17 -0
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +57 -5
- package/dist/Transaction.js.map +1 -1
- package/dist/adapters/NapiDbAdapter.d.ts +33 -4
- package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
- package/dist/adapters/NapiDbAdapter.js +101 -11
- package/dist/adapters/NapiDbAdapter.js.map +1 -1
- package/dist/console/migrationCommands.d.ts +48 -0
- package/dist/console/migrationCommands.d.ts.map +1 -0
- package/dist/console/migrationCommands.js +220 -0
- package/dist/console/migrationCommands.js.map +1 -0
- package/dist/decorators/entity.d.ts +37 -6
- package/dist/decorators/entity.d.ts.map +1 -1
- package/dist/decorators/entity.js +32 -2
- package/dist/decorators/entity.js.map +1 -1
- package/dist/events.d.ts +64 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +82 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/metadata-keys.d.ts +3 -2
- package/dist/metadata-keys.d.ts.map +1 -1
- package/dist/naming/NamingStrategy.d.ts +7 -0
- package/dist/naming/NamingStrategy.d.ts.map +1 -1
- package/dist/naming/NamingStrategy.js +16 -0
- package/dist/naming/NamingStrategy.js.map +1 -1
- package/dist/schema/Migration.d.ts +26 -3
- package/dist/schema/Migration.d.ts.map +1 -1
- package/dist/schema/Migration.js +33 -24
- package/dist/schema/Migration.js.map +1 -1
- package/dist/schema/MigrationRunner.d.ts +43 -32
- package/dist/schema/MigrationRunner.d.ts.map +1 -1
- package/dist/schema/MigrationRunner.js +211 -26
- package/dist/schema/MigrationRunner.js.map +1 -1
- package/dist/schema/Schema.d.ts +57 -0
- package/dist/schema/Schema.d.ts.map +1 -1
- package/dist/schema/Schema.js +138 -3
- package/dist/schema/Schema.js.map +1 -1
- package/dist/schema/TableBuilder.d.ts +247 -8
- package/dist/schema/TableBuilder.d.ts.map +1 -1
- package/dist/schema/TableBuilder.js +607 -41
- package/dist/schema/TableBuilder.js.map +1 -1
- package/dist/schema/catalog.d.ts +47 -0
- package/dist/schema/catalog.d.ts.map +1 -0
- package/dist/schema/catalog.js +111 -0
- package/dist/schema/catalog.js.map +1 -0
- package/dist/schema/types.d.ts +150 -1
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js +11 -0
- package/dist/schema/types.js.map +1 -1
- package/dist/services/db.d.ts +6 -0
- package/dist/services/db.d.ts.map +1 -1
- package/dist/services/db.js +17 -0
- package/dist/services/db.js.map +1 -1
- package/dist/testing/DatabaseCleanup.d.ts +7 -4
- package/dist/testing/DatabaseCleanup.d.ts.map +1 -1
- package/dist/testing/DatabaseCleanup.js +21 -18
- package/dist/testing/DatabaseCleanup.js.map +1 -1
- package/dist/testing/Factory.d.ts +70 -5
- package/dist/testing/Factory.d.ts.map +1 -1
- package/dist/testing/Factory.js +209 -10
- package/dist/testing/Factory.js.map +1 -1
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +3 -1
- package/scripts/guard-publish.mjs +15 -0
- package/src/AtlasProvider.ts +8 -1
- package/src/BaseEntity.ts +431 -41
- package/src/BaseModel.ts +324 -0
- package/src/BaseRepository.ts +1589 -419
- package/src/ModelQuery.ts +1817 -203
- package/src/Transaction.ts +68 -5
- package/src/adapters/NapiDbAdapter.ts +159 -10
- package/src/console/migrationCommands.ts +258 -0
- package/src/decorators/entity.ts +53 -6
- package/src/events.ts +112 -0
- package/src/index.ts +19 -0
- package/src/metadata-keys.ts +3 -2
- package/src/naming/NamingStrategy.ts +23 -0
- package/src/schema/Migration.ts +42 -3
- package/src/schema/MigrationRunner.ts +270 -27
- package/src/schema/Schema.ts +210 -3
- package/src/schema/TableBuilder.ts +735 -41
- package/src/schema/catalog.ts +166 -0
- package/src/schema/types.ts +137 -2
- package/src/services/db.ts +28 -0
- package/src/testing/DatabaseCleanup.ts +23 -22
- 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 {
|
|
15
|
-
|
|
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,16 +107,36 @@ export interface HasManyRelationProxy
|
|
|
102
107
|
readonly type: "hasMany";
|
|
103
108
|
}
|
|
104
109
|
|
|
105
|
-
/**
|
|
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
|
-
/**
|
|
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";
|
|
117
142
|
/** Insert pivot rows. Accepts `id[]` or `{ id: extras }`. */
|
|
@@ -130,11 +155,33 @@ export interface ManyToManyRelationProxy extends BulkRelationProxy {
|
|
|
130
155
|
): Promise<void>;
|
|
131
156
|
}
|
|
132
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
|
+
|
|
133
179
|
export type RelationProxy =
|
|
134
180
|
| HasOneRelationProxy
|
|
135
181
|
| HasManyRelationProxy
|
|
136
182
|
| BelongsToRelationProxy
|
|
137
|
-
| ManyToManyRelationProxy
|
|
183
|
+
| ManyToManyRelationProxy
|
|
184
|
+
| HasManyThroughRelationProxy;
|
|
138
185
|
|
|
139
186
|
export type { ColumnSerializeConfig };
|
|
140
187
|
|
|
@@ -142,7 +189,21 @@ export type { ColumnSerializeConfig };
|
|
|
142
189
|
* Internal reserved keys on BaseEntity that must never be treated as database
|
|
143
190
|
* columns or serialized as data. Used by dirty tracking and by `toJSON`.
|
|
144
191
|
*/
|
|
145
|
-
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
|
+
}
|
|
146
207
|
|
|
147
208
|
export class BaseEntity {
|
|
148
209
|
/** Index signature — entities have dynamic column properties set by hydrate/create. */
|
|
@@ -160,6 +221,14 @@ export class BaseEntity {
|
|
|
160
221
|
*/
|
|
161
222
|
$extras: Record<string, unknown> = {};
|
|
162
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
|
+
|
|
163
232
|
/**
|
|
164
233
|
* Snapshot of the column values at the moment this entity was hydrated from
|
|
165
234
|
* the database. Used by dirty tracking (`isDirty`, `$dirty`). Populated by
|
|
@@ -170,6 +239,89 @@ export class BaseEntity {
|
|
|
170
239
|
*/
|
|
171
240
|
$original: Record<string, unknown> = {};
|
|
172
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
|
+
|
|
173
325
|
/** Set a property dynamically (used by hydrate/create). */
|
|
174
326
|
setProp(key: string, value: unknown): void {
|
|
175
327
|
this[key] = value;
|
|
@@ -220,6 +372,7 @@ export class BaseEntity {
|
|
|
220
372
|
snapshot[key] = this[key];
|
|
221
373
|
}
|
|
222
374
|
this.$original = snapshot;
|
|
375
|
+
this.#persisted = true;
|
|
223
376
|
}
|
|
224
377
|
|
|
225
378
|
/**
|
|
@@ -255,6 +408,12 @@ export class BaseEntity {
|
|
|
255
408
|
if (current instanceof Date && original instanceof Date) {
|
|
256
409
|
return current.getTime() === original.getTime();
|
|
257
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
|
+
}
|
|
258
417
|
return Object.is(current, original);
|
|
259
418
|
}
|
|
260
419
|
|
|
@@ -267,6 +426,14 @@ export class BaseEntity {
|
|
|
267
426
|
return !this.#columnEqualsOriginal(field);
|
|
268
427
|
}
|
|
269
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
|
+
|
|
270
437
|
/**
|
|
271
438
|
* Revert all dirty columns back to their `$original` values.
|
|
272
439
|
*
|
|
@@ -374,6 +541,18 @@ export class BaseEntity {
|
|
|
374
541
|
return this;
|
|
375
542
|
}
|
|
376
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
|
+
|
|
377
556
|
/**
|
|
378
557
|
* Return a relation proxy bound to this instance. The proxy exposes
|
|
379
558
|
* `create` / `createMany` / `save` / `saveMany` that auto-set the FK.
|
|
@@ -397,7 +576,7 @@ export class BaseEntity {
|
|
|
397
576
|
*
|
|
398
577
|
* @implements Story 30.7
|
|
399
578
|
*/
|
|
400
|
-
fill(payload: Record<string, unknown
|
|
579
|
+
fill(payload: Record<string, unknown>, allowExtraProperties = false): this {
|
|
401
580
|
const ctor = this.constructor as typeof BaseEntity & {
|
|
402
581
|
fillable?: string[];
|
|
403
582
|
guarded?: string[];
|
|
@@ -407,6 +586,7 @@ export class BaseEntity {
|
|
|
407
586
|
`${ctor.name}: cannot declare both 'fillable' and 'guarded'`,
|
|
408
587
|
);
|
|
409
588
|
}
|
|
589
|
+
const known = this.#knownColumnKeys();
|
|
410
590
|
const allowed = (key: string): boolean => {
|
|
411
591
|
if (ctor.fillable) return ctor.fillable.includes(key);
|
|
412
592
|
if (ctor.guarded) return !ctor.guarded.includes(key);
|
|
@@ -430,18 +610,76 @@ export class BaseEntity {
|
|
|
430
610
|
}
|
|
431
611
|
}
|
|
432
612
|
for (const [k, v] of Object.entries(payload)) {
|
|
613
|
+
// Mass-assignment (fillable/guarded) is the more specific gate — it wins.
|
|
433
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
|
+
}
|
|
434
627
|
this[k] = v;
|
|
435
628
|
}
|
|
436
629
|
return this;
|
|
437
630
|
}
|
|
438
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
|
+
|
|
439
677
|
/**
|
|
440
678
|
* Patch the entity with a payload, only touching the provided keys. Same
|
|
441
679
|
* allowlist/blocklist rules as `fill` but preserves fields not present in
|
|
442
680
|
* the payload.
|
|
443
681
|
*/
|
|
444
|
-
merge(payload: Record<string, unknown
|
|
682
|
+
merge(payload: Record<string, unknown>, allowExtraProperties = false): this {
|
|
445
683
|
const ctor = this.constructor as typeof BaseEntity & {
|
|
446
684
|
fillable?: string[];
|
|
447
685
|
guarded?: string[];
|
|
@@ -451,6 +689,7 @@ export class BaseEntity {
|
|
|
451
689
|
`${ctor.name}: cannot declare both 'fillable' and 'guarded'`,
|
|
452
690
|
);
|
|
453
691
|
}
|
|
692
|
+
const known = this.#knownColumnKeys();
|
|
454
693
|
const allowed = (key: string): boolean => {
|
|
455
694
|
if (ctor.fillable) return ctor.fillable.includes(key);
|
|
456
695
|
if (ctor.guarded) return !ctor.guarded.includes(key);
|
|
@@ -460,6 +699,16 @@ export class BaseEntity {
|
|
|
460
699
|
if (!allowed(k)) {
|
|
461
700
|
throw new MassAssignmentError(ctor.name, k);
|
|
462
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
|
+
}
|
|
463
712
|
this[k] = v;
|
|
464
713
|
}
|
|
465
714
|
return this;
|
|
@@ -480,6 +729,27 @@ export class BaseEntity {
|
|
|
480
729
|
this.#domainEvents = [];
|
|
481
730
|
}
|
|
482
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
|
+
|
|
483
753
|
/** Get and clear accumulated domain events atomically. */
|
|
484
754
|
flushDomainEvents(): DomainEvent[] {
|
|
485
755
|
const events = [...this.#domainEvents];
|
|
@@ -501,60 +771,180 @@ export class BaseEntity {
|
|
|
501
771
|
* @implements Story 32.4
|
|
502
772
|
*/
|
|
503
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 } {
|
|
504
801
|
const ctor = this.constructor as typeof BaseEntity & {
|
|
505
802
|
hidden?: readonly string[];
|
|
506
803
|
visible?: readonly string[];
|
|
507
|
-
serializeExtras?: boolean;
|
|
508
804
|
};
|
|
509
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);
|
|
510
809
|
const visible =
|
|
511
810
|
ctor.visible && ctor.visible.length > 0 ? new Set(ctor.visible) : null;
|
|
811
|
+
return { hidden, visible };
|
|
812
|
+
}
|
|
512
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
|
+
}
|
|
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;
|
|
513
831
|
const serializeConfig = getColumnSerializeConfig(ctor);
|
|
514
|
-
|
|
515
|
-
|
|
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]),
|
|
832
|
+
const relKeys = new Set(
|
|
833
|
+
getRelationMetadata(ctor).map((r) => r.propertyKey),
|
|
520
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();
|
|
521
839
|
const result: Record<string, unknown> = {};
|
|
522
|
-
|
|
523
|
-
// Regular columns (respecting hidden/visible + serialize overrides)
|
|
524
840
|
for (const key of Object.keys(this)) {
|
|
525
841
|
if (INTERNAL_KEYS.has(key)) continue;
|
|
526
|
-
if (
|
|
527
|
-
if (
|
|
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
|
-
|
|
842
|
+
if (relKeys.has(key)) continue; // handled by serializeRelations
|
|
843
|
+
if (!this.#isVisible(key, hidden, visible)) continue;
|
|
538
844
|
const cfg = serializeConfig[key];
|
|
539
845
|
if (cfg?.serializeAs === null) continue; // explicit hide
|
|
540
|
-
|
|
541
846
|
const outKey = cfg?.serializeAs ?? key;
|
|
542
847
|
const rawValue = this[key];
|
|
543
|
-
|
|
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;
|
|
544
857
|
}
|
|
858
|
+
return result;
|
|
859
|
+
}
|
|
545
860
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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];
|
|
552
879
|
}
|
|
880
|
+
return result;
|
|
881
|
+
}
|
|
553
882
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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];
|
|
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
|
+
}
|
|
923
|
+
|
|
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 };
|
|
558
948
|
}
|
|
559
949
|
|
|
560
950
|
/**
|