@strav/database 0.1.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.
Files changed (39) hide show
  1. package/package.json +46 -0
  2. package/src/database/database.ts +181 -0
  3. package/src/database/domain/context.ts +84 -0
  4. package/src/database/domain/index.ts +17 -0
  5. package/src/database/domain/manager.ts +274 -0
  6. package/src/database/domain/wrapper.ts +105 -0
  7. package/src/database/index.ts +32 -0
  8. package/src/database/introspector.ts +446 -0
  9. package/src/database/migration/differ.ts +308 -0
  10. package/src/database/migration/file_generator.ts +125 -0
  11. package/src/database/migration/index.ts +18 -0
  12. package/src/database/migration/runner.ts +145 -0
  13. package/src/database/migration/sql_generator.ts +378 -0
  14. package/src/database/migration/tracker.ts +86 -0
  15. package/src/database/migration/types.ts +189 -0
  16. package/src/database/query_builder.ts +1034 -0
  17. package/src/database/seeder.ts +31 -0
  18. package/src/helpers/identity.ts +12 -0
  19. package/src/helpers/index.ts +1 -0
  20. package/src/index.ts +5 -0
  21. package/src/orm/base_model.ts +427 -0
  22. package/src/orm/decorators.ts +290 -0
  23. package/src/orm/index.ts +3 -0
  24. package/src/providers/database_provider.ts +25 -0
  25. package/src/providers/index.ts +1 -0
  26. package/src/schema/database_representation.ts +124 -0
  27. package/src/schema/define_association.ts +60 -0
  28. package/src/schema/define_schema.ts +46 -0
  29. package/src/schema/domain_discovery.ts +83 -0
  30. package/src/schema/field_builder.ts +160 -0
  31. package/src/schema/field_definition.ts +69 -0
  32. package/src/schema/index.ts +22 -0
  33. package/src/schema/naming.ts +19 -0
  34. package/src/schema/postgres.ts +109 -0
  35. package/src/schema/registry.ts +187 -0
  36. package/src/schema/representation_builder.ts +482 -0
  37. package/src/schema/type_builder.ts +115 -0
  38. package/src/schema/types.ts +35 -0
  39. package/tsconfig.json +5 -0
@@ -0,0 +1,31 @@
1
+ import Database from './database'
2
+
3
+ /**
4
+ * Base class for database seeders.
5
+ *
6
+ * Extend this class and implement {@link run} to populate the database
7
+ * with dev/test data. Use {@link call} to compose sub-seeders from a
8
+ * main `DatabaseSeeder`.
9
+ *
10
+ * @example
11
+ * import { Seeder } from '@stravigor/database/database'
12
+ *
13
+ * export default class DatabaseSeeder extends Seeder {
14
+ * async run(): Promise<void> {
15
+ * await this.call(UserSeeder)
16
+ * await this.call(PostSeeder)
17
+ * }
18
+ * }
19
+ */
20
+ export abstract class Seeder {
21
+ constructor(protected db: Database) {}
22
+
23
+ /** Insert seed data. */
24
+ abstract run(): Promise<void>
25
+
26
+ /** Invoke another seeder. */
27
+ async call(SeederClass: new (db: Database) => Seeder): Promise<void> {
28
+ const seeder = new SeederClass(this.db)
29
+ await seeder.run()
30
+ }
31
+ }
@@ -0,0 +1,12 @@
1
+ import BaseModel from '../orm/base_model'
2
+
3
+ /** Extract a user ID from a BaseModel instance or a raw string/number. */
4
+ export function extractUserId(user: unknown): string {
5
+ if (typeof user === 'string') return user
6
+ if (typeof user === 'number') return String(user)
7
+ if (user instanceof BaseModel) {
8
+ const ctor = user.constructor as typeof BaseModel
9
+ return String((user as unknown as Record<string, unknown>)[ctor.primaryKeyProperty])
10
+ }
11
+ throw new Error('Pass a BaseModel instance or a string/number user ID.')
12
+ }
@@ -0,0 +1 @@
1
+ export { extractUserId } from './identity'
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './database/index.ts'
2
+ export * from './orm/index.ts'
3
+ export * from './schema/index.ts'
4
+ export * from './helpers/index.ts'
5
+ export * from './providers/index.ts'
@@ -0,0 +1,427 @@
1
+ import { DateTime } from 'luxon'
2
+ import { toSnakeCase, toCamelCase } from '@stravigor/kernel/helpers/strings'
3
+ import { ulid as generateUlid } from '@stravigor/kernel/helpers'
4
+ import { inject } from '@stravigor/kernel/core/inject'
5
+ import {
6
+ getPrimaryKey,
7
+ getReferences,
8
+ getReferenceMeta,
9
+ getAssociates,
10
+ getCasts,
11
+ getEncrypted,
12
+ getUlids,
13
+ } from './decorators'
14
+ import type { ReferenceMetadata, AssociateMetadata } from './decorators'
15
+ import Database from '../database/database'
16
+ import { ConfigurationError, ModelNotFoundError } from '@stravigor/kernel/exceptions/errors'
17
+
18
+ type ModelStatic<T extends BaseModel> = (new (...args: any[]) => T) & typeof BaseModel
19
+
20
+ @inject
21
+ export default class BaseModel {
22
+ private static _db: Database
23
+
24
+ /** Whether this model supports soft deletes. Override in subclass. */
25
+ static softDeletes: boolean = false
26
+
27
+ constructor(db?: Database) {
28
+ if (db) BaseModel._db = db
29
+ }
30
+
31
+ /** The underlying database connection. */
32
+ static get db(): Database {
33
+ if (!BaseModel._db) {
34
+ throw new ConfigurationError(
35
+ 'Database not configured. Resolve BaseModel through the container first.'
36
+ )
37
+ }
38
+ return BaseModel._db
39
+ }
40
+
41
+ /** Derive table name from class name: User → user, OrderItem → order_item */
42
+ static get tableName(): string {
43
+ return toSnakeCase(this.name)
44
+ }
45
+
46
+ /** The primary key column name in snake_case (from @primary metadata). */
47
+ static get primaryKeyColumn(): string {
48
+ return toSnakeCase(getPrimaryKey(this))
49
+ }
50
+
51
+ /** The primary key property name in camelCase (from @primary metadata). */
52
+ static get primaryKeyProperty(): string {
53
+ return getPrimaryKey(this)
54
+ }
55
+
56
+ /** Whether this record was loaded from (or saved to) the database. */
57
+ _exists: boolean = false
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Static CRUD
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /** Find a record by primary key. Returns null if not found or soft-deleted. */
64
+ static async find<T extends BaseModel>(
65
+ this: ModelStatic<T>,
66
+ id: number | string | bigint
67
+ ): Promise<T | null> {
68
+ const db = BaseModel.db
69
+ const table = this.tableName
70
+ const pkCol = this.primaryKeyColumn
71
+ const softClause = this.softDeletes ? ' AND "deleted_at" IS NULL' : ''
72
+ const rows = await db.sql.unsafe(
73
+ `SELECT * FROM "${table}" WHERE "${pkCol}" = $1${softClause} LIMIT 1`,
74
+ [id]
75
+ )
76
+ if (rows.length === 0) return null
77
+ return this.hydrate<T>(rows[0] as Record<string, unknown>)
78
+ }
79
+
80
+ /** Find a record by primary key or throw. */
81
+ static async findOrFail<T extends BaseModel>(
82
+ this: ModelStatic<T>,
83
+ id: number | string | bigint
84
+ ): Promise<T> {
85
+ const result = (await (this as any).find(id)) as T | null
86
+ if (!result) {
87
+ throw new ModelNotFoundError(this.name, id)
88
+ }
89
+ return result
90
+ }
91
+
92
+ /** Retrieve all records (excluding soft-deleted). */
93
+ static async all<T extends BaseModel>(this: ModelStatic<T>): Promise<T[]> {
94
+ const db = BaseModel.db
95
+ const table = this.tableName
96
+ const softClause = this.softDeletes ? ' WHERE "deleted_at" IS NULL' : ''
97
+ const rows = await db.sql.unsafe(`SELECT * FROM "${table}"${softClause}`)
98
+ return rows.map((row: Record<string, unknown>) => this.hydrate<T>(row))
99
+ }
100
+
101
+ /** Create a new record, assign attributes, save, and return it. */
102
+ static async create<T extends BaseModel>(
103
+ this: ModelStatic<T>,
104
+ attrs: Record<string, unknown>,
105
+ trx?: any
106
+ ): Promise<T> {
107
+ const instance = new this()
108
+ instance.merge(attrs)
109
+ await instance.save(trx)
110
+ return instance
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Instance helpers
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /** Assign properties from a plain object onto this model instance. */
118
+ merge(data: Record<string, unknown>): this {
119
+ for (const [key, value] of Object.entries(data)) {
120
+ ;(this as any)[key] = value
121
+ }
122
+ return this
123
+ }
124
+
125
+ /**
126
+ * Serialize this model for JSON.stringify() (Vue islands, API responses, etc.).
127
+ * Skips internal (_-prefixed), @reference, and @associate properties.
128
+ * Converts DateTime to ISO strings. Leaves @cast-parsed values as-is.
129
+ */
130
+ toJSON(): Record<string, unknown> {
131
+ const ctor = this.constructor as typeof BaseModel
132
+ const refProps = new Set(getReferences(ctor))
133
+ const assocProps = new Set(getAssociates(ctor).map(a => a.property))
134
+ const encryptedProps = new Set(getEncrypted(ctor))
135
+ const result: Record<string, unknown> = {}
136
+
137
+ for (const key of Object.keys(this)) {
138
+ if (key.startsWith('_')) continue
139
+ if (refProps.has(key)) continue
140
+ if (assocProps.has(key)) continue
141
+ if (encryptedProps.has(key)) continue
142
+
143
+ const value = (this as any)[key]
144
+ if (value instanceof DateTime) {
145
+ result[key] = value.toISO()
146
+ } else if (typeof value === 'bigint') {
147
+ result[key] =
148
+ value <= Number.MAX_SAFE_INTEGER && value >= Number.MIN_SAFE_INTEGER
149
+ ? Number(value)
150
+ : String(value)
151
+ } else {
152
+ result[key] = value
153
+ }
154
+ }
155
+
156
+ return result
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Instance CRUD
161
+ // ---------------------------------------------------------------------------
162
+
163
+ /** INSERT or UPDATE depending on whether the record exists. */
164
+ async save(trx?: any): Promise<this> {
165
+ const ctor = this.constructor as typeof BaseModel
166
+ const conn = trx ?? BaseModel.db.sql
167
+ const table = ctor.tableName
168
+
169
+ if (this._exists) {
170
+ return this.performUpdate(conn, table)
171
+ } else {
172
+ return this.performInsert(conn, table)
173
+ }
174
+ }
175
+
176
+ /** Soft-delete (if model supports it) or hard-delete. */
177
+ async delete(trx?: any): Promise<void> {
178
+ const ctor = this.constructor as typeof BaseModel
179
+ const conn = trx ?? BaseModel.db.sql
180
+ const table = ctor.tableName
181
+ const pkCol = ctor.primaryKeyColumn
182
+ const pkProp = ctor.primaryKeyProperty
183
+ const pkValue = (this as any)[pkProp]
184
+
185
+ if (ctor.softDeletes && (this as any).deletedAt === null) {
186
+ const now = DateTime.now()
187
+ ;(this as any).deletedAt = now
188
+ await conn.unsafe(`UPDATE "${table}" SET "deleted_at" = $1 WHERE "${pkCol}" = $2`, [
189
+ now.toJSDate(),
190
+ pkValue,
191
+ ])
192
+ } else {
193
+ await conn.unsafe(`DELETE FROM "${table}" WHERE "${pkCol}" = $1`, [pkValue])
194
+ this._exists = false
195
+ }
196
+ }
197
+
198
+ /** Always hard-delete, regardless of soft-delete support. */
199
+ async forceDelete(trx?: any): Promise<void> {
200
+ const conn = trx ?? BaseModel.db.sql
201
+ const ctor = this.constructor as typeof BaseModel
202
+ const table = ctor.tableName
203
+ const pkCol = ctor.primaryKeyColumn
204
+ const pkProp = ctor.primaryKeyProperty
205
+ const pkValue = (this as any)[pkProp]
206
+ await conn.unsafe(`DELETE FROM "${table}" WHERE "${pkCol}" = $1`, [pkValue])
207
+ this._exists = false
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Relationship loading
212
+ // ---------------------------------------------------------------------------
213
+
214
+ /**
215
+ * Eagerly load one or more relationships by name.
216
+ *
217
+ * Supports both `@reference` (belongs-to) and `@associate` (many-to-many)
218
+ * relationships. Returns `this` for chaining.
219
+ *
220
+ * @example
221
+ * const team = await Team.find(1)
222
+ * await team.load('members') // many-to-many
223
+ * await user.load('profile', 'teams') // multiple relations
224
+ */
225
+ async load(...relations: string[]): Promise<this> {
226
+ const ctor = this.constructor as typeof BaseModel
227
+ const refMetas = getReferenceMeta(ctor)
228
+ const assocMetas = getAssociates(ctor)
229
+
230
+ for (const relation of relations) {
231
+ const refMeta = refMetas.find(r => r.property === relation)
232
+ if (refMeta) {
233
+ await this.loadReference(refMeta)
234
+ continue
235
+ }
236
+
237
+ const assocMeta = assocMetas.find(a => a.property === relation)
238
+ if (assocMeta) {
239
+ await this.loadAssociation(assocMeta)
240
+ continue
241
+ }
242
+
243
+ throw new Error(`Unknown relation "${relation}" on ${ctor.name}`)
244
+ }
245
+
246
+ return this
247
+ }
248
+
249
+ private async loadReference(meta: ReferenceMetadata): Promise<void> {
250
+ const db = BaseModel.db
251
+ const fkValue = (this as any)[meta.foreignKey]
252
+
253
+ if (fkValue === null || fkValue === undefined) {
254
+ ;(this as any)[meta.property] = null
255
+ return
256
+ }
257
+
258
+ const targetTable = toSnakeCase(meta.model)
259
+ const targetPKCol = toSnakeCase(meta.targetPK)
260
+ const rows = await db.sql.unsafe(
261
+ `SELECT * FROM "${targetTable}" WHERE "${targetPKCol}" = $1 LIMIT 1`,
262
+ [fkValue]
263
+ )
264
+
265
+ ;(this as any)[meta.property] =
266
+ rows.length > 0 ? hydrateRow(rows[0] as Record<string, unknown>) : null
267
+ }
268
+
269
+ private async loadAssociation(meta: AssociateMetadata): Promise<void> {
270
+ const db = BaseModel.db
271
+ const ctor = this.constructor as typeof BaseModel
272
+ const pkValue = (this as any)[ctor.primaryKeyProperty]
273
+
274
+ const targetTable = toSnakeCase(meta.model)
275
+ const rows = await db.sql.unsafe(
276
+ `SELECT t.* FROM "${targetTable}" t ` +
277
+ `INNER JOIN "${meta.through}" p ON p."${meta.otherKey}" = t."${toSnakeCase(meta.targetPK)}" ` +
278
+ `WHERE p."${meta.foreignKey}" = $1`,
279
+ [pkValue]
280
+ )
281
+
282
+ ;(this as any)[meta.property] = (rows as Record<string, unknown>[]).map(row => hydrateRow(row))
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Private helpers
287
+ // ---------------------------------------------------------------------------
288
+
289
+ private async performInsert(conn: any, table: string): Promise<this> {
290
+ // Auto-generate ULIDs for fields marked with @ulid decorator
291
+ const ctor = this.constructor as typeof BaseModel
292
+ const ulidFields = getUlids(ctor)
293
+ for (const field of ulidFields) {
294
+ const snakeField = toSnakeCase(field)
295
+ if (!(this as any)[field]) {
296
+ (this as any)[field] = generateUlid()
297
+ }
298
+ }
299
+
300
+ const data = this.dehydrate()
301
+ const columns = Object.keys(data)
302
+ const values = Object.values(data)
303
+
304
+ let sql: string
305
+ if (columns.length === 0) {
306
+ sql = `INSERT INTO "${table}" DEFAULT VALUES RETURNING *`
307
+ } else {
308
+ const colNames = columns.map(c => `"${c}"`).join(', ')
309
+ const placeholders = columns.map((_, i) => `$${i + 1}`).join(', ')
310
+ sql = `INSERT INTO "${table}" (${colNames}) VALUES (${placeholders}) RETURNING *`
311
+ }
312
+
313
+ const rows = await conn.unsafe(sql, values)
314
+
315
+ if (rows.length > 0) {
316
+ this.hydrateFrom(rows[0] as Record<string, unknown>)
317
+ }
318
+
319
+ this._exists = true
320
+ return this
321
+ }
322
+
323
+ private async performUpdate(conn: any, table: string): Promise<this> {
324
+ if ('updatedAt' in this) (this as any).updatedAt = DateTime.now()
325
+
326
+ const ctor = this.constructor as typeof BaseModel
327
+ const pkCol = ctor.primaryKeyColumn
328
+
329
+ const data = this.dehydrate()
330
+ const pkValue = data[pkCol]
331
+ delete data[pkCol]
332
+
333
+ const columns = Object.keys(data)
334
+ const values = Object.values(data)
335
+ const setClauses = columns.map((col, i) => `"${col}" = $${i + 1}`).join(', ')
336
+ values.push(pkValue)
337
+
338
+ await conn.unsafe(
339
+ `UPDATE "${table}" SET ${setClauses} WHERE "${pkCol}" = $${values.length}`,
340
+ values
341
+ )
342
+
343
+ return this
344
+ }
345
+
346
+ /**
347
+ * Convert a DB row to a model instance.
348
+ * snake_case columns → camelCase properties. Date → DateTime.
349
+ */
350
+ /** @internal Used by QueryBuilder to create model instances from DB rows. */
351
+ static hydrate<T extends BaseModel>(this: new () => T, row: Record<string, unknown>): T {
352
+ const instance = new this()
353
+ instance.hydrateFrom(row)
354
+ instance._exists = true
355
+ return instance
356
+ }
357
+
358
+ /** Populate this instance's properties from a DB row. */
359
+ private hydrateFrom(row: Record<string, unknown>): void {
360
+ const casts = getCasts(this.constructor as typeof BaseModel)
361
+
362
+ for (const [column, value] of Object.entries(row)) {
363
+ const prop = toCamelCase(column)
364
+
365
+ if (value == null) {
366
+ ;(this as any)[prop] = value
367
+ continue
368
+ }
369
+
370
+ const castDef = casts.get(prop)
371
+ if (castDef) {
372
+ ;(this as any)[prop] = castDef.get(value)
373
+ } else if (value instanceof Date) {
374
+ ;(this as any)[prop] = DateTime.fromJSDate(value)
375
+ } else {
376
+ ;(this as any)[prop] = value
377
+ }
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Convert model properties to DB columns.
383
+ * Skips _-prefixed props and @reference-decorated properties.
384
+ */
385
+ private dehydrate(): Record<string, unknown> {
386
+ const ctor = this.constructor as typeof BaseModel
387
+ const refProps = new Set(getReferences(ctor))
388
+ const assocProps = new Set(getAssociates(ctor).map(a => a.property))
389
+ const casts = getCasts(ctor)
390
+ const data: Record<string, unknown> = {}
391
+
392
+ for (const key of Object.keys(this)) {
393
+ if (key.startsWith('_')) continue
394
+ if (refProps.has(key)) continue
395
+ if (assocProps.has(key)) continue
396
+
397
+ const value = (this as any)[key]
398
+ const column = toSnakeCase(key)
399
+
400
+ if (value == null) {
401
+ data[column] = value
402
+ continue
403
+ }
404
+
405
+ const castDef = casts.get(key)
406
+ if (castDef) {
407
+ data[column] = castDef.set(value)
408
+ } else if (value instanceof DateTime) {
409
+ data[column] = value.toJSDate()
410
+ } else {
411
+ data[column] = value
412
+ }
413
+ }
414
+
415
+ return data
416
+ }
417
+ }
418
+
419
+ /** Convert a raw DB row to a plain object with camelCase keys and DateTime hydration. */
420
+ export function hydrateRow(row: Record<string, unknown>): Record<string, unknown> {
421
+ const obj: Record<string, unknown> = {}
422
+ for (const [column, value] of Object.entries(row)) {
423
+ const prop = toCamelCase(column)
424
+ obj[prop] = value instanceof Date ? DateTime.fromJSDate(value) : value
425
+ }
426
+ return obj
427
+ }