feat(hasura): add bootstrap script

This commit is contained in:
u1
2026-01-06 00:20:08 +00:00
parent d54405e32a
commit 90fe1c4b4f

View File

@@ -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;
});