feat(backend): add DB persistence layer (#79)

* feat(backend): add DB persistence layer

Replace raw pg Pool with Drizzle ORM backed by Bun.sql.
Add per-user source configuration table (user_sources).
Migrate Better Auth to drizzle-adapter.
Add AES-256-GCM credential encryption.

Co-authored-by: Ona <no-reply@ona.com>

* fix(backend): set updatedAt explicitly in all mutations

onConflictDoUpdate bypasses Drizzle's $onUpdate hook.
Set updatedAt explicitly in all mutation methods.

Co-authored-by: Ona <no-reply@ona.com>

* fix(backend): add composite index on user_sources

Add (user_id, enabled) index for the enabled() query path.

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2026-03-16 01:30:02 +00:00
committed by GitHub
parent 9ac88d921c
commit 61c1ade631
29 changed files with 2034 additions and 115 deletions

View File

@@ -1,15 +1,53 @@
import { WeatherSource, type WeatherSourceOptions } from "@aelis/source-weatherkit"
import { type } from "arktype"
import type { Database } from "../db/index.ts"
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
export class WeatherSourceProvider implements FeedSourceProvider {
private readonly options: WeatherSourceOptions
import { SourceDisabledError } from "../sources/errors.ts"
import { sources } from "../sources/user-sources.ts"
constructor(options: WeatherSourceOptions) {
this.options = options
export interface WeatherSourceProviderOptions {
db: Database
credentials: WeatherSourceOptions["credentials"]
client?: WeatherSourceOptions["client"]
}
const weatherConfig = type({
"units?": "'metric' | 'imperial'",
"hourlyLimit?": "number",
"dailyLimit?": "number",
})
export class WeatherSourceProvider implements FeedSourceProvider {
private readonly db: Database
private readonly credentials: WeatherSourceOptions["credentials"]
private readonly client: WeatherSourceOptions["client"]
constructor(options: WeatherSourceProviderOptions) {
this.db = options.db
this.credentials = options.credentials
this.client = options.client
}
async feedSourceForUser(_userId: string): Promise<WeatherSource> {
return new WeatherSource(this.options)
async feedSourceForUser(userId: string): Promise<WeatherSource> {
const row = await sources(this.db, userId).find("aelis.weather")
if (!row || !row.enabled) {
throw new SourceDisabledError("aelis.weather", userId)
}
const parsed = weatherConfig(row.config ?? {})
if (parsed instanceof type.errors) {
throw new Error(`Invalid weather config for user ${userId}: ${parsed.summary}`)
}
return new WeatherSource({
credentials: this.credentials,
client: this.client,
units: parsed.units,
hourlyLimit: parsed.hourlyLimit,
dailyLimit: parsed.dailyLimit,
})
}
}