@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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -0
  3. package/db.darwin-arm64.node +0 -0
  4. package/db.darwin-x64.node +0 -0
  5. package/db.linux-arm64-gnu.node +0 -0
  6. package/db.linux-x64-gnu.node +0 -0
  7. package/db.win32-x64-msvc.node +0 -0
  8. package/index.darwin-arm64.node +0 -0
  9. package/index.darwin-x64.node +0 -0
  10. package/index.linux-arm64-gnu.node +0 -0
  11. package/index.linux-x64-gnu.node +0 -0
  12. package/index.win32-x64-msvc.node +0 -0
  13. package/package.json +69 -0
  14. package/scripts/copy-napi.mjs +86 -0
  15. package/src/AtlasProvider.ts +297 -0
  16. package/src/BaseEntity.ts +585 -0
  17. package/src/BaseRepository.ts +1694 -0
  18. package/src/ModelQuery.ts +2293 -0
  19. package/src/Transaction.ts +83 -0
  20. package/src/adapters/NapiDbAdapter.ts +178 -0
  21. package/src/config.ts +7 -0
  22. package/src/configure.ts +37 -0
  23. package/src/decorators/entity.ts +532 -0
  24. package/src/decorators/hooks.ts +169 -0
  25. package/src/decorators/scope.ts +44 -0
  26. package/src/errors.ts +111 -0
  27. package/src/index.ts +114 -0
  28. package/src/naming/NamingStrategy.ts +106 -0
  29. package/src/query/QueryBuilder.ts +422 -0
  30. package/src/query/native.ts +74 -0
  31. package/src/schema/Migration.ts +81 -0
  32. package/src/schema/MigrationRunner.ts +532 -0
  33. package/src/schema/Schema.ts +78 -0
  34. package/src/schema/SchemaBuilder.ts +14 -0
  35. package/src/schema/Seeder.ts +132 -0
  36. package/src/schema/TableBuilder.ts +238 -0
  37. package/src/schema/types.ts +51 -0
  38. package/src/services/db.ts +45 -0
  39. package/src/testing/DatabaseCleanup.ts +49 -0
  40. package/src/testing/Factory.ts +164 -0
  41. package/src/testing/TestDatabase.ts +81 -0
  42. package/src/testing/index.ts +3 -0
  43. package/src/utils/casing.ts +11 -0
  44. package/src/utils/dialectFromUrl.ts +16 -0
  45. package/src/utils/identifier.ts +35 -0
  46. package/src/utils/safePath.ts +59 -0
  47. 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.