@apibara/indexer 0.4.1 → 2.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -9
- package/package.json +66 -45
- package/src/context.ts +19 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useKVStore.ts +12 -0
- package/src/hooks/useSink.ts +13 -0
- package/src/index.ts +7 -1
- package/src/indexer.test.ts +491 -0
- package/src/indexer.ts +322 -0
- package/src/otel.ts +3 -0
- package/src/plugins/config.ts +11 -0
- package/src/plugins/kv.test.ts +120 -0
- package/src/plugins/kv.ts +132 -0
- package/src/plugins/persistence.test.ts +151 -0
- package/src/plugins/persistence.ts +202 -0
- package/src/sink.ts +36 -0
- package/src/sinks/csv.test.ts +65 -0
- package/src/sinks/csv.ts +159 -0
- package/src/sinks/drizzle/Int8Range.ts +52 -0
- package/src/sinks/drizzle/delete.ts +42 -0
- package/src/sinks/drizzle/drizzle.test.ts +239 -0
- package/src/sinks/drizzle/drizzle.ts +115 -0
- package/src/sinks/drizzle/index.ts +6 -0
- package/src/sinks/drizzle/insert.ts +39 -0
- package/src/sinks/drizzle/select.ts +44 -0
- package/src/sinks/drizzle/transaction.ts +49 -0
- package/src/sinks/drizzle/update.ts +47 -0
- package/src/sinks/drizzle/utils.ts +36 -0
- package/src/sinks/sqlite.test.ts +99 -0
- package/src/sinks/sqlite.ts +170 -0
- package/src/testing/helper.ts +13 -0
- package/src/testing/index.ts +3 -0
- package/src/testing/indexer.ts +35 -0
- package/src/testing/setup.ts +59 -0
- package/src/testing/vcr.ts +54 -0
- package/src/vcr/config.ts +11 -0
- package/src/vcr/helper.ts +27 -0
- package/src/vcr/index.ts +4 -0
- package/src/vcr/record.ts +45 -0
- package/src/vcr/replay.ts +51 -0
- package/LICENSE.txt +0 -202
- package/dist/config.d.ts +0 -35
- package/dist/config.js +0 -2
- package/dist/config.js.map +0 -1
- package/dist/config.test-d.d.ts +0 -1
- package/dist/config.test-d.js +0 -38
- package/dist/config.test-d.js.map +0 -1
- package/dist/index.js +0 -2
- package/dist/index.js.map +0 -1
- package/dist/sink/console.d.ts +0 -10
- package/dist/sink/console.js +0 -2
- package/dist/sink/console.js.map +0 -1
- package/dist/sink/console.test-d.d.ts +0 -1
- package/dist/sink/console.test-d.js +0 -12
- package/dist/sink/console.test-d.js.map +0 -1
- package/dist/sink/mongo.d.ts +0 -14
- package/dist/sink/mongo.js +0 -2
- package/dist/sink/mongo.js.map +0 -1
- package/dist/sink/parquet.d.ts +0 -9
- package/dist/sink/parquet.js +0 -2
- package/dist/sink/parquet.js.map +0 -1
- package/dist/sink/postgres.d.ts +0 -10
- package/dist/sink/postgres.js +0 -2
- package/dist/sink/postgres.js.map +0 -1
- package/dist/sink/webhook.d.ts +0 -12
- package/dist/sink/webhook.js +0 -2
- package/dist/sink/webhook.js.map +0 -1
- package/dist/starknet/block.d.ts +0 -409
- package/dist/starknet/block.js +0 -2
- package/dist/starknet/block.js.map +0 -1
- package/dist/starknet/block.test-d.d.ts +0 -1
- package/dist/starknet/block.test-d.js +0 -95
- package/dist/starknet/block.test-d.js.map +0 -1
- package/dist/starknet/felt.d.ts +0 -11
- package/dist/starknet/felt.js +0 -25
- package/dist/starknet/felt.js.map +0 -1
- package/dist/starknet/felt.test.d.ts +0 -1
- package/dist/starknet/felt.test.js +0 -16
- package/dist/starknet/felt.test.js.map +0 -1
- package/dist/starknet/filter.d.ts +0 -175
- package/dist/starknet/filter.js +0 -2
- package/dist/starknet/filter.js.map +0 -1
- package/dist/starknet/filter.test-d.d.ts +0 -1
- package/dist/starknet/filter.test-d.js +0 -166
- package/dist/starknet/filter.test-d.js.map +0 -1
- package/dist/starknet/index.d.ts +0 -10
- package/dist/starknet/index.js +0 -5
- package/dist/starknet/index.js.map +0 -1
- package/dist/starknet/parser.d.ts +0 -16
- package/dist/starknet/parser.js +0 -49
- package/dist/starknet/parser.js.map +0 -1
- package/dist/starknet/parser.test.d.ts +0 -1
- package/dist/starknet/parser.test.js +0 -50
- package/dist/starknet/parser.test.js.map +0 -1
- package/src/config.test-d.ts +0 -55
- package/src/config.ts +0 -47
- package/src/sink/console.test-d.ts +0 -14
- package/src/sink/console.ts +0 -11
- package/src/sink/mongo.ts +0 -14
- package/src/sink/parquet.ts +0 -9
- package/src/sink/postgres.ts +0 -10
- package/src/sink/webhook.ts +0 -12
- package/src/starknet/block.test-d.ts +0 -112
- package/src/starknet/block.ts +0 -469
- package/src/starknet/felt.test.ts +0 -25
- package/src/starknet/felt.ts +0 -30
- package/src/starknet/filter.test-d.ts +0 -197
- package/src/starknet/filter.ts +0 -205
- package/src/starknet/index.ts +0 -12
- package/src/starknet/parser.test.ts +0 -67
- package/src/starknet/parser.ts +0 -69
- /package/{dist/index.d.ts → src/plugins/index.ts} +0 -0
package/src/indexer.ts
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Client,
|
|
3
|
+
Cursor,
|
|
4
|
+
DataFinality,
|
|
5
|
+
StreamConfig,
|
|
6
|
+
StreamDataOptions,
|
|
7
|
+
StreamDataRequest,
|
|
8
|
+
StreamDataResponse,
|
|
9
|
+
} from "@apibara/protocol";
|
|
10
|
+
import consola from "consola";
|
|
11
|
+
import {
|
|
12
|
+
type Hookable,
|
|
13
|
+
type NestedHooks,
|
|
14
|
+
createDebugger,
|
|
15
|
+
createHooks,
|
|
16
|
+
} from "hookable";
|
|
17
|
+
|
|
18
|
+
import assert from "node:assert";
|
|
19
|
+
import {
|
|
20
|
+
type IndexerContext,
|
|
21
|
+
indexerAsyncContext,
|
|
22
|
+
useIndexerContext,
|
|
23
|
+
} from "./context";
|
|
24
|
+
import { tracer } from "./otel";
|
|
25
|
+
import type { IndexerPlugin } from "./plugins";
|
|
26
|
+
import { type Sink, defaultSink } from "./sink";
|
|
27
|
+
|
|
28
|
+
export interface IndexerHooks<TFilter, TBlock> {
|
|
29
|
+
"run:before": () => void;
|
|
30
|
+
"run:after": () => void;
|
|
31
|
+
"connect:before": ({
|
|
32
|
+
request,
|
|
33
|
+
options,
|
|
34
|
+
}: {
|
|
35
|
+
request: StreamDataRequest<TFilter>;
|
|
36
|
+
options: StreamDataOptions;
|
|
37
|
+
}) => void;
|
|
38
|
+
"connect:after": () => void;
|
|
39
|
+
"connect:factory": ({
|
|
40
|
+
request,
|
|
41
|
+
endCursor,
|
|
42
|
+
}: {
|
|
43
|
+
request: StreamDataRequest<TFilter>;
|
|
44
|
+
endCursor?: Cursor;
|
|
45
|
+
}) => void;
|
|
46
|
+
"handler:before": ({
|
|
47
|
+
block,
|
|
48
|
+
finality,
|
|
49
|
+
endCursor,
|
|
50
|
+
}: {
|
|
51
|
+
block: TBlock;
|
|
52
|
+
finality: DataFinality;
|
|
53
|
+
endCursor?: Cursor;
|
|
54
|
+
}) => void;
|
|
55
|
+
"handler:after": ({
|
|
56
|
+
block,
|
|
57
|
+
finality,
|
|
58
|
+
endCursor,
|
|
59
|
+
}: {
|
|
60
|
+
block: TBlock;
|
|
61
|
+
finality: DataFinality;
|
|
62
|
+
endCursor?: Cursor;
|
|
63
|
+
}) => void;
|
|
64
|
+
"transaction:commit": ({
|
|
65
|
+
finality,
|
|
66
|
+
endCursor,
|
|
67
|
+
}: {
|
|
68
|
+
finality: DataFinality;
|
|
69
|
+
endCursor?: Cursor;
|
|
70
|
+
}) => void;
|
|
71
|
+
"handler:exception": ({ error }: { error: Error }) => void;
|
|
72
|
+
message: ({ message }: { message: StreamDataResponse<TBlock> }) => void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface IndexerConfig<TFilter, TBlock, TTxnParams> {
|
|
76
|
+
streamUrl: string;
|
|
77
|
+
filter: TFilter;
|
|
78
|
+
finality?: DataFinality;
|
|
79
|
+
startingCursor?: Cursor;
|
|
80
|
+
sink?: Sink<TTxnParams>;
|
|
81
|
+
factory?: ({
|
|
82
|
+
block,
|
|
83
|
+
context,
|
|
84
|
+
}: { block: TBlock; context: IndexerContext<TTxnParams> }) => Promise<{
|
|
85
|
+
filter?: TFilter;
|
|
86
|
+
}>;
|
|
87
|
+
transform: (args: {
|
|
88
|
+
block: TBlock;
|
|
89
|
+
cursor?: Cursor | undefined;
|
|
90
|
+
endCursor?: Cursor | undefined;
|
|
91
|
+
finality: DataFinality;
|
|
92
|
+
context: IndexerContext<TTxnParams>;
|
|
93
|
+
}) => Promise<void>;
|
|
94
|
+
hooks?: NestedHooks<IndexerHooks<TFilter, TBlock>>;
|
|
95
|
+
plugins?: ReadonlyArray<IndexerPlugin<TFilter, TBlock, TTxnParams>>;
|
|
96
|
+
debug?: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface IndexerWithStreamConfig<TFilter, TBlock, TTxnParams>
|
|
100
|
+
extends IndexerConfig<TFilter, TBlock, TTxnParams> {
|
|
101
|
+
streamConfig: StreamConfig<TFilter, TBlock>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function defineIndexer<TFilter, TBlock>(
|
|
105
|
+
streamConfig: StreamConfig<TFilter, TBlock>,
|
|
106
|
+
) {
|
|
107
|
+
return <TTxnParams>(
|
|
108
|
+
config: IndexerConfig<TFilter, TBlock, TTxnParams>,
|
|
109
|
+
): IndexerWithStreamConfig<TFilter, TBlock, TTxnParams> => ({
|
|
110
|
+
streamConfig,
|
|
111
|
+
...config,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface Indexer<TFilter, TBlock, TTxnParams> {
|
|
116
|
+
streamConfig: StreamConfig<TFilter, TBlock>;
|
|
117
|
+
options: IndexerConfig<TFilter, TBlock, TTxnParams>;
|
|
118
|
+
hooks: Hookable<IndexerHooks<TFilter, TBlock>>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function createIndexer<TFilter, TBlock, TTxnParams>({
|
|
122
|
+
streamConfig,
|
|
123
|
+
...options
|
|
124
|
+
}: IndexerWithStreamConfig<TFilter, TBlock, TTxnParams>) {
|
|
125
|
+
const indexer: Indexer<TFilter, TBlock, TTxnParams> = {
|
|
126
|
+
options,
|
|
127
|
+
streamConfig,
|
|
128
|
+
hooks: createHooks<IndexerHooks<TFilter, TBlock>>(),
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
if (indexer.options.debug) {
|
|
132
|
+
createDebugger(indexer.hooks, { tag: "indexer" });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
indexer.hooks.addHooks(indexer.options.hooks ?? {});
|
|
136
|
+
|
|
137
|
+
for (const plugin of indexer.options.plugins ?? []) {
|
|
138
|
+
plugin(indexer);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return indexer;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function run<TFilter, TBlock, TTxnParams>(
|
|
145
|
+
client: Client<TFilter, TBlock>,
|
|
146
|
+
indexer: Indexer<TFilter, TBlock, TTxnParams>,
|
|
147
|
+
) {
|
|
148
|
+
await indexerAsyncContext.callAsync({}, async () => {
|
|
149
|
+
const context = useIndexerContext<TTxnParams>();
|
|
150
|
+
|
|
151
|
+
const sink = indexer.options.sink ?? defaultSink();
|
|
152
|
+
|
|
153
|
+
context.sink = sink as Sink<TTxnParams>;
|
|
154
|
+
|
|
155
|
+
await indexer.hooks.callHook("run:before");
|
|
156
|
+
|
|
157
|
+
// Check if the it's factory mode or not
|
|
158
|
+
const isFactoryMode = indexer.options.factory !== undefined;
|
|
159
|
+
|
|
160
|
+
// if factory mode we add a empty filter
|
|
161
|
+
const request = indexer.streamConfig.Request.make({
|
|
162
|
+
filter: isFactoryMode
|
|
163
|
+
? [indexer.options.filter, {} as TFilter]
|
|
164
|
+
: [indexer.options.filter],
|
|
165
|
+
finality: indexer.options.finality,
|
|
166
|
+
startingCursor: indexer.options.startingCursor,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const options: StreamDataOptions = {};
|
|
170
|
+
|
|
171
|
+
await indexer.hooks.callHook("connect:before", { request, options });
|
|
172
|
+
|
|
173
|
+
// store main filter, so later it can be merged
|
|
174
|
+
let mainFilter: TFilter;
|
|
175
|
+
if (isFactoryMode) {
|
|
176
|
+
mainFilter = request.filter[1];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// create stream
|
|
180
|
+
let stream: AsyncIterator<
|
|
181
|
+
StreamDataResponse<TBlock>,
|
|
182
|
+
StreamDataResponse<TBlock>
|
|
183
|
+
> = client.streamData(request, options)[Symbol.asyncIterator]();
|
|
184
|
+
|
|
185
|
+
await indexer.hooks.callHook("connect:after");
|
|
186
|
+
|
|
187
|
+
while (true) {
|
|
188
|
+
const { value: message, done } = await stream.next();
|
|
189
|
+
|
|
190
|
+
if (done) {
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await indexer.hooks.callHook("message", { message });
|
|
195
|
+
|
|
196
|
+
switch (message._tag) {
|
|
197
|
+
case "data": {
|
|
198
|
+
await tracer.startActiveSpan("message data", async (span) => {
|
|
199
|
+
const blocks = message.data.data;
|
|
200
|
+
const { cursor, endCursor, finality } = message.data;
|
|
201
|
+
|
|
202
|
+
await sink.transaction(
|
|
203
|
+
{ cursor, endCursor, finality },
|
|
204
|
+
async (txn) => {
|
|
205
|
+
// attach transaction to context
|
|
206
|
+
context.sinkTransaction = txn as TTxnParams;
|
|
207
|
+
|
|
208
|
+
let block: TBlock | null;
|
|
209
|
+
|
|
210
|
+
// when factory mode
|
|
211
|
+
if (isFactoryMode) {
|
|
212
|
+
assert(indexer.options.factory !== undefined);
|
|
213
|
+
|
|
214
|
+
const [factoryBlock, mainBlock] = blocks;
|
|
215
|
+
|
|
216
|
+
block = mainBlock;
|
|
217
|
+
|
|
218
|
+
if (factoryBlock !== null) {
|
|
219
|
+
const { filter } = await indexer.options.factory({
|
|
220
|
+
block: factoryBlock,
|
|
221
|
+
context,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// write returned data from factory function if filter is not defined
|
|
225
|
+
if (filter) {
|
|
226
|
+
// when filter is defined
|
|
227
|
+
// merge old and new filters
|
|
228
|
+
mainFilter = indexer.streamConfig.mergeFilter(
|
|
229
|
+
mainFilter,
|
|
230
|
+
filter,
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// create request with new filters
|
|
234
|
+
const request = indexer.streamConfig.Request.make({
|
|
235
|
+
filter: [indexer.options.filter, mainFilter],
|
|
236
|
+
finality: indexer.options.finality,
|
|
237
|
+
startingCursor: cursor,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await indexer.hooks.callHook("connect:factory", {
|
|
241
|
+
request,
|
|
242
|
+
endCursor,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// create new stream with new request
|
|
246
|
+
stream = client
|
|
247
|
+
.streamData(request, options)
|
|
248
|
+
[Symbol.asyncIterator]();
|
|
249
|
+
|
|
250
|
+
const { value: message } = await stream.next();
|
|
251
|
+
|
|
252
|
+
assert(message._tag === "data");
|
|
253
|
+
|
|
254
|
+
const [_factoryBlock, _block] = message.data.data;
|
|
255
|
+
|
|
256
|
+
block = _block;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
// when not in factory mode
|
|
261
|
+
block = blocks[0];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// if block is not null
|
|
265
|
+
if (block) {
|
|
266
|
+
await tracer.startActiveSpan("handler", async (span) => {
|
|
267
|
+
await indexer.hooks.callHook("handler:before", {
|
|
268
|
+
block,
|
|
269
|
+
endCursor,
|
|
270
|
+
finality,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
await indexer.options.transform({
|
|
275
|
+
block,
|
|
276
|
+
cursor,
|
|
277
|
+
endCursor,
|
|
278
|
+
finality,
|
|
279
|
+
context,
|
|
280
|
+
});
|
|
281
|
+
await indexer.hooks.callHook("handler:after", {
|
|
282
|
+
block,
|
|
283
|
+
finality,
|
|
284
|
+
endCursor,
|
|
285
|
+
});
|
|
286
|
+
} catch (error) {
|
|
287
|
+
assert(error instanceof Error);
|
|
288
|
+
await indexer.hooks.callHook("handler:exception", {
|
|
289
|
+
error,
|
|
290
|
+
});
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
span.end();
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
await indexer.hooks.callHook("transaction:commit", {
|
|
300
|
+
finality,
|
|
301
|
+
endCursor,
|
|
302
|
+
});
|
|
303
|
+
span.end();
|
|
304
|
+
});
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
case "invalidate": {
|
|
308
|
+
await tracer.startActiveSpan("message invalidate", async (span) => {
|
|
309
|
+
await sink.invalidate(message.invalidate.cursor);
|
|
310
|
+
});
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
default: {
|
|
314
|
+
consola.warn("unexpected message", message);
|
|
315
|
+
throw new Error("not implemented");
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
await indexer.hooks.callHook("run:after");
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
}
|
package/src/otel.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Indexer } from "../indexer";
|
|
2
|
+
|
|
3
|
+
export type IndexerPlugin<TFilter, TBlock, TTxnParams> = (
|
|
4
|
+
indexer: Indexer<TFilter, TBlock, TTxnParams>,
|
|
5
|
+
) => void;
|
|
6
|
+
|
|
7
|
+
export function defineIndexerPlugin<TFilter, TBlock, TTxnParams>(
|
|
8
|
+
def: IndexerPlugin<TFilter, TBlock, TTxnParams>,
|
|
9
|
+
) {
|
|
10
|
+
return def;
|
|
11
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import Database, { type Database as SqliteDatabase } from "better-sqlite3";
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
3
|
+
import { KVStore } from "./kv";
|
|
4
|
+
|
|
5
|
+
type ValueType = { data: bigint };
|
|
6
|
+
|
|
7
|
+
type DatabaseRowType = {
|
|
8
|
+
from_block: number;
|
|
9
|
+
k: string;
|
|
10
|
+
to_block: number;
|
|
11
|
+
v: unknown;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("KVStore", () => {
|
|
15
|
+
let db: SqliteDatabase;
|
|
16
|
+
let store: KVStore;
|
|
17
|
+
const key = "test_key";
|
|
18
|
+
|
|
19
|
+
beforeAll(() => {
|
|
20
|
+
db = new Database(":memory:");
|
|
21
|
+
KVStore.initialize(db);
|
|
22
|
+
store = new KVStore(db, "finalized", { orderKey: 5_000_000n });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterAll(async () => {
|
|
26
|
+
db.close();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should begin transaction", () => {
|
|
30
|
+
store.beginTransaction();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should put and get a value", async () => {
|
|
34
|
+
const value = { data: 0n };
|
|
35
|
+
|
|
36
|
+
store.put<ValueType>(key, value);
|
|
37
|
+
const result = store.get<ValueType>(key);
|
|
38
|
+
|
|
39
|
+
expect(result).toEqual(value);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should commit transaction", async () => {
|
|
43
|
+
store.commitTransaction();
|
|
44
|
+
|
|
45
|
+
const value = { data: 0n };
|
|
46
|
+
|
|
47
|
+
const result = store.get<ValueType>(key);
|
|
48
|
+
|
|
49
|
+
expect(result).toEqual(value);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should return undefined for non-existing key", async () => {
|
|
53
|
+
const result = store.get<ValueType>("non_existent_key");
|
|
54
|
+
expect(result).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should begin transaction", async () => {
|
|
58
|
+
store.beginTransaction();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should update an existing value", async () => {
|
|
62
|
+
store = new KVStore(db, "finalized", { orderKey: 5_000_020n });
|
|
63
|
+
|
|
64
|
+
const value = { data: 50n };
|
|
65
|
+
|
|
66
|
+
store.put<ValueType>(key, value);
|
|
67
|
+
const result = store.get<ValueType>(key);
|
|
68
|
+
|
|
69
|
+
expect(result).toEqual(value);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("should delete a value", async () => {
|
|
73
|
+
store.del(key);
|
|
74
|
+
const result = store.get<ValueType>(key);
|
|
75
|
+
|
|
76
|
+
expect(result).toBeUndefined();
|
|
77
|
+
|
|
78
|
+
const rows = db
|
|
79
|
+
.prepare<string, DatabaseRowType>(
|
|
80
|
+
`
|
|
81
|
+
SELECT from_block, to_block, k, v
|
|
82
|
+
FROM kvs
|
|
83
|
+
WHERE k = ?
|
|
84
|
+
`,
|
|
85
|
+
)
|
|
86
|
+
.all(key);
|
|
87
|
+
|
|
88
|
+
// Check that the old is correctly marked with to_block
|
|
89
|
+
expect(rows[0].to_block).toBe(Number(5_000_020n));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should rollback transaction", async () => {
|
|
93
|
+
await store.rollbackTransaction();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("should revert the changes to last commit", async () => {
|
|
97
|
+
const rows = db
|
|
98
|
+
.prepare(
|
|
99
|
+
`
|
|
100
|
+
SELECT from_block, to_block, k, v
|
|
101
|
+
FROM kvs
|
|
102
|
+
WHERE k = ?
|
|
103
|
+
`,
|
|
104
|
+
)
|
|
105
|
+
.all([key]);
|
|
106
|
+
|
|
107
|
+
expect(rows).toMatchInlineSnapshot(`
|
|
108
|
+
[
|
|
109
|
+
{
|
|
110
|
+
"from_block": 5000000,
|
|
111
|
+
"k": "test_key",
|
|
112
|
+
"to_block": null,
|
|
113
|
+
"v": "{
|
|
114
|
+
"data": "0n"
|
|
115
|
+
}",
|
|
116
|
+
},
|
|
117
|
+
]
|
|
118
|
+
`);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import type { Cursor, DataFinality } from "@apibara/protocol";
|
|
3
|
+
import type { Database as SqliteDatabase, Statement } from "better-sqlite3";
|
|
4
|
+
import { useIndexerContext } from "../context";
|
|
5
|
+
import { deserialize, serialize } from "../vcr";
|
|
6
|
+
import { defineIndexerPlugin } from "./config";
|
|
7
|
+
|
|
8
|
+
export function kv<TFilter, TBlock, TTxnParams>({
|
|
9
|
+
database,
|
|
10
|
+
}: { database: SqliteDatabase }) {
|
|
11
|
+
return defineIndexerPlugin<TFilter, TBlock, TTxnParams>((indexer) => {
|
|
12
|
+
indexer.hooks.hook("run:before", () => {
|
|
13
|
+
KVStore.initialize(database);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
indexer.hooks.hook("handler:before", ({ finality, endCursor }) => {
|
|
17
|
+
const ctx = useIndexerContext();
|
|
18
|
+
|
|
19
|
+
assert(endCursor, new Error("endCursor cannot be undefined"));
|
|
20
|
+
|
|
21
|
+
ctx.kv = new KVStore(database, finality, endCursor);
|
|
22
|
+
|
|
23
|
+
ctx.kv.beginTransaction();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
indexer.hooks.hook("handler:after", () => {
|
|
27
|
+
const ctx = useIndexerContext();
|
|
28
|
+
|
|
29
|
+
ctx.kv.commitTransaction();
|
|
30
|
+
|
|
31
|
+
ctx.kv = null;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
indexer.hooks.hook("handler:exception", () => {
|
|
35
|
+
const ctx = useIndexerContext();
|
|
36
|
+
|
|
37
|
+
ctx.kv.rollbackTransaction();
|
|
38
|
+
|
|
39
|
+
ctx.kv = null;
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class KVStore {
|
|
45
|
+
/** Sqlite Queries Prepare Statements */
|
|
46
|
+
private _beginTxnQuery: Statement;
|
|
47
|
+
private _commitTxnQuery: Statement;
|
|
48
|
+
private _rollbackTxnQuery: Statement;
|
|
49
|
+
private _getQuery: Statement<string, { v: string }>;
|
|
50
|
+
private _updateToBlockQuery: Statement<[number, string]>;
|
|
51
|
+
private _insertIntoKvsQuery: Statement<[number, string, string]>;
|
|
52
|
+
private _delQuery: Statement<[number, string]>;
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
private _db: SqliteDatabase,
|
|
56
|
+
private _finality: DataFinality,
|
|
57
|
+
private _endCursor: Cursor,
|
|
58
|
+
) {
|
|
59
|
+
this._beginTxnQuery = this._db.prepare(statements.beginTxn);
|
|
60
|
+
this._commitTxnQuery = this._db.prepare(statements.commitTxn);
|
|
61
|
+
this._rollbackTxnQuery = this._db.prepare(statements.rollbackTxn);
|
|
62
|
+
this._getQuery = this._db.prepare(statements.get);
|
|
63
|
+
this._updateToBlockQuery = this._db.prepare(statements.updateToBlock);
|
|
64
|
+
this._insertIntoKvsQuery = this._db.prepare(statements.insertIntoKvs);
|
|
65
|
+
this._delQuery = this._db.prepare(statements.del);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static initialize(db: SqliteDatabase) {
|
|
69
|
+
db.prepare(statements.createTable).run();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
beginTransaction() {
|
|
73
|
+
this._beginTxnQuery.run();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
commitTransaction() {
|
|
77
|
+
this._commitTxnQuery.run();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
rollbackTransaction() {
|
|
81
|
+
this._rollbackTxnQuery.run();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get<T>(key: string): T {
|
|
85
|
+
const row = this._getQuery.get(key);
|
|
86
|
+
|
|
87
|
+
return row ? deserialize(row.v) : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
put<T>(key: string, value: T) {
|
|
91
|
+
this._updateToBlockQuery.run(Number(this._endCursor.orderKey), key);
|
|
92
|
+
|
|
93
|
+
this._insertIntoKvsQuery.run(
|
|
94
|
+
Number(this._endCursor.orderKey),
|
|
95
|
+
key,
|
|
96
|
+
serialize(value as Record<string, unknown>),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
del(key: string) {
|
|
101
|
+
this._delQuery.run(Number(this._endCursor.orderKey), key);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const statements = {
|
|
106
|
+
beginTxn: "BEGIN TRANSACTION",
|
|
107
|
+
commitTxn: "COMMIT TRANSACTION",
|
|
108
|
+
rollbackTxn: "ROLLBACK TRANSACTION",
|
|
109
|
+
createTable: `
|
|
110
|
+
CREATE TABLE IF NOT EXISTS kvs (
|
|
111
|
+
from_block INTEGER NOT NULL,
|
|
112
|
+
to_block INTEGER,
|
|
113
|
+
k TEXT NOT NULL,
|
|
114
|
+
v BLOB NOT NULL,
|
|
115
|
+
PRIMARY KEY (from_block, k)
|
|
116
|
+
);`,
|
|
117
|
+
get: `
|
|
118
|
+
SELECT v
|
|
119
|
+
FROM kvs
|
|
120
|
+
WHERE k = ? AND to_block IS NULL`,
|
|
121
|
+
updateToBlock: `
|
|
122
|
+
UPDATE kvs
|
|
123
|
+
SET to_block = ?
|
|
124
|
+
WHERE k = ? AND to_block IS NULL`,
|
|
125
|
+
insertIntoKvs: `
|
|
126
|
+
INSERT INTO kvs (from_block, to_block, k, v)
|
|
127
|
+
VALUES (?, NULL, ?, ?)`,
|
|
128
|
+
del: `
|
|
129
|
+
UPDATE kvs
|
|
130
|
+
SET to_block = ?
|
|
131
|
+
WHERE k = ? AND to_block IS NULL`,
|
|
132
|
+
};
|