-
Notifications
You must be signed in to change notification settings - Fork 9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add drizzle persistence plugin #122
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
{ | ||
"type": "prerelease", | ||
"comment": "indexer: add drizzle persistence plugin", | ||
"packageName": "@apibara/indexer", | ||
"email": "[email protected]", | ||
"dependentChangeType": "patch" | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,26 +1,43 @@ | ||
import { defineIndexer, useSink } from "@apibara/indexer"; | ||
import { drizzlePersistence } from "@apibara/indexer/plugins/drizzle-persistence"; | ||
import { useLogger } from "@apibara/indexer/plugins/logger"; | ||
import { sqlite } from "@apibara/indexer/sinks/sqlite"; | ||
import { StarknetStream } from "@apibara/starknet"; | ||
import type { ApibaraRuntimeConfig } from "apibara/types"; | ||
import Database from "better-sqlite3"; | ||
import { sql } from "drizzle-orm"; | ||
import { drizzle } from "drizzle-orm/node-postgres"; | ||
import { Client } from "pg"; | ||
import { hash } from "starknet"; | ||
|
||
export default function (runtimeConfig: ApibaraRuntimeConfig) { | ||
console.log("--> Starknet Indexer Runtime Config: ", runtimeConfig); | ||
const database = new Database(runtimeConfig.databasePath); | ||
|
||
// Sink Database | ||
const database = new Database(runtimeConfig.databasePath); | ||
database.exec("DROP TABLE IF EXISTS test"); | ||
database.exec( | ||
"CREATE TABLE IF NOT EXISTS test (number TEXT, hash TEXT, _cursor BIGINT)", | ||
); | ||
|
||
// Persistence Database | ||
const client = new Client({ | ||
connectionString: "postgres://postgres:postgres@localhost:5432/postgres", | ||
}); | ||
const persistDatabase = drizzle(client); | ||
|
||
return defineIndexer(StarknetStream)({ | ||
streamUrl: "https://starknet.preview.apibara.org", | ||
finality: "accepted", | ||
startingCursor: { | ||
orderKey: 800_000n, | ||
}, | ||
plugins: [ | ||
drizzlePersistence({ | ||
database: persistDatabase, | ||
indexerName: "2-starknet", | ||
}), | ||
], | ||
sink: sqlite({ database, tableName: "test" }), | ||
filter: { | ||
events: [ | ||
|
@@ -42,5 +59,32 @@ export default function (runtimeConfig: ApibaraRuntimeConfig) { | |
// hash: header?.blockHash, | ||
// }]) | ||
}, | ||
hooks: { | ||
async "run:before"() { | ||
await client.connect(); | ||
|
||
// Normally user will do migrations of both tables, which are defined in | ||
// ``` | ||
// import { checkpoints, filters } from "@apibara/indexer/plugins/drizzle-persistence" | ||
// ```, | ||
// but just for quick testing and example we create them here directly | ||
|
||
await persistDatabase.execute(sql` | ||
CREATE TABLE IF NOT EXISTS checkpoints ( | ||
id TEXT NOT NULL PRIMARY KEY, | ||
order_key INTEGER NOT NULL, | ||
unique_key TEXT | ||
); | ||
|
||
CREATE TABLE IF NOT EXISTS filters ( | ||
id TEXT NOT NULL, | ||
filter TEXT NOT NULL, | ||
from_block INTEGER NOT NULL, | ||
to_block INTEGER, | ||
PRIMARY KEY (id, from_block) | ||
); | ||
`); | ||
Comment on lines
+72
to
+86
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Improve Database Schema Management Several improvements are recommended for the schema management:
Consider refactoring to use Drizzle's schema definitions: import { pgTable, text, integer } from 'drizzle-orm/pg-core';
export const checkpoints = pgTable('checkpoints', {
id: text('id').primaryKey(),
orderKey: integer('order_key').notNull(),
uniqueKey: text('unique_key')
});
export const filters = pgTable('filters', {
id: text('id').notNull(),
filter: text('filter').notNull(),
fromBlock: integer('from_block').notNull(),
toBlock: integer('to_block'),
// Add composite primary key
}, (table) => ({
pk: primaryKey(table.id, table.fromBlock)
})); |
||
}, | ||
}, | ||
}); | ||
} |
Original file line number | Diff line number | Diff line change | ||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,192 @@ | ||||||||||||
import type { Cursor } from "@apibara/protocol"; | ||||||||||||
import { | ||||||||||||
type ExtractTablesWithRelations, | ||||||||||||
type TablesRelationalConfig, | ||||||||||||
and, | ||||||||||||
eq, | ||||||||||||
isNull, | ||||||||||||
} from "drizzle-orm"; | ||||||||||||
import { | ||||||||||||
type PgDatabase, | ||||||||||||
type PgQueryResultHKT, | ||||||||||||
integer, | ||||||||||||
pgTable, | ||||||||||||
primaryKey, | ||||||||||||
text, | ||||||||||||
} from "drizzle-orm/pg-core"; | ||||||||||||
import { deserialize, serialize } from "../vcr"; | ||||||||||||
import { defineIndexerPlugin } from "./config"; | ||||||||||||
|
||||||||||||
export const checkpoints = pgTable("checkpoints", { | ||||||||||||
id: text("id").notNull().primaryKey(), | ||||||||||||
orderKey: integer("order_key").notNull(), | ||||||||||||
uniqueKey: text("unique_key") | ||||||||||||
.$type<`0x${string}` | undefined>() | ||||||||||||
.notNull() | ||||||||||||
.default(undefined), | ||||||||||||
Comment on lines
+24
to
+26
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Inconsistency in Column Definition: The Consider adjusting the column definition to allow - .$type<`0x${string}` | undefined>()
- .notNull()
- .default(undefined),
+ .$type<`0x${string}` | null>()
+ .default(null), Alternatively, if 📝 Committable suggestion
Suggested change
|
||||||||||||
}); | ||||||||||||
|
||||||||||||
export const filters = pgTable( | ||||||||||||
"filters", | ||||||||||||
{ | ||||||||||||
id: text("id").notNull(), | ||||||||||||
filter: text("filter").notNull(), | ||||||||||||
fromBlock: integer("from_block").notNull(), | ||||||||||||
toBlock: integer("to_block"), | ||||||||||||
}, | ||||||||||||
(table) => ({ | ||||||||||||
pk: primaryKey({ columns: [table.id, table.fromBlock] }), | ||||||||||||
}), | ||||||||||||
); | ||||||||||||
|
||||||||||||
export function drizzlePersistence< | ||||||||||||
TFilter, | ||||||||||||
TBlock, | ||||||||||||
TTxnParams, | ||||||||||||
TQueryResult extends PgQueryResultHKT, | ||||||||||||
TFullSchema extends Record<string, unknown> = Record<string, never>, | ||||||||||||
TSchema extends | ||||||||||||
TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>, | ||||||||||||
>({ | ||||||||||||
database, | ||||||||||||
indexerName = "default", | ||||||||||||
}: { | ||||||||||||
database: PgDatabase<TQueryResult, TFullSchema, TSchema>; | ||||||||||||
indexerName?: string; | ||||||||||||
}) { | ||||||||||||
return defineIndexerPlugin<TFilter, TBlock, TTxnParams>((indexer) => { | ||||||||||||
let store: DrizzlePersistence<TFilter, TQueryResult, TFullSchema, TSchema>; | ||||||||||||
|
||||||||||||
indexer.hooks.hook("run:before", async () => { | ||||||||||||
store = new DrizzlePersistence(database, indexerName); | ||||||||||||
// Tables are created by user via migrations in Drizzle | ||||||||||||
}); | ||||||||||||
|
||||||||||||
indexer.hooks.hook("connect:before", async ({ request }) => { | ||||||||||||
const { cursor, filter } = await store.get(); | ||||||||||||
|
||||||||||||
if (cursor) { | ||||||||||||
request.startingCursor = cursor; | ||||||||||||
} | ||||||||||||
|
||||||||||||
if (filter) { | ||||||||||||
request.filter[1] = filter; | ||||||||||||
} | ||||||||||||
}); | ||||||||||||
|
||||||||||||
indexer.hooks.hook("transaction:commit", async ({ endCursor }) => { | ||||||||||||
if (endCursor) { | ||||||||||||
await store.put({ cursor: endCursor }); | ||||||||||||
} | ||||||||||||
}); | ||||||||||||
|
||||||||||||
indexer.hooks.hook("connect:factory", async ({ request, endCursor }) => { | ||||||||||||
if (request.filter[1]) { | ||||||||||||
await store.put({ cursor: endCursor, filter: request.filter[1] }); | ||||||||||||
} | ||||||||||||
}); | ||||||||||||
}); | ||||||||||||
} | ||||||||||||
|
||||||||||||
export class DrizzlePersistence< | ||||||||||||
TFilter, | ||||||||||||
TQueryResult extends PgQueryResultHKT, | ||||||||||||
TFullSchema extends Record<string, unknown> = Record<string, never>, | ||||||||||||
TSchema extends | ||||||||||||
TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>, | ||||||||||||
> { | ||||||||||||
constructor( | ||||||||||||
private _db: PgDatabase<TQueryResult, TFullSchema, TSchema>, | ||||||||||||
private _indexerName: string, | ||||||||||||
) {} | ||||||||||||
|
||||||||||||
public async get(): Promise<{ cursor?: Cursor; filter?: TFilter }> { | ||||||||||||
const cursor = await this._getCheckpoint(); | ||||||||||||
const filter = await this._getFilter(); | ||||||||||||
|
||||||||||||
return { cursor, filter }; | ||||||||||||
} | ||||||||||||
|
||||||||||||
public async put({ cursor, filter }: { cursor?: Cursor; filter?: TFilter }) { | ||||||||||||
if (cursor) { | ||||||||||||
await this._putCheckpoint(cursor); | ||||||||||||
|
||||||||||||
if (filter) { | ||||||||||||
await this._putFilter(filter, cursor); | ||||||||||||
} | ||||||||||||
} | ||||||||||||
} | ||||||||||||
|
||||||||||||
// --- CHECKPOINTS TABLE METHODS --- | ||||||||||||
|
||||||||||||
private async _getCheckpoint(): Promise<Cursor | undefined> { | ||||||||||||
const rows = await this._db | ||||||||||||
.select() | ||||||||||||
.from(checkpoints) | ||||||||||||
.where(eq(checkpoints.id, this._indexerName)); | ||||||||||||
|
||||||||||||
const row = rows[0]; | ||||||||||||
if (!row) return undefined; | ||||||||||||
|
||||||||||||
return { | ||||||||||||
orderKey: BigInt(row.orderKey), | ||||||||||||
uniqueKey: row.uniqueKey, | ||||||||||||
}; | ||||||||||||
} | ||||||||||||
|
||||||||||||
private async _putCheckpoint(cursor: Cursor) { | ||||||||||||
await this._db | ||||||||||||
.insert(checkpoints) | ||||||||||||
.values({ | ||||||||||||
id: this._indexerName, | ||||||||||||
orderKey: Number(cursor.orderKey), | ||||||||||||
uniqueKey: cursor.uniqueKey, | ||||||||||||
}) | ||||||||||||
.onConflictDoUpdate({ | ||||||||||||
target: checkpoints.id, | ||||||||||||
set: { | ||||||||||||
orderKey: Number(cursor.orderKey), | ||||||||||||
uniqueKey: cursor.uniqueKey, | ||||||||||||
}, | ||||||||||||
}); | ||||||||||||
} | ||||||||||||
|
||||||||||||
// --- FILTERS TABLE METHODS --- | ||||||||||||
|
||||||||||||
private async _getFilter(): Promise<TFilter | undefined> { | ||||||||||||
const rows = await this._db | ||||||||||||
.select() | ||||||||||||
.from(filters) | ||||||||||||
.where(and(eq(filters.id, this._indexerName), isNull(filters.toBlock))); | ||||||||||||
|
||||||||||||
const row = rows[0]; | ||||||||||||
|
||||||||||||
if (!row) return undefined; | ||||||||||||
|
||||||||||||
return deserialize(row.filter) as TFilter; | ||||||||||||
} | ||||||||||||
|
||||||||||||
private async _putFilter(filter: TFilter, endCursor: Cursor) { | ||||||||||||
// Update existing filter's to_block | ||||||||||||
await this._db | ||||||||||||
.update(filters) | ||||||||||||
.set({ toBlock: Number(endCursor.orderKey) }) | ||||||||||||
.where(and(eq(filters.id, this._indexerName), isNull(filters.toBlock))); | ||||||||||||
|
||||||||||||
// Insert new filter | ||||||||||||
await this._db | ||||||||||||
.insert(filters) | ||||||||||||
.values({ | ||||||||||||
id: this._indexerName, | ||||||||||||
filter: serialize(filter as Record<string, unknown>), | ||||||||||||
fromBlock: Number(endCursor.orderKey), | ||||||||||||
}) | ||||||||||||
.onConflictDoUpdate({ | ||||||||||||
target: [filters.id, filters.fromBlock], | ||||||||||||
set: { | ||||||||||||
filter: serialize(filter as Record<string, unknown>), | ||||||||||||
fromBlock: Number(endCursor.orderKey), | ||||||||||||
}, | ||||||||||||
}); | ||||||||||||
} | ||||||||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Security & Configuration Issues in Database Setup
Several critical issues need to be addressed:
Consider implementing the following changes:
Also, consider adding error handling: