From 90fe1c4b4f30377ffa61c4a867f12e8c4b12667d Mon Sep 17 00:00:00 2001 From: u1 Date: Tue, 6 Jan 2026 00:20:08 +0000 Subject: [PATCH] feat(hasura): add bootstrap script --- kustomize/base/hasura/hasura-bootstrap.mjs | 189 +++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 kustomize/base/hasura/hasura-bootstrap.mjs diff --git a/kustomize/base/hasura/hasura-bootstrap.mjs b/kustomize/base/hasura/hasura-bootstrap.mjs new file mode 100644 index 0000000..329d79c --- /dev/null +++ b/kustomize/base/hasura/hasura-bootstrap.mjs @@ -0,0 +1,189 @@ +const HASURA_URL = process.env.HASURA_URL || 'http://hasura:8080'; +const ADMIN_SECRET = process.env.HASURA_ADMIN_SECRET || 'devsecret'; +const TARGET_TICKS_TABLE = process.env.TICKS_TABLE; +const TARGET_CANDLES_FUNCTION = process.env.CANDLES_FUNCTION; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function httpJson(url, body) { + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-hasura-admin-secret': ADMIN_SECRET, + }, + body: JSON.stringify(body), + }); + const text = await res.text(); + let json; + try { + json = JSON.parse(text); + } catch { + json = { raw: text }; + } + return { ok: res.ok, status: res.status, json, text }; +} + +async function waitForHasura() { + const healthUrl = `${HASURA_URL.replace(/\/$/, '')}/healthz`; + for (let i = 0; i < 60; i++) { + try { + const res = await fetch(healthUrl); + if (res.ok) return; + } catch { + // ignore + } + await sleep(1000); + } + throw new Error(`Hasura not healthy at ${healthUrl}`); +} + +function isAlreadyExistsError(errText) { + const t = String(errText || '').toLowerCase(); + return ( + t.includes('already tracked') || + t.includes('already exists') || + t.includes('already present') || + t.includes('already been tracked') || + (t.includes('permission') && t.includes('already exists')) + ); +} + +function isMissingRelationError(errText) { + const t = String(errText || '').toLowerCase(); + return t.includes('does not exist') || t.includes('not found') || t.includes('not tracked'); +} + +function normalizeName(name) { + const v = String(name || '') + .trim() + .toLowerCase(); + if (!v) return undefined; + if (!/^[a-z][a-z0-9_]*$/.test(v)) throw new Error(`Invalid name: ${name}`); + return v; +} + +async function metadata(op) { + const url = `${HASURA_URL.replace(/\/$/, '')}/v1/metadata`; + const res = await httpJson(url, op); + if (res.ok) return { status: 'ok', res }; + + const errText = res.json?.error || res.text; + if (isAlreadyExistsError(errText)) return { status: 'skip', res }; + + throw new Error(`Hasura metadata error (${res.status}): ${JSON.stringify(res.json)}`); +} + +async function metadataIgnore(op) { + const url = `${HASURA_URL.replace(/\/$/, '')}/v1/metadata`; + const res = await httpJson(url, op); + if (res.ok) return { status: 'ok', res }; + + const errText = res.json?.error || res.text; + if (isAlreadyExistsError(errText) || isMissingRelationError(errText)) return { status: 'skip', res }; + + throw new Error(`Hasura metadata error (${res.status}): ${JSON.stringify(res.json)}`); +} + +async function main() { + console.log(`[hasura-bootstrap] HASURA_URL=${HASURA_URL}`); + await waitForHasura(); + + const apiTokensTable = { schema: 'public', name: 'api_tokens' }; + const source = 'default'; + + const baseTicks = { schema: 'public', name: 'drift_ticks' }; + const baseCandlesFn = { schema: 'public', name: 'get_drift_candles' }; + const candlesReturnTable = { schema: 'public', name: 'drift_candles' }; + + const extraTicksName = normalizeName(TARGET_TICKS_TABLE); + const extraCandlesName = normalizeName(TARGET_CANDLES_FUNCTION); + + const tickTables = [baseTicks]; + if (extraTicksName && extraTicksName !== baseTicks.name) { + tickTables.push({ schema: 'public', name: extraTicksName }); + } + + const candleFns = [baseCandlesFn]; + if (extraCandlesName && extraCandlesName !== baseCandlesFn.name) { + candleFns.push({ schema: 'public', name: extraCandlesName }); + } + + const ensureTickTable = async (table) => { + await metadata({ type: 'pg_track_table', args: { source, table } }); + + // Ensure latest permission definition (drop+create avoids stale column sets). + await metadataIgnore({ type: 'pg_drop_select_permission', args: { source, table, role: 'public' } }); + await metadata({ + type: 'pg_create_select_permission', + args: { + source, + table, + role: 'public', + permission: { + columns: ['ts', 'market_index', 'symbol', 'oracle_price', 'mark_price', 'oracle_slot', 'source'], + filter: {}, + }, + }, + }); + + await metadataIgnore({ type: 'pg_drop_insert_permission', args: { source, table, role: 'ingestor' } }); + await metadata({ + type: 'pg_create_insert_permission', + args: { + source, + table, + role: 'ingestor', + permission: { + check: {}, + set: {}, + columns: ['ts', 'market_index', 'symbol', 'oracle_price', 'mark_price', 'oracle_slot', 'source', 'raw'], + }, + }, + }); + + await metadataIgnore({ type: 'pg_drop_update_permission', args: { source, table, role: 'ingestor' } }); + await metadata({ + type: 'pg_create_update_permission', + args: { + source, + table, + role: 'ingestor', + permission: { + filter: {}, + check: {}, + columns: ['oracle_price', 'mark_price', 'oracle_slot', 'source', 'raw'], + }, + }, + }); + }; + + for (const t of tickTables) { + await ensureTickTable(t); + } + // Return table type for candle functions (needed for Hasura to track the function). + await metadataIgnore({ type: 'pg_track_table', args: { source, table: candlesReturnTable } }); + try { + await metadata({ type: 'pg_track_table', args: { source, table: apiTokensTable } }); + } catch (err) { + const msg = String(err?.message || err); + if (msg.toLowerCase().includes('api_tokens') && isMissingRelationError(msg)) { + console.log('[hasura-bootstrap] api_tokens missing (run initdb SQL to create it); skipping track'); + } else { + throw err; + } + } + + for (const fn of candleFns) { + // Function for aggregated candle queries (used by trade-api). + await metadataIgnore({ type: 'pg_untrack_function', args: { source, function: fn } }); + await metadata({ type: 'pg_track_function', args: { source, function: fn } }); + } + + console.log('[hasura-bootstrap] ok'); +} + +main().catch((err) => { + console.error(String(err?.stack || err)); + process.exitCode = 1; +});