Skip to content

Commit b9b657d

Browse files
committed
feat(enrichment): app migration framework + provider readiness + tmdb v4
Adds an app-data migration framework distinct from drizzle DDL migrations, backfills enrichment refs for legacy recently rows, and computes provider readiness server-side so the dashboard does not have to mirror gating logic. * `_app_migrations` ledger table + `migrate:app` / `migrate:all` scripts * `app-migrate.ts` runner uses the schema-distinct advisory lock and the full Nest DI graph; first migration backfills 21/92 legacy recently rows * providers declare `featureGateConfigKey` + `requiredConfigKeys`; `getProviders()` returns `ready` / `missingKeys` so admin no longer has to read the (sanitized) `thirdPartyServiceIntegration` itself * tmdb.client.ts switched to v4 Bearer auth (the dashboard hands out v4 tokens by default; v3 query-param auth was silently 401-ing)
1 parent 9bb9484 commit b9b657d

29 files changed

Lines changed: 5920 additions & 101 deletions

apps/core/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@
2222
"prebuild": "rimraf dist",
2323
"build": "npm run bundle",
2424
"dev": "npm run start",
25-
"predev": "npm run migrate",
25+
"predev": "npm run migrate:all",
2626
"dev:encrypt": "npm run start -- --encrypt_enable --encrypt_key=33b9405ac49f63ef4ccbf6b338aeeff8152e844d942ef22278abbec2b1f93b5e",
2727
"migrate": "tsx --env-file-if-exists=../../.env --tsconfig tsconfig.json src/migrate.ts",
28+
"migrate:app": "cross-env NODE_ENV=development nest start --no-watch --entryFile app-migrate --path tsconfig.json",
29+
"migrate:all": "npm run migrate && npm run migrate:app",
2830
"lint:migrations": "tsx --tsconfig tsconfig.json scripts/lint-migrations.ts",
2931
"repl": "npm run start -- --entryFile repl",
3032
"bundle": "tsdown",

apps/core/src/app-migrate.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* App-data migration runner.
3+
*
4+
* Boots a Nest application context (so migrations can `app.get(Service)` and
5+
* use real DI graph), acquires a Postgres advisory lock distinct from the
6+
* schema-migration lock, then iterates the registry in sorted id order. Each
7+
* migration's `up()` is responsible for its own row-level idempotency; the
8+
* runner only uses the `_app_migrations` ledger to skip work on re-runs.
9+
*
10+
* Schema migrations must run first — `migrate:all` chains the two binaries.
11+
*
12+
* Mirrors the import-order trick in `main.ts`: `initializeApp()` MUST run
13+
* before AppModule (and anything in its graph) evaluates ambient globals
14+
* such as `isDev`. ESM hoists static imports, so the AppModule import is
15+
* deferred via dynamic `import()`.
16+
*/
17+
import 'dotenv-expand/config'
18+
19+
import { Logger } from '@nestjs/common'
20+
import { NestFactory } from '@nestjs/core'
21+
import type pkg from 'pg'
22+
23+
import { initializeApp } from './global/index.global'
24+
25+
async function main() {
26+
initializeApp()
27+
28+
const [
29+
{ AppModule },
30+
{ PG_DB_TOKEN, PG_POOL_TOKEN },
31+
{ migrations },
32+
{ appMigrations: ledgerTable },
33+
{ APP_MIGRATION_LOCK_KEY, withAdvisoryLock },
34+
] = await Promise.all([
35+
import('./app.module'),
36+
import('./constants/system.constant'),
37+
import('./database/app-migrations/registry'),
38+
import('./database/schema'),
39+
import('./processors/database/postgres.lock'),
40+
])
41+
42+
const app = await NestFactory.createApplicationContext(
43+
AppModule.register(false),
44+
{
45+
logger: ['error', 'warn', 'log'],
46+
},
47+
)
48+
const logger = new Logger('app-migrate')
49+
const db = app.get(
50+
PG_DB_TOKEN,
51+
) as import('./processors/database/postgres.provider').AppDatabase
52+
const pool = app.get<pkg.Pool>(PG_POOL_TOKEN)
53+
54+
try {
55+
await withAdvisoryLock(pool, APP_MIGRATION_LOCK_KEY, async () => {
56+
const appliedRows = await db
57+
.select({ id: ledgerTable.id })
58+
.from(ledgerTable)
59+
const applied = new Set(appliedRows.map((r) => r.id))
60+
61+
const sorted = [...migrations].sort((a, b) => a.id.localeCompare(b.id))
62+
for (const m of sorted) {
63+
if (applied.has(m.id)) {
64+
logger.log(`⊘ ${m.id} (already applied)`)
65+
continue
66+
}
67+
logger.log(`▶ ${m.id}${m.description}`)
68+
const start = Date.now()
69+
try {
70+
await m.up({ app, logger })
71+
const ms = Date.now() - start
72+
await db.insert(ledgerTable).values({ id: m.id, durationMs: ms })
73+
logger.log(`✓ ${m.id} (${ms}ms)`)
74+
} catch (err) {
75+
logger.error(`✗ ${m.id}`, err as Error)
76+
throw err
77+
}
78+
}
79+
})
80+
} finally {
81+
await app.close()
82+
}
83+
}
84+
85+
main().catch((err) => {
86+
console.error('[app-migrate] failed:', err)
87+
process.exit(1)
88+
})
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { EnrichmentService } from '~/modules/enrichment/enrichment.service'
2+
import { RecentlyRepository } from '~/modules/recently/recently.repository'
3+
4+
import type { AppMigration } from './types'
5+
6+
const URL_REGEX = /https?:\/\/\S+/i
7+
// Strip trailing punctuation that the regex greedily includes; the CJK set
8+
// matches the same trim used by the Yohaku post-box auto-detect.
9+
const URL_TAIL_TRIM = /[!"'),.:;>?\]`}]+$/
10+
11+
export function extractFirstUrl(
12+
content: string | null | undefined,
13+
): string | null {
14+
if (!content) return null
15+
const m = content.match(URL_REGEX)
16+
if (!m) return null
17+
let url = m[0]
18+
while (URL_TAIL_TRIM.test(url)) url = url.replace(URL_TAIL_TRIM, '')
19+
return url || null
20+
}
21+
22+
export const migration: AppMigration = {
23+
id: '20260506-enrichment-backfill',
24+
description: 'Backfill enrichment refs for legacy recently rows',
25+
async up({ app, logger }) {
26+
const enrichmentService = app.get(EnrichmentService)
27+
const recentlyRepo = app.get(RecentlyRepository)
28+
29+
const rows = await recentlyRepo.findWithoutEnrichment()
30+
let matched = 0
31+
let skipped = 0
32+
let resolveFailed = 0
33+
for (const row of rows) {
34+
const url =
35+
(row.metadata as { url?: string } | null)?.url ??
36+
extractFirstUrl(row.content)
37+
if (!url) {
38+
skipped++
39+
continue
40+
}
41+
const ref = enrichmentService.matchUrlToRef(url)
42+
if (!ref) {
43+
skipped++
44+
continue
45+
}
46+
await recentlyRepo.update(row.id, {
47+
enrichmentProvider: ref.provider,
48+
enrichmentExternalId: ref.externalId,
49+
})
50+
try {
51+
await enrichmentService.resolve(url)
52+
} catch (err) {
53+
resolveFailed++
54+
logger.warn(`resolve failed for ${url}: ${(err as Error).message}`)
55+
}
56+
matched++
57+
}
58+
logger.log(
59+
`backfill: total=${rows.length} matched=${matched} skipped=${skipped} resolveFailed=${resolveFailed}`,
60+
)
61+
},
62+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { migration as enrichmentBackfill } from './20260506-enrichment-backfill'
2+
import type { AppMigration } from './types'
3+
4+
/**
5+
* Ordered list of app-data migrations. Runner sorts by id (lexicographic on
6+
* the `YYYYMMDD-slug` prefix) before iterating, so insertion order here is
7+
* not load-bearing — adding a new migration is just `import + push`.
8+
*/
9+
export const migrations: AppMigration[] = [enrichmentBackfill]
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { INestApplicationContext, Logger } from '@nestjs/common'
2+
3+
/**
4+
* App-data migration: a one-time runtime transform applied against an already
5+
* migrated schema. Distinct from drizzle-kit DDL migrations.
6+
*
7+
* `up` runs inside a Postgres advisory lock, so concurrent runners will not
8+
* race. Each implementation MUST be idempotent: a re-run after partial
9+
* failure or a second instance starting before the ledger row was written
10+
* must produce the same end state. Runner-level guard is the ledger; the
11+
* row-level guard is the migration's responsibility.
12+
*/
13+
export interface AppMigration {
14+
/** Stable id, `YYYYMMDD-slug`. Used as ledger primary key. */
15+
id: string
16+
description: string
17+
up: (ctx: { app: INestApplicationContext; logger: Logger }) => Promise<void>
18+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
CREATE TABLE "_app_migrations" (
2+
"id" text PRIMARY KEY NOT NULL,
3+
"applied_at" timestamp with time zone DEFAULT now() NOT NULL,
4+
"duration_ms" integer
5+
);

0 commit comments

Comments
 (0)