@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,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
+ }
@@ -0,0 +1,3 @@
1
+ export { default as BaseModel } from './base_model'
2
+ export { primary, reference, associate, cast, encrypt, ulid } from './decorators'
3
+ export type { CastDefinition } from './decorators'
@@ -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
+ }