@apibara/plugin-drizzle 2.1.0-beta.5 → 2.1.0-beta.50

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/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useIndexerContext } from "@apibara/indexer";
2
- import { defineIndexerPlugin } from "@apibara/indexer/plugins";
2
+ import { defineIndexerPlugin, useLogger } from "@apibara/indexer/plugins";
3
3
 
4
4
  import type {
5
5
  ExtractTablesWithRelations,
@@ -14,23 +14,37 @@ import type {
14
14
  PgQueryResultHKT,
15
15
  PgTransaction,
16
16
  } from "drizzle-orm/pg-core";
17
+ import { DRIZZLE_PROPERTY, DRIZZLE_STORAGE_DB_PROPERTY } from "./constants";
18
+ import { type MigrateOptions, migrate } from "./helper";
17
19
  import {
18
20
  finalizeState,
19
21
  getState,
20
22
  initializePersistentState,
21
23
  invalidateState,
22
24
  persistState,
25
+ recordChainReorganization,
26
+ resetPersistence,
23
27
  } from "./persistence";
24
28
  import {
29
+ cleanupStorage,
25
30
  finalize,
26
31
  initializeReorgRollbackTable,
27
32
  invalidate,
28
33
  registerTriggers,
29
34
  removeTriggers,
30
35
  } from "./storage";
31
- import { DrizzleStorageError, sleep, withTransaction } from "./utils";
36
+ import {
37
+ DrizzleStorageError,
38
+ type IdColumnMap,
39
+ getIdColumnForTable,
40
+ sleep,
41
+ withTransaction,
42
+ } from "./utils";
43
+
44
+ export * from "./helper";
45
+
46
+ export type { IdColumnMap };
32
47
 
33
- const DRIZZLE_PROPERTY = "_drizzle";
34
48
  const MAX_RETRIES = 5;
35
49
 
36
50
  export type DrizzleStorage<
@@ -61,17 +75,82 @@ export function useDrizzleStorage<
61
75
  return context[DRIZZLE_PROPERTY];
62
76
  }
63
77
 
78
+ export function useTestDrizzleStorage<
79
+ TQueryResult extends PgQueryResultHKT,
80
+ TFullSchema extends Record<string, unknown> = Record<string, never>,
81
+ TSchema extends
82
+ TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>,
83
+ >(): PgDatabase<TQueryResult, TFullSchema, TSchema> {
84
+ const context = useIndexerContext();
85
+
86
+ if (!context[DRIZZLE_STORAGE_DB_PROPERTY]) {
87
+ throw new DrizzleStorageError(
88
+ "drizzle storage db is not available. Did you register the plugin?",
89
+ );
90
+ }
91
+
92
+ return context[DRIZZLE_STORAGE_DB_PROPERTY];
93
+ }
94
+
64
95
  export interface DrizzleStorageOptions<
65
96
  TQueryResult extends PgQueryResultHKT,
66
97
  TFullSchema extends Record<string, unknown> = Record<string, never>,
67
98
  TSchema extends
68
99
  TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>,
69
100
  > {
101
+ /**
102
+ * The Drizzle database instance.
103
+ */
70
104
  db: PgDatabase<TQueryResult, TFullSchema, TSchema>;
105
+ /**
106
+ * Whether to persist the indexer's state. Defaults to true.
107
+ */
71
108
  persistState?: boolean;
109
+ /**
110
+ * The name of the indexer. Default value is 'default'.
111
+ */
72
112
  indexerName?: string;
113
+ /**
114
+ * The schema of the database.
115
+ */
73
116
  schema?: Record<string, unknown>;
74
- idColumn?: string;
117
+ /**
118
+ * The column to use as the primary identifier for each table.
119
+ *
120
+ * This identifier is used for tracking changes during reorgs and rollbacks.
121
+ *
122
+ * Can be specified in two ways:
123
+ *
124
+ * 1. As a single string that applies to all tables:
125
+ * ```ts
126
+ * idColumn: "_id" // Uses "_id" column for all tables
127
+ * ```
128
+ *
129
+ * 2. As an object mapping table names to their ID columns:
130
+ * ```ts
131
+ * idColumn: {
132
+ * transfers: "transaction_hash", // Use "transaction_hash" for transfers table
133
+ * blocks: "block_number", // Use "block_number" for blocks table
134
+ * "*": "_id" // Use "_id" for all other tables | defaults to "id"
135
+ * }
136
+ * ```
137
+ *
138
+ * The special "*" key acts as a fallback for any tables not explicitly mapped.
139
+ *
140
+ * @default "id"
141
+ * @type {string | Partial<IdColumnMap>}
142
+ */
143
+ idColumn?: string | Partial<IdColumnMap>;
144
+ /**
145
+ * The options for the database migration. When provided, the database will automatically run migrations before the indexer runs.
146
+ */
147
+ migrate?: MigrateOptions;
148
+
149
+ /**
150
+ * Whether to record chain reorganizations in the database.
151
+ * @default false
152
+ */
153
+ recordChainReorganizations?: boolean;
75
154
  }
76
155
 
77
156
  /**
@@ -83,6 +162,8 @@ export interface DrizzleStorageOptions<
83
162
  * @param options.indexerName - The name of the indexer. Defaults value is 'default'.
84
163
  * @param options.schema - The schema of the database.
85
164
  * @param options.idColumn - The column to use as the id. Defaults to 'id'.
165
+ * @param options.migrate - The options for the database migration. when provided, the database will automatically run migrations before the indexer runs.
166
+ * @param options.recordChainReorganizations - Whether to record chain reorganizations in the database. Defaults to false.
86
167
  */
87
168
  export function drizzleStorage<
88
169
  TFilter,
@@ -95,42 +176,102 @@ export function drizzleStorage<
95
176
  db,
96
177
  persistState: enablePersistence = true,
97
178
  indexerName: identifier = "default",
98
- schema,
99
- idColumn = "id",
179
+ schema: _schema,
180
+ idColumn,
181
+ migrate: migrateOptions,
182
+ recordChainReorganizations = false,
100
183
  }: DrizzleStorageOptions<TQueryResult, TFullSchema, TSchema>) {
101
184
  return defineIndexerPlugin<TFilter, TBlock>((indexer) => {
102
185
  let tableNames: string[] = [];
103
186
  let indexerId = "";
187
+ const alwaysReindex = process.env["APIBARA_ALWAYS_REINDEX"] === "true";
188
+ let prevFinality: DataFinality | undefined;
189
+ const schema: TSchema = (_schema as TSchema) ?? db._.schema ?? {};
190
+ const idColumnMap: IdColumnMap = {
191
+ "*": typeof idColumn === "string" ? idColumn : "id",
192
+ ...(typeof idColumn === "object" ? idColumn : {}),
193
+ };
104
194
 
105
195
  try {
106
- tableNames = Object.values((schema as TSchema) ?? db._.schema ?? {}).map(
107
- (table) => table.dbName,
108
- );
196
+ tableNames = Object.values(schema).map((table) => table.dbName);
109
197
  } catch (error) {
110
198
  throw new DrizzleStorageError("Failed to get table names from schema", {
111
199
  cause: error,
112
200
  });
113
201
  }
114
202
 
115
- indexer.hooks.hook("run:before", async () => {
203
+ // Check if specified idColumn exists in all the tables in schema
204
+ for (const table of Object.values(schema)) {
205
+ const columns = table.columns;
206
+ const tableIdColumn = getIdColumnForTable(table.dbName, idColumnMap);
207
+
208
+ const columnExists = Object.values(columns).some(
209
+ (column) => column.name === tableIdColumn,
210
+ );
211
+
212
+ if (!columnExists) {
213
+ throw new DrizzleStorageError(
214
+ `Column \`"${tableIdColumn}"\` does not exist in table \`"${table.dbName}"\`. ` +
215
+ "Make sure the table has the specified column or provide a valid `idColumn` mapping to `drizzleStorage`.",
216
+ );
217
+ }
218
+ }
219
+
220
+ indexer.hooks.hook("plugins:init", async () => {
221
+ const internalContext = useInternalContext();
222
+ const context = useIndexerContext();
223
+ const logger = useLogger();
224
+
225
+ // For testing purposes using vcr.
226
+ context[DRIZZLE_STORAGE_DB_PROPERTY] = db;
227
+
116
228
  const { indexerName: indexerFileName, availableIndexers } =
117
- useInternalContext();
229
+ internalContext;
118
230
 
119
231
  indexerId = generateIndexerId(indexerFileName, identifier);
120
232
 
121
233
  let retries = 0;
122
234
 
235
+ // incase the migrations are already applied, we don't want to run them again
236
+ let migrationsApplied = false;
237
+ let cleanupApplied = false;
238
+
123
239
  while (retries <= MAX_RETRIES) {
124
240
  try {
241
+ if (migrateOptions && !migrationsApplied) {
242
+ // @ts-ignore type mismatch for db
243
+ await migrate(db, migrateOptions);
244
+ migrationsApplied = true;
245
+ logger.success("Migrations applied");
246
+ }
125
247
  await withTransaction(db, async (tx) => {
126
248
  await initializeReorgRollbackTable(tx, indexerId);
127
249
  if (enablePersistence) {
128
250
  await initializePersistentState(tx);
129
251
  }
252
+
253
+ if (alwaysReindex && !cleanupApplied) {
254
+ logger.warn(
255
+ `Reindexing: Deleting all data from tables - ${tableNames.join(", ")}`,
256
+ );
257
+
258
+ await cleanupStorage(tx, tableNames, indexerId);
259
+
260
+ if (enablePersistence) {
261
+ await resetPersistence({ tx, indexerId });
262
+ }
263
+
264
+ cleanupApplied = true;
265
+
266
+ logger.success("Tables have been cleaned up for reindexing");
267
+ }
130
268
  });
131
269
  break;
132
270
  } catch (error) {
133
271
  if (retries === MAX_RETRIES) {
272
+ if (error instanceof DrizzleStorageError) {
273
+ throw error;
274
+ }
134
275
  throw new DrizzleStorageError(
135
276
  "Initialization failed after 5 retries",
136
277
  {
@@ -177,7 +318,8 @@ export function drizzleStorage<
177
318
  }
178
319
 
179
320
  await withTransaction(db, async (tx) => {
180
- await invalidate(tx, cursor, idColumn, indexerId);
321
+ // Use the appropriate idColumn for each table when calling invalidate
322
+ await invalidate(tx, cursor, idColumnMap, indexerId);
181
323
 
182
324
  if (enablePersistence) {
183
325
  await invalidateState({ tx, cursor, indexerId });
@@ -204,7 +346,7 @@ export function drizzleStorage<
204
346
  });
205
347
 
206
348
  indexer.hooks.hook("message:finalize", async ({ message }) => {
207
- const { cursor } = message.finalize;
349
+ const { cursor } = message;
208
350
 
209
351
  if (!cursor) {
210
352
  throw new DrizzleStorageError("Finalized Cursor is undefined");
@@ -220,14 +362,36 @@ export function drizzleStorage<
220
362
  });
221
363
 
222
364
  indexer.hooks.hook("message:invalidate", async ({ message }) => {
223
- const { cursor } = message.invalidate;
365
+ const { cursor } = message;
224
366
 
225
367
  if (!cursor) {
226
368
  throw new DrizzleStorageError("Invalidate Cursor is undefined");
227
369
  }
228
370
 
229
371
  await withTransaction(db, async (tx) => {
230
- await invalidate(tx, cursor, idColumn, indexerId);
372
+ let oldHead: Cursor | undefined;
373
+
374
+ if (recordChainReorganizations) {
375
+ const { cursor: currentCursor } = await getState<
376
+ TFilter,
377
+ TQueryResult,
378
+ TFullSchema,
379
+ TSchema
380
+ >({
381
+ tx,
382
+ indexerId,
383
+ });
384
+ oldHead = currentCursor;
385
+
386
+ await recordChainReorganization({
387
+ tx,
388
+ indexerId,
389
+ oldHead,
390
+ newHead: cursor,
391
+ });
392
+ }
393
+
394
+ await invalidate(tx, cursor, idColumnMap, indexerId);
231
395
 
232
396
  if (enablePersistence) {
233
397
  await invalidateState({ tx, cursor, indexerId });
@@ -238,7 +402,8 @@ export function drizzleStorage<
238
402
  indexer.hooks.hook("handler:middleware", async ({ use }) => {
239
403
  use(async (context, next) => {
240
404
  try {
241
- const { endCursor, finality } = context as {
405
+ const { endCursor, finality, cursor } = context as {
406
+ cursor: Cursor;
242
407
  endCursor: Cursor;
243
408
  finality: DataFinality;
244
409
  };
@@ -254,12 +419,17 @@ export function drizzleStorage<
254
419
  TSchema
255
420
  >;
256
421
 
422
+ if (prevFinality === "pending") {
423
+ // invalidate if previous block's finality was "pending"
424
+ await invalidate(tx, cursor, idColumnMap, indexerId);
425
+ }
426
+
257
427
  if (finality !== "finalized") {
258
428
  await registerTriggers(
259
429
  tx,
260
430
  tableNames,
261
431
  endCursor,
262
- idColumn,
432
+ idColumnMap,
263
433
  indexerId,
264
434
  );
265
435
  }
@@ -267,13 +437,15 @@ export function drizzleStorage<
267
437
  await next();
268
438
  delete context[DRIZZLE_PROPERTY];
269
439
 
270
- if (enablePersistence) {
440
+ if (enablePersistence && finality !== "pending") {
271
441
  await persistState({
272
442
  tx,
273
443
  endCursor,
274
444
  indexerId,
275
445
  });
276
446
  }
447
+
448
+ prevFinality = finality;
277
449
  });
278
450
 
279
451
  if (finality !== "finalized") {
@@ -283,9 +455,7 @@ export function drizzleStorage<
283
455
  } catch (error) {
284
456
  await removeTriggers(db, tableNames, indexerId);
285
457
 
286
- throw new DrizzleStorageError("Failed to run handler:middleware", {
287
- cause: error,
288
- });
458
+ throw error;
289
459
  }
290
460
  });
291
461
  });
@@ -1,24 +1,36 @@
1
1
  import { type Cursor, normalizeCursor } from "@apibara/protocol";
2
- import { and, eq, gt, isNull, lt } from "drizzle-orm";
2
+ import { and, eq, gt, isNull, lt, sql } from "drizzle-orm";
3
3
  import type {
4
4
  ExtractTablesWithRelations,
5
5
  TablesRelationalConfig,
6
6
  } from "drizzle-orm";
7
7
  import type { PgQueryResultHKT, PgTransaction } from "drizzle-orm/pg-core";
8
- import { integer, pgTable, primaryKey, text } from "drizzle-orm/pg-core";
8
+ import {
9
+ integer,
10
+ pgSchema,
11
+ primaryKey,
12
+ serial,
13
+ text,
14
+ timestamp,
15
+ } from "drizzle-orm/pg-core";
16
+ import { SCHEMA_NAME } from "./constants";
9
17
  import { DrizzleStorageError, deserialize, serialize } from "./utils";
10
18
 
11
- const CHECKPOINTS_TABLE_NAME = "__indexer_checkpoints";
12
- const FILTERS_TABLE_NAME = "__indexer_filters";
13
- const SCHEMA_VERSION_TABLE_NAME = "__indexer_schema_version";
19
+ const CHECKPOINTS_TABLE_NAME = "checkpoints";
20
+ const FILTERS_TABLE_NAME = "filters";
21
+ const SCHEMA_VERSION_TABLE_NAME = "schema_version";
14
22
 
15
- export const checkpoints = pgTable(CHECKPOINTS_TABLE_NAME, {
23
+ const schema = pgSchema(SCHEMA_NAME);
24
+
25
+ /** This table is not used for migrations, its only used for ease of internal operations with drizzle. */
26
+ export const checkpoints = schema.table(CHECKPOINTS_TABLE_NAME, {
16
27
  id: text("id").notNull().primaryKey(),
17
28
  orderKey: integer("order_key").notNull(),
18
29
  uniqueKey: text("unique_key"),
19
30
  });
20
31
 
21
- export const filters = pgTable(
32
+ /** This table is not used for migrations, its only used for ease of internal operations with drizzle. */
33
+ export const filters = schema.table(
22
34
  FILTERS_TABLE_NAME,
23
35
  {
24
36
  id: text("id").notNull(),
@@ -33,7 +45,23 @@ export const filters = pgTable(
33
45
  ],
34
46
  );
35
47
 
36
- export const schemaVersion = pgTable(SCHEMA_VERSION_TABLE_NAME, {
48
+ /** Table for recording chain reorganizations */
49
+ export const chainReorganizations = schema.table("chain_reorganizations", {
50
+ id: serial("id").primaryKey(),
51
+ indexerId: text("indexer_id").notNull(),
52
+ oldHeadOrderKey: integer("old_head_order_key"),
53
+ oldHeadUniqueKey: text("old_head_unique_key")
54
+ .$type<string | null>()
55
+ .default(null),
56
+ newHeadOrderKey: integer("new_head_order_key").notNull(),
57
+ newHeadUniqueKey: text("new_head_unique_key")
58
+ .$type<string | null>()
59
+ .default(null),
60
+ recordedAt: timestamp("recorded_at").defaultNow().notNull(),
61
+ });
62
+
63
+ /** This table is not used for migrations, its only used for ease of internal operations with drizzle. */
64
+ export const schemaVersion = schema.table(SCHEMA_VERSION_TABLE_NAME, {
37
65
  k: integer("k").notNull().primaryKey(),
38
66
  version: integer("version").notNull(),
39
67
  });
@@ -53,13 +81,43 @@ export async function initializePersistentState<
53
81
  TSchema extends
54
82
  TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>,
55
83
  >(tx: PgTransaction<TQueryResult, TFullSchema, TSchema>) {
84
+ // Create schema if it doesn't exist
85
+ await tx.execute(
86
+ sql.raw(`
87
+ CREATE SCHEMA IF NOT EXISTS ${SCHEMA_NAME};
88
+ `),
89
+ );
90
+
56
91
  // Create schema version table
57
- await tx.execute(`
58
- CREATE TABLE IF NOT EXISTS ${SCHEMA_VERSION_TABLE_NAME} (
92
+ await tx.execute(
93
+ sql.raw(`
94
+ CREATE TABLE IF NOT EXISTS ${SCHEMA_NAME}.${SCHEMA_VERSION_TABLE_NAME} (
59
95
  k INTEGER PRIMARY KEY,
60
96
  version INTEGER NOT NULL
61
97
  );
62
- `);
98
+ `),
99
+ );
100
+
101
+ await tx.execute(
102
+ sql.raw(`
103
+ CREATE TABLE IF NOT EXISTS ${SCHEMA_NAME}.chain_reorganizations (
104
+ id SERIAL PRIMARY KEY,
105
+ indexer_id TEXT NOT NULL,
106
+ old_head_order_key INTEGER,
107
+ old_head_unique_key TEXT DEFAULT NULL,
108
+ new_head_order_key INTEGER NOT NULL,
109
+ new_head_unique_key TEXT DEFAULT NULL,
110
+ recorded_at TIMESTAMP NOT NULL DEFAULT NOW()
111
+ );
112
+ `),
113
+ );
114
+
115
+ await tx.execute(
116
+ sql.raw(`
117
+ CREATE INDEX IF NOT EXISTS idx_chain_reorgs_indexer_id
118
+ ON ${SCHEMA_NAME}.chain_reorganizations(indexer_id);
119
+ `),
120
+ );
63
121
 
64
122
  // Get current schema version
65
123
  const versionRows = await tx
@@ -80,23 +138,27 @@ export async function initializePersistentState<
80
138
  try {
81
139
  if (storedVersion === -1) {
82
140
  // First time initialization
83
- await tx.execute(`
84
- CREATE TABLE IF NOT EXISTS ${CHECKPOINTS_TABLE_NAME} (
141
+ await tx.execute(
142
+ sql.raw(`
143
+ CREATE TABLE IF NOT EXISTS ${SCHEMA_NAME}.${CHECKPOINTS_TABLE_NAME} (
85
144
  id TEXT PRIMARY KEY,
86
145
  order_key INTEGER NOT NULL,
87
146
  unique_key TEXT
88
147
  );
89
- `);
148
+ `),
149
+ );
90
150
 
91
- await tx.execute(`
92
- CREATE TABLE IF NOT EXISTS ${FILTERS_TABLE_NAME} (
151
+ await tx.execute(
152
+ sql.raw(`
153
+ CREATE TABLE IF NOT EXISTS ${SCHEMA_NAME}.${FILTERS_TABLE_NAME} (
93
154
  id TEXT NOT NULL,
94
155
  filter TEXT NOT NULL,
95
156
  from_block INTEGER NOT NULL,
96
157
  to_block INTEGER DEFAULT NULL,
97
158
  PRIMARY KEY (id, from_block)
98
159
  );
99
- `);
160
+ `),
161
+ );
100
162
 
101
163
  // Set initial schema version
102
164
  await tx.insert(schemaVersion).values({
@@ -128,6 +190,34 @@ export async function initializePersistentState<
128
190
  }
129
191
  }
130
192
 
193
+ export async function recordChainReorganization<
194
+ TQueryResult extends PgQueryResultHKT,
195
+ TFullSchema extends Record<string, unknown> = Record<string, never>,
196
+ TSchema extends
197
+ TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>,
198
+ >(props: {
199
+ tx: PgTransaction<TQueryResult, TFullSchema, TSchema>;
200
+ indexerId: string;
201
+ oldHead: Cursor | undefined;
202
+ newHead: Cursor;
203
+ }) {
204
+ const { tx, indexerId, oldHead, newHead } = props;
205
+
206
+ try {
207
+ await tx.insert(chainReorganizations).values({
208
+ indexerId: indexerId,
209
+ oldHeadOrderKey: oldHead ? Number(oldHead.orderKey) : null,
210
+ oldHeadUniqueKey: oldHead?.uniqueKey ? oldHead.uniqueKey : null,
211
+ newHeadOrderKey: Number(newHead.orderKey),
212
+ newHeadUniqueKey: newHead.uniqueKey ? newHead.uniqueKey : null,
213
+ });
214
+ } catch (error) {
215
+ throw new DrizzleStorageError("Failed to record chain reorganization", {
216
+ cause: error,
217
+ });
218
+ }
219
+ }
220
+
131
221
  export async function persistState<
132
222
  TFilter,
133
223
  TQueryResult extends PgQueryResultHKT,
@@ -155,7 +245,9 @@ export async function persistState<
155
245
  target: checkpoints.id,
156
246
  set: {
157
247
  orderKey: Number(endCursor.orderKey),
158
- uniqueKey: endCursor.uniqueKey,
248
+ // Explicitly set the unique key to `null` to indicate that it has been deleted
249
+ // Otherwise drizzle will not update its value.
250
+ uniqueKey: endCursor.uniqueKey ? endCursor.uniqueKey : null,
159
251
  },
160
252
  });
161
253
 
@@ -245,6 +337,14 @@ export async function invalidateState<
245
337
  const { tx, cursor, indexerId } = props;
246
338
 
247
339
  try {
340
+ await tx
341
+ .update(checkpoints)
342
+ .set({
343
+ orderKey: Number(cursor.orderKey),
344
+ uniqueKey: cursor.uniqueKey ? cursor.uniqueKey : null,
345
+ })
346
+ .where(eq(checkpoints.id, indexerId));
347
+
248
348
  await tx
249
349
  .delete(filters)
250
350
  .where(
@@ -297,3 +397,24 @@ export async function finalizeState<
297
397
  });
298
398
  }
299
399
  }
400
+
401
+ export async function resetPersistence<
402
+ TQueryResult extends PgQueryResultHKT,
403
+ TFullSchema extends Record<string, unknown> = Record<string, never>,
404
+ TSchema extends
405
+ TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>,
406
+ >(props: {
407
+ tx: PgTransaction<TQueryResult, TFullSchema, TSchema>;
408
+ indexerId: string;
409
+ }) {
410
+ const { tx, indexerId } = props;
411
+
412
+ try {
413
+ await tx.delete(checkpoints).where(eq(checkpoints.id, indexerId));
414
+ await tx.delete(filters).where(eq(filters.id, indexerId));
415
+ } catch (error) {
416
+ throw new DrizzleStorageError("Failed to reset persistence state", {
417
+ cause: error,
418
+ });
419
+ }
420
+ }