@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,532 @@
1
+ /**
2
+ * Entity decorators — @Entity, @Column, @PrimaryKey, @BelongsTo, @HasMany, @computed
3
+ *
4
+ * @implements FR29, FR30, stories 32.3, 32.4
5
+ */
6
+
7
+ import "reflect-metadata";
8
+ import {
9
+ COLUMN_SERIALIZE_KEY,
10
+ COMPUTED_KEY,
11
+ type ColumnSerializeConfig,
12
+ } from "../BaseEntity.js";
13
+
14
+ const ENTITY_KEY = Symbol("atlas:entity");
15
+ const COLUMNS_KEY = Symbol("atlas:columns");
16
+ const PRIMARY_KEY = Symbol("atlas:primary");
17
+ const PRIMARY_KEY_GEN = Symbol("atlas:primary-gen");
18
+ const RELATIONS_KEY = Symbol("atlas:relations");
19
+
20
+ /** Auto-generation strategy for `@PrimaryKey({ generated: ... })`. */
21
+ export type PrimaryKeyGenerator = "uuid";
22
+
23
+ export interface PrimaryKeyOptions {
24
+ /**
25
+ * Auto-generate the primary-key value on INSERT when the entity has none
26
+ * (undefined). `'uuid'` produces an RFC-4122 v4 string via `crypto.randomUUID()`.
27
+ * Default: no auto-generation — the column relies on a DB default
28
+ * (`AUTOINCREMENT`, `SERIAL`, …) or the caller supplies a value.
29
+ */
30
+ generated?: PrimaryKeyGenerator;
31
+ }
32
+
33
+ export interface EntityMetadata {
34
+ tableName: string;
35
+ }
36
+
37
+ /**
38
+ * Column adapter — the `{ prepare?, consume? }` callback pair shared between
39
+ * `@Column({ prepare, consume })` (entity columns) and `pivotColumnAdapters`
40
+ * (m2m pivot extras). Single source of truth for the adapter shape; both
41
+ * `ColumnOptions` and `ColumnMetadata` extend this interface.
42
+ */
43
+ export interface ColumnAdapter {
44
+ /**
45
+ * Transform the model value before it is persisted (model → DB). Mirror of
46
+ * Adonis Lucid's `@column.prepare`.
47
+ *
48
+ * For entity columns, runs in `#entityToRow`, `#entityToRowPairs`,
49
+ * `#plainToRowPairs`, `#update`, and `#buildSetPairs`. For m2m pivot extras
50
+ * (`pivotColumnAdapters`), runs in the `attach()`/`sync()` row builder.
51
+ *
52
+ * **You MUST handle `null` and `undefined` yourself** — atlas calls the
53
+ * callback unconditionally for every value, including null/undefined, so a
54
+ * naive `(v: Decimal) => v.toString()` will throw on a null column. Return
55
+ * `null` for null/undefined inputs to preserve nullable semantics.
56
+ *
57
+ * For pivot extras specifically: when `attach()` is called with
58
+ * heterogeneous entries (the same extra key present on some entries and
59
+ * absent on others), the absent entries back-fill the missing key as
60
+ * `null` BEFORE `prepare` is called — your adapter must be null-safe even
61
+ * when no caller wrote null explicitly on that row.
62
+ *
63
+ * MUST stay synchronous; the bind layer cannot await before handing values
64
+ * to the Rust DML compiler. Returning a Promise throws.
65
+ */
66
+ prepare?: (value: unknown) => unknown;
67
+ /**
68
+ * Transform the raw DB value into the model attribute (DB → model). Mirror
69
+ * of Adonis Lucid's `@column.consume`. For entity columns, runs in
70
+ * `#hydrate`. For m2m pivot extras, runs in the `$extras.pivot_<col>`
71
+ * projection (currently dormant — see `pivotColumnAdapters` JSDoc).
72
+ *
73
+ * **You MUST handle `null` and `undefined` yourself** — atlas calls the
74
+ * callback unconditionally for every present row key, so a naive
75
+ * `(v: string) => v.trim()` will throw on a null column. Return `null` for
76
+ * null/undefined inputs to preserve nullable semantics.
77
+ *
78
+ * MUST stay synchronous (same constraint as `prepare`).
79
+ */
80
+ consume?: (value: unknown) => unknown;
81
+ }
82
+
83
+ export interface ColumnMetadata extends ColumnAdapter {
84
+ propertyKey: string;
85
+ type?: string;
86
+ nullable?: boolean;
87
+ default?: unknown;
88
+ serializeAs?: string | null;
89
+ serialize?: (value: unknown) => unknown;
90
+ }
91
+
92
+ export interface ColumnOptions extends ColumnAdapter {
93
+ type?: string;
94
+ nullable?: boolean;
95
+ default?: unknown;
96
+ /** Rename this column at `toJSON` time. Use `null` to hide it entirely. */
97
+ serializeAs?: string | null;
98
+ /** Transform the value at `toJSON` time (e.g. mask a phone number, coerce a Date). */
99
+ serialize?: (value: unknown) => unknown;
100
+ }
101
+
102
+ type Constructor = new (...args: unknown[]) => unknown;
103
+
104
+ export interface ManyToManyOptions {
105
+ /** Pivot table name joining the two sides (e.g., 'users_roles'). */
106
+ pivotTable: string;
107
+ /** Foreign key in the pivot table pointing to THIS entity (default: `${thisTable}_id`). */
108
+ foreignKey?: string;
109
+ /** Foreign key in the pivot table pointing to the RELATED entity (default: `${relatedTable}_id`). */
110
+ otherKey?: string;
111
+ /** Pivot extra columns to project into `$extras.pivot_<col>` on loaded relations (Story 31.8). */
112
+ pivotColumns?: string[];
113
+ /** Auto-write `created_at`/`updated_at` on `attach`/`sync` (Story 31.8). */
114
+ pivotTimestamps?:
115
+ | boolean
116
+ | { createdAt?: string | false; updatedAt?: string | false };
117
+ /**
118
+ * Per-extra-column adapter map for typed pivot values. Mirrors
119
+ * `@Column({ prepare, consume })` for entity columns: `prepare` runs on
120
+ * every `attach()` / `sync()` write, before the value reaches the SQL bind
121
+ * layer; `consume` runs on every load that projects the extra into
122
+ * `$extras.pivot_<col>` (when the projection mechanism lands — currently
123
+ * the load-side hook is dormant).
124
+ *
125
+ * Keys are pivot-row column names as written in the SQL (e.g. `amount`);
126
+ * adapters are reused verbatim from `@c9up/atom/atlas` and friends so a
127
+ * caller can pass `{ amount: decimalAtlasAdapter }` without wrapping.
128
+ *
129
+ * Adapters that are NOT listed here keep current pass-through behaviour
130
+ * — backward compatible.
131
+ */
132
+ pivotColumnAdapters?: Record<string, ColumnAdapter>;
133
+ }
134
+
135
+ /** Per-relation key/onQuery overrides (Story 31.3 + 31.4). */
136
+ export interface RelationOptions {
137
+ /** Override the parent-side join column (default: parent PK). */
138
+ localKey?: string;
139
+ /** Override the child-side FK (default: `${parentSnake}_id` for hasOne/hasMany, `${relatedSnake}_id` on belongsTo parent row). */
140
+ foreignKey?: string;
141
+ /** `belongsTo` only — owner (target) side join column (default: related PK). */
142
+ ownerKey?: string;
143
+ /** Default constraint applied on every preload + lazy load (Story 31.4). */
144
+ onQuery?: (q: unknown) => void;
145
+ /** `toJSON()` key override — `null` hides the relation from serialization (Story 31.3). */
146
+ serializeAs?: string | null;
147
+ }
148
+
149
+ /** Configuration for `@HasOneThrough` / `@HasManyThrough` — two-hop relations (Story 31.2). */
150
+ export interface ThroughOptions extends RelationOptions {
151
+ /** FK on the *intermediate* table that points at the parent (default: `${parentSnake}_id`). */
152
+ firstKey?: string;
153
+ /** FK on the *related* table that points at the intermediate (default: `${intermediateSnake}_id`). */
154
+ secondKey?: string;
155
+ /** Parent-side local join column (default: parent PK). */
156
+ localKey?: string;
157
+ /** Intermediate-side local join column matched by `secondKey` (default: intermediate PK). */
158
+ secondLocalKey?: string;
159
+ }
160
+
161
+ export interface RelationMetadata extends RelationOptions {
162
+ propertyKey: string;
163
+ type:
164
+ | "belongsTo"
165
+ | "hasOne"
166
+ | "hasMany"
167
+ | "hasOneThrough"
168
+ | "hasManyThrough"
169
+ | "manyToMany";
170
+ target: () => Constructor;
171
+ /** Intermediate ("through") model for two-hop relations — Story 31.2. */
172
+ through?: () => Constructor;
173
+ /** Extra through-specific keys. */
174
+ firstKey?: string;
175
+ secondKey?: string;
176
+ secondLocalKey?: string;
177
+ /** ManyToMany pivot configuration — required for type === 'manyToMany'. */
178
+ pivot?: ManyToManyOptions;
179
+ }
180
+
181
+ /** @Entity('table_name') — marks a class as a database entity. */
182
+ export function Entity(tableName: string): ClassDecorator {
183
+ return (target) => {
184
+ Reflect.defineMetadata(ENTITY_KEY, { tableName }, target);
185
+ };
186
+ }
187
+
188
+ /** @Column() — marks a property as a database column. */
189
+ export function Column(options?: ColumnOptions): PropertyDecorator {
190
+ return (target, propertyKey) => {
191
+ const columns: ColumnMetadata[] =
192
+ Reflect.getOwnMetadata(COLUMNS_KEY, target.constructor) ?? [];
193
+ const key = String(propertyKey);
194
+ // Deduplicate — @PrimaryKey also calls Column()
195
+ if (!columns.some((c) => c.propertyKey === key)) {
196
+ columns.push({
197
+ propertyKey: key,
198
+ type: options?.type,
199
+ nullable: options?.nullable,
200
+ default: options?.default,
201
+ serializeAs: options?.serializeAs,
202
+ serialize: options?.serialize,
203
+ prepare: options?.prepare,
204
+ consume: options?.consume,
205
+ });
206
+ Reflect.defineMetadata(COLUMNS_KEY, columns, target.constructor);
207
+ }
208
+
209
+ // Register serialize overrides on a separate map read by BaseEntity.toJSON.
210
+ // Stored per-class so subclasses can override a parent's serializeAs.
211
+ if (
212
+ options?.serializeAs !== undefined ||
213
+ options?.serialize !== undefined
214
+ ) {
215
+ const serializeMap: Record<string, ColumnSerializeConfig> =
216
+ Reflect.getOwnMetadata(COLUMN_SERIALIZE_KEY, target.constructor) ?? {};
217
+ serializeMap[key] = {
218
+ serializeAs: options.serializeAs,
219
+ serialize: options.serialize,
220
+ };
221
+ Reflect.defineMetadata(
222
+ COLUMN_SERIALIZE_KEY,
223
+ serializeMap,
224
+ target.constructor,
225
+ );
226
+ }
227
+ };
228
+ }
229
+
230
+ /**
231
+ * @computed() — marks a getter as a computed column that shows up in `toJSON()`.
232
+ * The getter is NEVER read or written during persistence (it's derived from other
233
+ * real columns). Lucid-compatible alias.
234
+ *
235
+ * @implements Story 32.3
236
+ */
237
+ export function computed(): MethodDecorator {
238
+ return (target, propertyKey) => {
239
+ const list: string[] =
240
+ Reflect.getOwnMetadata(COMPUTED_KEY, target.constructor) ?? [];
241
+ const key = String(propertyKey);
242
+ if (!list.includes(key)) {
243
+ list.push(key);
244
+ Reflect.defineMetadata(COMPUTED_KEY, list, target.constructor);
245
+ }
246
+ };
247
+ }
248
+
249
+ // ─── Date / DateTime column sub-decorators (story 32.8) ──────────
250
+
251
+ export interface DateTimeColumnOptions extends ColumnOptions {
252
+ /** Set the column to `new Date()` on INSERT (like `created_at`). */
253
+ autoCreate?: boolean;
254
+ /** Set the column to `new Date()` on every UPDATE (like `updated_at`). */
255
+ autoUpdate?: boolean;
256
+ }
257
+
258
+ /** Symbol metadata key for per-column date-column config. Read by BaseRepository. */
259
+ export const DATE_COLUMNS_KEY = Symbol.for("atlas:dateColumns");
260
+
261
+ export interface DateColumnConfig {
262
+ /** `true` means the column holds date-only values (no time). */
263
+ dateOnly: boolean;
264
+ autoCreate?: boolean;
265
+ autoUpdate?: boolean;
266
+ }
267
+
268
+ function registerDateColumn(
269
+ target: object,
270
+ propertyKey: string | symbol,
271
+ config: DateColumnConfig,
272
+ ): void {
273
+ const ctor = (target as { constructor: object }).constructor;
274
+ const map: Record<string, DateColumnConfig> =
275
+ Reflect.getOwnMetadata(DATE_COLUMNS_KEY, ctor) ?? {};
276
+ map[String(propertyKey)] = config;
277
+ Reflect.defineMetadata(DATE_COLUMNS_KEY, map, ctor);
278
+ }
279
+
280
+ /**
281
+ * `@column.date()` — marks a property as a date-only column (YYYY-MM-DD).
282
+ * The raw DB value is hydrated to a JS Date; nulls pass through.
283
+ */
284
+ function columnDate(options?: ColumnOptions): PropertyDecorator {
285
+ return (target, propertyKey) => {
286
+ Column(options)(target, propertyKey);
287
+ registerDateColumn(target, propertyKey, { dateOnly: true });
288
+ };
289
+ }
290
+
291
+ /**
292
+ * `@column.dateTime({ autoCreate, autoUpdate })` — marks a property as a
293
+ * timestamp column. Hydrated to JS Date on read.
294
+ *
295
+ * `autoCreate`: BaseRepository sets it to `new Date()` on INSERT.
296
+ * `autoUpdate`: BaseRepository sets it to `new Date()` on every UPDATE.
297
+ */
298
+ function columnDateTime(options?: DateTimeColumnOptions): PropertyDecorator {
299
+ return (target, propertyKey) => {
300
+ Column(options)(target, propertyKey);
301
+ registerDateColumn(target, propertyKey, {
302
+ dateOnly: false,
303
+ autoCreate: options?.autoCreate,
304
+ autoUpdate: options?.autoUpdate,
305
+ });
306
+ };
307
+ }
308
+
309
+ /** Namespace access so users write `@column.date()` / `@column.dateTime()`. */
310
+ const columnWithSubs = Column as typeof Column & {
311
+ date: typeof columnDate;
312
+ dateTime: typeof columnDateTime;
313
+ };
314
+ columnWithSubs.date = columnDate;
315
+ columnWithSubs.dateTime = columnDateTime;
316
+
317
+ /**
318
+ * `Column` exposed with its sub-decorators (`Column.date()`, `Column.dateTime()`).
319
+ * Alias exports so TS users can `import { column } from '@c9up/atlas'` for a
320
+ * Lucid-style lowercase naming when they prefer.
321
+ */
322
+ export const column = Column as typeof Column & {
323
+ date: typeof columnDate;
324
+ dateTime: typeof columnDateTime;
325
+ };
326
+
327
+ /** Read the date-column configuration map for an entity class (walks prototype chain). */
328
+ export function getDateColumnConfig(
329
+ entityClass: object,
330
+ ): Record<string, DateColumnConfig> {
331
+ const merged: Record<string, DateColumnConfig> = {};
332
+ let current: object | null = entityClass;
333
+ while (current && current !== Function.prototype) {
334
+ const map = Reflect.getOwnMetadata(DATE_COLUMNS_KEY, current) as
335
+ | Record<string, DateColumnConfig>
336
+ | undefined;
337
+ if (map) Object.assign(merged, map);
338
+ current = Object.getPrototypeOf(current);
339
+ }
340
+ return merged;
341
+ }
342
+
343
+ /** @PrimaryKey() — marks a property as the primary key. */
344
+ export function PrimaryKey(options?: PrimaryKeyOptions): PropertyDecorator {
345
+ return (target, propertyKey) => {
346
+ Reflect.defineMetadata(
347
+ PRIMARY_KEY,
348
+ String(propertyKey),
349
+ target.constructor,
350
+ );
351
+ if (options?.generated) {
352
+ Reflect.defineMetadata(
353
+ PRIMARY_KEY_GEN,
354
+ options.generated,
355
+ target.constructor,
356
+ );
357
+ }
358
+ // Also register as a column
359
+ Column()(target, propertyKey);
360
+ };
361
+ }
362
+
363
+ /**
364
+ * Read the PK auto-generation strategy declared via `@PrimaryKey({ generated })`.
365
+ * Returns `undefined` when the entity's PK has no generator — caller-supplied
366
+ * or DB-defaulted.
367
+ */
368
+ export function getPrimaryKeyGenerator(
369
+ entityClass: object,
370
+ ): PrimaryKeyGenerator | undefined {
371
+ let current: object | null = entityClass;
372
+ while (current && current !== Function.prototype) {
373
+ const gen = Reflect.getOwnMetadata(PRIMARY_KEY_GEN, current) as
374
+ | PrimaryKeyGenerator
375
+ | undefined;
376
+ if (gen) return gen;
377
+ current = Object.getPrototypeOf(current);
378
+ }
379
+ return undefined;
380
+ }
381
+
382
+ /** @BelongsTo(() => Related, { foreignKey, ownerKey, onQuery, serializeAs }) */
383
+ export function BelongsTo(
384
+ target: () => Constructor,
385
+ options: RelationOptions = {},
386
+ ): PropertyDecorator {
387
+ return (proto, propertyKey) => {
388
+ addRelation(proto.constructor, {
389
+ propertyKey: String(propertyKey),
390
+ type: "belongsTo",
391
+ target,
392
+ ...options,
393
+ });
394
+ };
395
+ }
396
+
397
+ /** @HasOne(() => Related, { localKey, foreignKey, onQuery, serializeAs }) */
398
+ export function HasOne(
399
+ target: () => Constructor,
400
+ options: RelationOptions = {},
401
+ ): PropertyDecorator {
402
+ return (proto, propertyKey) => {
403
+ addRelation(proto.constructor, {
404
+ propertyKey: String(propertyKey),
405
+ type: "hasOne",
406
+ target,
407
+ ...options,
408
+ });
409
+ };
410
+ }
411
+
412
+ /** @HasMany(() => Related, { localKey, foreignKey, onQuery, serializeAs }) */
413
+ export function HasMany(
414
+ target: () => Constructor,
415
+ options: RelationOptions = {},
416
+ ): PropertyDecorator {
417
+ return (proto, propertyKey) => {
418
+ addRelation(proto.constructor, {
419
+ propertyKey: String(propertyKey),
420
+ type: "hasMany",
421
+ target,
422
+ ...options,
423
+ });
424
+ };
425
+ }
426
+
427
+ /** @HasOneThrough(() => Related, () => Through, { firstKey, secondKey, localKey, secondLocalKey, onQuery }) */
428
+ export function HasOneThrough(
429
+ target: () => Constructor,
430
+ through: () => Constructor,
431
+ options: ThroughOptions = {},
432
+ ): PropertyDecorator {
433
+ return (proto, propertyKey) => {
434
+ addRelation(proto.constructor, {
435
+ propertyKey: String(propertyKey),
436
+ type: "hasOneThrough",
437
+ target,
438
+ through,
439
+ ...options,
440
+ });
441
+ };
442
+ }
443
+
444
+ /** @HasManyThrough(() => Related, () => Through, { firstKey, secondKey, localKey, secondLocalKey, onQuery }) */
445
+ export function HasManyThrough(
446
+ target: () => Constructor,
447
+ through: () => Constructor,
448
+ options: ThroughOptions = {},
449
+ ): PropertyDecorator {
450
+ return (proto, propertyKey) => {
451
+ addRelation(proto.constructor, {
452
+ propertyKey: String(propertyKey),
453
+ type: "hasManyThrough",
454
+ target,
455
+ through,
456
+ ...options,
457
+ });
458
+ };
459
+ }
460
+
461
+ /** @ManyToMany(() => Related, { pivotTable: 'users_roles' }) */
462
+ export function ManyToMany(
463
+ target: () => Constructor,
464
+ options: ManyToManyOptions & RelationOptions,
465
+ ): PropertyDecorator {
466
+ return (proto, propertyKey) => {
467
+ addRelation(proto.constructor, {
468
+ propertyKey: String(propertyKey),
469
+ type: "manyToMany",
470
+ target,
471
+ pivot: options,
472
+ onQuery: options.onQuery,
473
+ serializeAs: options.serializeAs,
474
+ });
475
+ };
476
+ }
477
+
478
+ function addRelation(target: object, relation: RelationMetadata): void {
479
+ const relations: RelationMetadata[] =
480
+ Reflect.getOwnMetadata(RELATIONS_KEY, target) ?? [];
481
+ relations.push(relation);
482
+ Reflect.defineMetadata(RELATIONS_KEY, relations, target);
483
+ }
484
+
485
+ /** Get entity metadata for a class. */
486
+ export function getEntityMetadata(
487
+ target: Constructor,
488
+ ): EntityMetadata | undefined {
489
+ return Reflect.getMetadata(ENTITY_KEY, target);
490
+ }
491
+
492
+ /** Get column metadata for a class (returns a copy). */
493
+ export function getColumnMetadata(target: Constructor): ColumnMetadata[] {
494
+ return [...(Reflect.getMetadata(COLUMNS_KEY, target) ?? [])];
495
+ }
496
+
497
+ /** Get primary key property name. */
498
+ export function getPrimaryKey(target: Constructor): string | undefined {
499
+ return Reflect.getMetadata(PRIMARY_KEY, target);
500
+ }
501
+
502
+ /** Get relation metadata for a class (returns a copy). */
503
+ export function getRelationMetadata(target: Constructor): RelationMetadata[] {
504
+ return [...(Reflect.getMetadata(RELATIONS_KEY, target) ?? [])];
505
+ }
506
+
507
+ // ─── Soft Deletes ────────────────────────────────────────────
508
+
509
+ const SOFT_DELETE_KEY = Symbol("atlas:softDeletes");
510
+
511
+ /** @SoftDeletes() — marks entity as soft-deletable via deleted_at column. */
512
+ export function SoftDeletes(): ClassDecorator {
513
+ return (target) => {
514
+ Reflect.defineMetadata(SOFT_DELETE_KEY, true, target);
515
+ // Auto-add deleted_at as a column
516
+ const columns: ColumnMetadata[] =
517
+ Reflect.getOwnMetadata(COLUMNS_KEY, target) ?? [];
518
+ if (!columns.some((c) => c.propertyKey === "deletedAt")) {
519
+ columns.push({
520
+ propertyKey: "deletedAt",
521
+ type: "timestamp",
522
+ nullable: true,
523
+ });
524
+ Reflect.defineMetadata(COLUMNS_KEY, columns, target);
525
+ }
526
+ };
527
+ }
528
+
529
+ /** Check if an entity class uses soft deletes. */
530
+ export function hasSoftDeletes(target: Constructor): boolean {
531
+ return Reflect.getMetadata(SOFT_DELETE_KEY, target) === true;
532
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Lifecycle hook decorators — Lucid-compatible hook surface.
3
+ *
4
+ * Decorate a static method on an entity with one of the hooks below; Atlas
5
+ * fires it at the matching point in the BaseRepository CRUD pipeline. Hooks
6
+ * are inherited from the prototype chain — a hook on a parent entity also
7
+ * fires for subclasses.
8
+ *
9
+ * class User extends BaseEntity {
10
+ * @beforeSave()
11
+ * static async hashPassword(user: User) {
12
+ * if (user.isDirty('password')) {
13
+ * user.password = await hash(user.password)
14
+ * }
15
+ * }
16
+ * }
17
+ *
18
+ * @implements Story 32.1
19
+ */
20
+
21
+ import "reflect-metadata";
22
+ import type { BaseEntity } from "../BaseEntity.js";
23
+ import type { ModelQuery } from "../ModelQuery.js";
24
+
25
+ /** All hook kinds Atlas supports — mirrors Lucid's surface. */
26
+ export type HookKind =
27
+ | "beforeSave"
28
+ | "afterSave"
29
+ | "beforeCreate"
30
+ | "afterCreate"
31
+ | "beforeUpdate"
32
+ | "afterUpdate"
33
+ | "beforeDelete"
34
+ | "afterDelete"
35
+ | "beforeFind"
36
+ | "afterFind"
37
+ | "beforeFetch"
38
+ | "afterFetch"
39
+ | "beforePaginate"
40
+ | "afterPaginate";
41
+
42
+ /** Argument shape for each hook kind. */
43
+ export interface HookArgs {
44
+ beforeSave: BaseEntity;
45
+ afterSave: BaseEntity;
46
+ beforeCreate: BaseEntity;
47
+ afterCreate: BaseEntity;
48
+ beforeUpdate: BaseEntity;
49
+ afterUpdate: BaseEntity;
50
+ beforeDelete: BaseEntity;
51
+ afterDelete: BaseEntity;
52
+ beforeFind: ModelQuery<BaseEntity>;
53
+ afterFind: BaseEntity | null;
54
+ beforeFetch: ModelQuery<BaseEntity>;
55
+ afterFetch: BaseEntity[];
56
+ beforePaginate: ModelQuery<BaseEntity>;
57
+ afterPaginate: BaseEntity[];
58
+ }
59
+
60
+ /** A hook handler is a static function that receives the kind-specific arg. */
61
+ export type HookHandler<K extends HookKind = HookKind> = (
62
+ arg: HookArgs[K],
63
+ ) => void | Promise<void>;
64
+
65
+ const HOOKS_KEY = Symbol.for("atlas:hooks");
66
+
67
+ /** Per-entity-class store: kind → handlers. Walked at fire time across the prototype chain. */
68
+ type HookRegistry = Partial<Record<HookKind, HookHandler[]>>;
69
+
70
+ function getOwnRegistry(target: object): HookRegistry {
71
+ const existing: unknown = Reflect.getOwnMetadata(HOOKS_KEY, target);
72
+ if (isHookRegistry(existing)) return existing;
73
+ const registry: HookRegistry = {};
74
+ Reflect.defineMetadata(HOOKS_KEY, registry, target);
75
+ return registry;
76
+ }
77
+
78
+ function isHookRegistry(value: unknown): value is HookRegistry {
79
+ if (typeof value !== "object" || value === null) return false;
80
+ for (const v of Object.values(value)) {
81
+ if (v !== undefined && !Array.isArray(v)) return false;
82
+ }
83
+ return true;
84
+ }
85
+
86
+ function isHookHandler(value: unknown): value is HookHandler {
87
+ return typeof value === "function";
88
+ }
89
+
90
+ /** Register a static method on the class as a hook handler. */
91
+ function register(kind: HookKind): MethodDecorator {
92
+ return (target, propertyKey) => {
93
+ // target is the constructor for static methods, the prototype for instance methods
94
+ const ctor = typeof target === "function" ? target : target.constructor;
95
+ const handler: unknown = Reflect.get(ctor, propertyKey);
96
+ if (!isHookHandler(handler)) {
97
+ throw new Error(
98
+ `@${kind} must decorate a static method, got ${String(propertyKey)}`,
99
+ );
100
+ }
101
+ const registry = getOwnRegistry(ctor);
102
+ if (!registry[kind]) registry[kind] = [];
103
+ registry[kind]?.push(handler);
104
+ };
105
+ }
106
+
107
+ // ─── Decorators (one per hook kind) ───────────────────────────
108
+
109
+ export const beforeSave = (): MethodDecorator => register("beforeSave");
110
+ export const afterSave = (): MethodDecorator => register("afterSave");
111
+ export const beforeCreate = (): MethodDecorator => register("beforeCreate");
112
+ export const afterCreate = (): MethodDecorator => register("afterCreate");
113
+ export const beforeUpdate = (): MethodDecorator => register("beforeUpdate");
114
+ export const afterUpdate = (): MethodDecorator => register("afterUpdate");
115
+ export const beforeDelete = (): MethodDecorator => register("beforeDelete");
116
+ export const afterDelete = (): MethodDecorator => register("afterDelete");
117
+ export const beforeFind = (): MethodDecorator => register("beforeFind");
118
+ export const afterFind = (): MethodDecorator => register("afterFind");
119
+ export const beforeFetch = (): MethodDecorator => register("beforeFetch");
120
+ export const afterFetch = (): MethodDecorator => register("afterFetch");
121
+ export const beforePaginate = (): MethodDecorator => register("beforePaginate");
122
+ export const afterPaginate = (): MethodDecorator => register("afterPaginate");
123
+
124
+ /**
125
+ * Walk the prototype chain and collect every handler registered for `kind`.
126
+ * Parent-class hooks fire BEFORE child-class hooks (so a base entity can set
127
+ * up scoping that the child then refines).
128
+ */
129
+ export function collectHooks<K extends HookKind>(
130
+ entityClass: new (...args: unknown[]) => BaseEntity,
131
+ kind: K,
132
+ ): HookHandler<K>[] {
133
+ const handlers: HookHandler<K>[] = [];
134
+ let ctor: object | null = entityClass;
135
+ const chain: object[] = [];
136
+ while (
137
+ ctor &&
138
+ ctor !== Function.prototype &&
139
+ (ctor as { name?: string }).name
140
+ ) {
141
+ chain.push(ctor);
142
+ ctor = Object.getPrototypeOf(ctor);
143
+ }
144
+ // Walk parent → child so the most-base hook fires first
145
+ for (const c of chain.reverse()) {
146
+ const registry = Reflect.getOwnMetadata(HOOKS_KEY, c) as
147
+ | HookRegistry
148
+ | undefined;
149
+ const list = registry?.[kind] as HookHandler<K>[] | undefined;
150
+ if (list) handlers.push(...list);
151
+ }
152
+ return handlers;
153
+ }
154
+
155
+ /**
156
+ * Fire every handler for `kind` sequentially. Awaits each one so a hook can
157
+ * mutate the entity before the next hook (or the persistence step) sees it.
158
+ * If any hook throws, the whole operation aborts and the error propagates.
159
+ */
160
+ export async function fireHooks<K extends HookKind>(
161
+ entityClass: new (...args: unknown[]) => BaseEntity,
162
+ kind: K,
163
+ arg: HookArgs[K],
164
+ ): Promise<void> {
165
+ const handlers = collectHooks(entityClass, kind);
166
+ for (const handler of handlers) {
167
+ await handler(arg);
168
+ }
169
+ }