feat(hasura): add bootstrap script
This commit is contained in:
189
kustomize/base/hasura/hasura-bootstrap.mjs
Normal file
189
kustomize/base/hasura/hasura-bootstrap.mjs
Normal 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;
|
||||
});
|
||||
Reference in New Issue
Block a user