@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,116 @@
|
|
|
1
|
+
import Scheduler from './scheduler.ts'
|
|
2
|
+
import type { Schedule } from './schedule.ts'
|
|
3
|
+
|
|
4
|
+
export interface RunnerOptions {
|
|
5
|
+
/** Timezone for schedule evaluation. @default 'UTC' */
|
|
6
|
+
timezone?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Long-running scheduler loop.
|
|
11
|
+
*
|
|
12
|
+
* Aligns to minute boundaries (standard cron behaviour), checks which
|
|
13
|
+
* tasks are due, and executes them concurrently. Supports graceful
|
|
14
|
+
* shutdown via SIGINT/SIGTERM and per-task overlap prevention.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* const runner = new SchedulerRunner()
|
|
18
|
+
* await runner.start() // blocks until runner.stop()
|
|
19
|
+
*/
|
|
20
|
+
export default class SchedulerRunner {
|
|
21
|
+
private running = false
|
|
22
|
+
private active = new Set<string>()
|
|
23
|
+
|
|
24
|
+
/** Start the scheduler loop. Blocks until stop() is called. */
|
|
25
|
+
async start(): Promise<void> {
|
|
26
|
+
this.running = true
|
|
27
|
+
|
|
28
|
+
const onSignal = () => this.stop()
|
|
29
|
+
process.on('SIGINT', onSignal)
|
|
30
|
+
process.on('SIGTERM', onSignal)
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
while (this.running) {
|
|
34
|
+
await this.sleepUntilNextMinute()
|
|
35
|
+
if (!this.running) break
|
|
36
|
+
|
|
37
|
+
const now = new Date()
|
|
38
|
+
const due = Scheduler.due(now)
|
|
39
|
+
|
|
40
|
+
if (due.length > 0) {
|
|
41
|
+
await this.runAll(due)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Wait for active tasks to finish before exiting
|
|
46
|
+
if (this.active.size > 0) {
|
|
47
|
+
await this.waitForActive()
|
|
48
|
+
}
|
|
49
|
+
} finally {
|
|
50
|
+
process.off('SIGINT', onSignal)
|
|
51
|
+
process.off('SIGTERM', onSignal)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Signal the runner to stop after the current tick completes. */
|
|
56
|
+
stop(): void {
|
|
57
|
+
this.running = false
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Number of tasks currently executing. */
|
|
61
|
+
get activeCount(): number {
|
|
62
|
+
return this.active.size
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Internal
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/** Sleep until the start of the next minute (XX:XX:00.000). */
|
|
70
|
+
private async sleepUntilNextMinute(): Promise<void> {
|
|
71
|
+
const now = Date.now()
|
|
72
|
+
const nextMinute = Math.ceil(now / 60_000) * 60_000
|
|
73
|
+
const delay = nextMinute - now
|
|
74
|
+
|
|
75
|
+
// Minimum 1s, maximum 60s — avoid sleeping 0ms on exact boundaries
|
|
76
|
+
const ms = Math.max(1_000, Math.min(delay, 60_000))
|
|
77
|
+
await Bun.sleep(ms)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Execute all due tasks concurrently. */
|
|
81
|
+
private async runAll(tasks: Schedule[]): Promise<void> {
|
|
82
|
+
const promises: Promise<void>[] = []
|
|
83
|
+
|
|
84
|
+
for (const task of tasks) {
|
|
85
|
+
if (task.preventsOverlap && this.active.has(task.name)) {
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
promises.push(this.runTask(task))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
await Promise.allSettled(promises)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Execute a single task with overlap tracking and error handling. */
|
|
96
|
+
private async runTask(task: Schedule): Promise<void> {
|
|
97
|
+
this.active.add(task.name)
|
|
98
|
+
try {
|
|
99
|
+
await task.handler()
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
102
|
+
console.error(`[scheduler] Task "${task.name}" failed: ${message}`)
|
|
103
|
+
} finally {
|
|
104
|
+
this.active.delete(task.name)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Wait for all active tasks to complete (for graceful shutdown). */
|
|
109
|
+
private async waitForActive(): Promise<void> {
|
|
110
|
+
const maxWait = 30_000
|
|
111
|
+
const start = Date.now()
|
|
112
|
+
while (this.active.size > 0 && Date.now() - start < maxWait) {
|
|
113
|
+
await Bun.sleep(100)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { parseCron, cronMatches } from './cron.ts'
|
|
2
|
+
import type { CronExpression } from './cron.ts'
|
|
3
|
+
|
|
4
|
+
export type TaskHandler = () => void | Promise<void>
|
|
5
|
+
|
|
6
|
+
const DAY_NAMES: Record<string, number> = {
|
|
7
|
+
sunday: 0, sun: 0,
|
|
8
|
+
monday: 1, mon: 1,
|
|
9
|
+
tuesday: 2, tue: 2,
|
|
10
|
+
wednesday: 3, wed: 3,
|
|
11
|
+
thursday: 4, thu: 4,
|
|
12
|
+
friday: 5, fri: 5,
|
|
13
|
+
saturday: 6, sat: 6,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A scheduled task definition with a fluent configuration API.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* new Schedule('cleanup', handler).dailyAt('02:30').withoutOverlapping()
|
|
21
|
+
*/
|
|
22
|
+
export class Schedule {
|
|
23
|
+
readonly name: string
|
|
24
|
+
readonly handler: TaskHandler
|
|
25
|
+
|
|
26
|
+
private _cron: CronExpression | null = null
|
|
27
|
+
private _noOverlap = false
|
|
28
|
+
|
|
29
|
+
constructor(name: string, handler: TaskHandler) {
|
|
30
|
+
this.name = name
|
|
31
|
+
this.handler = handler
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Raw cron ──────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/** Set a raw 5-field cron expression. */
|
|
37
|
+
cron(expression: string): this {
|
|
38
|
+
this._cron = parseCron(expression)
|
|
39
|
+
return this
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Minute-based ──────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/** Run every minute. */
|
|
45
|
+
everyMinute(): this {
|
|
46
|
+
return this.cron('* * * * *')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Run every 2 minutes. */
|
|
50
|
+
everyTwoMinutes(): this {
|
|
51
|
+
return this.cron('*/2 * * * *')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Run every 5 minutes. */
|
|
55
|
+
everyFiveMinutes(): this {
|
|
56
|
+
return this.cron('*/5 * * * *')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Run every 10 minutes. */
|
|
60
|
+
everyTenMinutes(): this {
|
|
61
|
+
return this.cron('*/10 * * * *')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Run every 15 minutes. */
|
|
65
|
+
everyFifteenMinutes(): this {
|
|
66
|
+
return this.cron('*/15 * * * *')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Run every 30 minutes. */
|
|
70
|
+
everyThirtyMinutes(): this {
|
|
71
|
+
return this.cron('*/30 * * * *')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Hourly ────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/** Run once per hour at minute 0. */
|
|
77
|
+
hourly(): this {
|
|
78
|
+
return this.cron('0 * * * *')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Run once per hour at the given minute. */
|
|
82
|
+
hourlyAt(minute: number): this {
|
|
83
|
+
return this.cron(`${minute} * * * *`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Daily ─────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/** Run once per day at midnight. */
|
|
89
|
+
daily(): this {
|
|
90
|
+
return this.cron('0 0 * * *')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Run once per day at the given time (HH:MM). */
|
|
94
|
+
dailyAt(time: string): this {
|
|
95
|
+
const [hour, minute] = parseTime(time)
|
|
96
|
+
return this.cron(`${minute} ${hour} * * *`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Run twice per day at the given hours (minute 0). */
|
|
100
|
+
twiceDaily(hour1: number, hour2: number): this {
|
|
101
|
+
return this.cron(`0 ${hour1},${hour2} * * *`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Weekly ────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/** Run once per week on Sunday at midnight. */
|
|
107
|
+
weekly(): this {
|
|
108
|
+
return this.cron('0 0 * * 0')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Run once per week on the given day and optional time. */
|
|
112
|
+
weeklyOn(day: string | number, time?: string): this {
|
|
113
|
+
const dow = typeof day === 'string' ? dayToNumber(day) : day
|
|
114
|
+
const [hour, minute] = time ? parseTime(time) : [0, 0]
|
|
115
|
+
return this.cron(`${minute} ${hour} * * ${dow}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Monthly ───────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
/** Run once per month on the 1st at midnight. */
|
|
121
|
+
monthly(): this {
|
|
122
|
+
return this.cron('0 0 1 * *')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Run once per month on the given day and optional time. */
|
|
126
|
+
monthlyOn(day: number, time?: string): this {
|
|
127
|
+
const [hour, minute] = time ? parseTime(time) : [0, 0]
|
|
128
|
+
return this.cron(`${minute} ${hour} ${day} * *`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Options ───────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/** Prevent overlapping runs within this process. */
|
|
134
|
+
withoutOverlapping(): this {
|
|
135
|
+
this._noOverlap = true
|
|
136
|
+
return this
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Internal ──────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/** Check if this task is due at the given Date (evaluated in UTC). */
|
|
142
|
+
isDue(now: Date): boolean {
|
|
143
|
+
if (!this._cron) return false
|
|
144
|
+
return cronMatches(this._cron, now)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Whether overlap prevention is enabled. */
|
|
148
|
+
get preventsOverlap(): boolean {
|
|
149
|
+
return this._noOverlap
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The parsed cron expression (for testing/debugging). */
|
|
153
|
+
get expression(): CronExpression | null {
|
|
154
|
+
return this._cron
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Helpers
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
function parseTime(time: string): [number, number] {
|
|
163
|
+
const parts = time.split(':')
|
|
164
|
+
if (parts.length !== 2) {
|
|
165
|
+
throw new Error(`Invalid time format "${time}": expected HH:MM`)
|
|
166
|
+
}
|
|
167
|
+
const hour = parseInt(parts[0]!, 10)
|
|
168
|
+
const minute = parseInt(parts[1]!, 10)
|
|
169
|
+
if (isNaN(hour) || isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
|
170
|
+
throw new Error(`Invalid time "${time}": hour must be 0–23, minute must be 0–59`)
|
|
171
|
+
}
|
|
172
|
+
return [hour, minute]
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function dayToNumber(day: string): number {
|
|
176
|
+
const n = DAY_NAMES[day.toLowerCase()]
|
|
177
|
+
if (n === undefined) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Invalid day name "${day}": expected one of ${Object.keys(DAY_NAMES).filter((_, i) => i % 2 === 0).join(', ')}`
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
return n
|
|
183
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Schedule } from './schedule.ts'
|
|
2
|
+
import type { TaskHandler } from './schedule.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Static task registry for periodic jobs.
|
|
6
|
+
*
|
|
7
|
+
* No DI, no database — tasks are registered in code and evaluated
|
|
8
|
+
* in-memory by the {@link SchedulerRunner}.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* Scheduler.task('cleanup:sessions', async () => {
|
|
12
|
+
* await db.sql`DELETE FROM "_strav_sessions" WHERE "expires_at" < NOW()`
|
|
13
|
+
* }).hourly()
|
|
14
|
+
*
|
|
15
|
+
* Scheduler.task('reports:daily', () => generateDailyReport()).dailyAt('02:00')
|
|
16
|
+
*/
|
|
17
|
+
export default class Scheduler {
|
|
18
|
+
private static _tasks: Schedule[] = []
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Register a periodic task. Returns the {@link Schedule} for fluent configuration.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* Scheduler.task('prune-cache', () => cache.flush()).everyFifteenMinutes()
|
|
25
|
+
*/
|
|
26
|
+
static task(name: string, handler: TaskHandler): Schedule {
|
|
27
|
+
const schedule = new Schedule(name, handler)
|
|
28
|
+
Scheduler._tasks.push(schedule)
|
|
29
|
+
return schedule
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** All registered tasks. */
|
|
33
|
+
static get tasks(): readonly Schedule[] {
|
|
34
|
+
return Scheduler._tasks
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Return tasks that are due at the given time (defaults to now, UTC). */
|
|
38
|
+
static due(now?: Date): Schedule[] {
|
|
39
|
+
const date = now ?? new Date()
|
|
40
|
+
return Scheduler._tasks.filter(t => t.isDue(date))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Clear all registered tasks. For testing. */
|
|
44
|
+
static reset(): void {
|
|
45
|
+
Scheduler._tasks = []
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { PostgreSQLType } from './postgres.ts'
|
|
2
|
+
import type { Archetype } from './types.ts'
|
|
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
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A default value for a column — either a literal value or a SQL expression.
|
|
81
|
+
*/
|
|
82
|
+
export type DefaultValue =
|
|
83
|
+
| { kind: 'literal'; value: string | number | boolean | null }
|
|
84
|
+
| { kind: 'expression'; sql: string }
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A primary key constraint (single or composite).
|
|
88
|
+
*/
|
|
89
|
+
export interface PrimaryKeyConstraint {
|
|
90
|
+
columns: string[]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A foreign key constraint.
|
|
95
|
+
*/
|
|
96
|
+
export interface ForeignKeyConstraint {
|
|
97
|
+
/** Column(s) in this table. */
|
|
98
|
+
columns: string[]
|
|
99
|
+
/** Referenced table. */
|
|
100
|
+
referencedTable: string
|
|
101
|
+
/** Referenced column(s). */
|
|
102
|
+
referencedColumns: string[]
|
|
103
|
+
/** ON DELETE behavior. */
|
|
104
|
+
onDelete: 'CASCADE' | 'SET NULL' | 'RESTRICT' | 'NO ACTION'
|
|
105
|
+
/** ON UPDATE behavior. */
|
|
106
|
+
onUpdate: 'CASCADE' | 'SET NULL' | 'RESTRICT' | 'NO ACTION'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A unique constraint across one or more columns.
|
|
111
|
+
*/
|
|
112
|
+
export interface UniqueConstraint {
|
|
113
|
+
columns: string[]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* An index definition.
|
|
118
|
+
*/
|
|
119
|
+
export interface IndexDefinition {
|
|
120
|
+
columns: string[]
|
|
121
|
+
unique: boolean
|
|
122
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Archetype } from './types.ts'
|
|
2
|
+
import type { SchemaDefinition } from './types.ts'
|
|
3
|
+
import type FieldBuilder from './field_builder.ts'
|
|
4
|
+
import defineSchema from './define_schema.ts'
|
|
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.ts'
|
|
2
|
+
import type { SchemaInput, SchemaDefinition } from './types.ts'
|
|
3
|
+
import type { FieldDefinition } from './field_definition.ts'
|
|
4
|
+
import type { PostgreSQLCustomType } from './postgres.ts'
|
|
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
|
+
parent: input.parent,
|
|
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
|
+
}
|