@c9up/atlas 0.1.3
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/LICENSE +21 -0
- package/README.md +35 -0
- 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/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 +69 -0
- package/scripts/copy-napi.mjs +86 -0
- package/src/AtlasProvider.ts +297 -0
- package/src/BaseEntity.ts +585 -0
- package/src/BaseRepository.ts +1694 -0
- package/src/ModelQuery.ts +2293 -0
- package/src/Transaction.ts +83 -0
- package/src/adapters/NapiDbAdapter.ts +178 -0
- package/src/config.ts +7 -0
- package/src/configure.ts +37 -0
- package/src/decorators/entity.ts +532 -0
- package/src/decorators/hooks.ts +169 -0
- package/src/decorators/scope.ts +44 -0
- package/src/errors.ts +111 -0
- package/src/index.ts +114 -0
- package/src/naming/NamingStrategy.ts +106 -0
- package/src/query/QueryBuilder.ts +422 -0
- package/src/query/native.ts +74 -0
- package/src/schema/Migration.ts +81 -0
- package/src/schema/MigrationRunner.ts +532 -0
- package/src/schema/Schema.ts +78 -0
- package/src/schema/SchemaBuilder.ts +14 -0
- package/src/schema/Seeder.ts +132 -0
- package/src/schema/TableBuilder.ts +238 -0
- package/src/schema/types.ts +51 -0
- package/src/services/db.ts +45 -0
- package/src/testing/DatabaseCleanup.ts +49 -0
- package/src/testing/Factory.ts +164 -0
- package/src/testing/TestDatabase.ts +81 -0
- package/src/testing/index.ts +3 -0
- package/src/utils/casing.ts +11 -0
- package/src/utils/dialectFromUrl.ts +16 -0
- package/src/utils/identifier.ts +35 -0
- package/src/utils/safePath.ts +59 -0
- package/src/utils/transactionBrand.ts +10 -0
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BaseEntity — base class for all Atlas entities.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - Domain event accumulation (flushed post-commit through the event bus)
|
|
6
|
+
* - `$extras` bag for ad-hoc / computed columns (32.5)
|
|
7
|
+
* - `$original` snapshot + `$dirty` diff tracking (32.2)
|
|
8
|
+
* - Serialization layer hooks (`hidden` / `visible` / `@column` serializeAs) (32.4)
|
|
9
|
+
* - Computed-property collection (32.3)
|
|
10
|
+
*
|
|
11
|
+
* @implements FR29, FR35, stories 32.1 through 32.5
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { MassAssignmentError } from "./errors.js";
|
|
15
|
+
|
|
16
|
+
export interface DomainEvent {
|
|
17
|
+
name: string;
|
|
18
|
+
data: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Symbol metadata key for the computed-property registry on an entity class. */
|
|
22
|
+
export const COMPUTED_KEY = Symbol.for("atlas:computed");
|
|
23
|
+
|
|
24
|
+
/** Symbol metadata key for the serialize-as / serializer overrides on columns. */
|
|
25
|
+
export const COLUMN_SERIALIZE_KEY = Symbol.for("atlas:columnSerialize");
|
|
26
|
+
|
|
27
|
+
/** Symbol property key used by entities to back-reference their hydrating repo. */
|
|
28
|
+
export const REPO_REF = Symbol.for("atlas:repoRef");
|
|
29
|
+
|
|
30
|
+
/** Minimal repo-back-reference interface used by `entity.refresh()` / `entity.fresh()` / `entity.loadCount()`. */
|
|
31
|
+
export interface EntityRepoRef {
|
|
32
|
+
refresh(entity: BaseEntity): Promise<void>;
|
|
33
|
+
fresh(entity: BaseEntity): Promise<BaseEntity>;
|
|
34
|
+
loadCount(
|
|
35
|
+
entity: BaseEntity,
|
|
36
|
+
relationName: string,
|
|
37
|
+
alias?: string,
|
|
38
|
+
): Promise<void>;
|
|
39
|
+
loadAggregate(
|
|
40
|
+
entity: BaseEntity,
|
|
41
|
+
relationName: string,
|
|
42
|
+
build: (q: unknown) => void,
|
|
43
|
+
): Promise<void>;
|
|
44
|
+
/** Lazy-load a relation onto `entity` — Story 31.10. */
|
|
45
|
+
loadRelation(
|
|
46
|
+
entity: BaseEntity,
|
|
47
|
+
relationName: string,
|
|
48
|
+
callback?: (q: unknown) => void,
|
|
49
|
+
): Promise<void>;
|
|
50
|
+
/** Build a related-entity proxy for fluent create/save + pivot ops — Stories 31.5–31.9. */
|
|
51
|
+
relatedProxy(entity: BaseEntity, relationName: string): RelationProxy;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── Relation proxy discriminated union ─────────────────────────────────────
|
|
55
|
+
// Stories 31.5–31.9. Each relation type returns a proxy with a `type`
|
|
56
|
+
// discriminator so `user.related('skills').type === 'manyToMany' && …` narrows
|
|
57
|
+
// to the m2m-specific methods and TS catches misuse at compile time.
|
|
58
|
+
|
|
59
|
+
interface BaseRelationProxy {
|
|
60
|
+
create(data: Record<string, unknown>): Promise<BaseEntity>;
|
|
61
|
+
save(related: BaseEntity): Promise<void>;
|
|
62
|
+
/** Scoped query builder over the related table (Story 31.9). */
|
|
63
|
+
query(): unknown;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface BulkRelationProxy extends BaseRelationProxy {
|
|
67
|
+
createMany(rows: Array<Record<string, unknown>>): Promise<BaseEntity[]>;
|
|
68
|
+
saveMany(related: BaseEntity[]): Promise<BaseEntity[]>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `@HasOne` — single related row. `createMany`/`saveMany` are intentionally absent. */
|
|
72
|
+
export interface HasOneRelationProxy extends BaseRelationProxy {
|
|
73
|
+
readonly type: "hasOne";
|
|
74
|
+
/** Throws with a clear "not supported on @HasOne" — exposed as a typed no-op for symmetry. */
|
|
75
|
+
createMany(rows: Array<Record<string, unknown>>): Promise<never>;
|
|
76
|
+
saveMany(related: BaseEntity[]): Promise<never>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `@HasMany` — zero or more related rows with bulk write support. */
|
|
80
|
+
export interface HasManyRelationProxy extends BulkRelationProxy {
|
|
81
|
+
readonly type: "hasMany";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `@BelongsTo` — set/clear the FK via `associate`/`dissociate`. */
|
|
85
|
+
export interface BelongsToRelationProxy extends BulkRelationProxy {
|
|
86
|
+
readonly type: "belongsTo";
|
|
87
|
+
/** Set `parent.<fk> = model.<ownerKey>` and save the parent. Rejects null/undefined. */
|
|
88
|
+
associate(model: BaseEntity): Promise<void>;
|
|
89
|
+
/** Clear the FK and save the parent. */
|
|
90
|
+
dissociate(): Promise<void>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `@ManyToMany` — full Lucid pivot API. */
|
|
94
|
+
export interface ManyToManyRelationProxy extends BulkRelationProxy {
|
|
95
|
+
readonly type: "manyToMany";
|
|
96
|
+
/** Insert pivot rows. Accepts `id[]` or `{ id: extras }`. */
|
|
97
|
+
attach(
|
|
98
|
+
ids: Array<string | number> | Record<string, Record<string, unknown>>,
|
|
99
|
+
): Promise<void>;
|
|
100
|
+
/** Delete pivot rows. No args = delete all for this parent. */
|
|
101
|
+
detach(ids?: Array<string | number>): Promise<void>;
|
|
102
|
+
/**
|
|
103
|
+
* Diff-compute the target set. `additive=false` (default) removes orphans;
|
|
104
|
+
* `additive=true` only inserts, never deletes.
|
|
105
|
+
*/
|
|
106
|
+
sync(
|
|
107
|
+
target: Array<string | number> | Record<string, Record<string, unknown>>,
|
|
108
|
+
additive?: boolean,
|
|
109
|
+
): Promise<void>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type RelationProxy =
|
|
113
|
+
| HasOneRelationProxy
|
|
114
|
+
| HasManyRelationProxy
|
|
115
|
+
| BelongsToRelationProxy
|
|
116
|
+
| ManyToManyRelationProxy;
|
|
117
|
+
|
|
118
|
+
/** Per-column serialization config (populated by @Column options). */
|
|
119
|
+
export interface ColumnSerializeConfig {
|
|
120
|
+
/** Rename this column at toJSON time (e.g. `password` → `passwordHash`). Null = hidden. */
|
|
121
|
+
serializeAs?: string | null;
|
|
122
|
+
/** Transform function applied to the value at toJSON time. */
|
|
123
|
+
serialize?: (value: unknown) => unknown;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Internal reserved keys on BaseEntity that must never be treated as database
|
|
128
|
+
* columns or serialized as data. Used by dirty tracking and by `toJSON`.
|
|
129
|
+
*/
|
|
130
|
+
const INTERNAL_KEYS = new Set<string>(["$extras", "$original"]);
|
|
131
|
+
|
|
132
|
+
export class BaseEntity {
|
|
133
|
+
/** Index signature — entities have dynamic column properties set by hydrate/create. */
|
|
134
|
+
[key: string]: unknown;
|
|
135
|
+
|
|
136
|
+
/** Accumulated domain events — dispatched on the event bus after DB commit. */
|
|
137
|
+
#domainEvents: DomainEvent[] = [];
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* `$extras` — bag for ad-hoc/computed values that are NOT declared as `@Column`.
|
|
141
|
+
* Used by `withCount`, pivot extras, and aggregate loaders. Kept separate from
|
|
142
|
+
* real columns so persistence (`#entityToRow`) never tries to write them back.
|
|
143
|
+
*
|
|
144
|
+
* @implements Story 32.5
|
|
145
|
+
*/
|
|
146
|
+
$extras: Record<string, unknown> = {};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Snapshot of the column values at the moment this entity was hydrated from
|
|
150
|
+
* the database. Used by dirty tracking (`isDirty`, `$dirty`). Populated by
|
|
151
|
+
* `BaseRepository.#hydrate` via `markAsPersisted` below; empty for entities
|
|
152
|
+
* built in memory with `new MyEntity()`.
|
|
153
|
+
*
|
|
154
|
+
* @implements Story 32.2
|
|
155
|
+
*/
|
|
156
|
+
$original: Record<string, unknown> = {};
|
|
157
|
+
|
|
158
|
+
/** Set a property dynamically (used by hydrate/create). */
|
|
159
|
+
setProp(key: string, value: unknown): void {
|
|
160
|
+
this[key] = value;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Set an `$extras` value (used by `withCount`, pivot extras, aggregate loaders). */
|
|
164
|
+
setExtra(key: string, value: unknown): void {
|
|
165
|
+
this.$extras[key] = value;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Get an `$extras` value with optional default. */
|
|
169
|
+
getExtra<T = unknown>(key: string, defaultValue?: T): T | undefined {
|
|
170
|
+
return (this.$extras[key] as T | undefined) ?? defaultValue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Freeze the current column values as the "persisted" snapshot. Called by
|
|
175
|
+
* `BaseRepository.#hydrate` after a SELECT and by `save()` after INSERT/UPDATE
|
|
176
|
+
* succeeds. From now on, `isDirty()` compares against this snapshot.
|
|
177
|
+
*/
|
|
178
|
+
/**
|
|
179
|
+
* Freeze the current column values as the persisted snapshot. Atlas uses
|
|
180
|
+
* **reference-based dirty tracking**: `$original` holds the SAME reference
|
|
181
|
+
* the hydrator produced, not a deep clone. A column is dirty iff
|
|
182
|
+
* `Object.is(current, original) === false`.
|
|
183
|
+
*
|
|
184
|
+
* **Contract (important)**: to mark an object/array column dirty, the user
|
|
185
|
+
* MUST reassign it — in-place mutation is undetectable by design:
|
|
186
|
+
*
|
|
187
|
+
* entity.settings = { ...entity.settings, theme: 'dark' } // ✅ dirty
|
|
188
|
+
* entity.settings.theme = 'dark' // ❌ NOT detected
|
|
189
|
+
*
|
|
190
|
+
* This matches Lucid's contract and gives us O(1) dirty checks + zero
|
|
191
|
+
* allocations on hydrate. The alternative (deep-equal with cloned snapshot)
|
|
192
|
+
* was correct but allocated a full copy of every column on every load and
|
|
193
|
+
* traversed nested JSON on every `save()` — unacceptable for hot paths.
|
|
194
|
+
*
|
|
195
|
+
* Rollback can only restore reassigned columns; in-place mutations are
|
|
196
|
+
* unrecoverable because the snapshot is the same reference as the current
|
|
197
|
+
* value. Use immutable update patterns if you rely on rollback.
|
|
198
|
+
*
|
|
199
|
+
* @implements Story 32.2
|
|
200
|
+
*/
|
|
201
|
+
markAsPersisted(): void {
|
|
202
|
+
const snapshot: Record<string, unknown> = {};
|
|
203
|
+
for (const key of Object.keys(this)) {
|
|
204
|
+
if (INTERNAL_KEYS.has(key)) continue;
|
|
205
|
+
snapshot[key] = this[key];
|
|
206
|
+
}
|
|
207
|
+
this.$original = snapshot;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Compute the set of columns whose current value differs from `$original`.
|
|
212
|
+
* Called on demand by `save()` (to emit UPDATEs that only touch dirty cols)
|
|
213
|
+
* and by lifecycle hooks (e.g. `beforeSave` only rehashes password if dirty).
|
|
214
|
+
*
|
|
215
|
+
* @implements Story 32.2
|
|
216
|
+
*/
|
|
217
|
+
get $dirty(): Record<string, unknown> {
|
|
218
|
+
const diff: Record<string, unknown> = {};
|
|
219
|
+
for (const key of Object.keys(this)) {
|
|
220
|
+
if (INTERNAL_KEYS.has(key)) continue;
|
|
221
|
+
if (!this.#columnEqualsOriginal(key)) {
|
|
222
|
+
diff[key] = this[key];
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return diff;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Reference-based dirty comparison for a single column — O(1), no allocation.
|
|
230
|
+
* `Object.is` handles NaN correctly and treats same-reference objects as
|
|
231
|
+
* equal (the core of the tracking contract — see `markAsPersisted` doc).
|
|
232
|
+
*
|
|
233
|
+
* The only structural exception is `Date`: two Date instances representing
|
|
234
|
+
* the same instant are compared by `getTime()` so hydration through a driver
|
|
235
|
+
* that rebuilds Date objects from ISO strings doesn't flag spurious dirty.
|
|
236
|
+
*/
|
|
237
|
+
#columnEqualsOriginal(key: string): boolean {
|
|
238
|
+
const current = this[key];
|
|
239
|
+
const original = this.$original[key];
|
|
240
|
+
if (current instanceof Date && original instanceof Date) {
|
|
241
|
+
return current.getTime() === original.getTime();
|
|
242
|
+
}
|
|
243
|
+
return Object.is(current, original);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Check whether a specific column is dirty, or whether any column is dirty
|
|
248
|
+
* when called without arguments.
|
|
249
|
+
*/
|
|
250
|
+
isDirty(field?: string): boolean {
|
|
251
|
+
if (field === undefined) return Object.keys(this.$dirty).length > 0;
|
|
252
|
+
return !this.#columnEqualsOriginal(field);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Revert all dirty columns back to their `$original` values.
|
|
257
|
+
*
|
|
258
|
+
* Because dirty tracking is reference-based, `rollback` only restores
|
|
259
|
+
* reassigned columns to their persisted reference. In-place mutations on
|
|
260
|
+
* object/array columns are NOT recoverable — the snapshot holds the same
|
|
261
|
+
* reference the user mutated. If you rely on rollback, use immutable
|
|
262
|
+
* update patterns (`entity.field = { ...entity.field, x: y }`).
|
|
263
|
+
*/
|
|
264
|
+
rollback(): void {
|
|
265
|
+
for (const key of Object.keys(this.$dirty)) {
|
|
266
|
+
this[key] = this.$original[key];
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Repository back-pointer set by `BaseRepository.#hydrate` so instances can
|
|
272
|
+
* self-refresh / lazy-load. Not serialized (symbol key).
|
|
273
|
+
*
|
|
274
|
+
* @implements Story 32.6
|
|
275
|
+
*/
|
|
276
|
+
[REPO_REF]?: EntityRepoRef;
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Re-read this entity's row from the database and mutate THIS instance
|
|
280
|
+
* with the latest values. Throws if the row no longer exists.
|
|
281
|
+
*/
|
|
282
|
+
async refresh(): Promise<this> {
|
|
283
|
+
const repo = this[REPO_REF];
|
|
284
|
+
if (!repo)
|
|
285
|
+
throw new Error(
|
|
286
|
+
"refresh() requires the entity to be hydrated by a BaseRepository",
|
|
287
|
+
);
|
|
288
|
+
await repo.refresh(this);
|
|
289
|
+
return this;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Re-read this entity's row from the database and return a NEW instance.
|
|
294
|
+
* `this` is NOT mutated. The returned object has the same runtime class
|
|
295
|
+
* because the repository that produced it is the same one we back-reference.
|
|
296
|
+
*/
|
|
297
|
+
async fresh(): Promise<BaseEntity> {
|
|
298
|
+
const repo = this[REPO_REF];
|
|
299
|
+
if (!repo)
|
|
300
|
+
throw new Error(
|
|
301
|
+
"fresh() requires the entity to be hydrated by a BaseRepository",
|
|
302
|
+
);
|
|
303
|
+
return repo.fresh(this);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Lazy-load a relation count into `this.$extras[alias ?? `${relationName}_count`]`.
|
|
308
|
+
* Issues a single `SELECT COUNT(*) FROM related WHERE <fk> = ?` for this entity.
|
|
309
|
+
*
|
|
310
|
+
* @implements Story 29.2
|
|
311
|
+
*/
|
|
312
|
+
async loadCount(relationName: string, alias?: string): Promise<this> {
|
|
313
|
+
const repo = this[REPO_REF];
|
|
314
|
+
if (!repo)
|
|
315
|
+
throw new Error(
|
|
316
|
+
"loadCount() requires the entity to be hydrated by a BaseRepository",
|
|
317
|
+
);
|
|
318
|
+
await repo.loadCount(this, relationName, alias);
|
|
319
|
+
return this;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Lazy-load a relation aggregate. The builder callback must set the aggregate
|
|
324
|
+
* via `.sum('col')` / `.avg(...)` / `.min(...)` / `.max(...)` / `.count()` and
|
|
325
|
+
* an alias via `.as('name')`. The result lands on `this.$extras[alias]`.
|
|
326
|
+
*
|
|
327
|
+
* await user.loadAggregate('posts', q => q.sum('views').as('total_views'))
|
|
328
|
+
*
|
|
329
|
+
* @implements Story 29.2
|
|
330
|
+
*/
|
|
331
|
+
async loadAggregate(
|
|
332
|
+
relationName: string,
|
|
333
|
+
build: (q: unknown) => void,
|
|
334
|
+
): Promise<this> {
|
|
335
|
+
const repo = this[REPO_REF];
|
|
336
|
+
if (!repo)
|
|
337
|
+
throw new Error(
|
|
338
|
+
"loadAggregate() requires the entity to be hydrated by a BaseRepository",
|
|
339
|
+
);
|
|
340
|
+
await repo.loadAggregate(this, relationName, build);
|
|
341
|
+
return this;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Lazy-load a relation onto this entity after it was initially fetched.
|
|
346
|
+
*
|
|
347
|
+
* @implements Story 31.10
|
|
348
|
+
*/
|
|
349
|
+
async load(
|
|
350
|
+
relationName: string,
|
|
351
|
+
callback?: (q: unknown) => void,
|
|
352
|
+
): Promise<this> {
|
|
353
|
+
const repo = this[REPO_REF];
|
|
354
|
+
if (!repo)
|
|
355
|
+
throw new Error(
|
|
356
|
+
"load() requires the entity to be hydrated by a BaseRepository",
|
|
357
|
+
);
|
|
358
|
+
await repo.loadRelation(this, relationName, callback);
|
|
359
|
+
return this;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Return a relation proxy bound to this instance. The proxy exposes
|
|
364
|
+
* `create` / `createMany` / `save` / `saveMany` that auto-set the FK.
|
|
365
|
+
*
|
|
366
|
+
* @implements Story 31.5
|
|
367
|
+
*/
|
|
368
|
+
related(relationName: string): ReturnType<EntityRepoRef["relatedProxy"]> {
|
|
369
|
+
const repo = this[REPO_REF];
|
|
370
|
+
if (!repo)
|
|
371
|
+
throw new Error(
|
|
372
|
+
"related() requires the entity to be hydrated by a BaseRepository",
|
|
373
|
+
);
|
|
374
|
+
return repo.relatedProxy(this, relationName);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Mass-assign columns from a plain payload. Only columns that are in the
|
|
379
|
+
* class's `static fillable` allowlist (or absent from `static guarded` when
|
|
380
|
+
* no fillable is declared) are assigned. Columns not in the payload are
|
|
381
|
+
* reset to undefined so the entity reflects exactly what was filled.
|
|
382
|
+
*
|
|
383
|
+
* @implements Story 30.7
|
|
384
|
+
*/
|
|
385
|
+
fill(payload: Record<string, unknown>): this {
|
|
386
|
+
const ctor = this.constructor as typeof BaseEntity & {
|
|
387
|
+
fillable?: string[];
|
|
388
|
+
guarded?: string[];
|
|
389
|
+
};
|
|
390
|
+
if (ctor.fillable && ctor.guarded) {
|
|
391
|
+
throw new Error(
|
|
392
|
+
`${ctor.name}: cannot declare both 'fillable' and 'guarded'`,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
const allowed = (key: string): boolean => {
|
|
396
|
+
if (ctor.fillable) return ctor.fillable.includes(key);
|
|
397
|
+
if (ctor.guarded) return !ctor.guarded.includes(key);
|
|
398
|
+
return true;
|
|
399
|
+
};
|
|
400
|
+
// Reset fillable fields that are absent from the payload. To keep dirty
|
|
401
|
+
// tracking honest, we restore the persisted `$original` reference (so the
|
|
402
|
+
// column reads as clean) for hydrated entities, and `delete` the property
|
|
403
|
+
// entirely for freshly-constructed ones (so `Object.keys(this)` doesn't
|
|
404
|
+
// list a phantom undefined column).
|
|
405
|
+
if (ctor.fillable) {
|
|
406
|
+
const hasOriginal = Object.keys(this.$original).length > 0;
|
|
407
|
+
for (const f of ctor.fillable) {
|
|
408
|
+
if (!(f in payload)) {
|
|
409
|
+
if (hasOriginal && f in this.$original) {
|
|
410
|
+
this[f] = this.$original[f];
|
|
411
|
+
} else {
|
|
412
|
+
delete this[f];
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
for (const [k, v] of Object.entries(payload)) {
|
|
418
|
+
if (!allowed(k)) throw new MassAssignmentError(ctor.name, k);
|
|
419
|
+
this[k] = v;
|
|
420
|
+
}
|
|
421
|
+
return this;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Patch the entity with a payload, only touching the provided keys. Same
|
|
426
|
+
* allowlist/blocklist rules as `fill` but preserves fields not present in
|
|
427
|
+
* the payload.
|
|
428
|
+
*/
|
|
429
|
+
merge(payload: Record<string, unknown>): this {
|
|
430
|
+
const ctor = this.constructor as typeof BaseEntity & {
|
|
431
|
+
fillable?: string[];
|
|
432
|
+
guarded?: string[];
|
|
433
|
+
};
|
|
434
|
+
if (ctor.fillable && ctor.guarded) {
|
|
435
|
+
throw new Error(
|
|
436
|
+
`${ctor.name}: cannot declare both 'fillable' and 'guarded'`,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
const allowed = (key: string): boolean => {
|
|
440
|
+
if (ctor.fillable) return ctor.fillable.includes(key);
|
|
441
|
+
if (ctor.guarded) return !ctor.guarded.includes(key);
|
|
442
|
+
return true;
|
|
443
|
+
};
|
|
444
|
+
for (const [k, v] of Object.entries(payload)) {
|
|
445
|
+
if (!allowed(k)) {
|
|
446
|
+
throw new MassAssignmentError(ctor.name, k);
|
|
447
|
+
}
|
|
448
|
+
this[k] = v;
|
|
449
|
+
}
|
|
450
|
+
return this;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Add a domain event to be dispatched after save. */
|
|
454
|
+
addDomainEvent(name: string, data: Record<string, unknown>): void {
|
|
455
|
+
this.#domainEvents.push({ name, data });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Get accumulated domain events (non-destructive read). */
|
|
459
|
+
getDomainEvents(): readonly DomainEvent[] {
|
|
460
|
+
return [...this.#domainEvents];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Clear accumulated domain events. */
|
|
464
|
+
clearDomainEvents(): void {
|
|
465
|
+
this.#domainEvents = [];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Get and clear accumulated domain events atomically. */
|
|
469
|
+
flushDomainEvents(): DomainEvent[] {
|
|
470
|
+
const events = [...this.#domainEvents];
|
|
471
|
+
this.#domainEvents = [];
|
|
472
|
+
return events;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Check if entity has pending domain events. */
|
|
476
|
+
hasDomainEvents(): boolean {
|
|
477
|
+
return this.#domainEvents.length > 0;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Serialize to JSON — honors class-level `hidden`/`visible` allowlists,
|
|
482
|
+
* per-column `serializeAs` / `serialize` overrides, and `@computed` getters.
|
|
483
|
+
* `$extras` is merged on top so callers see `withCount` / pivot extras next
|
|
484
|
+
* to regular columns. `#private` fields are excluded automatically by ES.
|
|
485
|
+
*
|
|
486
|
+
* @implements Story 32.4
|
|
487
|
+
*/
|
|
488
|
+
toJSON(): Record<string, unknown> {
|
|
489
|
+
const ctor = this.constructor as typeof BaseEntity & {
|
|
490
|
+
hidden?: readonly string[];
|
|
491
|
+
visible?: readonly string[];
|
|
492
|
+
};
|
|
493
|
+
const hidden = new Set(ctor.hidden ?? []);
|
|
494
|
+
const visible =
|
|
495
|
+
ctor.visible && ctor.visible.length > 0 ? new Set(ctor.visible) : null;
|
|
496
|
+
|
|
497
|
+
const serializeConfig = getColumnSerializeConfig(ctor);
|
|
498
|
+
const result: Record<string, unknown> = {};
|
|
499
|
+
|
|
500
|
+
// Regular columns (respecting hidden/visible + serialize overrides)
|
|
501
|
+
for (const key of Object.keys(this)) {
|
|
502
|
+
if (INTERNAL_KEYS.has(key)) continue;
|
|
503
|
+
if (visible && !visible.has(key)) continue;
|
|
504
|
+
if (hidden.has(key)) continue;
|
|
505
|
+
|
|
506
|
+
const cfg = serializeConfig[key];
|
|
507
|
+
if (cfg?.serializeAs === null) continue; // explicit hide
|
|
508
|
+
|
|
509
|
+
const outKey = cfg?.serializeAs ?? key;
|
|
510
|
+
const rawValue = this[key];
|
|
511
|
+
result[outKey] = cfg?.serialize ? cfg.serialize(rawValue) : rawValue;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Computed getters (@computed on the prototype)
|
|
515
|
+
const computed = getComputedProperties(ctor);
|
|
516
|
+
for (const prop of computed) {
|
|
517
|
+
if (visible && !visible.has(prop)) continue;
|
|
518
|
+
if (hidden.has(prop)) continue;
|
|
519
|
+
result[prop] = (this as Record<string, unknown>)[prop];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// $extras merged last — aggregates and pivot values show up alongside columns
|
|
523
|
+
return { ...result, ...this.$extras };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Pick / limit the fields returned by `toJSON()` for a single call.
|
|
528
|
+
*
|
|
529
|
+
* entity.serialize({ fields: ['id', 'title'] })
|
|
530
|
+
*/
|
|
531
|
+
serialize(options?: { fields?: readonly string[] }): Record<string, unknown> {
|
|
532
|
+
const full = this.toJSON();
|
|
533
|
+
if (!options?.fields) return full;
|
|
534
|
+
const picked: Record<string, unknown> = {};
|
|
535
|
+
for (const key of options.fields) {
|
|
536
|
+
if (key in full) picked[key] = full[key];
|
|
537
|
+
}
|
|
538
|
+
return picked;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ─── Computed / serialize metadata accessors ────────────────────
|
|
543
|
+
|
|
544
|
+
/** Collect the names of all `@computed` getters declared on the prototype chain. */
|
|
545
|
+
function getComputedProperties(ctor: unknown): string[] {
|
|
546
|
+
const names = new Set<string>();
|
|
547
|
+
let current: object | null =
|
|
548
|
+
typeof ctor === "object" || typeof ctor === "function"
|
|
549
|
+
? (ctor as object | null)
|
|
550
|
+
: null;
|
|
551
|
+
while (current && current !== Function.prototype) {
|
|
552
|
+
const list = Reflect.getOwnMetadata?.(COMPUTED_KEY, current) as
|
|
553
|
+
| string[]
|
|
554
|
+
| undefined;
|
|
555
|
+
if (list) for (const n of list) names.add(n);
|
|
556
|
+
current = Object.getPrototypeOf(current);
|
|
557
|
+
}
|
|
558
|
+
return [...names];
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Collect the serialize config for every column declared on the prototype chain. */
|
|
562
|
+
function getColumnSerializeConfig(
|
|
563
|
+
ctor: unknown,
|
|
564
|
+
): Record<string, ColumnSerializeConfig> {
|
|
565
|
+
const config: Record<string, ColumnSerializeConfig> = {};
|
|
566
|
+
let current: object | null =
|
|
567
|
+
typeof ctor === "object" || typeof ctor === "function"
|
|
568
|
+
? (ctor as object | null)
|
|
569
|
+
: null;
|
|
570
|
+
while (current && current !== Function.prototype) {
|
|
571
|
+
const map = Reflect.getOwnMetadata?.(COLUMN_SERIALIZE_KEY, current) as
|
|
572
|
+
| Record<string, ColumnSerializeConfig>
|
|
573
|
+
| undefined;
|
|
574
|
+
if (map) Object.assign(config, map);
|
|
575
|
+
current = Object.getPrototypeOf(current);
|
|
576
|
+
}
|
|
577
|
+
return config;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// `equalsDeep` was intentionally removed with the move to reference-based
|
|
581
|
+
// dirty tracking (Story 32.2 perf revision). The single comparison path is
|
|
582
|
+
// `Object.is` in `#columnEqualsOriginal`, with `Date` as the only structural
|
|
583
|
+
// exception. If you find yourself wanting a deep-equal here, reach for an
|
|
584
|
+
// immutable update pattern at the call site instead — the framework does not
|
|
585
|
+
// traverse object columns at save time by design.
|