@apibara/plugin-drizzle 2.0.0-beta.27 → 2.0.0-beta.29

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/dist/index.mjs CHANGED
@@ -1,12 +1,542 @@
1
+ import { useIndexerContext } from '@apibara/indexer';
2
+ import { defineIndexerPlugin } from '@apibara/indexer/plugins';
3
+ import { and, eq, isNull, gt, lt, sql } from 'drizzle-orm';
4
+ import { pgTable, text, integer, primaryKey, serial, char, jsonb } from 'drizzle-orm/pg-core';
5
+
1
6
  class DrizzleStorageError extends Error {
2
- constructor(message) {
3
- super(message);
7
+ constructor(message, options) {
8
+ super(message, options);
4
9
  this.name = "DrizzleStorageError";
5
10
  }
6
11
  }
12
+ async function withTransaction(db, cb) {
13
+ return await db.transaction(async (txnDb) => {
14
+ return await cb(txnDb);
15
+ });
16
+ }
17
+ function deserialize(str) {
18
+ return JSON.parse(
19
+ str,
20
+ (_, value) => typeof value === "string" && value.match(/^\d+n$/) ? BigInt(value.slice(0, -1)) : value
21
+ );
22
+ }
23
+ function serialize(obj) {
24
+ return JSON.stringify(
25
+ obj,
26
+ (_, value) => typeof value === "bigint" ? `${value.toString()}n` : value,
27
+ " "
28
+ );
29
+ }
30
+
31
+ const CHECKPOINTS_TABLE_NAME = "__indexer_checkpoints";
32
+ const FILTERS_TABLE_NAME = "__indexer_filters";
33
+ const SCHEMA_VERSION_TABLE_NAME = "__indexer_schema_version";
34
+ const checkpoints = pgTable(CHECKPOINTS_TABLE_NAME, {
35
+ id: text("id").notNull().primaryKey(),
36
+ orderKey: integer("order_key").notNull(),
37
+ uniqueKey: text("unique_key").$type().notNull().default(void 0)
38
+ });
39
+ const filters = pgTable(
40
+ FILTERS_TABLE_NAME,
41
+ {
42
+ id: text("id").notNull(),
43
+ filter: text("filter").notNull(),
44
+ fromBlock: integer("from_block").notNull(),
45
+ toBlock: integer("to_block").$type().default(null)
46
+ },
47
+ (table) => [
48
+ {
49
+ pk: primaryKey({ columns: [table.id, table.fromBlock] })
50
+ }
51
+ ]
52
+ );
53
+ const schemaVersion = pgTable(SCHEMA_VERSION_TABLE_NAME, {
54
+ k: integer("k").notNull().primaryKey(),
55
+ version: integer("version").notNull()
56
+ });
57
+ const CURRENT_SCHEMA_VERSION = 0;
58
+ const MIGRATIONS = [
59
+ // migrations[0]: v0 -> v1 (for future use)
60
+ []
61
+ // Add more migration arrays for future versions
62
+ ];
63
+ async function initializePersistentState(tx) {
64
+ await tx.execute(`
65
+ CREATE TABLE IF NOT EXISTS ${SCHEMA_VERSION_TABLE_NAME} (
66
+ k INTEGER PRIMARY KEY,
67
+ version INTEGER NOT NULL
68
+ );
69
+ `);
70
+ const versionRows = await tx.select().from(schemaVersion).where(eq(schemaVersion.k, 0));
71
+ const storedVersion = versionRows[0]?.version ?? -1;
72
+ if (storedVersion > CURRENT_SCHEMA_VERSION) {
73
+ throw new DrizzleStorageError(
74
+ `Database Persistence schema version v${storedVersion} is newer than supported version v${CURRENT_SCHEMA_VERSION}`
75
+ );
76
+ }
77
+ try {
78
+ if (storedVersion === -1) {
79
+ await tx.execute(`
80
+ CREATE TABLE IF NOT EXISTS ${CHECKPOINTS_TABLE_NAME} (
81
+ id TEXT PRIMARY KEY,
82
+ order_key INTEGER NOT NULL,
83
+ unique_key TEXT NOT NULL DEFAULT ''
84
+ );
85
+ `);
86
+ await tx.execute(`
87
+ CREATE TABLE IF NOT EXISTS ${FILTERS_TABLE_NAME} (
88
+ id TEXT NOT NULL,
89
+ filter TEXT NOT NULL,
90
+ from_block INTEGER NOT NULL,
91
+ to_block INTEGER DEFAULT NULL,
92
+ PRIMARY KEY (id, from_block)
93
+ );
94
+ `);
95
+ await tx.insert(schemaVersion).values({
96
+ k: 0,
97
+ version: CURRENT_SCHEMA_VERSION
98
+ });
99
+ } else {
100
+ let currentVersion = storedVersion;
101
+ while (currentVersion < CURRENT_SCHEMA_VERSION) {
102
+ const migrationStatements = MIGRATIONS[currentVersion];
103
+ for (const statement of migrationStatements) {
104
+ await tx.execute(statement);
105
+ }
106
+ currentVersion++;
107
+ }
108
+ await tx.update(schemaVersion).set({ version: CURRENT_SCHEMA_VERSION }).where(eq(schemaVersion.k, 0));
109
+ }
110
+ } catch (error) {
111
+ throw new DrizzleStorageError(
112
+ "Failed to initialize or migrate database schema",
113
+ { cause: error }
114
+ );
115
+ }
116
+ }
117
+ async function persistState(props) {
118
+ const { tx, endCursor, filter, indexerName } = props;
119
+ try {
120
+ if (endCursor) {
121
+ await tx.insert(checkpoints).values({
122
+ id: indexerName,
123
+ orderKey: Number(endCursor.orderKey),
124
+ uniqueKey: endCursor.uniqueKey
125
+ }).onConflictDoUpdate({
126
+ target: checkpoints.id,
127
+ set: {
128
+ orderKey: Number(endCursor.orderKey),
129
+ uniqueKey: endCursor.uniqueKey
130
+ }
131
+ });
132
+ if (filter) {
133
+ await tx.update(filters).set({ toBlock: Number(endCursor.orderKey) }).where(and(eq(filters.id, indexerName), isNull(filters.toBlock)));
134
+ await tx.insert(filters).values({
135
+ id: indexerName,
136
+ filter: serialize(filter),
137
+ fromBlock: Number(endCursor.orderKey),
138
+ toBlock: null
139
+ }).onConflictDoUpdate({
140
+ target: [filters.id, filters.fromBlock],
141
+ set: {
142
+ filter: serialize(filter),
143
+ fromBlock: Number(endCursor.orderKey),
144
+ toBlock: null
145
+ }
146
+ });
147
+ }
148
+ }
149
+ } catch (error) {
150
+ throw new DrizzleStorageError("Failed to persist state", {
151
+ cause: error
152
+ });
153
+ }
154
+ }
155
+ async function getState(props) {
156
+ const { tx, indexerName } = props;
157
+ try {
158
+ const checkpointRows = await tx.select().from(checkpoints).where(eq(checkpoints.id, indexerName));
159
+ const cursor = checkpointRows[0] ? {
160
+ orderKey: BigInt(checkpointRows[0].orderKey),
161
+ uniqueKey: checkpointRows[0].uniqueKey
162
+ } : void 0;
163
+ const filterRows = await tx.select().from(filters).where(and(eq(filters.id, indexerName), isNull(filters.toBlock)));
164
+ const filter = filterRows[0] ? deserialize(filterRows[0].filter) : void 0;
165
+ return { cursor, filter };
166
+ } catch (error) {
167
+ throw new DrizzleStorageError("Failed to get persistent state", {
168
+ cause: error
169
+ });
170
+ }
171
+ }
172
+ async function invalidateState(props) {
173
+ const { tx, cursor, indexerName } = props;
174
+ try {
175
+ await tx.delete(filters).where(
176
+ and(
177
+ eq(filters.id, indexerName),
178
+ gt(filters.fromBlock, Number(cursor.orderKey))
179
+ )
180
+ );
181
+ await tx.update(filters).set({ toBlock: null }).where(
182
+ and(
183
+ eq(filters.id, indexerName),
184
+ gt(filters.toBlock, Number(cursor.orderKey))
185
+ )
186
+ );
187
+ } catch (error) {
188
+ throw new DrizzleStorageError("Failed to invalidate state", {
189
+ cause: error
190
+ });
191
+ }
192
+ }
193
+ async function finalizeState(props) {
194
+ const { tx, cursor, indexerName } = props;
195
+ try {
196
+ await tx.delete(filters).where(
197
+ and(
198
+ eq(filters.id, indexerName),
199
+ lt(filters.toBlock, Number(cursor.orderKey))
200
+ )
201
+ );
202
+ } catch (error) {
203
+ throw new DrizzleStorageError("Failed to finalize state", {
204
+ cause: error
205
+ });
206
+ }
207
+ }
7
208
 
8
- function drizzleStorage() {
9
- throw new DrizzleStorageError("Not implemented");
209
+ pgTable("__reorg_rollback", {
210
+ n: serial("n").primaryKey(),
211
+ op: char("op", { length: 1 }).$type().notNull(),
212
+ table_name: text("table_name").notNull(),
213
+ cursor: integer("cursor").notNull(),
214
+ row_id: text("row_id"),
215
+ row_value: jsonb("row_value")
216
+ });
217
+ async function initializeReorgRollbackTable(tx) {
218
+ try {
219
+ await tx.execute(
220
+ sql.raw(`
221
+ CREATE TABLE IF NOT EXISTS __reorg_rollback(
222
+ n SERIAL PRIMARY KEY,
223
+ op CHAR(1) NOT NULL,
224
+ table_name TEXT NOT NULL,
225
+ cursor INTEGER NOT NULL,
226
+ row_id TEXT,
227
+ row_value JSONB
228
+ );
229
+ `)
230
+ );
231
+ await tx.execute(
232
+ sql.raw(`
233
+ CREATE OR REPLACE FUNCTION reorg_checkpoint()
234
+ RETURNS TRIGGER AS $$
235
+ DECLARE
236
+ id_col TEXT := TG_ARGV[0]::TEXT;
237
+ order_key INTEGER := TG_ARGV[1]::INTEGER;
238
+ new_id_value TEXT := row_to_json(NEW.*)->>id_col;
239
+ old_id_value TEXT := row_to_json(OLD.*)->>id_col;
240
+ BEGIN
241
+ IF (TG_OP = 'DELETE') THEN
242
+ INSERT INTO __reorg_rollback(op, table_name, cursor, row_id, row_value)
243
+ SELECT 'D', TG_TABLE_NAME, order_key, old_id_value, row_to_json(OLD.*);
244
+ ELSIF (TG_OP = 'UPDATE') THEN
245
+ INSERT INTO __reorg_rollback(op, table_name, cursor, row_id, row_value)
246
+ SELECT 'U', TG_TABLE_NAME, order_key, new_id_value, row_to_json(OLD.*);
247
+ ELSIF (TG_OP = 'INSERT') THEN
248
+ INSERT INTO __reorg_rollback(op, table_name, cursor, row_id, row_value)
249
+ SELECT 'I', TG_TABLE_NAME, order_key, new_id_value, null;
250
+ END IF;
251
+ RETURN NULL;
252
+ END;
253
+ $$ LANGUAGE plpgsql;
254
+ `)
255
+ );
256
+ } catch (error) {
257
+ throw new DrizzleStorageError("Failed to initialize reorg rollback table", {
258
+ cause: error
259
+ });
260
+ }
261
+ }
262
+ async function registerTriggers(tx, tables, endCursor, idColumn) {
263
+ try {
264
+ for (const table of tables) {
265
+ await tx.execute(
266
+ sql.raw(`DROP TRIGGER IF EXISTS ${table}_reorg ON ${table};`)
267
+ );
268
+ await tx.execute(
269
+ sql.raw(`
270
+ CREATE CONSTRAINT TRIGGER ${table}_reorg
271
+ AFTER INSERT OR UPDATE OR DELETE ON ${table}
272
+ DEFERRABLE INITIALLY DEFERRED
273
+ FOR EACH ROW EXECUTE FUNCTION reorg_checkpoint('${idColumn}', ${`${Number(endCursor.orderKey)}`});
274
+ `)
275
+ );
276
+ }
277
+ } catch (error) {
278
+ throw new DrizzleStorageError("Failed to register triggers", {
279
+ cause: error
280
+ });
281
+ }
282
+ }
283
+ async function removeTriggers(db, tables) {
284
+ try {
285
+ for (const table of tables) {
286
+ await db.execute(
287
+ sql.raw(`DROP TRIGGER IF EXISTS ${table}_reorg ON ${table};`)
288
+ );
289
+ }
290
+ } catch (error) {
291
+ throw new DrizzleStorageError("Failed to remove triggers", {
292
+ cause: error
293
+ });
294
+ }
295
+ }
296
+ async function invalidate(tx, cursor, idColumn) {
297
+ const { rows: result } = await tx.execute(
298
+ sql.raw(`
299
+ WITH deleted AS (
300
+ DELETE FROM __reorg_rollback
301
+ WHERE cursor > ${Number(cursor.orderKey)}
302
+ RETURNING *
303
+ )
304
+ SELECT * FROM deleted ORDER BY n DESC;
305
+ `)
306
+ );
307
+ if (!Array.isArray(result)) {
308
+ throw new DrizzleStorageError(
309
+ "Invalid result format from reorg_rollback query"
310
+ );
311
+ }
312
+ for (const op of result) {
313
+ switch (op.op) {
314
+ case "I":
315
+ try {
316
+ if (!op.row_id) {
317
+ throw new DrizzleStorageError("Insert operation has no row_id");
318
+ }
319
+ await tx.execute(
320
+ sql.raw(`
321
+ DELETE FROM ${op.table_name}
322
+ WHERE ${idColumn} = '${op.row_id}'
323
+ `)
324
+ );
325
+ } catch (error) {
326
+ throw new DrizzleStorageError(
327
+ "Failed to invalidate | Operation - I",
328
+ {
329
+ cause: error
330
+ }
331
+ );
332
+ }
333
+ break;
334
+ case "D":
335
+ try {
336
+ if (!op.row_value) {
337
+ throw new DrizzleStorageError("Delete operation has no row_value");
338
+ }
339
+ await tx.execute(
340
+ sql.raw(`
341
+ INSERT INTO ${op.table_name}
342
+ SELECT * FROM json_populate_record(null::${op.table_name}, '${JSON.stringify(op.row_value)}'::json)
343
+ `)
344
+ );
345
+ } catch (error) {
346
+ throw new DrizzleStorageError(
347
+ "Failed to invalidate | Operation - D",
348
+ {
349
+ cause: error
350
+ }
351
+ );
352
+ }
353
+ break;
354
+ case "U":
355
+ try {
356
+ if (!op.row_value || !op.row_id) {
357
+ throw new DrizzleStorageError(
358
+ "Update operation has no row_value or row_id"
359
+ );
360
+ }
361
+ const rowValue = typeof op.row_value === "string" ? JSON.parse(op.row_value) : op.row_value;
362
+ const nonIdKeys = Object.keys(rowValue).filter((k) => k !== idColumn);
363
+ const fields = nonIdKeys.map((c) => `${c} = prev.${c}`).join(", ");
364
+ const query = sql.raw(`
365
+ UPDATE ${op.table_name}
366
+ SET ${fields}
367
+ FROM (
368
+ SELECT * FROM json_populate_record(null::${op.table_name}, '${JSON.stringify(op.row_value)}'::json)
369
+ ) as prev
370
+ WHERE ${op.table_name}.${idColumn} = '${op.row_id}'
371
+ `);
372
+ await tx.execute(query);
373
+ } catch (error) {
374
+ throw new DrizzleStorageError(
375
+ "Failed to invalidate | Operation - U",
376
+ {
377
+ cause: error
378
+ }
379
+ );
380
+ }
381
+ break;
382
+ default: {
383
+ throw new DrizzleStorageError(`Unknown operation: ${op.op}`);
384
+ }
385
+ }
386
+ }
387
+ }
388
+ async function finalize(tx, cursor) {
389
+ try {
390
+ await tx.execute(
391
+ sql.raw(`
392
+ DELETE FROM __reorg_rollback
393
+ WHERE cursor <= ${Number(cursor.orderKey)}
394
+ `)
395
+ );
396
+ } catch (error) {
397
+ throw new DrizzleStorageError("Failed to finalize", {
398
+ cause: error
399
+ });
400
+ }
401
+ }
402
+
403
+ const DRIZZLE_PROPERTY = "_drizzle";
404
+ function useDrizzleStorage(_db) {
405
+ const context = useIndexerContext();
406
+ if (!context[DRIZZLE_PROPERTY]) {
407
+ throw new DrizzleStorageError(
408
+ "drizzle storage is not available. Did you register the plugin?"
409
+ );
410
+ }
411
+ return context[DRIZZLE_PROPERTY];
412
+ }
413
+ function drizzleStorage({
414
+ db,
415
+ persistState: enablePersistence = true,
416
+ indexerName = "default",
417
+ schema,
418
+ idColumn = "id"
419
+ }) {
420
+ return defineIndexerPlugin((indexer) => {
421
+ let tableNames = [];
422
+ try {
423
+ tableNames = Object.values(schema ?? db._.schema ?? {}).map(
424
+ (table) => table.dbName
425
+ );
426
+ } catch (error) {
427
+ throw new DrizzleStorageError("Failed to get table names from schema", {
428
+ cause: error
429
+ });
430
+ }
431
+ indexer.hooks.hook("run:before", async () => {
432
+ await withTransaction(db, async (tx) => {
433
+ await initializeReorgRollbackTable(tx);
434
+ if (enablePersistence) {
435
+ await initializePersistentState(tx);
436
+ }
437
+ });
438
+ });
439
+ indexer.hooks.hook("connect:before", async ({ request }) => {
440
+ if (!enablePersistence) {
441
+ return;
442
+ }
443
+ await withTransaction(db, async (tx) => {
444
+ const { cursor, filter } = await getState({
445
+ tx,
446
+ indexerName
447
+ });
448
+ if (cursor) {
449
+ request.startingCursor = cursor;
450
+ }
451
+ if (filter) {
452
+ request.filter[1] = filter;
453
+ }
454
+ });
455
+ });
456
+ indexer.hooks.hook("connect:after", async ({ request }) => {
457
+ const cursor = request.startingCursor;
458
+ if (!cursor) {
459
+ return;
460
+ }
461
+ await withTransaction(db, async (tx) => {
462
+ await invalidate(tx, cursor, idColumn);
463
+ if (enablePersistence) {
464
+ await invalidateState({ tx, cursor, indexerName });
465
+ }
466
+ });
467
+ });
468
+ indexer.hooks.hook("connect:factory", async ({ request, endCursor }) => {
469
+ if (!enablePersistence) {
470
+ return;
471
+ }
472
+ const { db: tx } = useDrizzleStorage();
473
+ if (endCursor && request.filter[1]) {
474
+ await persistState({
475
+ tx,
476
+ endCursor,
477
+ filter: request.filter[1],
478
+ indexerName
479
+ });
480
+ }
481
+ });
482
+ indexer.hooks.hook("message:finalize", async ({ message }) => {
483
+ const { cursor } = message.finalize;
484
+ if (!cursor) {
485
+ throw new DrizzleStorageError("Finalized Cursor is undefined");
486
+ }
487
+ await withTransaction(db, async (tx) => {
488
+ await finalize(tx, cursor);
489
+ if (enablePersistence) {
490
+ await finalizeState({ tx, cursor, indexerName });
491
+ }
492
+ });
493
+ });
494
+ indexer.hooks.hook("message:invalidate", async ({ message }) => {
495
+ const { cursor } = message.invalidate;
496
+ if (!cursor) {
497
+ throw new DrizzleStorageError("Invalidate Cursor is undefined");
498
+ }
499
+ await withTransaction(db, async (tx) => {
500
+ await invalidate(tx, cursor, idColumn);
501
+ if (enablePersistence) {
502
+ await invalidateState({ tx, cursor, indexerName });
503
+ }
504
+ });
505
+ });
506
+ indexer.hooks.hook("handler:middleware", async ({ use }) => {
507
+ use(async (context, next) => {
508
+ try {
509
+ const { endCursor, finality } = context;
510
+ if (!endCursor) {
511
+ throw new DrizzleStorageError("End Cursor is undefined");
512
+ }
513
+ await withTransaction(db, async (tx) => {
514
+ context[DRIZZLE_PROPERTY] = { db: tx };
515
+ if (finality !== "finalized") {
516
+ await registerTriggers(tx, tableNames, endCursor, idColumn);
517
+ }
518
+ await next();
519
+ delete context[DRIZZLE_PROPERTY];
520
+ if (enablePersistence) {
521
+ await persistState({
522
+ tx,
523
+ endCursor,
524
+ indexerName
525
+ });
526
+ }
527
+ });
528
+ if (finality !== "finalized") {
529
+ await removeTriggers(db, tableNames);
530
+ }
531
+ } catch (error) {
532
+ await removeTriggers(db, tableNames);
533
+ throw new DrizzleStorageError("Failed to run handler:middleware", {
534
+ cause: error
535
+ });
536
+ }
537
+ });
538
+ });
539
+ });
10
540
  }
11
541
 
12
- export { drizzleStorage };
542
+ export { drizzleStorage, useDrizzleStorage };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apibara/plugin-drizzle",
3
- "version": "2.0.0-beta.27",
3
+ "version": "2.0.0-beta.29",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -25,17 +25,22 @@
25
25
  "test": "vitest",
26
26
  "test:ci": "vitest run"
27
27
  },
28
+ "peerDependencies": {
29
+ "drizzle-orm": "^0.37.0",
30
+ "pg": "^8.13.1"
31
+ },
28
32
  "devDependencies": {
29
33
  "@electric-sql/pglite": "^0.2.14",
30
34
  "@types/node": "^20.14.0",
35
+ "@types/pg": "^8.11.10",
36
+ "drizzle-orm": "^0.37.0",
37
+ "pg": "^8.13.1",
31
38
  "unbuild": "^2.0.0",
32
39
  "vitest": "^1.6.0"
33
40
  },
34
41
  "dependencies": {
35
- "@apibara/indexer": "2.0.0-beta.28",
36
- "@apibara/protocol": "2.0.0-beta.28",
37
- "drizzle-orm": "^0.37.0",
38
- "pg": "^8.13.1",
42
+ "@apibara/indexer": "2.0.0-beta.30",
43
+ "@apibara/protocol": "2.0.0-beta.30",
39
44
  "postgres-range": "^1.1.4"
40
45
  }
41
46
  }