@stravigor/core 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 (165) hide show
  1. package/README.md +45 -0
  2. package/package.json +83 -0
  3. package/src/auth/access_token.ts +122 -0
  4. package/src/auth/auth.ts +86 -0
  5. package/src/auth/index.ts +7 -0
  6. package/src/auth/middleware/authenticate.ts +64 -0
  7. package/src/auth/middleware/csrf.ts +62 -0
  8. package/src/auth/middleware/guest.ts +46 -0
  9. package/src/broadcast/broadcast_manager.ts +411 -0
  10. package/src/broadcast/client.ts +302 -0
  11. package/src/broadcast/index.ts +58 -0
  12. package/src/cache/cache_manager.ts +56 -0
  13. package/src/cache/cache_store.ts +31 -0
  14. package/src/cache/helpers.ts +74 -0
  15. package/src/cache/http_cache.ts +109 -0
  16. package/src/cache/index.ts +6 -0
  17. package/src/cache/memory_store.ts +63 -0
  18. package/src/cli/bootstrap.ts +37 -0
  19. package/src/cli/commands/generate_api.ts +74 -0
  20. package/src/cli/commands/generate_key.ts +46 -0
  21. package/src/cli/commands/generate_models.ts +48 -0
  22. package/src/cli/commands/migration_compare.ts +152 -0
  23. package/src/cli/commands/migration_fresh.ts +123 -0
  24. package/src/cli/commands/migration_generate.ts +79 -0
  25. package/src/cli/commands/migration_rollback.ts +53 -0
  26. package/src/cli/commands/migration_run.ts +44 -0
  27. package/src/cli/commands/queue_flush.ts +35 -0
  28. package/src/cli/commands/queue_retry.ts +34 -0
  29. package/src/cli/commands/queue_work.ts +40 -0
  30. package/src/cli/commands/scheduler_work.ts +45 -0
  31. package/src/cli/strav.ts +33 -0
  32. package/src/config/configuration.ts +105 -0
  33. package/src/config/loaders/base_loader.ts +69 -0
  34. package/src/config/loaders/env_loader.ts +112 -0
  35. package/src/config/loaders/typescript_loader.ts +56 -0
  36. package/src/config/types.ts +8 -0
  37. package/src/core/application.ts +4 -0
  38. package/src/core/container.ts +117 -0
  39. package/src/core/index.ts +3 -0
  40. package/src/core/inject.ts +39 -0
  41. package/src/database/database.ts +54 -0
  42. package/src/database/index.ts +30 -0
  43. package/src/database/introspector.ts +446 -0
  44. package/src/database/migration/differ.ts +308 -0
  45. package/src/database/migration/file_generator.ts +125 -0
  46. package/src/database/migration/index.ts +18 -0
  47. package/src/database/migration/runner.ts +133 -0
  48. package/src/database/migration/sql_generator.ts +378 -0
  49. package/src/database/migration/tracker.ts +76 -0
  50. package/src/database/migration/types.ts +189 -0
  51. package/src/database/query_builder.ts +474 -0
  52. package/src/encryption/encryption_manager.ts +209 -0
  53. package/src/encryption/helpers.ts +158 -0
  54. package/src/encryption/index.ts +3 -0
  55. package/src/encryption/types.ts +6 -0
  56. package/src/events/emitter.ts +101 -0
  57. package/src/events/index.ts +2 -0
  58. package/src/exceptions/errors.ts +75 -0
  59. package/src/exceptions/exception_handler.ts +126 -0
  60. package/src/exceptions/helpers.ts +25 -0
  61. package/src/exceptions/http_exception.ts +129 -0
  62. package/src/exceptions/index.ts +23 -0
  63. package/src/exceptions/strav_error.ts +11 -0
  64. package/src/generators/api_generator.ts +972 -0
  65. package/src/generators/config.ts +87 -0
  66. package/src/generators/doc_generator.ts +974 -0
  67. package/src/generators/index.ts +11 -0
  68. package/src/generators/model_generator.ts +586 -0
  69. package/src/generators/route_generator.ts +188 -0
  70. package/src/generators/test_generator.ts +1666 -0
  71. package/src/helpers/crypto.ts +4 -0
  72. package/src/helpers/env.ts +50 -0
  73. package/src/helpers/identity.ts +12 -0
  74. package/src/helpers/index.ts +4 -0
  75. package/src/helpers/strings.ts +67 -0
  76. package/src/http/context.ts +215 -0
  77. package/src/http/cookie.ts +59 -0
  78. package/src/http/cors.ts +163 -0
  79. package/src/http/index.ts +16 -0
  80. package/src/http/middleware.ts +39 -0
  81. package/src/http/rate_limit.ts +173 -0
  82. package/src/http/router.ts +556 -0
  83. package/src/http/server.ts +79 -0
  84. package/src/i18n/defaults/en/validation.json +20 -0
  85. package/src/i18n/helpers.ts +72 -0
  86. package/src/i18n/i18n_manager.ts +155 -0
  87. package/src/i18n/index.ts +4 -0
  88. package/src/i18n/middleware.ts +90 -0
  89. package/src/i18n/translator.ts +96 -0
  90. package/src/i18n/types.ts +17 -0
  91. package/src/logger/index.ts +6 -0
  92. package/src/logger/logger.ts +100 -0
  93. package/src/logger/request_logger.ts +19 -0
  94. package/src/logger/sinks/console_sink.ts +24 -0
  95. package/src/logger/sinks/file_sink.ts +24 -0
  96. package/src/logger/sinks/sink.ts +36 -0
  97. package/src/mail/css_inliner.ts +79 -0
  98. package/src/mail/helpers.ts +212 -0
  99. package/src/mail/index.ts +19 -0
  100. package/src/mail/mail_manager.ts +92 -0
  101. package/src/mail/transports/log_transport.ts +69 -0
  102. package/src/mail/transports/resend_transport.ts +59 -0
  103. package/src/mail/transports/sendgrid_transport.ts +77 -0
  104. package/src/mail/transports/smtp_transport.ts +48 -0
  105. package/src/mail/types.ts +80 -0
  106. package/src/notification/base_notification.ts +67 -0
  107. package/src/notification/channels/database_channel.ts +30 -0
  108. package/src/notification/channels/discord_channel.ts +43 -0
  109. package/src/notification/channels/email_channel.ts +37 -0
  110. package/src/notification/channels/webhook_channel.ts +45 -0
  111. package/src/notification/helpers.ts +214 -0
  112. package/src/notification/index.ts +20 -0
  113. package/src/notification/notification_manager.ts +126 -0
  114. package/src/notification/types.ts +122 -0
  115. package/src/orm/base_model.ts +351 -0
  116. package/src/orm/decorators.ts +127 -0
  117. package/src/orm/index.ts +4 -0
  118. package/src/policy/authorize.ts +44 -0
  119. package/src/policy/index.ts +3 -0
  120. package/src/policy/policy_result.ts +13 -0
  121. package/src/queue/index.ts +11 -0
  122. package/src/queue/queue.ts +338 -0
  123. package/src/queue/worker.ts +197 -0
  124. package/src/scheduler/cron.ts +140 -0
  125. package/src/scheduler/index.ts +7 -0
  126. package/src/scheduler/runner.ts +116 -0
  127. package/src/scheduler/schedule.ts +183 -0
  128. package/src/scheduler/scheduler.ts +47 -0
  129. package/src/schema/database_representation.ts +122 -0
  130. package/src/schema/define_association.ts +60 -0
  131. package/src/schema/define_schema.ts +46 -0
  132. package/src/schema/field_builder.ts +155 -0
  133. package/src/schema/field_definition.ts +66 -0
  134. package/src/schema/index.ts +21 -0
  135. package/src/schema/naming.ts +19 -0
  136. package/src/schema/postgres.ts +109 -0
  137. package/src/schema/registry.ts +157 -0
  138. package/src/schema/representation_builder.ts +479 -0
  139. package/src/schema/type_builder.ts +107 -0
  140. package/src/schema/types.ts +35 -0
  141. package/src/session/index.ts +4 -0
  142. package/src/session/middleware.ts +46 -0
  143. package/src/session/session.ts +308 -0
  144. package/src/session/session_manager.ts +81 -0
  145. package/src/storage/index.ts +13 -0
  146. package/src/storage/local_driver.ts +46 -0
  147. package/src/storage/s3_driver.ts +51 -0
  148. package/src/storage/storage.ts +43 -0
  149. package/src/storage/storage_manager.ts +59 -0
  150. package/src/storage/types.ts +42 -0
  151. package/src/storage/upload.ts +91 -0
  152. package/src/validation/index.ts +18 -0
  153. package/src/validation/rules.ts +170 -0
  154. package/src/validation/validate.ts +41 -0
  155. package/src/view/cache.ts +47 -0
  156. package/src/view/client/islands.ts +50 -0
  157. package/src/view/compiler.ts +185 -0
  158. package/src/view/engine.ts +139 -0
  159. package/src/view/escape.ts +14 -0
  160. package/src/view/index.ts +13 -0
  161. package/src/view/islands/island_builder.ts +161 -0
  162. package/src/view/islands/vue_plugin.ts +140 -0
  163. package/src/view/middleware/static.ts +35 -0
  164. package/src/view/tokenizer.ts +172 -0
  165. package/tsconfig.json +4 -0
@@ -0,0 +1,122 @@
1
+ /** A recipient that can receive notifications. */
2
+ export interface Notifiable {
3
+ /** Unique identifier for the notifiable entity. */
4
+ notifiableId(): string | number
5
+ /** Type discriminator (e.g., 'user', 'organization'). */
6
+ notifiableType(): string
7
+ /** Email address for the email channel. Returns null to skip email. */
8
+ routeNotificationForEmail?(): string | null
9
+ /** Webhook URL for the webhook channel. Returns null to skip. */
10
+ routeNotificationForWebhook?(): string | null
11
+ /** Discord webhook URL. Returns null to skip. */
12
+ routeNotificationForDiscord?(): string | null
13
+ }
14
+
15
+ // -- Channel envelopes --------------------------------------------------------
16
+
17
+ /** Envelope produced by a notification for the email channel. */
18
+ export interface MailEnvelope {
19
+ subject: string
20
+ template?: string
21
+ templateData?: Record<string, unknown>
22
+ html?: string
23
+ text?: string
24
+ from?: string
25
+ cc?: string | string[]
26
+ bcc?: string | string[]
27
+ replyTo?: string
28
+ }
29
+
30
+ /** Envelope produced by a notification for the database (in-app) channel. */
31
+ export interface DatabaseEnvelope {
32
+ /** Notification type / category (e.g., 'task.assigned', 'invoice.paid'). */
33
+ type: string
34
+ /** Structured data stored as JSONB. */
35
+ data: Record<string, unknown>
36
+ }
37
+
38
+ /** Envelope produced by a notification for the webhook channel. */
39
+ export interface WebhookEnvelope {
40
+ /** JSON payload to POST. */
41
+ payload: Record<string, unknown>
42
+ /** Optional custom headers. */
43
+ headers?: Record<string, string>
44
+ /** Override the webhook URL from notifiable routing. */
45
+ url?: string
46
+ }
47
+
48
+ /** Envelope produced by a notification for the Discord channel. */
49
+ export interface DiscordEnvelope {
50
+ /** Plain text content. */
51
+ content?: string
52
+ /** Discord embed objects. */
53
+ embeds?: DiscordEmbed[]
54
+ /** Override the webhook URL from notifiable routing. */
55
+ url?: string
56
+ }
57
+
58
+ export interface DiscordEmbed {
59
+ title?: string
60
+ description?: string
61
+ url?: string
62
+ color?: number
63
+ fields?: { name: string; value: string; inline?: boolean }[]
64
+ footer?: { text: string }
65
+ timestamp?: string
66
+ }
67
+
68
+ // -- Channel interface --------------------------------------------------------
69
+
70
+ /** Serializable envelope bundle built by BaseNotification. */
71
+ export interface NotificationPayload {
72
+ notificationClass: string
73
+ channels: string[]
74
+ mail?: MailEnvelope
75
+ database?: DatabaseEnvelope
76
+ webhook?: WebhookEnvelope
77
+ discord?: DiscordEnvelope
78
+ }
79
+
80
+ /**
81
+ * Pluggable notification channel backend.
82
+ * Implement this interface for custom channels.
83
+ */
84
+ export interface NotificationChannel {
85
+ readonly name: string
86
+ send(notifiable: Notifiable, payload: NotificationPayload): Promise<void>
87
+ }
88
+
89
+ // -- Database records ---------------------------------------------------------
90
+
91
+ /** A stored in-app notification row. */
92
+ export interface NotificationRecord {
93
+ id: string
94
+ notifiableType: string
95
+ notifiableId: string
96
+ type: string
97
+ data: Record<string, unknown>
98
+ readAt: Date | null
99
+ createdAt: Date
100
+ }
101
+
102
+ // -- Configuration ------------------------------------------------------------
103
+
104
+ export interface NotificationConfig {
105
+ /** Default channels when a notification does not specify via(). */
106
+ channels: string[]
107
+ /** Queue name for async notifications. */
108
+ queue: string
109
+ /** Named webhook endpoints. */
110
+ webhooks: Record<string, { url: string; headers?: Record<string, string> }>
111
+ /** Named Discord webhook URLs. */
112
+ discord: Record<string, string>
113
+ }
114
+
115
+ // -- Event binding ------------------------------------------------------------
116
+
117
+ export interface EventNotificationBinding {
118
+ /** Factory that creates the notification from the event payload. */
119
+ create: (payload: any) => import('./base_notification.ts').BaseNotification
120
+ /** Resolves which notifiable(s) should receive this notification. */
121
+ recipients: (payload: any) => Notifiable | Notifiable[] | Promise<Notifiable | Notifiable[]>
122
+ }
@@ -0,0 +1,351 @@
1
+ import { DateTime } from 'luxon'
2
+ import { toSnakeCase, toCamelCase } from '../helpers/strings.ts'
3
+ import { inject } from '../core/inject.ts'
4
+ import { getPrimaryKey, getReferences, getReferenceMeta, getAssociates } from './decorators.ts'
5
+ import type { ReferenceMetadata, AssociateMetadata } from './decorators.ts'
6
+ import Database from '../database/database.ts'
7
+ import { ConfigurationError, ModelNotFoundError } from '../exceptions/errors.ts'
8
+
9
+ type ModelStatic<T extends BaseModel> = (new (...args: any[]) => T) & typeof BaseModel
10
+
11
+ @inject
12
+ export default class BaseModel {
13
+ private static _db: Database
14
+
15
+ /** Whether this model supports soft deletes. Override in subclass. */
16
+ static softDeletes: boolean = false
17
+
18
+ constructor(db?: Database) {
19
+ if (db) BaseModel._db = db
20
+ }
21
+
22
+ /** The underlying database connection. */
23
+ static get db(): Database {
24
+ if (!BaseModel._db) {
25
+ throw new ConfigurationError('Database not configured. Resolve BaseModel through the container first.')
26
+ }
27
+ return BaseModel._db
28
+ }
29
+
30
+ /** Derive table name from class name: User → user, OrderItem → order_item */
31
+ static get tableName(): string {
32
+ return toSnakeCase(this.name)
33
+ }
34
+
35
+ /** The primary key column name in snake_case (from @primary metadata). */
36
+ static get primaryKeyColumn(): string {
37
+ return toSnakeCase(getPrimaryKey(this))
38
+ }
39
+
40
+ /** The primary key property name in camelCase (from @primary metadata). */
41
+ static get primaryKeyProperty(): string {
42
+ return getPrimaryKey(this)
43
+ }
44
+
45
+ /** Whether this record was loaded from (or saved to) the database. */
46
+ _exists: boolean = false
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Static CRUD
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /** Find a record by primary key. Returns null if not found or soft-deleted. */
53
+ static async find<T extends BaseModel>(
54
+ this: ModelStatic<T>,
55
+ id: number | string | bigint
56
+ ): Promise<T | null> {
57
+ const db = BaseModel.db
58
+ const table = this.tableName
59
+ const pkCol = this.primaryKeyColumn
60
+ const softClause = this.softDeletes ? ' AND "deleted_at" IS NULL' : ''
61
+ const rows = await db.sql.unsafe(
62
+ `SELECT * FROM "${table}" WHERE "${pkCol}" = $1${softClause} LIMIT 1`,
63
+ [id]
64
+ )
65
+ if (rows.length === 0) return null
66
+ return this.hydrate<T>(rows[0] as Record<string, unknown>)
67
+ }
68
+
69
+ /** Find a record by primary key or throw. */
70
+ static async findOrFail<T extends BaseModel>(
71
+ this: ModelStatic<T>,
72
+ id: number | string | bigint
73
+ ): Promise<T> {
74
+ const result = (await (this as any).find(id)) as T | null
75
+ if (!result) {
76
+ throw new ModelNotFoundError(this.name, id)
77
+ }
78
+ return result
79
+ }
80
+
81
+ /** Retrieve all records (excluding soft-deleted). */
82
+ static async all<T extends BaseModel>(this: ModelStatic<T>): Promise<T[]> {
83
+ const db = BaseModel.db
84
+ const table = this.tableName
85
+ const softClause = this.softDeletes ? ' WHERE "deleted_at" IS NULL' : ''
86
+ const rows = await db.sql.unsafe(`SELECT * FROM "${table}"${softClause}`)
87
+ return rows.map((row: Record<string, unknown>) => this.hydrate<T>(row))
88
+ }
89
+
90
+ /** Create a new record, assign attributes, save, and return it. */
91
+ static async create<T extends BaseModel>(
92
+ this: ModelStatic<T>,
93
+ attrs: Record<string, unknown>
94
+ ): Promise<T> {
95
+ const instance = new this()
96
+ instance.merge(attrs)
97
+ await instance.save()
98
+ return instance
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Instance helpers
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /** Assign properties from a plain object onto this model instance. */
106
+ merge(data: Record<string, unknown>): this {
107
+ for (const [key, value] of Object.entries(data)) {
108
+ ;(this as any)[key] = value
109
+ }
110
+ return this
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Instance CRUD
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /** INSERT or UPDATE depending on whether the record exists. */
118
+ async save(): Promise<this> {
119
+ const ctor = this.constructor as typeof BaseModel
120
+ const db = BaseModel.db
121
+ const table = ctor.tableName
122
+
123
+ if (this._exists) {
124
+ return this.performUpdate(db, table)
125
+ } else {
126
+ return this.performInsert(db, table)
127
+ }
128
+ }
129
+
130
+ /** Soft-delete (if model supports it) or hard-delete. */
131
+ async delete(): Promise<void> {
132
+ const ctor = this.constructor as typeof BaseModel
133
+ const db = BaseModel.db
134
+ const table = ctor.tableName
135
+ const pkCol = ctor.primaryKeyColumn
136
+ const pkProp = ctor.primaryKeyProperty
137
+ const pkValue = (this as any)[pkProp]
138
+
139
+ if (ctor.softDeletes && (this as any).deletedAt === null) {
140
+ const now = DateTime.now()
141
+ ;(this as any).deletedAt = now
142
+ await db.sql.unsafe(`UPDATE "${table}" SET "deleted_at" = $1 WHERE "${pkCol}" = $2`, [
143
+ now.toJSDate(),
144
+ pkValue,
145
+ ])
146
+ } else {
147
+ await db.sql.unsafe(`DELETE FROM "${table}" WHERE "${pkCol}" = $1`, [pkValue])
148
+ this._exists = false
149
+ }
150
+ }
151
+
152
+ /** Always hard-delete, regardless of soft-delete support. */
153
+ async forceDelete(): Promise<void> {
154
+ const db = BaseModel.db
155
+ const ctor = this.constructor as typeof BaseModel
156
+ const table = ctor.tableName
157
+ const pkCol = ctor.primaryKeyColumn
158
+ const pkProp = ctor.primaryKeyProperty
159
+ const pkValue = (this as any)[pkProp]
160
+ await db.sql.unsafe(`DELETE FROM "${table}" WHERE "${pkCol}" = $1`, [pkValue])
161
+ this._exists = false
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Relationship loading
166
+ // ---------------------------------------------------------------------------
167
+
168
+ /**
169
+ * Eagerly load one or more relationships by name.
170
+ *
171
+ * Supports both `@reference` (belongs-to) and `@associate` (many-to-many)
172
+ * relationships. Returns `this` for chaining.
173
+ *
174
+ * @example
175
+ * const team = await Team.find(1)
176
+ * await team.load('members') // many-to-many
177
+ * await user.load('profile', 'teams') // multiple relations
178
+ */
179
+ async load(...relations: string[]): Promise<this> {
180
+ const ctor = this.constructor as typeof BaseModel
181
+ const refMetas = getReferenceMeta(ctor)
182
+ const assocMetas = getAssociates(ctor)
183
+
184
+ for (const relation of relations) {
185
+ const refMeta = refMetas.find(r => r.property === relation)
186
+ if (refMeta) {
187
+ await this.loadReference(refMeta)
188
+ continue
189
+ }
190
+
191
+ const assocMeta = assocMetas.find(a => a.property === relation)
192
+ if (assocMeta) {
193
+ await this.loadAssociation(assocMeta)
194
+ continue
195
+ }
196
+
197
+ throw new Error(`Unknown relation "${relation}" on ${ctor.name}`)
198
+ }
199
+
200
+ return this
201
+ }
202
+
203
+ private async loadReference(meta: ReferenceMetadata): Promise<void> {
204
+ const db = BaseModel.db
205
+ const fkValue = (this as any)[meta.foreignKey]
206
+
207
+ if (fkValue === null || fkValue === undefined) {
208
+ ;(this as any)[meta.property] = null
209
+ return
210
+ }
211
+
212
+ const targetTable = toSnakeCase(meta.model)
213
+ const targetPKCol = toSnakeCase(meta.targetPK)
214
+ const rows = await db.sql.unsafe(
215
+ `SELECT * FROM "${targetTable}" WHERE "${targetPKCol}" = $1 LIMIT 1`,
216
+ [fkValue]
217
+ )
218
+
219
+ ;(this as any)[meta.property] =
220
+ rows.length > 0 ? hydrateRow(rows[0] as Record<string, unknown>) : null
221
+ }
222
+
223
+ private async loadAssociation(meta: AssociateMetadata): Promise<void> {
224
+ const db = BaseModel.db
225
+ const ctor = this.constructor as typeof BaseModel
226
+ const pkValue = (this as any)[ctor.primaryKeyProperty]
227
+
228
+ const targetTable = toSnakeCase(meta.model)
229
+ const rows = await db.sql.unsafe(
230
+ `SELECT t.* FROM "${targetTable}" t ` +
231
+ `INNER JOIN "${meta.through}" p ON p."${meta.otherKey}" = t."${toSnakeCase(meta.targetPK)}" ` +
232
+ `WHERE p."${meta.foreignKey}" = $1`,
233
+ [pkValue]
234
+ )
235
+
236
+ ;(this as any)[meta.property] = (rows as Record<string, unknown>[]).map(row => hydrateRow(row))
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Private helpers
241
+ // ---------------------------------------------------------------------------
242
+
243
+ private async performInsert(db: Database, table: string): Promise<this> {
244
+ const data = this.dehydrate()
245
+ const columns = Object.keys(data)
246
+ const values = Object.values(data)
247
+
248
+ let sql: string
249
+ if (columns.length === 0) {
250
+ sql = `INSERT INTO "${table}" DEFAULT VALUES RETURNING *`
251
+ } else {
252
+ const colNames = columns.map(c => `"${c}"`).join(', ')
253
+ const placeholders = columns.map((_, i) => `$${i + 1}`).join(', ')
254
+ sql = `INSERT INTO "${table}" (${colNames}) VALUES (${placeholders}) RETURNING *`
255
+ }
256
+
257
+ const rows = await db.sql.unsafe(sql, values)
258
+
259
+ if (rows.length > 0) {
260
+ this.hydrateFrom(rows[0] as Record<string, unknown>)
261
+ }
262
+
263
+ this._exists = true
264
+ return this
265
+ }
266
+
267
+ private async performUpdate(db: Database, table: string): Promise<this> {
268
+ if ('updatedAt' in this) (this as any).updatedAt = DateTime.now()
269
+
270
+ const ctor = this.constructor as typeof BaseModel
271
+ const pkCol = ctor.primaryKeyColumn
272
+
273
+ const data = this.dehydrate()
274
+ const pkValue = data[pkCol]
275
+ delete data[pkCol]
276
+
277
+ const columns = Object.keys(data)
278
+ const values = Object.values(data)
279
+ const setClauses = columns.map((col, i) => `"${col}" = $${i + 1}`).join(', ')
280
+ values.push(pkValue)
281
+
282
+ await db.sql.unsafe(
283
+ `UPDATE "${table}" SET ${setClauses} WHERE "${pkCol}" = $${values.length}`,
284
+ values
285
+ )
286
+
287
+ return this
288
+ }
289
+
290
+ /**
291
+ * Convert a DB row to a model instance.
292
+ * snake_case columns → camelCase properties. Date → DateTime.
293
+ */
294
+ /** @internal Used by QueryBuilder to create model instances from DB rows. */
295
+ static hydrate<T extends BaseModel>(this: new () => T, row: Record<string, unknown>): T {
296
+ const instance = new this()
297
+ instance.hydrateFrom(row)
298
+ instance._exists = true
299
+ return instance
300
+ }
301
+
302
+ /** Populate this instance's properties from a DB row. */
303
+ private hydrateFrom(row: Record<string, unknown>): void {
304
+ for (const [column, value] of Object.entries(row)) {
305
+ const prop = toCamelCase(column)
306
+ if (value instanceof Date) {
307
+ ;(this as any)[prop] = DateTime.fromJSDate(value)
308
+ } else {
309
+ ;(this as any)[prop] = value
310
+ }
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Convert model properties to DB columns.
316
+ * Skips _-prefixed props and @reference-decorated properties.
317
+ */
318
+ private dehydrate(): Record<string, unknown> {
319
+ const ctor = this.constructor as typeof BaseModel
320
+ const refProps = new Set(getReferences(ctor))
321
+ const assocProps = new Set(getAssociates(ctor).map(a => a.property))
322
+ const data: Record<string, unknown> = {}
323
+
324
+ for (const key of Object.keys(this)) {
325
+ if (key.startsWith('_')) continue
326
+ if (refProps.has(key)) continue
327
+ if (assocProps.has(key)) continue
328
+
329
+ const value = (this as any)[key]
330
+ const column = toSnakeCase(key)
331
+
332
+ if (value instanceof DateTime) {
333
+ data[column] = value.toJSDate()
334
+ } else {
335
+ data[column] = value
336
+ }
337
+ }
338
+
339
+ return data
340
+ }
341
+ }
342
+
343
+ /** Convert a raw DB row to a plain object with camelCase keys and DateTime hydration. */
344
+ function hydrateRow(row: Record<string, unknown>): Record<string, unknown> {
345
+ const obj: Record<string, unknown> = {}
346
+ for (const [column, value] of Object.entries(row)) {
347
+ const prop = toCamelCase(column)
348
+ obj[prop] = value instanceof Date ? DateTime.fromJSDate(value) : value
349
+ }
350
+ return obj
351
+ }
@@ -0,0 +1,127 @@
1
+ import 'reflect-metadata'
2
+
3
+ const PRIMARY_KEY = Symbol('orm:primary')
4
+ const REFERENCE_KEY = Symbol('orm:references')
5
+ const REFERENCE_META_KEY = Symbol('orm:reference_meta')
6
+ const ASSOCIATE_KEY = Symbol('orm:associates')
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // @primary
10
+ // ---------------------------------------------------------------------------
11
+
12
+ /**
13
+ * Property decorator that marks a field as the primary key.
14
+ * BaseModel reads this metadata to build queries with the correct PK column.
15
+ */
16
+ export function primary(target: any, propertyKey: string) {
17
+ Reflect.defineMetadata(PRIMARY_KEY, propertyKey, target.constructor)
18
+ }
19
+
20
+ /** Get the primary key property name (camelCase) for a model class. Defaults to 'id'. */
21
+ export function getPrimaryKey(target: Function): string {
22
+ return Reflect.getMetadata(PRIMARY_KEY, target) ?? 'id'
23
+ }
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // @reference
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export interface ReferenceOptions {
30
+ model: string
31
+ foreignKey: string
32
+ targetPK: string
33
+ }
34
+
35
+ export interface ReferenceMetadata extends ReferenceOptions {
36
+ property: string
37
+ }
38
+
39
+ /**
40
+ * Property decorator that marks a field as a reference object.
41
+ * Reference properties are excluded from database persistence (dehydrate).
42
+ *
43
+ * Can be used as a bare decorator or with options for load() support.
44
+ *
45
+ * @example
46
+ * // Bare (backward-compatible):
47
+ * @reference
48
+ * declare widget: Widget
49
+ *
50
+ * // With options (supports load()):
51
+ * @reference({ model: 'User', foreignKey: 'userId', targetPK: 'id' })
52
+ * declare user: User
53
+ */
54
+ export function reference(targetOrOptions: any, propertyKey?: string): any {
55
+ if (propertyKey !== undefined) {
56
+ // Bare decorator: @reference
57
+ addReferenceProperty(targetOrOptions, propertyKey)
58
+ return
59
+ }
60
+
61
+ // Factory: @reference({ model, foreignKey, targetPK })
62
+ const options = targetOrOptions as ReferenceOptions
63
+ return function (target: any, propertyKey: string) {
64
+ addReferenceProperty(target, propertyKey)
65
+
66
+ const metas: ReferenceMetadata[] = [
67
+ ...(Reflect.getMetadata(REFERENCE_META_KEY, target.constructor) ?? []),
68
+ ]
69
+ metas.push({ property: propertyKey, ...options })
70
+ Reflect.defineMetadata(REFERENCE_META_KEY, metas, target.constructor)
71
+ }
72
+ }
73
+
74
+ function addReferenceProperty(target: any, propertyKey: string): void {
75
+ const refs: string[] = [...(Reflect.getMetadata(REFERENCE_KEY, target.constructor) ?? [])]
76
+ refs.push(propertyKey)
77
+ Reflect.defineMetadata(REFERENCE_KEY, refs, target.constructor)
78
+ }
79
+
80
+ /** Get the list of reference property names for a model class. */
81
+ export function getReferences(target: Function): string[] {
82
+ return Reflect.getMetadata(REFERENCE_KEY, target) ?? []
83
+ }
84
+
85
+ /** Get rich @reference metadata (only present when options were used). */
86
+ export function getReferenceMeta(target: Function): ReferenceMetadata[] {
87
+ return Reflect.getMetadata(REFERENCE_META_KEY, target) ?? []
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // @associate
92
+ // ---------------------------------------------------------------------------
93
+
94
+ export interface AssociateOptions {
95
+ through: string
96
+ foreignKey: string
97
+ otherKey: string
98
+ model: string
99
+ targetPK: string
100
+ }
101
+
102
+ export interface AssociateMetadata extends AssociateOptions {
103
+ property: string
104
+ }
105
+
106
+ /**
107
+ * Property decorator that marks a field as a many-to-many association.
108
+ * Associates are loaded via pivot table queries and excluded from dehydrate.
109
+ *
110
+ * @example
111
+ * @associate({ through: 'team_user', foreignKey: 'team_id', otherKey: 'user_pid', model: 'User', targetPK: 'pid' })
112
+ * declare members: User[]
113
+ */
114
+ export function associate(options: AssociateOptions) {
115
+ return function (target: any, propertyKey: string) {
116
+ const assocs: AssociateMetadata[] = [
117
+ ...(Reflect.getMetadata(ASSOCIATE_KEY, target.constructor) ?? []),
118
+ ]
119
+ assocs.push({ property: propertyKey, ...options })
120
+ Reflect.defineMetadata(ASSOCIATE_KEY, assocs, target.constructor)
121
+ }
122
+ }
123
+
124
+ /** Get all @associate metadata for a model class. */
125
+ export function getAssociates(target: Function): AssociateMetadata[] {
126
+ return Reflect.getMetadata(ASSOCIATE_KEY, target) ?? []
127
+ }
@@ -0,0 +1,4 @@
1
+ export { default as BaseModel } from './base_model.ts'
2
+ export { primary, reference, associate } from './decorators.ts'
3
+ export { default as QueryBuilder, query } from '../database/query_builder.ts'
4
+ export type { PaginationResult, PaginationMeta } from '../database/query_builder.ts'
@@ -0,0 +1,44 @@
1
+ import type { Middleware } from '../http/middleware.ts'
2
+ import type { PolicyResult } from './policy_result.ts'
3
+
4
+ type PolicyReturn = PolicyResult | Promise<PolicyResult>
5
+
6
+ export function authorize(
7
+ policy: Record<string, (...args: any[]) => PolicyReturn>,
8
+ method: string,
9
+ loadResource?: (ctx: import('../http/context.ts').default) => Promise<unknown>
10
+ ): Middleware {
11
+ return async (ctx, next) => {
12
+ const user = ctx.get('user')
13
+ if (!user) {
14
+ return new Response(JSON.stringify({ error: 'Unauthorized' }), {
15
+ status: 401,
16
+ headers: { 'Content-Type': 'application/json' },
17
+ })
18
+ }
19
+
20
+ let resource: unknown
21
+ if (loadResource) {
22
+ resource = await loadResource(ctx)
23
+ ctx.set('resource', resource)
24
+ }
25
+
26
+ const policyMethod = policy[method]
27
+ if (!policyMethod) {
28
+ return new Response(JSON.stringify({ error: 'Forbidden' }), {
29
+ status: 403,
30
+ headers: { 'Content-Type': 'application/json' },
31
+ })
32
+ }
33
+
34
+ const access = await policyMethod(user, resource)
35
+ if (!access.allowed) {
36
+ return new Response(JSON.stringify({ error: access.reason }), {
37
+ status: access.status,
38
+ headers: { 'Content-Type': 'application/json' },
39
+ })
40
+ }
41
+
42
+ return next()
43
+ }
44
+ }
@@ -0,0 +1,3 @@
1
+ export { authorize } from './authorize.ts'
2
+ export { allow, deny } from './policy_result.ts'
3
+ export type { PolicyResult } from './policy_result.ts'
@@ -0,0 +1,13 @@
1
+ export type PolicyResult = {
2
+ allowed: boolean
3
+ status: number
4
+ reason: string
5
+ }
6
+
7
+ export function allow(): PolicyResult {
8
+ return { allowed: true, status: 200, reason: '' }
9
+ }
10
+
11
+ export function deny(status = 403, reason = 'Action forbidden'): PolicyResult {
12
+ return { allowed: false, status, reason }
13
+ }
@@ -0,0 +1,11 @@
1
+ export { default as Queue } from './queue.ts'
2
+ export { default as Worker } from './worker.ts'
3
+ export type {
4
+ JobOptions,
5
+ QueueConfig,
6
+ JobMeta,
7
+ JobRecord,
8
+ FailedJobRecord,
9
+ JobHandler,
10
+ } from './queue.ts'
11
+ export type { WorkerOptions } from './worker.ts'