@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.
- package/README.md +45 -0
- package/package.json +83 -0
- package/src/auth/access_token.ts +122 -0
- package/src/auth/auth.ts +86 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/middleware/authenticate.ts +64 -0
- package/src/auth/middleware/csrf.ts +62 -0
- package/src/auth/middleware/guest.ts +46 -0
- package/src/broadcast/broadcast_manager.ts +411 -0
- package/src/broadcast/client.ts +302 -0
- package/src/broadcast/index.ts +58 -0
- package/src/cache/cache_manager.ts +56 -0
- package/src/cache/cache_store.ts +31 -0
- package/src/cache/helpers.ts +74 -0
- package/src/cache/http_cache.ts +109 -0
- package/src/cache/index.ts +6 -0
- package/src/cache/memory_store.ts +63 -0
- package/src/cli/bootstrap.ts +37 -0
- package/src/cli/commands/generate_api.ts +74 -0
- package/src/cli/commands/generate_key.ts +46 -0
- package/src/cli/commands/generate_models.ts +48 -0
- package/src/cli/commands/migration_compare.ts +152 -0
- package/src/cli/commands/migration_fresh.ts +123 -0
- package/src/cli/commands/migration_generate.ts +79 -0
- package/src/cli/commands/migration_rollback.ts +53 -0
- package/src/cli/commands/migration_run.ts +44 -0
- package/src/cli/commands/queue_flush.ts +35 -0
- package/src/cli/commands/queue_retry.ts +34 -0
- package/src/cli/commands/queue_work.ts +40 -0
- package/src/cli/commands/scheduler_work.ts +45 -0
- package/src/cli/strav.ts +33 -0
- package/src/config/configuration.ts +105 -0
- package/src/config/loaders/base_loader.ts +69 -0
- package/src/config/loaders/env_loader.ts +112 -0
- package/src/config/loaders/typescript_loader.ts +56 -0
- package/src/config/types.ts +8 -0
- package/src/core/application.ts +4 -0
- package/src/core/container.ts +117 -0
- package/src/core/index.ts +3 -0
- package/src/core/inject.ts +39 -0
- package/src/database/database.ts +54 -0
- package/src/database/index.ts +30 -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 +133 -0
- package/src/database/migration/sql_generator.ts +378 -0
- package/src/database/migration/tracker.ts +76 -0
- package/src/database/migration/types.ts +189 -0
- package/src/database/query_builder.ts +474 -0
- package/src/encryption/encryption_manager.ts +209 -0
- package/src/encryption/helpers.ts +158 -0
- package/src/encryption/index.ts +3 -0
- package/src/encryption/types.ts +6 -0
- package/src/events/emitter.ts +101 -0
- package/src/events/index.ts +2 -0
- package/src/exceptions/errors.ts +75 -0
- package/src/exceptions/exception_handler.ts +126 -0
- package/src/exceptions/helpers.ts +25 -0
- package/src/exceptions/http_exception.ts +129 -0
- package/src/exceptions/index.ts +23 -0
- package/src/exceptions/strav_error.ts +11 -0
- package/src/generators/api_generator.ts +972 -0
- package/src/generators/config.ts +87 -0
- package/src/generators/doc_generator.ts +974 -0
- package/src/generators/index.ts +11 -0
- package/src/generators/model_generator.ts +586 -0
- package/src/generators/route_generator.ts +188 -0
- package/src/generators/test_generator.ts +1666 -0
- package/src/helpers/crypto.ts +4 -0
- package/src/helpers/env.ts +50 -0
- package/src/helpers/identity.ts +12 -0
- package/src/helpers/index.ts +4 -0
- package/src/helpers/strings.ts +67 -0
- package/src/http/context.ts +215 -0
- package/src/http/cookie.ts +59 -0
- package/src/http/cors.ts +163 -0
- package/src/http/index.ts +16 -0
- package/src/http/middleware.ts +39 -0
- package/src/http/rate_limit.ts +173 -0
- package/src/http/router.ts +556 -0
- package/src/http/server.ts +79 -0
- package/src/i18n/defaults/en/validation.json +20 -0
- package/src/i18n/helpers.ts +72 -0
- package/src/i18n/i18n_manager.ts +155 -0
- package/src/i18n/index.ts +4 -0
- package/src/i18n/middleware.ts +90 -0
- package/src/i18n/translator.ts +96 -0
- package/src/i18n/types.ts +17 -0
- package/src/logger/index.ts +6 -0
- package/src/logger/logger.ts +100 -0
- package/src/logger/request_logger.ts +19 -0
- package/src/logger/sinks/console_sink.ts +24 -0
- package/src/logger/sinks/file_sink.ts +24 -0
- package/src/logger/sinks/sink.ts +36 -0
- package/src/mail/css_inliner.ts +79 -0
- package/src/mail/helpers.ts +212 -0
- package/src/mail/index.ts +19 -0
- package/src/mail/mail_manager.ts +92 -0
- package/src/mail/transports/log_transport.ts +69 -0
- package/src/mail/transports/resend_transport.ts +59 -0
- package/src/mail/transports/sendgrid_transport.ts +77 -0
- package/src/mail/transports/smtp_transport.ts +48 -0
- package/src/mail/types.ts +80 -0
- package/src/notification/base_notification.ts +67 -0
- package/src/notification/channels/database_channel.ts +30 -0
- package/src/notification/channels/discord_channel.ts +43 -0
- package/src/notification/channels/email_channel.ts +37 -0
- package/src/notification/channels/webhook_channel.ts +45 -0
- package/src/notification/helpers.ts +214 -0
- package/src/notification/index.ts +20 -0
- package/src/notification/notification_manager.ts +126 -0
- package/src/notification/types.ts +122 -0
- package/src/orm/base_model.ts +351 -0
- package/src/orm/decorators.ts +127 -0
- package/src/orm/index.ts +4 -0
- package/src/policy/authorize.ts +44 -0
- package/src/policy/index.ts +3 -0
- package/src/policy/policy_result.ts +13 -0
- package/src/queue/index.ts +11 -0
- package/src/queue/queue.ts +338 -0
- package/src/queue/worker.ts +197 -0
- package/src/scheduler/cron.ts +140 -0
- package/src/scheduler/index.ts +7 -0
- package/src/scheduler/runner.ts +116 -0
- package/src/scheduler/schedule.ts +183 -0
- package/src/scheduler/scheduler.ts +47 -0
- package/src/schema/database_representation.ts +122 -0
- package/src/schema/define_association.ts +60 -0
- package/src/schema/define_schema.ts +46 -0
- package/src/schema/field_builder.ts +155 -0
- package/src/schema/field_definition.ts +66 -0
- package/src/schema/index.ts +21 -0
- package/src/schema/naming.ts +19 -0
- package/src/schema/postgres.ts +109 -0
- package/src/schema/registry.ts +157 -0
- package/src/schema/representation_builder.ts +479 -0
- package/src/schema/type_builder.ts +107 -0
- package/src/schema/types.ts +35 -0
- package/src/session/index.ts +4 -0
- package/src/session/middleware.ts +46 -0
- package/src/session/session.ts +308 -0
- package/src/session/session_manager.ts +81 -0
- package/src/storage/index.ts +13 -0
- package/src/storage/local_driver.ts +46 -0
- package/src/storage/s3_driver.ts +51 -0
- package/src/storage/storage.ts +43 -0
- package/src/storage/storage_manager.ts +59 -0
- package/src/storage/types.ts +42 -0
- package/src/storage/upload.ts +91 -0
- package/src/validation/index.ts +18 -0
- package/src/validation/rules.ts +170 -0
- package/src/validation/validate.ts +41 -0
- package/src/view/cache.ts +47 -0
- package/src/view/client/islands.ts +50 -0
- package/src/view/compiler.ts +185 -0
- package/src/view/engine.ts +139 -0
- package/src/view/escape.ts +14 -0
- package/src/view/index.ts +13 -0
- package/src/view/islands/island_builder.ts +161 -0
- package/src/view/islands/vue_plugin.ts +140 -0
- package/src/view/middleware/static.ts +35 -0
- package/src/view/tokenizer.ts +172 -0
- 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
|
+
}
|
package/src/orm/index.ts
ADDED
|
@@ -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,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'
|