@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.
- package/package.json +46 -0
- package/src/database/database.ts +181 -0
- package/src/database/domain/context.ts +84 -0
- package/src/database/domain/index.ts +17 -0
- package/src/database/domain/manager.ts +274 -0
- package/src/database/domain/wrapper.ts +105 -0
- package/src/database/index.ts +32 -0
- package/src/database/introspector.ts +446 -0
- package/src/database/migration/differ.ts +308 -0
- package/src/database/migration/file_generator.ts +125 -0
- package/src/database/migration/index.ts +18 -0
- package/src/database/migration/runner.ts +145 -0
- package/src/database/migration/sql_generator.ts +378 -0
- package/src/database/migration/tracker.ts +86 -0
- package/src/database/migration/types.ts +189 -0
- package/src/database/query_builder.ts +1034 -0
- package/src/database/seeder.ts +31 -0
- package/src/helpers/identity.ts +12 -0
- package/src/helpers/index.ts +1 -0
- package/src/index.ts +5 -0
- package/src/orm/base_model.ts +427 -0
- package/src/orm/decorators.ts +290 -0
- package/src/orm/index.ts +3 -0
- package/src/providers/database_provider.ts +25 -0
- package/src/providers/index.ts +1 -0
- package/src/schema/database_representation.ts +124 -0
- package/src/schema/define_association.ts +60 -0
- package/src/schema/define_schema.ts +46 -0
- package/src/schema/domain_discovery.ts +83 -0
- package/src/schema/field_builder.ts +160 -0
- package/src/schema/field_definition.ts +69 -0
- package/src/schema/index.ts +22 -0
- package/src/schema/naming.ts +19 -0
- package/src/schema/postgres.ts +109 -0
- package/src/schema/registry.ts +187 -0
- package/src/schema/representation_builder.ts +482 -0
- package/src/schema/type_builder.ts +115 -0
- package/src/schema/types.ts +35 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import 'reflect-metadata'
|
|
2
|
+
import EncryptionManager from '@stravigor/kernel/encryption/encryption_manager'
|
|
3
|
+
|
|
4
|
+
const PRIMARY_KEY = Symbol('orm:primary')
|
|
5
|
+
const REFERENCE_KEY = Symbol('orm:references')
|
|
6
|
+
const REFERENCE_META_KEY = Symbol('orm:reference_meta')
|
|
7
|
+
const ASSOCIATE_KEY = Symbol('orm:associates')
|
|
8
|
+
const CAST_KEY = Symbol('orm:casts')
|
|
9
|
+
const ENCRYPT_KEY = Symbol('orm:encrypted')
|
|
10
|
+
const ULID_KEY = Symbol('orm:ulid')
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// @primary
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Property decorator that marks a field as the primary key.
|
|
18
|
+
* BaseModel reads this metadata to build queries with the correct PK column.
|
|
19
|
+
*/
|
|
20
|
+
export function primary(target: any, propertyKey: string) {
|
|
21
|
+
Reflect.defineMetadata(PRIMARY_KEY, propertyKey, target.constructor)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Get the primary key property name (camelCase) for a model class. Defaults to 'id'. */
|
|
25
|
+
export function getPrimaryKey(target: Function): string {
|
|
26
|
+
return Reflect.getMetadata(PRIMARY_KEY, target) ?? 'id'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// @reference
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export interface ReferenceOptions {
|
|
34
|
+
model: string
|
|
35
|
+
foreignKey: string
|
|
36
|
+
targetPK: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ReferenceMetadata extends ReferenceOptions {
|
|
40
|
+
property: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Property decorator that marks a field as a reference object.
|
|
45
|
+
* Reference properties are excluded from database persistence (dehydrate).
|
|
46
|
+
*
|
|
47
|
+
* Can be used as a bare decorator or with options for load() support.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* // Bare (backward-compatible):
|
|
51
|
+
* @reference
|
|
52
|
+
* declare widget: Widget
|
|
53
|
+
*
|
|
54
|
+
* // With options (supports load()):
|
|
55
|
+
* @reference({ model: 'User', foreignKey: 'userId', targetPK: 'id' })
|
|
56
|
+
* declare user: User
|
|
57
|
+
*/
|
|
58
|
+
export function reference(targetOrOptions: any, propertyKey?: string): any {
|
|
59
|
+
if (propertyKey !== undefined) {
|
|
60
|
+
// Bare decorator: @reference
|
|
61
|
+
addReferenceProperty(targetOrOptions, propertyKey)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Factory: @reference({ model, foreignKey, targetPK })
|
|
66
|
+
const options = targetOrOptions as ReferenceOptions
|
|
67
|
+
return function (target: any, propertyKey: string) {
|
|
68
|
+
addReferenceProperty(target, propertyKey)
|
|
69
|
+
|
|
70
|
+
const metas: ReferenceMetadata[] = [
|
|
71
|
+
...(Reflect.getMetadata(REFERENCE_META_KEY, target.constructor) ?? []),
|
|
72
|
+
]
|
|
73
|
+
metas.push({ property: propertyKey, ...options })
|
|
74
|
+
Reflect.defineMetadata(REFERENCE_META_KEY, metas, target.constructor)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function addReferenceProperty(target: any, propertyKey: string): void {
|
|
79
|
+
const refs: string[] = [...(Reflect.getMetadata(REFERENCE_KEY, target.constructor) ?? [])]
|
|
80
|
+
refs.push(propertyKey)
|
|
81
|
+
Reflect.defineMetadata(REFERENCE_KEY, refs, target.constructor)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Get the list of reference property names for a model class. */
|
|
85
|
+
export function getReferences(target: Function): string[] {
|
|
86
|
+
return Reflect.getMetadata(REFERENCE_KEY, target) ?? []
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Get rich @reference metadata (only present when options were used). */
|
|
90
|
+
export function getReferenceMeta(target: Function): ReferenceMetadata[] {
|
|
91
|
+
return Reflect.getMetadata(REFERENCE_META_KEY, target) ?? []
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// @associate
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
export interface AssociateOptions {
|
|
99
|
+
through: string
|
|
100
|
+
foreignKey: string
|
|
101
|
+
otherKey: string
|
|
102
|
+
model: string
|
|
103
|
+
targetPK: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface AssociateMetadata extends AssociateOptions {
|
|
107
|
+
property: string
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Property decorator that marks a field as a many-to-many association.
|
|
112
|
+
* Associates are loaded via pivot table queries and excluded from dehydrate.
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* @associate({ through: 'team_user', foreignKey: 'team_id', otherKey: 'user_pid', model: 'User', targetPK: 'pid' })
|
|
116
|
+
* declare members: User[]
|
|
117
|
+
*/
|
|
118
|
+
export function associate(options: AssociateOptions) {
|
|
119
|
+
return function (target: any, propertyKey: string) {
|
|
120
|
+
const assocs: AssociateMetadata[] = [
|
|
121
|
+
...(Reflect.getMetadata(ASSOCIATE_KEY, target.constructor) ?? []),
|
|
122
|
+
]
|
|
123
|
+
assocs.push({ property: propertyKey, ...options })
|
|
124
|
+
Reflect.defineMetadata(ASSOCIATE_KEY, assocs, target.constructor)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Get all @associate metadata for a model class. */
|
|
129
|
+
export function getAssociates(target: Function): AssociateMetadata[] {
|
|
130
|
+
return Reflect.getMetadata(ASSOCIATE_KEY, target) ?? []
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// @cast
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
export interface CastDefinition {
|
|
138
|
+
get: (dbValue: unknown) => unknown
|
|
139
|
+
set: (appValue: unknown) => unknown
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const BUILT_IN_CASTS: Record<string, CastDefinition> = {
|
|
143
|
+
json: {
|
|
144
|
+
get: v => (typeof v === 'string' ? JSON.parse(v) : v),
|
|
145
|
+
set: v => JSON.stringify(v),
|
|
146
|
+
},
|
|
147
|
+
boolean: {
|
|
148
|
+
get: v => {
|
|
149
|
+
if (typeof v === 'boolean') return v
|
|
150
|
+
if (typeof v === 'number') return v !== 0
|
|
151
|
+
if (typeof v === 'string') return v === 'true' || v === '1'
|
|
152
|
+
return Boolean(v)
|
|
153
|
+
},
|
|
154
|
+
set: v => Boolean(v),
|
|
155
|
+
},
|
|
156
|
+
number: {
|
|
157
|
+
get: v => Number(v),
|
|
158
|
+
set: v => Number(v),
|
|
159
|
+
},
|
|
160
|
+
integer: {
|
|
161
|
+
get: v => Math.trunc(Number(v)),
|
|
162
|
+
set: v => Math.trunc(Number(v)),
|
|
163
|
+
},
|
|
164
|
+
string: {
|
|
165
|
+
get: v => String(v),
|
|
166
|
+
set: v => String(v),
|
|
167
|
+
},
|
|
168
|
+
bigint: {
|
|
169
|
+
get: v => {
|
|
170
|
+
if (typeof v === 'bigint')
|
|
171
|
+
return v <= Number.MAX_SAFE_INTEGER && v >= Number.MIN_SAFE_INTEGER ? Number(v) : String(v)
|
|
172
|
+
return Number(v)
|
|
173
|
+
},
|
|
174
|
+
set: v => (typeof v === 'bigint' ? String(v) : v),
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Property decorator that defines type casting between DB and application values.
|
|
180
|
+
* Applied automatically during hydration (DB → model) and dehydration (model → DB).
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* // Bare (defaults to JSON):
|
|
184
|
+
* @cast
|
|
185
|
+
* declare state: CanvasState
|
|
186
|
+
*
|
|
187
|
+
* // Named built-in type ('json' | 'boolean' | 'number' | 'integer' | 'string'):
|
|
188
|
+
* @cast('json')
|
|
189
|
+
* declare metadata: SomeType
|
|
190
|
+
*
|
|
191
|
+
* // Custom get/set:
|
|
192
|
+
* @cast({ get: (v) => new Money(v as number), set: (v: Money) => v.toCents() })
|
|
193
|
+
* declare price: Money
|
|
194
|
+
*/
|
|
195
|
+
export function cast(targetOrTypeOrDef: any, propertyKey?: string): any {
|
|
196
|
+
if (propertyKey !== undefined) {
|
|
197
|
+
// Bare decorator: @cast
|
|
198
|
+
addCastMetadata(targetOrTypeOrDef, propertyKey, BUILT_IN_CASTS.json!)
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Factory with string type: @cast('json')
|
|
203
|
+
if (typeof targetOrTypeOrDef === 'string') {
|
|
204
|
+
const castDef = BUILT_IN_CASTS[targetOrTypeOrDef]
|
|
205
|
+
if (!castDef) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`Unknown cast type "${targetOrTypeOrDef}". Available: ${Object.keys(BUILT_IN_CASTS).join(', ')}`
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
return function (target: any, propertyKey: string) {
|
|
211
|
+
addCastMetadata(target, propertyKey, castDef)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Factory with custom { get, set }: @cast({ get: ..., set: ... })
|
|
216
|
+
const def = targetOrTypeOrDef as CastDefinition
|
|
217
|
+
return function (target: any, propertyKey: string) {
|
|
218
|
+
addCastMetadata(target, propertyKey, def)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function addCastMetadata(target: any, propertyKey: string, castDef: CastDefinition): void {
|
|
223
|
+
const casts = new Map<string, CastDefinition>(
|
|
224
|
+
Reflect.getMetadata(CAST_KEY, target.constructor) ?? []
|
|
225
|
+
)
|
|
226
|
+
casts.set(propertyKey, castDef)
|
|
227
|
+
Reflect.defineMetadata(CAST_KEY, casts, target.constructor)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Get all @cast metadata for a model class as a Map<propertyName, CastDefinition>. */
|
|
231
|
+
export function getCasts(target: Function): Map<string, CastDefinition> {
|
|
232
|
+
return Reflect.getMetadata(CAST_KEY, target) ?? new Map()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// @encrypt
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Property decorator that encrypts a field before database storage and
|
|
241
|
+
* decrypts it on hydration. Uses AES-256-GCM via EncryptionManager.
|
|
242
|
+
*
|
|
243
|
+
* The database column **must** be TEXT to avoid truncating the encrypted payload.
|
|
244
|
+
*
|
|
245
|
+
* Internally registers a CastDefinition so hydrateFrom/dehydrate handle it
|
|
246
|
+
* automatically. Encrypted fields are excluded from toJSON() output.
|
|
247
|
+
*
|
|
248
|
+
* @example
|
|
249
|
+
* @encrypt
|
|
250
|
+
* declare ssn: string
|
|
251
|
+
*/
|
|
252
|
+
export function encrypt(target: any, propertyKey: string) {
|
|
253
|
+
addCastMetadata(target, propertyKey, {
|
|
254
|
+
get: v => EncryptionManager.decrypt(v as string),
|
|
255
|
+
set: v => EncryptionManager.encrypt(String(v)),
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
const fields: string[] = [...(Reflect.getMetadata(ENCRYPT_KEY, target.constructor) ?? [])]
|
|
259
|
+
fields.push(propertyKey)
|
|
260
|
+
Reflect.defineMetadata(ENCRYPT_KEY, fields, target.constructor)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Get the list of @encrypt-decorated property names for a model class. */
|
|
264
|
+
export function getEncrypted(target: Function): string[] {
|
|
265
|
+
return Reflect.getMetadata(ENCRYPT_KEY, target) ?? []
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// @ulid
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Property decorator that marks a field as a ULID (Universally Unique
|
|
274
|
+
* Lexicographically Sortable Identifier). ULIDs are auto-generated during
|
|
275
|
+
* insert if the field value is not set.
|
|
276
|
+
*
|
|
277
|
+
* @example
|
|
278
|
+
* @ulid
|
|
279
|
+
* declare id: string
|
|
280
|
+
*/
|
|
281
|
+
export function ulid(target: any, propertyKey: string) {
|
|
282
|
+
const fields: string[] = [...(Reflect.getMetadata(ULID_KEY, target.constructor) ?? [])]
|
|
283
|
+
fields.push(propertyKey)
|
|
284
|
+
Reflect.defineMetadata(ULID_KEY, fields, target.constructor)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Get the list of @ulid-decorated property names for a model class. */
|
|
288
|
+
export function getUlids(target: Function): string[] {
|
|
289
|
+
return Reflect.getMetadata(ULID_KEY, target) ?? []
|
|
290
|
+
}
|
package/src/orm/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import ServiceProvider from '@stravigor/kernel/core/service_provider'
|
|
2
|
+
import type Application from '@stravigor/kernel/core/application'
|
|
3
|
+
import Database from '../database/database'
|
|
4
|
+
|
|
5
|
+
export default class DatabaseProvider extends ServiceProvider {
|
|
6
|
+
readonly name = 'database'
|
|
7
|
+
override readonly dependencies = ['config']
|
|
8
|
+
|
|
9
|
+
private db: Database | null = null
|
|
10
|
+
|
|
11
|
+
override register(app: Application): void {
|
|
12
|
+
app.singleton(Database)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
override boot(app: Application): void {
|
|
16
|
+
this.db = app.resolve(Database)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
override async shutdown(): Promise<void> {
|
|
20
|
+
if (this.db) {
|
|
21
|
+
await this.db.close()
|
|
22
|
+
this.db = null
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as DatabaseProvider } from './database_provider'
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { PostgreSQLType } from './postgres'
|
|
2
|
+
import type { Archetype } from './types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Represents the full database schema derived from all registered SchemaDefinitions.
|
|
6
|
+
* Contains all enum types and table definitions in dependency order.
|
|
7
|
+
*/
|
|
8
|
+
export interface DatabaseRepresentation {
|
|
9
|
+
/** PostgreSQL enum types that must be created before tables. */
|
|
10
|
+
enums: EnumDefinition[]
|
|
11
|
+
/** Table definitions in dependency order. */
|
|
12
|
+
tables: TableDefinition[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A PostgreSQL CREATE TYPE ... AS ENUM definition.
|
|
17
|
+
*/
|
|
18
|
+
export interface EnumDefinition {
|
|
19
|
+
/** Enum type name (e.g. 'order_status'). */
|
|
20
|
+
name: string
|
|
21
|
+
/** Allowed values. */
|
|
22
|
+
values: string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A PostgreSQL table definition.
|
|
27
|
+
*/
|
|
28
|
+
export interface TableDefinition {
|
|
29
|
+
/** Table name in snake_case, not pluralized. */
|
|
30
|
+
name: string
|
|
31
|
+
/** The archetype this table was derived from (absent when introspected from DB). */
|
|
32
|
+
archetype?: Archetype
|
|
33
|
+
/** Ordered list of columns. */
|
|
34
|
+
columns: ColumnDefinition[]
|
|
35
|
+
/** Primary key constraint, or null for associations. */
|
|
36
|
+
primaryKey: PrimaryKeyConstraint | null
|
|
37
|
+
/** Foreign key constraints. */
|
|
38
|
+
foreignKeys: ForeignKeyConstraint[]
|
|
39
|
+
/** Unique constraints (beyond individual column UNIQUE). */
|
|
40
|
+
uniqueConstraints: UniqueConstraint[]
|
|
41
|
+
/** Index definitions. */
|
|
42
|
+
indexes: IndexDefinition[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A single column in a table.
|
|
47
|
+
*/
|
|
48
|
+
export interface ColumnDefinition {
|
|
49
|
+
/** Column name in snake_case. */
|
|
50
|
+
name: string
|
|
51
|
+
/** PostgreSQL column type. */
|
|
52
|
+
pgType: PostgreSQLType
|
|
53
|
+
/** Whether the column is NOT NULL. */
|
|
54
|
+
notNull: boolean
|
|
55
|
+
/** Default value (literal or SQL expression). */
|
|
56
|
+
defaultValue?: DefaultValue
|
|
57
|
+
/** Whether the column has a UNIQUE constraint. */
|
|
58
|
+
unique: boolean
|
|
59
|
+
/** Whether the column is part of the primary key. */
|
|
60
|
+
primaryKey: boolean
|
|
61
|
+
/** Whether the column is auto-incrementing (serial). */
|
|
62
|
+
autoIncrement: boolean
|
|
63
|
+
/** Whether the column should be indexed. */
|
|
64
|
+
index: boolean
|
|
65
|
+
/** Whether the column contains sensitive data (app-level, absent when introspected). */
|
|
66
|
+
sensitive?: boolean
|
|
67
|
+
/** Whether this is an array column. */
|
|
68
|
+
isArray: boolean
|
|
69
|
+
/** Array dimensions. */
|
|
70
|
+
arrayDimensions: number
|
|
71
|
+
/** Max length for varchar/char. */
|
|
72
|
+
length?: number
|
|
73
|
+
/** Precision for decimal/numeric. */
|
|
74
|
+
precision?: number
|
|
75
|
+
/** Scale for decimal/numeric. */
|
|
76
|
+
scale?: number
|
|
77
|
+
/** Whether this field is a ULID (stored as char(26)). */
|
|
78
|
+
isUlid?: boolean
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A default value for a column — either a literal value or a SQL expression.
|
|
83
|
+
*/
|
|
84
|
+
export type DefaultValue =
|
|
85
|
+
| { kind: 'literal'; value: string | number | boolean | null }
|
|
86
|
+
| { kind: 'expression'; sql: string }
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A primary key constraint (single or composite).
|
|
90
|
+
*/
|
|
91
|
+
export interface PrimaryKeyConstraint {
|
|
92
|
+
columns: string[]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A foreign key constraint.
|
|
97
|
+
*/
|
|
98
|
+
export interface ForeignKeyConstraint {
|
|
99
|
+
/** Column(s) in this table. */
|
|
100
|
+
columns: string[]
|
|
101
|
+
/** Referenced table. */
|
|
102
|
+
referencedTable: string
|
|
103
|
+
/** Referenced column(s). */
|
|
104
|
+
referencedColumns: string[]
|
|
105
|
+
/** ON DELETE behavior. */
|
|
106
|
+
onDelete: 'CASCADE' | 'SET NULL' | 'RESTRICT' | 'NO ACTION'
|
|
107
|
+
/** ON UPDATE behavior. */
|
|
108
|
+
onUpdate: 'CASCADE' | 'SET NULL' | 'RESTRICT' | 'NO ACTION'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A unique constraint across one or more columns.
|
|
113
|
+
*/
|
|
114
|
+
export interface UniqueConstraint {
|
|
115
|
+
columns: string[]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* An index definition.
|
|
120
|
+
*/
|
|
121
|
+
export interface IndexDefinition {
|
|
122
|
+
columns: string[]
|
|
123
|
+
unique: boolean
|
|
124
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Archetype } from './types'
|
|
2
|
+
import type { SchemaDefinition } from './types'
|
|
3
|
+
import type FieldBuilder from './field_builder'
|
|
4
|
+
import defineSchema from './define_schema'
|
|
5
|
+
|
|
6
|
+
interface AssociationOptions {
|
|
7
|
+
as?: Record<string, string>
|
|
8
|
+
fields?: Record<string, FieldBuilder>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Define an association (many-to-many junction) schema between two entities.
|
|
13
|
+
*
|
|
14
|
+
* Automatically sets archetype to `'association'`, generates the table name
|
|
15
|
+
* as `{entityA}_{entityB}`, and records the two associates.
|
|
16
|
+
*
|
|
17
|
+
* Only specify additional pivot fields — the FK columns to both entities
|
|
18
|
+
* are injected automatically by the {@link RepresentationBuilder}.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* // With named properties on each entity model:
|
|
22
|
+
* export default defineAssociation(['team', 'user'], {
|
|
23
|
+
* as: { team: 'members', user: 'teams' },
|
|
24
|
+
* fields: {
|
|
25
|
+
* role: t.enum(['admin', 'developer', 'tester']),
|
|
26
|
+
* },
|
|
27
|
+
* })
|
|
28
|
+
*
|
|
29
|
+
* // Legacy shorthand (fields only, no model properties):
|
|
30
|
+
* export default defineAssociation(['team', 'user'], {
|
|
31
|
+
* role: t.enum(['admin', 'developer', 'tester']),
|
|
32
|
+
* })
|
|
33
|
+
*/
|
|
34
|
+
export default function defineAssociation(
|
|
35
|
+
entities: [string, string],
|
|
36
|
+
options: AssociationOptions | Record<string, FieldBuilder> = {}
|
|
37
|
+
): SchemaDefinition {
|
|
38
|
+
const [entityA, entityB] = entities
|
|
39
|
+
|
|
40
|
+
let fields: Record<string, FieldBuilder>
|
|
41
|
+
let as: Record<string, string> | undefined
|
|
42
|
+
|
|
43
|
+
if (isAssociationOptions(options)) {
|
|
44
|
+
fields = options.fields ?? {}
|
|
45
|
+
as = options.as
|
|
46
|
+
} else {
|
|
47
|
+
fields = options
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return defineSchema(`${entityA}_${entityB}`, {
|
|
51
|
+
archetype: Archetype.Association,
|
|
52
|
+
associates: [entityA, entityB],
|
|
53
|
+
as,
|
|
54
|
+
fields,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isAssociationOptions(obj: unknown): obj is AssociationOptions {
|
|
59
|
+
return typeof obj === 'object' && obj !== null && ('as' in obj || 'fields' in obj)
|
|
60
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Archetype } from './types'
|
|
2
|
+
import type { SchemaInput, SchemaDefinition } from './types'
|
|
3
|
+
import type { FieldDefinition } from './field_definition'
|
|
4
|
+
import type { PostgreSQLCustomType } from './postgres'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Define a data schema for the application.
|
|
8
|
+
*
|
|
9
|
+
* Resolves all {@link FieldBuilder} instances into {@link FieldDefinition}s,
|
|
10
|
+
* assigns proper enum names, and returns a {@link SchemaDefinition}.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* export default defineSchema('user', {
|
|
14
|
+
* archetype: Archetype.Entity,
|
|
15
|
+
* fields: {
|
|
16
|
+
* email: t.varchar().email().unique().required(),
|
|
17
|
+
* role: t.enum(['user', 'admin']).default('user'),
|
|
18
|
+
* },
|
|
19
|
+
* })
|
|
20
|
+
*/
|
|
21
|
+
export default function defineSchema(name: string, input: SchemaInput): SchemaDefinition {
|
|
22
|
+
const fields: Record<string, FieldDefinition> = {}
|
|
23
|
+
|
|
24
|
+
for (const [fieldName, builder] of Object.entries(input.fields)) {
|
|
25
|
+
const def = builder.toDefinition()
|
|
26
|
+
|
|
27
|
+
if (isCustomType(def.pgType) && def.pgType.values?.length) {
|
|
28
|
+
def.pgType = { ...def.pgType, name: `${name}_${fieldName}` }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
fields[fieldName] = def
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
name,
|
|
36
|
+
archetype: input.archetype ?? Archetype.Entity,
|
|
37
|
+
parents: input.parents,
|
|
38
|
+
associates: input.associates,
|
|
39
|
+
as: input.as,
|
|
40
|
+
fields,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isCustomType(pgType: unknown): pgType is PostgreSQLCustomType {
|
|
45
|
+
return typeof pgType === 'object' && pgType !== null && (pgType as any).type === 'custom'
|
|
46
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Discovers available domains by scanning the database/schemas directory.
|
|
6
|
+
*
|
|
7
|
+
* Domains are subdirectories under database/schemas/.
|
|
8
|
+
* The 'public' domain always exists (system schemas).
|
|
9
|
+
* Other domains represent business domains (tenant, factory, marketing, etc.).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Validate that a domain name is safe for use in PostgreSQL table names */
|
|
13
|
+
export function validateDomainName(domainName: string): void {
|
|
14
|
+
// Allow alphanumeric characters and underscores only
|
|
15
|
+
if (!/^[a-z][a-z0-9_]*$/i.test(domainName)) {
|
|
16
|
+
throw new Error(`Invalid domain name: ${domainName}. Must start with a letter and contain only letters, numbers, and underscores.`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Prevent SQL injection and reserved words
|
|
20
|
+
const reservedWords = ['public', 'information_schema', 'pg_catalog', 'pg_toast']
|
|
21
|
+
if (reservedWords.includes(domainName.toLowerCase()) && domainName.toLowerCase() !== 'public') {
|
|
22
|
+
throw new Error(`Domain name '${domainName}' is reserved and cannot be used.`)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Discover available domains from the filesystem */
|
|
27
|
+
export function discoverDomains(schemasPath: string = 'database/schemas'): string[] {
|
|
28
|
+
const basePath = resolve(schemasPath)
|
|
29
|
+
|
|
30
|
+
let entries: string[]
|
|
31
|
+
try {
|
|
32
|
+
entries = readdirSync(basePath, { withFileTypes: true })
|
|
33
|
+
.filter(entry => entry.isDirectory())
|
|
34
|
+
.map(entry => entry.name)
|
|
35
|
+
} catch {
|
|
36
|
+
// If schemas directory doesn't exist, return just public
|
|
37
|
+
return ['public']
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Validate all discovered domain names
|
|
41
|
+
for (const domainName of entries) {
|
|
42
|
+
validateDomainName(domainName)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Ensure public is always included and comes first
|
|
46
|
+
const domains = new Set(entries)
|
|
47
|
+
domains.add('public')
|
|
48
|
+
|
|
49
|
+
// Sort with public first, then alphabetically
|
|
50
|
+
return Array.from(domains).sort((a, b) => {
|
|
51
|
+
if (a === 'public') return -1
|
|
52
|
+
if (b === 'public') return 1
|
|
53
|
+
return a.localeCompare(b)
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Check if a specific domain exists */
|
|
58
|
+
export function domainExists(domainName: string, schemasPath: string = 'database/schemas'): boolean {
|
|
59
|
+
validateDomainName(domainName)
|
|
60
|
+
|
|
61
|
+
if (domainName === 'public') {
|
|
62
|
+
return true // public always exists conceptually
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const domainPath = join(resolve(schemasPath), domainName)
|
|
66
|
+
try {
|
|
67
|
+
const stat = readdirSync(domainPath)
|
|
68
|
+
return true
|
|
69
|
+
} catch {
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Get the migration tracking table name for a domain */
|
|
75
|
+
export function getMigrationTableName(domain: string): string {
|
|
76
|
+
validateDomainName(domain)
|
|
77
|
+
|
|
78
|
+
if (domain === 'public') {
|
|
79
|
+
return '_strav_migrations'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return `_strav_${domain}_migrations`
|
|
83
|
+
}
|