@apibara/plugin-sqlite 2.0.0-beta.27
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 +7 -0
- package/dist/index.cjs +274 -0
- package/dist/index.d.cts +40 -0
- package/dist/index.d.mts +40 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.mjs +270 -0
- package/package.json +41 -0
- package/src/index.ts +144 -0
- package/src/kv.ts +84 -0
- package/src/persistence.ts +113 -0
- package/src/utils.ts +47 -0
package/README.md
ADDED
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const indexer = require('@apibara/indexer');
|
|
4
|
+
const plugins = require('@apibara/indexer/plugins');
|
|
5
|
+
const protocol = require('@apibara/protocol');
|
|
6
|
+
|
|
7
|
+
class SqliteStorageError extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "SqliteStorageError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
async function withTransaction(db, cb) {
|
|
14
|
+
db.prepare("BEGIN TRANSACTION").run();
|
|
15
|
+
try {
|
|
16
|
+
await cb(db);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
db.prepare("ROLLBACK TRANSACTION").run();
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
db.prepare("COMMIT TRANSACTION").run();
|
|
22
|
+
}
|
|
23
|
+
function assertInTransaction(db) {
|
|
24
|
+
if (!db.inTransaction) {
|
|
25
|
+
throw new SqliteStorageError("Database is not in transaction");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function deserialize(str) {
|
|
29
|
+
return JSON.parse(
|
|
30
|
+
str,
|
|
31
|
+
(_, value) => typeof value === "string" && value.match(/^\d+n$/) ? BigInt(value.slice(0, -1)) : value
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
function serialize(obj) {
|
|
35
|
+
return JSON.stringify(
|
|
36
|
+
obj,
|
|
37
|
+
(_, value) => typeof value === "bigint" ? `${value.toString()}n` : value,
|
|
38
|
+
" "
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function initializeKeyValueStore(db) {
|
|
43
|
+
assertInTransaction(db);
|
|
44
|
+
db.exec(statements$1.createTable);
|
|
45
|
+
}
|
|
46
|
+
class KeyValueStore {
|
|
47
|
+
constructor(db, endCursor, finality, serialize, deserialize) {
|
|
48
|
+
this.db = db;
|
|
49
|
+
this.endCursor = endCursor;
|
|
50
|
+
this.finality = finality;
|
|
51
|
+
this.serialize = serialize;
|
|
52
|
+
this.deserialize = deserialize;
|
|
53
|
+
assertInTransaction(db);
|
|
54
|
+
}
|
|
55
|
+
get(key) {
|
|
56
|
+
const row = this.db.prepare(statements$1.get).get(key);
|
|
57
|
+
return row ? this.deserialize(row.v) : void 0;
|
|
58
|
+
}
|
|
59
|
+
put(key, value) {
|
|
60
|
+
this.db.prepare(statements$1.updateToBlock).run(Number(this.endCursor.orderKey), key);
|
|
61
|
+
this.db.prepare(statements$1.insertIntoKvs).run(
|
|
62
|
+
Number(this.endCursor.orderKey),
|
|
63
|
+
key,
|
|
64
|
+
this.serialize(value)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
del(key) {
|
|
68
|
+
this.db.prepare(statements$1.del).run(Number(this.endCursor.orderKey), key);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const statements$1 = {
|
|
72
|
+
createTable: `
|
|
73
|
+
CREATE TABLE IF NOT EXISTS kvs (
|
|
74
|
+
from_block INTEGER NOT NULL,
|
|
75
|
+
to_block INTEGER,
|
|
76
|
+
k TEXT NOT NULL,
|
|
77
|
+
v BLOB NOT NULL,
|
|
78
|
+
PRIMARY KEY (from_block, k)
|
|
79
|
+
);`,
|
|
80
|
+
get: `
|
|
81
|
+
SELECT v
|
|
82
|
+
FROM kvs
|
|
83
|
+
WHERE k = ? AND to_block IS NULL`,
|
|
84
|
+
updateToBlock: `
|
|
85
|
+
UPDATE kvs
|
|
86
|
+
SET to_block = ?
|
|
87
|
+
WHERE k = ? AND to_block IS NULL`,
|
|
88
|
+
insertIntoKvs: `
|
|
89
|
+
INSERT INTO kvs (from_block, to_block, k, v)
|
|
90
|
+
VALUES (?, NULL, ?, ?)`,
|
|
91
|
+
del: `
|
|
92
|
+
UPDATE kvs
|
|
93
|
+
SET to_block = ?
|
|
94
|
+
WHERE k = ? AND to_block IS NULL`
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const DEFAULT_INDEXER_ID = "default";
|
|
98
|
+
function initializePersistentState(db) {
|
|
99
|
+
assertInTransaction(db);
|
|
100
|
+
db.exec(statements.createCheckpointsTable);
|
|
101
|
+
db.exec(statements.createFiltersTable);
|
|
102
|
+
}
|
|
103
|
+
function persistState(db, endCursor, filter) {
|
|
104
|
+
assertInTransaction(db);
|
|
105
|
+
db.prepare(statements.putCheckpoint).run(
|
|
106
|
+
DEFAULT_INDEXER_ID,
|
|
107
|
+
Number(endCursor.orderKey),
|
|
108
|
+
endCursor.uniqueKey
|
|
109
|
+
);
|
|
110
|
+
if (filter) {
|
|
111
|
+
db.prepare(statements.updateFilterToBlock).run(
|
|
112
|
+
Number(endCursor.orderKey),
|
|
113
|
+
DEFAULT_INDEXER_ID
|
|
114
|
+
);
|
|
115
|
+
db.prepare(statements.insertFilter).run(
|
|
116
|
+
DEFAULT_INDEXER_ID,
|
|
117
|
+
serialize(filter),
|
|
118
|
+
Number(endCursor.orderKey)
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function getState(db) {
|
|
123
|
+
assertInTransaction(db);
|
|
124
|
+
const storedCursor = db.prepare(
|
|
125
|
+
statements.getCheckpoint
|
|
126
|
+
).get(DEFAULT_INDEXER_ID);
|
|
127
|
+
const storedFilter = db.prepare(statements.getFilter).get(DEFAULT_INDEXER_ID);
|
|
128
|
+
let cursor;
|
|
129
|
+
let filter;
|
|
130
|
+
if (storedCursor?.order_key) {
|
|
131
|
+
cursor = {
|
|
132
|
+
orderKey: BigInt(storedCursor.order_key),
|
|
133
|
+
uniqueKey: storedCursor.unique_key
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (storedFilter) {
|
|
137
|
+
filter = deserialize(storedFilter.filter);
|
|
138
|
+
}
|
|
139
|
+
return { cursor, filter };
|
|
140
|
+
}
|
|
141
|
+
const statements = {
|
|
142
|
+
createCheckpointsTable: `
|
|
143
|
+
CREATE TABLE IF NOT EXISTS checkpoints (
|
|
144
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
145
|
+
order_key INTEGER,
|
|
146
|
+
unique_key TEXT
|
|
147
|
+
);`,
|
|
148
|
+
createFiltersTable: `
|
|
149
|
+
CREATE TABLE IF NOT EXISTS filters (
|
|
150
|
+
id TEXT NOT NULL,
|
|
151
|
+
filter BLOB NOT NULL,
|
|
152
|
+
from_block INTEGER NOT NULL,
|
|
153
|
+
to_block INTEGER,
|
|
154
|
+
PRIMARY KEY (id, from_block)
|
|
155
|
+
);`,
|
|
156
|
+
getCheckpoint: `
|
|
157
|
+
SELECT *
|
|
158
|
+
FROM checkpoints
|
|
159
|
+
WHERE id = ?`,
|
|
160
|
+
putCheckpoint: `
|
|
161
|
+
INSERT INTO checkpoints (id, order_key, unique_key)
|
|
162
|
+
VALUES (?, ?, ?)
|
|
163
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
164
|
+
order_key = excluded.order_key,
|
|
165
|
+
unique_key = excluded.unique_key`,
|
|
166
|
+
delCheckpoint: `
|
|
167
|
+
DELETE FROM checkpoints
|
|
168
|
+
WHERE id = ?`,
|
|
169
|
+
getFilter: `
|
|
170
|
+
SELECT *
|
|
171
|
+
FROM filters
|
|
172
|
+
WHERE id = ? AND to_block IS NULL`,
|
|
173
|
+
updateFilterToBlock: `
|
|
174
|
+
UPDATE filters
|
|
175
|
+
SET to_block = ?
|
|
176
|
+
WHERE id = ? AND to_block IS NULL`,
|
|
177
|
+
insertFilter: `
|
|
178
|
+
INSERT INTO filters (id, filter, from_block)
|
|
179
|
+
VALUES (?, ?, ?)
|
|
180
|
+
ON CONFLICT(id, from_block) DO UPDATE SET
|
|
181
|
+
filter = excluded.filter,
|
|
182
|
+
from_block = excluded.from_block`,
|
|
183
|
+
delFilter: `
|
|
184
|
+
DELETE FROM filters
|
|
185
|
+
WHERE id = ?`
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const KV_PROPERTY = "_kv_sqlite";
|
|
189
|
+
function useSqliteKeyValueStore() {
|
|
190
|
+
const kv = indexer.useIndexerContext()[KV_PROPERTY];
|
|
191
|
+
if (!kv) {
|
|
192
|
+
throw new SqliteStorageError(
|
|
193
|
+
"SQLite key-value store is not available. Did you forget to enable it?"
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return kv;
|
|
197
|
+
}
|
|
198
|
+
function sqliteStorage({
|
|
199
|
+
database,
|
|
200
|
+
persistState: enablePersistState = true,
|
|
201
|
+
keyValueStore: enableKeyValueStore = true,
|
|
202
|
+
serialize: serializeFn = serialize,
|
|
203
|
+
deserialize: deserializeFn = deserialize
|
|
204
|
+
}) {
|
|
205
|
+
return plugins.defineIndexerPlugin((indexer) => {
|
|
206
|
+
indexer.hooks.hook("run:before", async () => {
|
|
207
|
+
await withTransaction(database, async (db) => {
|
|
208
|
+
if (enablePersistState) {
|
|
209
|
+
initializePersistentState(db);
|
|
210
|
+
}
|
|
211
|
+
if (enableKeyValueStore) {
|
|
212
|
+
initializeKeyValueStore(db);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
indexer.hooks.hook("connect:before", async ({ request }) => {
|
|
217
|
+
if (!enablePersistState) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
return await withTransaction(database, async (db) => {
|
|
221
|
+
const { cursor, filter } = getState(db);
|
|
222
|
+
if (cursor) {
|
|
223
|
+
request.startingCursor = cursor;
|
|
224
|
+
}
|
|
225
|
+
if (filter) {
|
|
226
|
+
request.filter[1] = filter;
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
indexer.hooks.hook("handler:middleware", ({ use }) => {
|
|
231
|
+
use(async (ctx, next) => {
|
|
232
|
+
if (!ctx.finality) {
|
|
233
|
+
throw new SqliteStorageError("finality is undefined");
|
|
234
|
+
}
|
|
235
|
+
if (!ctx.endCursor || !protocol.isCursor(ctx.endCursor)) {
|
|
236
|
+
throw new SqliteStorageError(
|
|
237
|
+
"endCursor is undefined or not a cursor"
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
await withTransaction(database, async (db) => {
|
|
241
|
+
if (enableKeyValueStore) {
|
|
242
|
+
ctx[KV_PROPERTY] = new KeyValueStore(
|
|
243
|
+
db,
|
|
244
|
+
ctx.endCursor,
|
|
245
|
+
ctx.finality,
|
|
246
|
+
serializeFn,
|
|
247
|
+
deserializeFn
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
await next();
|
|
251
|
+
if (enablePersistState) {
|
|
252
|
+
persistState(db, ctx.endCursor);
|
|
253
|
+
}
|
|
254
|
+
if (enableKeyValueStore) {
|
|
255
|
+
delete ctx[KV_PROPERTY];
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
indexer.hooks.hook("connect:factory", ({ request, endCursor }) => {
|
|
261
|
+
if (!enablePersistState) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
assertInTransaction(database);
|
|
265
|
+
if (endCursor && request.filter[1]) {
|
|
266
|
+
persistState(database, endCursor, request.filter[1]);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
exports.KeyValueStore = KeyValueStore;
|
|
273
|
+
exports.sqliteStorage = sqliteStorage;
|
|
274
|
+
exports.useSqliteKeyValueStore = useSqliteKeyValueStore;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as _apibara_indexer_plugins from '@apibara/indexer/plugins';
|
|
2
|
+
import { Database } from 'better-sqlite3';
|
|
3
|
+
import { Cursor, DataFinality } from '@apibara/protocol';
|
|
4
|
+
|
|
5
|
+
type SerializeFn = <T>(value: T) => string;
|
|
6
|
+
type DeserializeFn = <T>(value: string) => T;
|
|
7
|
+
|
|
8
|
+
declare class KeyValueStore {
|
|
9
|
+
private readonly db;
|
|
10
|
+
private readonly endCursor;
|
|
11
|
+
private readonly finality;
|
|
12
|
+
private readonly serialize;
|
|
13
|
+
private readonly deserialize;
|
|
14
|
+
constructor(db: Database, endCursor: Cursor, finality: DataFinality, serialize: SerializeFn, deserialize: DeserializeFn);
|
|
15
|
+
get<T>(key: string): T | undefined;
|
|
16
|
+
put<T>(key: string, value: T): void;
|
|
17
|
+
del(key: string): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
declare function useSqliteKeyValueStore(): KeyValueStore;
|
|
21
|
+
type SqliteStorageOptions = {
|
|
22
|
+
database: Database;
|
|
23
|
+
keyValueStore?: boolean;
|
|
24
|
+
persistState?: boolean;
|
|
25
|
+
serialize?: SerializeFn;
|
|
26
|
+
deserialize?: DeserializeFn;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Creates a plugin that uses SQLite as the storage layer.
|
|
30
|
+
*
|
|
31
|
+
* Supports storing the indexer's state and provides a simple Key-Value store.
|
|
32
|
+
* @param options.database - The SQLite database instance.
|
|
33
|
+
* @param options.persistState - Whether to persist the indexer's state. Defaults to true.
|
|
34
|
+
* @param options.keyValueStore - Whether to enable the Key-Value store. Defaults to true.
|
|
35
|
+
* @param options.serialize - A function to serialize the value to the KV.
|
|
36
|
+
* @param options.deserialize - A function to deserialize the value from the KV.
|
|
37
|
+
*/
|
|
38
|
+
declare function sqliteStorage<TFilter, TBlock>({ database, persistState: enablePersistState, keyValueStore: enableKeyValueStore, serialize: serializeFn, deserialize: deserializeFn, }: SqliteStorageOptions): _apibara_indexer_plugins.IndexerPlugin<TFilter, TBlock>;
|
|
39
|
+
|
|
40
|
+
export { KeyValueStore, type SqliteStorageOptions, sqliteStorage, useSqliteKeyValueStore };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as _apibara_indexer_plugins from '@apibara/indexer/plugins';
|
|
2
|
+
import { Database } from 'better-sqlite3';
|
|
3
|
+
import { Cursor, DataFinality } from '@apibara/protocol';
|
|
4
|
+
|
|
5
|
+
type SerializeFn = <T>(value: T) => string;
|
|
6
|
+
type DeserializeFn = <T>(value: string) => T;
|
|
7
|
+
|
|
8
|
+
declare class KeyValueStore {
|
|
9
|
+
private readonly db;
|
|
10
|
+
private readonly endCursor;
|
|
11
|
+
private readonly finality;
|
|
12
|
+
private readonly serialize;
|
|
13
|
+
private readonly deserialize;
|
|
14
|
+
constructor(db: Database, endCursor: Cursor, finality: DataFinality, serialize: SerializeFn, deserialize: DeserializeFn);
|
|
15
|
+
get<T>(key: string): T | undefined;
|
|
16
|
+
put<T>(key: string, value: T): void;
|
|
17
|
+
del(key: string): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
declare function useSqliteKeyValueStore(): KeyValueStore;
|
|
21
|
+
type SqliteStorageOptions = {
|
|
22
|
+
database: Database;
|
|
23
|
+
keyValueStore?: boolean;
|
|
24
|
+
persistState?: boolean;
|
|
25
|
+
serialize?: SerializeFn;
|
|
26
|
+
deserialize?: DeserializeFn;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Creates a plugin that uses SQLite as the storage layer.
|
|
30
|
+
*
|
|
31
|
+
* Supports storing the indexer's state and provides a simple Key-Value store.
|
|
32
|
+
* @param options.database - The SQLite database instance.
|
|
33
|
+
* @param options.persistState - Whether to persist the indexer's state. Defaults to true.
|
|
34
|
+
* @param options.keyValueStore - Whether to enable the Key-Value store. Defaults to true.
|
|
35
|
+
* @param options.serialize - A function to serialize the value to the KV.
|
|
36
|
+
* @param options.deserialize - A function to deserialize the value from the KV.
|
|
37
|
+
*/
|
|
38
|
+
declare function sqliteStorage<TFilter, TBlock>({ database, persistState: enablePersistState, keyValueStore: enableKeyValueStore, serialize: serializeFn, deserialize: deserializeFn, }: SqliteStorageOptions): _apibara_indexer_plugins.IndexerPlugin<TFilter, TBlock>;
|
|
39
|
+
|
|
40
|
+
export { KeyValueStore, type SqliteStorageOptions, sqliteStorage, useSqliteKeyValueStore };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as _apibara_indexer_plugins from '@apibara/indexer/plugins';
|
|
2
|
+
import { Database } from 'better-sqlite3';
|
|
3
|
+
import { Cursor, DataFinality } from '@apibara/protocol';
|
|
4
|
+
|
|
5
|
+
type SerializeFn = <T>(value: T) => string;
|
|
6
|
+
type DeserializeFn = <T>(value: string) => T;
|
|
7
|
+
|
|
8
|
+
declare class KeyValueStore {
|
|
9
|
+
private readonly db;
|
|
10
|
+
private readonly endCursor;
|
|
11
|
+
private readonly finality;
|
|
12
|
+
private readonly serialize;
|
|
13
|
+
private readonly deserialize;
|
|
14
|
+
constructor(db: Database, endCursor: Cursor, finality: DataFinality, serialize: SerializeFn, deserialize: DeserializeFn);
|
|
15
|
+
get<T>(key: string): T | undefined;
|
|
16
|
+
put<T>(key: string, value: T): void;
|
|
17
|
+
del(key: string): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
declare function useSqliteKeyValueStore(): KeyValueStore;
|
|
21
|
+
type SqliteStorageOptions = {
|
|
22
|
+
database: Database;
|
|
23
|
+
keyValueStore?: boolean;
|
|
24
|
+
persistState?: boolean;
|
|
25
|
+
serialize?: SerializeFn;
|
|
26
|
+
deserialize?: DeserializeFn;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Creates a plugin that uses SQLite as the storage layer.
|
|
30
|
+
*
|
|
31
|
+
* Supports storing the indexer's state and provides a simple Key-Value store.
|
|
32
|
+
* @param options.database - The SQLite database instance.
|
|
33
|
+
* @param options.persistState - Whether to persist the indexer's state. Defaults to true.
|
|
34
|
+
* @param options.keyValueStore - Whether to enable the Key-Value store. Defaults to true.
|
|
35
|
+
* @param options.serialize - A function to serialize the value to the KV.
|
|
36
|
+
* @param options.deserialize - A function to deserialize the value from the KV.
|
|
37
|
+
*/
|
|
38
|
+
declare function sqliteStorage<TFilter, TBlock>({ database, persistState: enablePersistState, keyValueStore: enableKeyValueStore, serialize: serializeFn, deserialize: deserializeFn, }: SqliteStorageOptions): _apibara_indexer_plugins.IndexerPlugin<TFilter, TBlock>;
|
|
39
|
+
|
|
40
|
+
export { KeyValueStore, type SqliteStorageOptions, sqliteStorage, useSqliteKeyValueStore };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { useIndexerContext } from '@apibara/indexer';
|
|
2
|
+
import { defineIndexerPlugin } from '@apibara/indexer/plugins';
|
|
3
|
+
import { isCursor } from '@apibara/protocol';
|
|
4
|
+
|
|
5
|
+
class SqliteStorageError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "SqliteStorageError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
async function withTransaction(db, cb) {
|
|
12
|
+
db.prepare("BEGIN TRANSACTION").run();
|
|
13
|
+
try {
|
|
14
|
+
await cb(db);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
db.prepare("ROLLBACK TRANSACTION").run();
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
db.prepare("COMMIT TRANSACTION").run();
|
|
20
|
+
}
|
|
21
|
+
function assertInTransaction(db) {
|
|
22
|
+
if (!db.inTransaction) {
|
|
23
|
+
throw new SqliteStorageError("Database is not in transaction");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function deserialize(str) {
|
|
27
|
+
return JSON.parse(
|
|
28
|
+
str,
|
|
29
|
+
(_, value) => typeof value === "string" && value.match(/^\d+n$/) ? BigInt(value.slice(0, -1)) : value
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
function serialize(obj) {
|
|
33
|
+
return JSON.stringify(
|
|
34
|
+
obj,
|
|
35
|
+
(_, value) => typeof value === "bigint" ? `${value.toString()}n` : value,
|
|
36
|
+
" "
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function initializeKeyValueStore(db) {
|
|
41
|
+
assertInTransaction(db);
|
|
42
|
+
db.exec(statements$1.createTable);
|
|
43
|
+
}
|
|
44
|
+
class KeyValueStore {
|
|
45
|
+
constructor(db, endCursor, finality, serialize, deserialize) {
|
|
46
|
+
this.db = db;
|
|
47
|
+
this.endCursor = endCursor;
|
|
48
|
+
this.finality = finality;
|
|
49
|
+
this.serialize = serialize;
|
|
50
|
+
this.deserialize = deserialize;
|
|
51
|
+
assertInTransaction(db);
|
|
52
|
+
}
|
|
53
|
+
get(key) {
|
|
54
|
+
const row = this.db.prepare(statements$1.get).get(key);
|
|
55
|
+
return row ? this.deserialize(row.v) : void 0;
|
|
56
|
+
}
|
|
57
|
+
put(key, value) {
|
|
58
|
+
this.db.prepare(statements$1.updateToBlock).run(Number(this.endCursor.orderKey), key);
|
|
59
|
+
this.db.prepare(statements$1.insertIntoKvs).run(
|
|
60
|
+
Number(this.endCursor.orderKey),
|
|
61
|
+
key,
|
|
62
|
+
this.serialize(value)
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
del(key) {
|
|
66
|
+
this.db.prepare(statements$1.del).run(Number(this.endCursor.orderKey), key);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const statements$1 = {
|
|
70
|
+
createTable: `
|
|
71
|
+
CREATE TABLE IF NOT EXISTS kvs (
|
|
72
|
+
from_block INTEGER NOT NULL,
|
|
73
|
+
to_block INTEGER,
|
|
74
|
+
k TEXT NOT NULL,
|
|
75
|
+
v BLOB NOT NULL,
|
|
76
|
+
PRIMARY KEY (from_block, k)
|
|
77
|
+
);`,
|
|
78
|
+
get: `
|
|
79
|
+
SELECT v
|
|
80
|
+
FROM kvs
|
|
81
|
+
WHERE k = ? AND to_block IS NULL`,
|
|
82
|
+
updateToBlock: `
|
|
83
|
+
UPDATE kvs
|
|
84
|
+
SET to_block = ?
|
|
85
|
+
WHERE k = ? AND to_block IS NULL`,
|
|
86
|
+
insertIntoKvs: `
|
|
87
|
+
INSERT INTO kvs (from_block, to_block, k, v)
|
|
88
|
+
VALUES (?, NULL, ?, ?)`,
|
|
89
|
+
del: `
|
|
90
|
+
UPDATE kvs
|
|
91
|
+
SET to_block = ?
|
|
92
|
+
WHERE k = ? AND to_block IS NULL`
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const DEFAULT_INDEXER_ID = "default";
|
|
96
|
+
function initializePersistentState(db) {
|
|
97
|
+
assertInTransaction(db);
|
|
98
|
+
db.exec(statements.createCheckpointsTable);
|
|
99
|
+
db.exec(statements.createFiltersTable);
|
|
100
|
+
}
|
|
101
|
+
function persistState(db, endCursor, filter) {
|
|
102
|
+
assertInTransaction(db);
|
|
103
|
+
db.prepare(statements.putCheckpoint).run(
|
|
104
|
+
DEFAULT_INDEXER_ID,
|
|
105
|
+
Number(endCursor.orderKey),
|
|
106
|
+
endCursor.uniqueKey
|
|
107
|
+
);
|
|
108
|
+
if (filter) {
|
|
109
|
+
db.prepare(statements.updateFilterToBlock).run(
|
|
110
|
+
Number(endCursor.orderKey),
|
|
111
|
+
DEFAULT_INDEXER_ID
|
|
112
|
+
);
|
|
113
|
+
db.prepare(statements.insertFilter).run(
|
|
114
|
+
DEFAULT_INDEXER_ID,
|
|
115
|
+
serialize(filter),
|
|
116
|
+
Number(endCursor.orderKey)
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function getState(db) {
|
|
121
|
+
assertInTransaction(db);
|
|
122
|
+
const storedCursor = db.prepare(
|
|
123
|
+
statements.getCheckpoint
|
|
124
|
+
).get(DEFAULT_INDEXER_ID);
|
|
125
|
+
const storedFilter = db.prepare(statements.getFilter).get(DEFAULT_INDEXER_ID);
|
|
126
|
+
let cursor;
|
|
127
|
+
let filter;
|
|
128
|
+
if (storedCursor?.order_key) {
|
|
129
|
+
cursor = {
|
|
130
|
+
orderKey: BigInt(storedCursor.order_key),
|
|
131
|
+
uniqueKey: storedCursor.unique_key
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (storedFilter) {
|
|
135
|
+
filter = deserialize(storedFilter.filter);
|
|
136
|
+
}
|
|
137
|
+
return { cursor, filter };
|
|
138
|
+
}
|
|
139
|
+
const statements = {
|
|
140
|
+
createCheckpointsTable: `
|
|
141
|
+
CREATE TABLE IF NOT EXISTS checkpoints (
|
|
142
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
143
|
+
order_key INTEGER,
|
|
144
|
+
unique_key TEXT
|
|
145
|
+
);`,
|
|
146
|
+
createFiltersTable: `
|
|
147
|
+
CREATE TABLE IF NOT EXISTS filters (
|
|
148
|
+
id TEXT NOT NULL,
|
|
149
|
+
filter BLOB NOT NULL,
|
|
150
|
+
from_block INTEGER NOT NULL,
|
|
151
|
+
to_block INTEGER,
|
|
152
|
+
PRIMARY KEY (id, from_block)
|
|
153
|
+
);`,
|
|
154
|
+
getCheckpoint: `
|
|
155
|
+
SELECT *
|
|
156
|
+
FROM checkpoints
|
|
157
|
+
WHERE id = ?`,
|
|
158
|
+
putCheckpoint: `
|
|
159
|
+
INSERT INTO checkpoints (id, order_key, unique_key)
|
|
160
|
+
VALUES (?, ?, ?)
|
|
161
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
162
|
+
order_key = excluded.order_key,
|
|
163
|
+
unique_key = excluded.unique_key`,
|
|
164
|
+
delCheckpoint: `
|
|
165
|
+
DELETE FROM checkpoints
|
|
166
|
+
WHERE id = ?`,
|
|
167
|
+
getFilter: `
|
|
168
|
+
SELECT *
|
|
169
|
+
FROM filters
|
|
170
|
+
WHERE id = ? AND to_block IS NULL`,
|
|
171
|
+
updateFilterToBlock: `
|
|
172
|
+
UPDATE filters
|
|
173
|
+
SET to_block = ?
|
|
174
|
+
WHERE id = ? AND to_block IS NULL`,
|
|
175
|
+
insertFilter: `
|
|
176
|
+
INSERT INTO filters (id, filter, from_block)
|
|
177
|
+
VALUES (?, ?, ?)
|
|
178
|
+
ON CONFLICT(id, from_block) DO UPDATE SET
|
|
179
|
+
filter = excluded.filter,
|
|
180
|
+
from_block = excluded.from_block`,
|
|
181
|
+
delFilter: `
|
|
182
|
+
DELETE FROM filters
|
|
183
|
+
WHERE id = ?`
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const KV_PROPERTY = "_kv_sqlite";
|
|
187
|
+
function useSqliteKeyValueStore() {
|
|
188
|
+
const kv = useIndexerContext()[KV_PROPERTY];
|
|
189
|
+
if (!kv) {
|
|
190
|
+
throw new SqliteStorageError(
|
|
191
|
+
"SQLite key-value store is not available. Did you forget to enable it?"
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return kv;
|
|
195
|
+
}
|
|
196
|
+
function sqliteStorage({
|
|
197
|
+
database,
|
|
198
|
+
persistState: enablePersistState = true,
|
|
199
|
+
keyValueStore: enableKeyValueStore = true,
|
|
200
|
+
serialize: serializeFn = serialize,
|
|
201
|
+
deserialize: deserializeFn = deserialize
|
|
202
|
+
}) {
|
|
203
|
+
return defineIndexerPlugin((indexer) => {
|
|
204
|
+
indexer.hooks.hook("run:before", async () => {
|
|
205
|
+
await withTransaction(database, async (db) => {
|
|
206
|
+
if (enablePersistState) {
|
|
207
|
+
initializePersistentState(db);
|
|
208
|
+
}
|
|
209
|
+
if (enableKeyValueStore) {
|
|
210
|
+
initializeKeyValueStore(db);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
indexer.hooks.hook("connect:before", async ({ request }) => {
|
|
215
|
+
if (!enablePersistState) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
return await withTransaction(database, async (db) => {
|
|
219
|
+
const { cursor, filter } = getState(db);
|
|
220
|
+
if (cursor) {
|
|
221
|
+
request.startingCursor = cursor;
|
|
222
|
+
}
|
|
223
|
+
if (filter) {
|
|
224
|
+
request.filter[1] = filter;
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
indexer.hooks.hook("handler:middleware", ({ use }) => {
|
|
229
|
+
use(async (ctx, next) => {
|
|
230
|
+
if (!ctx.finality) {
|
|
231
|
+
throw new SqliteStorageError("finality is undefined");
|
|
232
|
+
}
|
|
233
|
+
if (!ctx.endCursor || !isCursor(ctx.endCursor)) {
|
|
234
|
+
throw new SqliteStorageError(
|
|
235
|
+
"endCursor is undefined or not a cursor"
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
await withTransaction(database, async (db) => {
|
|
239
|
+
if (enableKeyValueStore) {
|
|
240
|
+
ctx[KV_PROPERTY] = new KeyValueStore(
|
|
241
|
+
db,
|
|
242
|
+
ctx.endCursor,
|
|
243
|
+
ctx.finality,
|
|
244
|
+
serializeFn,
|
|
245
|
+
deserializeFn
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
await next();
|
|
249
|
+
if (enablePersistState) {
|
|
250
|
+
persistState(db, ctx.endCursor);
|
|
251
|
+
}
|
|
252
|
+
if (enableKeyValueStore) {
|
|
253
|
+
delete ctx[KV_PROPERTY];
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
indexer.hooks.hook("connect:factory", ({ request, endCursor }) => {
|
|
259
|
+
if (!enablePersistState) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
assertInTransaction(database);
|
|
263
|
+
if (endCursor && request.filter[1]) {
|
|
264
|
+
persistState(database, endCursor, request.filter[1]);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export { KeyValueStore, sqliteStorage, useSqliteKeyValueStore };
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@apibara/plugin-sqlite",
|
|
3
|
+
"version": "2.0.0-beta.27",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"files": [
|
|
6
|
+
"dist",
|
|
7
|
+
"src",
|
|
8
|
+
"README.md"
|
|
9
|
+
],
|
|
10
|
+
"main": "./dist/index.mjs",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.mjs",
|
|
16
|
+
"require": "./dist/index.cjs",
|
|
17
|
+
"default": "./dist/index.mjs"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "unbuild",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"lint": "biome check .",
|
|
24
|
+
"lint:fix": "pnpm lint --write",
|
|
25
|
+
"test": "vitest",
|
|
26
|
+
"test:ci": "vitest run"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/better-sqlite3": "^7.6.11",
|
|
30
|
+
"@types/node": "^20.14.0",
|
|
31
|
+
"unbuild": "^2.0.0",
|
|
32
|
+
"vitest": "^1.6.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"better-sqlite3": "^9.0.0"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@apibara/indexer": "2.0.0-beta.28",
|
|
39
|
+
"@apibara/protocol": "2.0.0-beta.28"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useIndexerContext } from "@apibara/indexer";
|
|
2
|
+
import { defineIndexerPlugin } from "@apibara/indexer/plugins";
|
|
3
|
+
import { isCursor } from "@apibara/protocol";
|
|
4
|
+
import type { Database as SqliteDatabase } from "better-sqlite3";
|
|
5
|
+
|
|
6
|
+
import { KeyValueStore, initializeKeyValueStore } from "./kv";
|
|
7
|
+
import {
|
|
8
|
+
getState,
|
|
9
|
+
initializePersistentState,
|
|
10
|
+
persistState,
|
|
11
|
+
} from "./persistence";
|
|
12
|
+
import {
|
|
13
|
+
type DeserializeFn,
|
|
14
|
+
type SerializeFn,
|
|
15
|
+
SqliteStorageError,
|
|
16
|
+
assertInTransaction,
|
|
17
|
+
deserialize,
|
|
18
|
+
serialize,
|
|
19
|
+
withTransaction,
|
|
20
|
+
} from "./utils";
|
|
21
|
+
|
|
22
|
+
const KV_PROPERTY = "_kv_sqlite" as const;
|
|
23
|
+
|
|
24
|
+
export { KeyValueStore } from "./kv";
|
|
25
|
+
|
|
26
|
+
export function useSqliteKeyValueStore(): KeyValueStore {
|
|
27
|
+
const kv = useIndexerContext()[KV_PROPERTY] as KeyValueStore | undefined;
|
|
28
|
+
if (!kv) {
|
|
29
|
+
throw new SqliteStorageError(
|
|
30
|
+
"SQLite key-value store is not available. Did you forget to enable it?",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return kv;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type SqliteStorageOptions = {
|
|
38
|
+
database: SqliteDatabase;
|
|
39
|
+
keyValueStore?: boolean;
|
|
40
|
+
persistState?: boolean;
|
|
41
|
+
|
|
42
|
+
serialize?: SerializeFn;
|
|
43
|
+
deserialize?: DeserializeFn;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Creates a plugin that uses SQLite as the storage layer.
|
|
48
|
+
*
|
|
49
|
+
* Supports storing the indexer's state and provides a simple Key-Value store.
|
|
50
|
+
* @param options.database - The SQLite database instance.
|
|
51
|
+
* @param options.persistState - Whether to persist the indexer's state. Defaults to true.
|
|
52
|
+
* @param options.keyValueStore - Whether to enable the Key-Value store. Defaults to true.
|
|
53
|
+
* @param options.serialize - A function to serialize the value to the KV.
|
|
54
|
+
* @param options.deserialize - A function to deserialize the value from the KV.
|
|
55
|
+
*/
|
|
56
|
+
export function sqliteStorage<TFilter, TBlock>({
|
|
57
|
+
database,
|
|
58
|
+
persistState: enablePersistState = true,
|
|
59
|
+
keyValueStore: enableKeyValueStore = true,
|
|
60
|
+
serialize: serializeFn = serialize,
|
|
61
|
+
deserialize: deserializeFn = deserialize,
|
|
62
|
+
}: SqliteStorageOptions) {
|
|
63
|
+
return defineIndexerPlugin<TFilter, TBlock>((indexer) => {
|
|
64
|
+
indexer.hooks.hook("run:before", async () => {
|
|
65
|
+
await withTransaction(database, async (db) => {
|
|
66
|
+
if (enablePersistState) {
|
|
67
|
+
initializePersistentState(db);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (enableKeyValueStore) {
|
|
71
|
+
initializeKeyValueStore(db);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
indexer.hooks.hook("connect:before", async ({ request }) => {
|
|
77
|
+
if (!enablePersistState) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return await withTransaction(database, async (db) => {
|
|
82
|
+
const { cursor, filter } = getState<TFilter>(db);
|
|
83
|
+
|
|
84
|
+
if (cursor) {
|
|
85
|
+
request.startingCursor = cursor;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (filter) {
|
|
89
|
+
request.filter[1] = filter;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
indexer.hooks.hook("handler:middleware", ({ use }) => {
|
|
95
|
+
use(async (ctx, next) => {
|
|
96
|
+
if (!ctx.finality) {
|
|
97
|
+
throw new SqliteStorageError("finality is undefined");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!ctx.endCursor || !isCursor(ctx.endCursor)) {
|
|
101
|
+
throw new SqliteStorageError(
|
|
102
|
+
"endCursor is undefined or not a cursor",
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await withTransaction(database, async (db) => {
|
|
107
|
+
if (enableKeyValueStore) {
|
|
108
|
+
ctx[KV_PROPERTY] = new KeyValueStore(
|
|
109
|
+
db,
|
|
110
|
+
ctx.endCursor,
|
|
111
|
+
ctx.finality,
|
|
112
|
+
serializeFn,
|
|
113
|
+
deserializeFn,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await next();
|
|
118
|
+
|
|
119
|
+
if (enablePersistState) {
|
|
120
|
+
persistState(db, ctx.endCursor);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (enableKeyValueStore) {
|
|
124
|
+
delete ctx[KV_PROPERTY];
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
indexer.hooks.hook("connect:factory", ({ request, endCursor }) => {
|
|
131
|
+
if (!enablePersistState) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The connect factory hook is called while indexing a block, so the database should be in a transaction
|
|
136
|
+
// created by the middleware.
|
|
137
|
+
assertInTransaction(database);
|
|
138
|
+
|
|
139
|
+
if (endCursor && request.filter[1]) {
|
|
140
|
+
persistState(database, endCursor, request.filter[1]);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
package/src/kv.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Cursor, DataFinality } from "@apibara/protocol";
|
|
2
|
+
import type { Database } from "better-sqlite3";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type DeserializeFn,
|
|
6
|
+
type SerializeFn,
|
|
7
|
+
assertInTransaction,
|
|
8
|
+
} from "./utils";
|
|
9
|
+
|
|
10
|
+
export function initializeKeyValueStore(db: Database) {
|
|
11
|
+
assertInTransaction(db);
|
|
12
|
+
db.exec(statements.createTable);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class KeyValueStore {
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly db: Database,
|
|
18
|
+
private readonly endCursor: Cursor,
|
|
19
|
+
private readonly finality: DataFinality,
|
|
20
|
+
private readonly serialize: SerializeFn,
|
|
21
|
+
private readonly deserialize: DeserializeFn,
|
|
22
|
+
) {
|
|
23
|
+
assertInTransaction(db);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get<T>(key: string): T | undefined {
|
|
27
|
+
const row = this.db.prepare<string, KeyValueRow>(statements.get).get(key);
|
|
28
|
+
|
|
29
|
+
return row ? this.deserialize(row.v) : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
put<T>(key: string, value: T) {
|
|
33
|
+
this.db
|
|
34
|
+
.prepare<[number, string], KeyValueRow>(statements.updateToBlock)
|
|
35
|
+
.run(Number(this.endCursor.orderKey), key);
|
|
36
|
+
|
|
37
|
+
this.db
|
|
38
|
+
.prepare<[number, string, string], KeyValueRow>(statements.insertIntoKvs)
|
|
39
|
+
.run(
|
|
40
|
+
Number(this.endCursor.orderKey),
|
|
41
|
+
key,
|
|
42
|
+
this.serialize(value as Record<string, unknown>),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
del(key: string) {
|
|
47
|
+
this.db
|
|
48
|
+
.prepare<[number, string], KeyValueRow>(statements.del)
|
|
49
|
+
.run(Number(this.endCursor.orderKey), key);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type KeyValueRow = {
|
|
54
|
+
from_block: number;
|
|
55
|
+
to_block: number;
|
|
56
|
+
k: string;
|
|
57
|
+
v: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const statements = {
|
|
61
|
+
createTable: `
|
|
62
|
+
CREATE TABLE IF NOT EXISTS kvs (
|
|
63
|
+
from_block INTEGER NOT NULL,
|
|
64
|
+
to_block INTEGER,
|
|
65
|
+
k TEXT NOT NULL,
|
|
66
|
+
v BLOB NOT NULL,
|
|
67
|
+
PRIMARY KEY (from_block, k)
|
|
68
|
+
);`,
|
|
69
|
+
get: `
|
|
70
|
+
SELECT v
|
|
71
|
+
FROM kvs
|
|
72
|
+
WHERE k = ? AND to_block IS NULL`,
|
|
73
|
+
updateToBlock: `
|
|
74
|
+
UPDATE kvs
|
|
75
|
+
SET to_block = ?
|
|
76
|
+
WHERE k = ? AND to_block IS NULL`,
|
|
77
|
+
insertIntoKvs: `
|
|
78
|
+
INSERT INTO kvs (from_block, to_block, k, v)
|
|
79
|
+
VALUES (?, NULL, ?, ?)`,
|
|
80
|
+
del: `
|
|
81
|
+
UPDATE kvs
|
|
82
|
+
SET to_block = ?
|
|
83
|
+
WHERE k = ? AND to_block IS NULL`,
|
|
84
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { Cursor } from "@apibara/protocol";
|
|
2
|
+
import type { Database } from "better-sqlite3";
|
|
3
|
+
|
|
4
|
+
import { assertInTransaction, deserialize, serialize } from "./utils";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_INDEXER_ID = "default";
|
|
7
|
+
|
|
8
|
+
export function initializePersistentState(db: Database) {
|
|
9
|
+
assertInTransaction(db);
|
|
10
|
+
db.exec(statements.createCheckpointsTable);
|
|
11
|
+
db.exec(statements.createFiltersTable);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function persistState<TFilter>(
|
|
15
|
+
db: Database,
|
|
16
|
+
endCursor: Cursor,
|
|
17
|
+
filter?: TFilter,
|
|
18
|
+
) {
|
|
19
|
+
assertInTransaction(db);
|
|
20
|
+
|
|
21
|
+
db.prepare(statements.putCheckpoint).run(
|
|
22
|
+
DEFAULT_INDEXER_ID,
|
|
23
|
+
Number(endCursor.orderKey),
|
|
24
|
+
endCursor.uniqueKey,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
if (filter) {
|
|
28
|
+
db.prepare(statements.updateFilterToBlock).run(
|
|
29
|
+
Number(endCursor.orderKey),
|
|
30
|
+
DEFAULT_INDEXER_ID,
|
|
31
|
+
);
|
|
32
|
+
db.prepare(statements.insertFilter).run(
|
|
33
|
+
DEFAULT_INDEXER_ID,
|
|
34
|
+
serialize(filter as Record<string, unknown>),
|
|
35
|
+
Number(endCursor.orderKey),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getState<TFilter>(db: Database) {
|
|
41
|
+
assertInTransaction(db);
|
|
42
|
+
const storedCursor = db
|
|
43
|
+
.prepare<string, { order_key?: number; unique_key?: string }>(
|
|
44
|
+
statements.getCheckpoint,
|
|
45
|
+
)
|
|
46
|
+
.get(DEFAULT_INDEXER_ID);
|
|
47
|
+
const storedFilter = db
|
|
48
|
+
.prepare<string, { filter: string }>(statements.getFilter)
|
|
49
|
+
.get(DEFAULT_INDEXER_ID);
|
|
50
|
+
|
|
51
|
+
let cursor: Cursor | undefined;
|
|
52
|
+
let filter: TFilter | undefined;
|
|
53
|
+
|
|
54
|
+
if (storedCursor?.order_key) {
|
|
55
|
+
cursor = {
|
|
56
|
+
orderKey: BigInt(storedCursor.order_key),
|
|
57
|
+
uniqueKey: storedCursor.unique_key as `0x${string}`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (storedFilter) {
|
|
62
|
+
filter = deserialize(storedFilter.filter) as TFilter;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { cursor, filter };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const statements = {
|
|
69
|
+
createCheckpointsTable: `
|
|
70
|
+
CREATE TABLE IF NOT EXISTS checkpoints (
|
|
71
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
72
|
+
order_key INTEGER,
|
|
73
|
+
unique_key TEXT
|
|
74
|
+
);`,
|
|
75
|
+
createFiltersTable: `
|
|
76
|
+
CREATE TABLE IF NOT EXISTS filters (
|
|
77
|
+
id TEXT NOT NULL,
|
|
78
|
+
filter BLOB NOT NULL,
|
|
79
|
+
from_block INTEGER NOT NULL,
|
|
80
|
+
to_block INTEGER,
|
|
81
|
+
PRIMARY KEY (id, from_block)
|
|
82
|
+
);`,
|
|
83
|
+
getCheckpoint: `
|
|
84
|
+
SELECT *
|
|
85
|
+
FROM checkpoints
|
|
86
|
+
WHERE id = ?`,
|
|
87
|
+
putCheckpoint: `
|
|
88
|
+
INSERT INTO checkpoints (id, order_key, unique_key)
|
|
89
|
+
VALUES (?, ?, ?)
|
|
90
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
91
|
+
order_key = excluded.order_key,
|
|
92
|
+
unique_key = excluded.unique_key`,
|
|
93
|
+
delCheckpoint: `
|
|
94
|
+
DELETE FROM checkpoints
|
|
95
|
+
WHERE id = ?`,
|
|
96
|
+
getFilter: `
|
|
97
|
+
SELECT *
|
|
98
|
+
FROM filters
|
|
99
|
+
WHERE id = ? AND to_block IS NULL`,
|
|
100
|
+
updateFilterToBlock: `
|
|
101
|
+
UPDATE filters
|
|
102
|
+
SET to_block = ?
|
|
103
|
+
WHERE id = ? AND to_block IS NULL`,
|
|
104
|
+
insertFilter: `
|
|
105
|
+
INSERT INTO filters (id, filter, from_block)
|
|
106
|
+
VALUES (?, ?, ?)
|
|
107
|
+
ON CONFLICT(id, from_block) DO UPDATE SET
|
|
108
|
+
filter = excluded.filter,
|
|
109
|
+
from_block = excluded.from_block`,
|
|
110
|
+
delFilter: `
|
|
111
|
+
DELETE FROM filters
|
|
112
|
+
WHERE id = ?`,
|
|
113
|
+
};
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Database } from "better-sqlite3";
|
|
2
|
+
|
|
3
|
+
export type SerializeFn = <T>(value: T) => string;
|
|
4
|
+
export type DeserializeFn = <T>(value: string) => T;
|
|
5
|
+
|
|
6
|
+
export class SqliteStorageError extends Error {
|
|
7
|
+
constructor(message: string) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "SqliteStorageError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function withTransaction(
|
|
14
|
+
db: Database,
|
|
15
|
+
cb: (db: Database) => Promise<void>,
|
|
16
|
+
) {
|
|
17
|
+
db.prepare("BEGIN TRANSACTION").run();
|
|
18
|
+
try {
|
|
19
|
+
await cb(db);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
db.prepare("ROLLBACK TRANSACTION").run();
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
db.prepare("COMMIT TRANSACTION").run();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function assertInTransaction(db: Database) {
|
|
28
|
+
if (!db.inTransaction) {
|
|
29
|
+
throw new SqliteStorageError("Database is not in transaction");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function deserialize<T>(str: string): T {
|
|
34
|
+
return JSON.parse(str, (_, value) =>
|
|
35
|
+
typeof value === "string" && value.match(/^\d+n$/)
|
|
36
|
+
? BigInt(value.slice(0, -1))
|
|
37
|
+
: value,
|
|
38
|
+
) as T;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function serialize<T>(obj: T): string {
|
|
42
|
+
return JSON.stringify(
|
|
43
|
+
obj,
|
|
44
|
+
(_, value) => (typeof value === "bigint" ? `${value.toString()}n` : value),
|
|
45
|
+
"\t",
|
|
46
|
+
);
|
|
47
|
+
}
|