282 lines
8.0 KiB
JavaScript
282 lines
8.0 KiB
JavaScript
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 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;
|
|
});
|