-
Notifications
You must be signed in to change notification settings - Fork 6
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
indexer: add kv plugin #84
Changes from 6 commits
751aa8a
b44f97e
9a9e504
9280b7f
6ac28bd
ca10222
e788e54
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 @@ | ||
export * from "./useKVStore"; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
import { useIndexerContext } from "../context"; | ||
import type { KVStore } from "../plugins/kv"; | ||
|
||
export type UseKVStoreResult = InstanceType<typeof KVStore>; | ||
|
||
export function useKVStore(): UseKVStoreResult { | ||
const ctx = useIndexerContext(); | ||
|
||
if (!ctx?.kv) throw new Error("KV Plugin is not available in context!"); | ||
|
||
return ctx.kv; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1 @@ | ||
export * from "./kv"; | ||
export * from "./config"; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
import { type Database, open } from "sqlite"; | ||
import sqlite3 from "sqlite3"; | ||
import { afterAll, beforeAll, describe, expect, it } from "vitest"; | ||
import { KVStore } from "./kv"; | ||
|
||
type ValueType = { data: bigint }; | ||
|
||
describe("KVStore", () => { | ||
let db: Database<sqlite3.Database, sqlite3.Statement>; | ||
let store: KVStore; | ||
const key = "test_key"; | ||
|
||
beforeAll(async () => { | ||
db = await open({ driver: sqlite3.Database, filename: ":memory:" }); | ||
await db.exec(` | ||
CREATE TABLE IF NOT EXISTS kvs ( | ||
from_block INTEGER NOT NULL, | ||
to_block INTEGER, | ||
k TEXT NOT NULL, | ||
v BLOB NOT NULL, | ||
PRIMARY KEY (from_block, k) | ||
); | ||
`); | ||
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. Maybe put this logic in a static 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. Sorry I should have caught it in the previous review round! |
||
store = new KVStore(db, "finalized", { orderKey: 5_000_000n }); | ||
}); | ||
|
||
afterAll(async () => { | ||
await db.close(); | ||
}); | ||
|
||
it("should put and get a value", async () => { | ||
const value = { data: 0n }; | ||
|
||
await store.put<ValueType>(key, value); | ||
const result = await store.get<ValueType>(key); | ||
|
||
expect(result).toEqual(value); | ||
}); | ||
|
||
it("should return undefined for non-existing key", async () => { | ||
const result = await store.get<ValueType>("non_existent_key"); | ||
expect(result).toBeUndefined(); | ||
}); | ||
|
||
it("should update an existing value", async () => { | ||
store = new KVStore(db, "finalized", { orderKey: 5_000_020n }); | ||
|
||
const value = { data: 50n }; | ||
|
||
await store.put<ValueType>(key, value); | ||
const result = await store.get<ValueType>(key); | ||
|
||
expect(result).toEqual(value); | ||
}); | ||
|
||
it("should delete a value", async () => { | ||
await store.del(key); | ||
const result = await store.get<ValueType>(key); | ||
|
||
expect(result).toBeUndefined(); | ||
|
||
const rows = await db.all( | ||
` | ||
SELECT from_block, to_block, k, v | ||
FROM kvs | ||
WHERE k = ? | ||
`, | ||
[key], | ||
); | ||
|
||
// Check that the old is correctly marked with to_block | ||
expect(rows[0].to_block).toBe(Number(5_000_020n)); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change | ||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -1,16 +1,112 @@ | ||||||||||||||
import assert from "node:assert"; | ||||||||||||||
import type { Cursor, DataFinality } from "@apibara/protocol"; | ||||||||||||||
import { type Database, type ISqlite, open } from "sqlite"; | ||||||||||||||
import { useIndexerContext } from "../context"; | ||||||||||||||
import { defineIndexerPlugin } from "../plugins"; | ||||||||||||||
import { deserialize, serialize } from "../vcr"; | ||||||||||||||
import { defineIndexerPlugin } from "./config"; | ||||||||||||||
|
||||||||||||||
/** This plugin is a placeholder for a future key-value store plugin. */ | ||||||||||||||
export function kv<TFilter, TBlock, TRet>() { | ||||||||||||||
type SqliteArgs = ISqlite.Config; | ||||||||||||||
|
||||||||||||||
export function kv<TFilter, TBlock, TRet>(args: SqliteArgs) { | ||||||||||||||
return defineIndexerPlugin<TFilter, TBlock, TRet>((indexer) => { | ||||||||||||||
let db: Database; | ||||||||||||||
|
||||||||||||||
indexer.hooks.hook("run:before", async () => { | ||||||||||||||
db = await open(args); | ||||||||||||||
await db.exec(` | ||||||||||||||
CREATE TABLE IF NOT EXISTS kvs ( | ||||||||||||||
from_block INTEGER NOT NULL, | ||||||||||||||
to_block INTEGER, | ||||||||||||||
k TEXT NOT NULL, | ||||||||||||||
v BLOB NOT NULL, | ||||||||||||||
PRIMARY KEY (from_block, k) | ||||||||||||||
); | ||||||||||||||
`); | ||||||||||||||
}); | ||||||||||||||
|
||||||||||||||
indexer.hooks.hook("handler:before", async ({ finality, endCursor }) => { | ||||||||||||||
const ctx = useIndexerContext(); | ||||||||||||||
|
||||||||||||||
assert(endCursor, new Error("endCursor cannot be undefined")); | ||||||||||||||
|
||||||||||||||
ctx.kv = new KVStore(db, finality, endCursor); | ||||||||||||||
|
||||||||||||||
await db.exec("BEGIN TRANSACTION"); | ||||||||||||||
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. My advice is to put this into a function on the kv so that it can be tested. You should test both the successfull flow (begin tx -> commit) and the exception flow (begin tx -> rollback)
Suggested change
|
||||||||||||||
}); | ||||||||||||||
|
||||||||||||||
indexer.hooks.hook("handler:after", async () => { | ||||||||||||||
await db.exec("COMMIT TRANSACTION"); | ||||||||||||||
|
||||||||||||||
const ctx = useIndexerContext(); | ||||||||||||||
|
||||||||||||||
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.
Suggested change
|
||||||||||||||
ctx.kv = null; | ||||||||||||||
}); | ||||||||||||||
|
||||||||||||||
indexer.hooks.hook("handler:exception", async () => { | ||||||||||||||
await db.exec("ROLLBACK TRANSACTION"); | ||||||||||||||
|
||||||||||||||
const ctx = useIndexerContext(); | ||||||||||||||
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.
Suggested change
|
||||||||||||||
ctx.kv = {}; | ||||||||||||||
|
||||||||||||||
ctx.kv = null; | ||||||||||||||
}); | ||||||||||||||
|
||||||||||||||
indexer.hooks.hook("run:after", async () => { | ||||||||||||||
console.log("kv: ", useIndexerContext().kv); | ||||||||||||||
}); | ||||||||||||||
}); | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
export class KVStore { | ||||||||||||||
constructor( | ||||||||||||||
private _db: Database, | ||||||||||||||
private _finality: DataFinality, | ||||||||||||||
private _endCursor: Cursor, | ||||||||||||||
) {} | ||||||||||||||
|
||||||||||||||
async get<T>(key: string): Promise<T> { | ||||||||||||||
const row = await this._db.get<{ v: string }>( | ||||||||||||||
` | ||||||||||||||
SELECT v | ||||||||||||||
FROM kvs | ||||||||||||||
WHERE k = ? AND to_block IS NULL | ||||||||||||||
`, | ||||||||||||||
[key], | ||||||||||||||
); | ||||||||||||||
|
||||||||||||||
return row ? deserialize(row.v) : undefined; | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
async put<T>(key: string, value: T) { | ||||||||||||||
await this._db.run( | ||||||||||||||
` | ||||||||||||||
UPDATE kvs | ||||||||||||||
SET to_block = ? | ||||||||||||||
WHERE k = ? AND to_block IS NULL | ||||||||||||||
`, | ||||||||||||||
[Number(this._endCursor.orderKey), key], | ||||||||||||||
); | ||||||||||||||
|
||||||||||||||
await this._db.run( | ||||||||||||||
` | ||||||||||||||
INSERT INTO kvs (from_block, to_block, k, v) | ||||||||||||||
VALUES (?, NULL, ?, ?) | ||||||||||||||
`, | ||||||||||||||
[ | ||||||||||||||
Number(this._endCursor.orderKey), | ||||||||||||||
key, | ||||||||||||||
serialize(value as Record<string, unknown>), | ||||||||||||||
], | ||||||||||||||
); | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
async del(key: string) { | ||||||||||||||
await this._db.run( | ||||||||||||||
` | ||||||||||||||
UPDATE kvs | ||||||||||||||
SET to_block = ? | ||||||||||||||
WHERE k = ? AND to_block IS NULL | ||||||||||||||
`, | ||||||||||||||
[Number(this._endCursor.orderKey), key], | ||||||||||||||
); | ||||||||||||||
} | ||||||||||||||
} |
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.
Is this handler called anywhere? I think we should catch the handler exception and then rethrow it after calling the hook.