Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions backend/src/api/routes/backfill.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { FastifyInstance } from "fastify";
import { backfillService } from "../../services/backfill.service.js";
import { logger } from "../../utils/logger.js";

export async function backfillRoutes(server: FastifyInstance) {
server.post<{ Params: { sourceId: string } }>(
"/:sourceId/start",
{
schema: {
tags: ["Backfill"],
summary: "Start a backfill for a source",
params: {
type: "object",
required: ["sourceId"],
properties: { sourceId: { type: "string" } },
},
body: {
type: "object",
required: ["rangeStart", "rangeEnd", "chunkSize"],
properties: {
rangeStart: { type: "integer" },
rangeEnd: { type: "integer" },
chunkSize: { type: "integer", minimum: 1 },
}
},
response: { 200: { type: "object", additionalProperties: true } },
},
},
async (request, reply) => {
const { sourceId } = request.params;
const config = request.body as any;

try {
const jobId = await backfillService.startBackfillForSource(sourceId, config, {
processChunk: async (chunk) => {
// Placeholder: actual processing logic would be injected or handled here
// e.g. await fetchHistoricalDataForChunk(sourceId, chunk);
await new Promise(r => setTimeout(r, 100)); // mock work
}
});
return { success: true, jobId };
} catch (err: any) {
return reply.code(400).send({ error: err.message });
}
}
);

server.post<{ Params: { sourceId: string } }>(
"/:sourceId/stop",
{
schema: {
tags: ["Backfill"],
summary: "Stop a backfill for a source",
params: {
type: "object",
required: ["sourceId"],
properties: { sourceId: { type: "string" } },
},
response: { 200: { type: "object", additionalProperties: true } },
},
},
async (request, reply) => {
const { sourceId } = request.params;
try {
await backfillService.stopBackfillForSource(sourceId);
return { success: true };
} catch (err: any) {
return reply.code(400).send({ error: err.message });
}
}
);

server.get<{ Params: { sourceId: string } }>(
"/:sourceId/status",
{
schema: {
tags: ["Backfill"],
summary: "Get backfill status for a source",
params: {
type: "object",
required: ["sourceId"],
properties: { sourceId: { type: "string" } },
},
response: { 200: { type: "object", additionalProperties: true } },
},
},
async (request, reply) => {
const status = await backfillService.getBackfillStatus(request.params.sourceId);
if (!status) {
return reply.code(404).send({ error: "No backfill job found for source" });
}
return status;
}
);
}
5 changes: 5 additions & 0 deletions backend/src/api/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { registerCompatibilityRoutes } from "./route-groups/compatibility-routes
import { registerOperationalRoutes } from "./route-groups/operational-routes.js";
import { registerOperationalMonitoringRoutes } from "./route-groups/operational-monitoring-routes.js";
import { registerLiquidityRoutes } from "./route-groups/liquidity-routes.js";
import { sorobanEventsRoutes } from "./sorobanEvents.routes.js";
import { backfillRoutes } from "./backfill.routes.js";

export async function registerRoutes(server: FastifyInstance): Promise<void> {
// Core routes: health, websocket, config, preferences, caching
Expand Down Expand Up @@ -80,4 +82,7 @@ export async function registerRoutes(server: FastifyInstance): Promise<void> {

// Operational routes: query baseline, rollback readiness, canary metrics, promotion gates, risk clustering
await registerOperationalRoutes(server);

server.register(sorobanEventsRoutes, { prefix: "/api/v1/soroban-events" });
server.register(backfillRoutes, { prefix: "/api/v1/backfill" });
}
62 changes: 62 additions & 0 deletions backend/src/api/routes/sorobanEvents.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { FastifyInstance } from "fastify";
import { sorobanEventIndexService } from "../../services/sorobanEventIndex.service.js";

export async function sorobanEventsRoutes(server: FastifyInstance) {
server.get(
"/",
{
schema: {
tags: ["Soroban Events"],
summary: "List paginated Soroban events",
querystring: {
type: "object",
additionalProperties: false,
properties: {
contractId: { type: "string" },
limit: { type: "integer", minimum: 1, maximum: 500, default: 100 },
cursor: { type: "string" },
},
},
response: { 200: { type: "object", additionalProperties: true } },
},
},
async (request) => {
const q = request.query as {
contractId?: string;
limit?: number;
cursor?: string;
};

return sorobanEventIndexService.getPaginatedEvents(
q.contractId,
q.limit ?? 100,
q.cursor
);
}
);

server.post(
"/sync",
{
schema: {
tags: ["Soroban Events"],
summary: "Trigger a manual sync of Soroban events",
body: {
type: "object",
required: ["contractId"],
additionalProperties: false,
properties: {
contractId: { type: "string" },
limit: { type: "integer", minimum: 1, maximum: 10000, default: 1000 },
},
},
response: { 200: { type: "object", additionalProperties: true } },
},
},
async (request) => {
const body = request.body as { contractId: string; limit?: number };
const syncedCount = await sorobanEventIndexService.syncEvents(body.contractId, body.limit);
return { syncedCount };
}
);
}
25 changes: 25 additions & 0 deletions backend/src/database/migrations/042_health_score_confidence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Knex } from "knex";

export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("source_health_scores", (table) => {
table.integer("confidence_score");
table.string("confidence_band");
});

await knex.schema.alterTable("source_health_score_history", (table) => {
table.integer("confidence_score");
table.string("confidence_band");
});
}

export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable("source_health_score_history", (table) => {
table.dropColumn("confidence_band");
table.dropColumn("confidence_score");
});

await knex.schema.alterTable("source_health_scores", (table) => {
table.dropColumn("confidence_band");
table.dropColumn("confidence_score");
});
}
22 changes: 22 additions & 0 deletions backend/src/database/migrations/043_soroban_event_index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Knex } from "knex";

export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable("soroban_events", (table) => {
table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()"));
table.string("cursor").notNullable().unique();
table.integer("ledger").notNullable();
table.timestamp("ledger_closed_at").notNullable();
table.string("contract_id").notNullable();
table.string("topic").notNullable();
table.jsonb("value").notNullable();
table.timestamp("created_at").defaultTo(knex.fn.now());
});

await knex.raw("CREATE INDEX soroban_events_contract_idx ON soroban_events(contract_id, ledger_closed_at DESC);");
await knex.raw("SELECT create_hypertable('soroban_events', 'ledger_closed_at', if_not_exists => TRUE);");
await knex.raw("SELECT add_retention_policy('soroban_events', INTERVAL '90 days', if_not_exists => TRUE);");
}

export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists("soroban_events");
}
24 changes: 24 additions & 0 deletions backend/src/database/migrations/044_per_source_backfill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Knex } from "knex";

export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable("backfill_jobs", (table) => {
table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()"));
table.string("source_id").notNullable();
table.string("status").notNullable().defaultTo("PENDING"); // PENDING, RUNNING, COMPLETED, FAILED
table.integer("range_start").notNullable();
table.integer("range_end").notNullable();
table.integer("chunk_size").notNullable();
table.jsonb("completed_chunks").defaultTo("[]");
table.jsonb("failed_chunks").defaultTo("[]");
table.timestamp("started_at");
table.timestamp("completed_at");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
});

await knex.raw("CREATE INDEX backfill_jobs_source_idx ON backfill_jobs(source_id);");
}

export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists("backfill_jobs");
}
44 changes: 44 additions & 0 deletions backend/src/database/models/backfillJob.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { getDatabase } from "../connection.js";

export interface BackfillJobRecord {
id?: string;
source_id: string;
status: "PENDING" | "RUNNING" | "COMPLETED" | "FAILED" | "STOPPED";
range_start: number;
range_end: number;
chunk_size: number;
completed_chunks: string; // JSON
failed_chunks: string; // JSON
started_at?: Date;
completed_at?: Date;
created_at?: Date;
updated_at?: Date;
}

export class BackfillJobModel {
private db = getDatabase();
private table = "backfill_jobs";

async create(job: Partial<BackfillJobRecord>): Promise<BackfillJobRecord> {
const [inserted] = await this.db(this.table).insert(job).returning("*");
return inserted as BackfillJobRecord;
}

async updateStatus(id: string, status: BackfillJobRecord["status"], details?: Partial<BackfillJobRecord>): Promise<void> {
const updateData: any = { status, updated_at: new Date(), ...details };
if (status === "RUNNING") updateData.started_at = new Date();
if (status === "COMPLETED" || status === "FAILED" || status === "STOPPED") updateData.completed_at = new Date();

await this.db(this.table).where("id", id).update(updateData);
}

async getLatestForSource(sourceId: string): Promise<BackfillJobRecord | undefined> {
const record = await this.db(this.table)
.where("source_id", sourceId)
.orderBy("created_at", "desc")
.first();
return record as BackfillJobRecord | undefined;
}
}

export const backfillJobModel = new BackfillJobModel();
2 changes: 2 additions & 0 deletions backend/src/database/models/healthScore.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export interface HealthScoreRecord {
bridge_uptime_score: number;
reserve_backing_score: number;
volume_trend_score: number;
confidence_score?: number;
confidence_band?: string;
}

export class HealthScoreModel {
Expand Down
63 changes: 63 additions & 0 deletions backend/src/database/models/sorobanEvent.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { getDatabase } from "../connection.js";

export interface SorobanEventRecord {
id?: string;
cursor: string;
ledger: number;
ledger_closed_at: Date;
contract_id: string;
topic: string;
value: any;
created_at?: Date;
}

export class SorobanEventModel {
private db = getDatabase();
private table = "soroban_events";

async insert(events: SorobanEventRecord[]): Promise<void> {
if (events.length === 0) return;
await this.db(this.table)
.insert(events)
.onConflict("cursor")
.ignore(); // cursor is unique
}

async getLatestCursor(contractId: string): Promise<string | undefined> {
const record = await this.db(this.table)
.where("contract_id", contractId)
.orderBy("ledger_closed_at", "desc")
.first("cursor");
return record?.cursor;
}

async getPaginatedEvents(
contractId?: string,
limit = 100,
cursor?: string
): Promise<{ data: SorobanEventRecord[]; nextCursor?: string }> {
let query = this.db(this.table).orderBy("ledger_closed_at", "desc").limit(limit);

if (contractId) {
query = query.where("contract_id", contractId);
}

if (cursor) {
// Find the event with this cursor to get its ledger_closed_at
const cursorEvent = await this.db(this.table).where("cursor", cursor).first();
if (cursorEvent) {
query = query.where("ledger_closed_at", "<=", cursorEvent.ledger_closed_at).andWhereNot("cursor", cursor);
}
}

const rows = await query;
const nextCursor = rows.length === limit ? rows[rows.length - 1].cursor : undefined;

return {
data: rows as SorobanEventRecord[],
nextCursor,
};
}
}

export const sorobanEventModel = new SorobanEventModel();
4 changes: 3 additions & 1 deletion backend/src/database/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,9 @@ CREATE TABLE health_scores (
price_stability_score SMALLINT NOT NULL,
bridge_uptime_score SMALLINT NOT NULL,
reserve_backing_score SMALLINT NOT NULL,
volume_trend_score SMALLINT NOT NULL
volume_trend_score SMALLINT NOT NULL,
confidence_score SMALLINT,
confidence_band TEXT
);
CREATE INDEX health_scores_symbol_time_idx ON health_scores (symbol, time DESC);
SELECT create_hypertable('health_scores', 'time', if_not_exists => TRUE);
Expand Down
Loading
Loading