-
Notifications
You must be signed in to change notification settings - Fork 0
Implementations Of The Deployment Database Introspection Logic #65
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,131 @@ | ||||||||||||||||||
| import { DatabaseSchemasCollection, DeploymentsCollection } from './schema.ts' | ||||||||||||||||||
| import { DB_SCHEMA_REFRESH_MS } from './lib/env.ts' | ||||||||||||||||||
| import { log } from './lib/log.ts' | ||||||||||||||||||
|
|
||||||||||||||||||
| async function runSQL( | ||||||||||||||||||
| endpoint: string, | ||||||||||||||||||
| token: string, | ||||||||||||||||||
| query: string, | ||||||||||||||||||
| params?: unknown, | ||||||||||||||||||
| ) { | ||||||||||||||||||
| const res = await fetch(endpoint, { | ||||||||||||||||||
| method: 'POST', | ||||||||||||||||||
| headers: { | ||||||||||||||||||
| 'Content-Type': 'application/json', | ||||||||||||||||||
| Authorization: `Bearer ${token}`, | ||||||||||||||||||
| }, | ||||||||||||||||||
| body: JSON.stringify({ query, params }), | ||||||||||||||||||
| }) | ||||||||||||||||||
| if (!res.ok) throw Error(`sql endpoint error ${res.status}`) | ||||||||||||||||||
| const data = await res.json() | ||||||||||||||||||
|
|
||||||||||||||||||
| return data | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // Dialect detection attempts (run first successful) | ||||||||||||||||||
| const DETECTION_QUERIES: { name: string; sql: string; matcher: RegExp }[] = [ | ||||||||||||||||||
| { | ||||||||||||||||||
| name: 'sqlite', | ||||||||||||||||||
| sql: 'SELECT sqlite_version() as v', | ||||||||||||||||||
| matcher: /\d+\.\d+\.\d+/, | ||||||||||||||||||
| }, | ||||||||||||||||||
| ] | ||||||||||||||||||
|
|
||||||||||||||||||
| async function detectDialect(endpoint: string, token: string): Promise<string> { | ||||||||||||||||||
| for (const d of DETECTION_QUERIES) { | ||||||||||||||||||
| try { | ||||||||||||||||||
| const rows = await runSQL(endpoint, token, d.sql) | ||||||||||||||||||
| log.debug('dialect-detection', { dialect: d.name, rows }) | ||||||||||||||||||
| if (rows.length) { | ||||||||||||||||||
| const text = JSON.stringify(rows[0]) | ||||||||||||||||||
| if (d.matcher.test(text)) return d.name | ||||||||||||||||||
| } | ||||||||||||||||||
| } catch { /* ignore */ } | ||||||||||||||||||
| } | ||||||||||||||||||
| return 'unknown' | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // Introspection queries per dialect returning columns list | ||||||||||||||||||
| // Standardized output fields: table_schema (nullable), table_name, column_name, data_type, ordinal_position | ||||||||||||||||||
| const INTROSPECTION: Record<string, string> = { | ||||||||||||||||||
| sqlite: | ||||||||||||||||||
| `SELECT NULL AS table_schema, m.name AS table_name, p.name AS column_name, p.type AS data_type, p.cid + 1 AS ordinal_position FROM sqlite_master m JOIN pragma_table_info(m.name) p WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' ORDER BY m.name, p.cid`, | ||||||||||||||||||
| unknown: | ||||||||||||||||||
| `SELECT table_schema, table_name, column_name, data_type, ordinal_position FROM information_schema.columns ORDER BY table_schema, table_name, ordinal_position`, | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| async function fetchSchema(endpoint: string, token: string, dialect: string) { | ||||||||||||||||||
| const sql = INTROSPECTION[dialect] ?? INTROSPECTION.unknown | ||||||||||||||||||
| return await runSQL(endpoint, token, sql) | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| type ColumnInfo = { name: string; type: string; ordinal: number } | ||||||||||||||||||
| type TableInfo = { | ||||||||||||||||||
| schema: string | undefined | ||||||||||||||||||
| table: string | ||||||||||||||||||
| columns: ColumnInfo[] | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| export async function refreshOneSchema( | ||||||||||||||||||
| dep: ReturnType<typeof DeploymentsCollection.get>, | ||||||||||||||||||
| ) { | ||||||||||||||||||
| if (!dep || !dep.databaseEnabled || !dep.sqlEndpoint || !dep.sqlToken) return | ||||||||||||||||||
| try { | ||||||||||||||||||
| const dialect = await detectDialect(dep.sqlEndpoint, dep.sqlToken) | ||||||||||||||||||
| const rows = await fetchSchema(dep.sqlEndpoint, dep.sqlToken, dialect) | ||||||||||||||||||
| // group rows | ||||||||||||||||||
| const tableMap = new Map<string, TableInfo>() | ||||||||||||||||||
| for (const r of rows) { | ||||||||||||||||||
| const schema = (r.table_schema as string) || undefined | ||||||||||||||||||
| const table = r.table_name as string | ||||||||||||||||||
| if (!table) continue | ||||||||||||||||||
| const key = (schema ? schema + '.' : '') + table | ||||||||||||||||||
| if (!tableMap.has(key)) tableMap.set(key, { schema, table, columns: [] }) | ||||||||||||||||||
| tableMap.get(key)!.columns.push({ | ||||||||||||||||||
| name: String(r.column_name), | ||||||||||||||||||
| type: String(r.data_type || ''), | ||||||||||||||||||
| ordinal: Number(r.ordinal_position || 0), | ||||||||||||||||||
| }) | ||||||||||||||||||
| } | ||||||||||||||||||
| const tables = [...tableMap.values()].map((t) => ({ | ||||||||||||||||||
| ...t, | ||||||||||||||||||
| columns: t.columns.sort((a, b) => a.ordinal - b.ordinal), | ||||||||||||||||||
| })) | ||||||||||||||||||
| const payload = { | ||||||||||||||||||
| deploymentUrl: dep.url, | ||||||||||||||||||
| dialect, | ||||||||||||||||||
| refreshedAt: new Date().toISOString(), | ||||||||||||||||||
| tables: tables, | ||||||||||||||||||
| } | ||||||||||||||||||
| const existing = DatabaseSchemasCollection.get(dep.url) | ||||||||||||||||||
| if (existing) { | ||||||||||||||||||
| await DatabaseSchemasCollection.update(dep.url, payload) | ||||||||||||||||||
| } else { | ||||||||||||||||||
| await DatabaseSchemasCollection.insert(payload) | ||||||||||||||||||
| } | ||||||||||||||||||
| log.info('schema-refreshed', { | ||||||||||||||||||
| deployment: dep.url, | ||||||||||||||||||
| dialect, | ||||||||||||||||||
| tables: tables.length, | ||||||||||||||||||
| }) | ||||||||||||||||||
| } catch (err) { | ||||||||||||||||||
| log.error('schema-refresh-failed', { deployment: dep.url, err }) | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| export async function refreshAllSchemas() { | ||||||||||||||||||
| for (const dep of DeploymentsCollection.values()) { | ||||||||||||||||||
| await refreshOneSchema(dep) | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+117
to
+119
|
||||||||||||||||||
| for (const dep of DeploymentsCollection.values()) { | |
| await refreshOneSchema(dep) | |
| } | |
| await Promise.allSettled( | |
| Array.from(DeploymentsCollection.values()).map(dep => refreshOneSchema(dep)) | |
| ); |
Copilot
AI
Oct 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The initial schema refresh call is not awaited, which means the interval starts immediately without waiting for the first refresh to complete. This could lead to overlapping refresh operations if the refresh takes longer than the interval. Consider awaiting this call or adding concurrency protection.
| export function startSchemaRefreshLoop() { | |
| if (intervalHandle) return | |
| // initial kick (non-blocking) | |
| refreshAllSchemas() | |
| export async function startSchemaRefreshLoop() { | |
| if (intervalHandle) return | |
| // initial kick (awaited) | |
| await refreshAllSchemas() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The output schema definition duplicates the DatabaseSchemaDef from schema.ts. Consider importing and reusing DatabaseSchemaDef to maintain consistency and reduce duplication.