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') || t.includes('already untracked'); } 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 dlobL2LatestTable = { schema: 'public', name: 'dlob_l2_latest' }; const dlobStatsLatestTable = { schema: 'public', name: 'dlob_stats_latest' }; 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); } const ensureDlobTable = async (table, columns) => { await metadataIgnore({ type: 'pg_untrack_table', args: { source, table } }); await metadata({ type: 'pg_track_table', args: { source, table } }); 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, 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, }, }, }); 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, }, }, }); }; await ensureDlobTable(dlobL2LatestTable, [ 'market_name', 'market_type', 'market_index', 'ts', 'slot', 'mark_price', 'oracle_price', 'best_bid_price', 'best_ask_price', 'bids', 'asks', 'raw', 'updated_at', ]); await ensureDlobTable(dlobStatsLatestTable, [ 'market_name', 'market_type', 'market_index', 'ts', 'slot', 'mark_price', 'oracle_price', 'best_bid_price', 'best_ask_price', 'mid_price', 'spread_abs', 'spread_bps', 'depth_levels', 'depth_bid_base', 'depth_ask_base', 'depth_bid_usd', 'depth_ask_usd', 'imbalance', 'raw', 'updated_at', ]); // 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; });