@apibara/plugin-drizzle 2.1.0-beta.20 → 2.1.0-beta.21

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.cjs CHANGED
@@ -46,6 +46,12 @@ function serialize(obj) {
46
46
  function sleep(ms) {
47
47
  return new Promise((resolve) => setTimeout(resolve, ms));
48
48
  }
49
+ const getIdColumnForTable = (tableName, idColumn) => {
50
+ if (idColumn[tableName]) {
51
+ return idColumn[tableName];
52
+ }
53
+ return idColumn["*"];
54
+ };
49
55
 
50
56
  function drizzle(options) {
51
57
  const {
@@ -375,9 +381,10 @@ async function initializeReorgRollbackTable(tx, indexerId) {
375
381
  );
376
382
  }
377
383
  }
378
- async function registerTriggers(tx, tables, endCursor, idColumn, indexerId) {
384
+ async function registerTriggers(tx, tables, endCursor, idColumnMap, indexerId) {
379
385
  try {
380
386
  for (const table of tables) {
387
+ const tableIdColumn = getIdColumnForTable(table, idColumnMap);
381
388
  await tx.execute(
382
389
  drizzleOrm.sql.raw(
383
390
  `DROP TRIGGER IF EXISTS ${getReorgTriggerName(table, indexerId)} ON ${table};`
@@ -388,7 +395,7 @@ async function registerTriggers(tx, tables, endCursor, idColumn, indexerId) {
388
395
  CREATE CONSTRAINT TRIGGER ${getReorgTriggerName(table, indexerId)}
389
396
  AFTER INSERT OR UPDATE OR DELETE ON ${table}
390
397
  DEFERRABLE INITIALLY DEFERRED
391
- FOR EACH ROW EXECUTE FUNCTION ${constants.SCHEMA_NAME}.reorg_checkpoint('${idColumn}', ${`${Number(endCursor.orderKey)}`}, '${indexerId}');
398
+ FOR EACH ROW EXECUTE FUNCTION ${constants.SCHEMA_NAME}.reorg_checkpoint('${tableIdColumn}', ${Number(endCursor.orderKey)}, '${indexerId}');
392
399
  `)
393
400
  );
394
401
  }
@@ -413,7 +420,7 @@ async function removeTriggers(db, tables, indexerId) {
413
420
  });
414
421
  }
415
422
  }
416
- async function invalidate(tx, cursor, idColumn, indexerId) {
423
+ async function invalidate(tx, cursor, idColumnMap, indexerId) {
417
424
  const { rows: result } = await tx.execute(
418
425
  drizzleOrm.sql.raw(`
419
426
  WITH deleted AS (
@@ -431,6 +438,7 @@ async function invalidate(tx, cursor, idColumn, indexerId) {
431
438
  );
432
439
  }
433
440
  for (const op of result) {
441
+ const tableIdColumn = getIdColumnForTable(op.table_name, idColumnMap);
434
442
  switch (op.op) {
435
443
  case "I":
436
444
  try {
@@ -440,7 +448,7 @@ async function invalidate(tx, cursor, idColumn, indexerId) {
440
448
  await tx.execute(
441
449
  drizzleOrm.sql.raw(`
442
450
  DELETE FROM ${op.table_name}
443
- WHERE ${idColumn} = '${op.row_id}'
451
+ WHERE ${tableIdColumn} = '${op.row_id}'
444
452
  `)
445
453
  );
446
454
  } catch (error) {
@@ -480,7 +488,9 @@ async function invalidate(tx, cursor, idColumn, indexerId) {
480
488
  );
481
489
  }
482
490
  const rowValue = typeof op.row_value === "string" ? JSON.parse(op.row_value) : op.row_value;
483
- const nonIdKeys = Object.keys(rowValue).filter((k) => k !== idColumn);
491
+ const nonIdKeys = Object.keys(rowValue).filter(
492
+ (k) => k !== tableIdColumn
493
+ );
484
494
  const fields = nonIdKeys.map((c) => `${c} = prev.${c}`).join(", ");
485
495
  const query = drizzleOrm.sql.raw(`
486
496
  UPDATE ${op.table_name}
@@ -488,7 +498,7 @@ async function invalidate(tx, cursor, idColumn, indexerId) {
488
498
  FROM (
489
499
  SELECT * FROM json_populate_record(null::${op.table_name}, '${JSON.stringify(op.row_value)}'::json)
490
500
  ) as prev
491
- WHERE ${op.table_name}.${idColumn} = '${op.row_id}'
501
+ WHERE ${op.table_name}.${tableIdColumn} = '${op.row_id}'
492
502
  `);
493
503
  await tx.execute(query);
494
504
  } catch (error) {
@@ -566,8 +576,8 @@ function drizzleStorage({
566
576
  db,
567
577
  persistState: enablePersistence = true,
568
578
  indexerName: identifier = "default",
569
- schema,
570
- idColumn = "id",
579
+ schema: _schema,
580
+ idColumn,
571
581
  migrate: migrateOptions
572
582
  }) {
573
583
  return plugins.defineIndexerPlugin((indexer$1) => {
@@ -575,15 +585,30 @@ function drizzleStorage({
575
585
  let indexerId = "";
576
586
  const alwaysReindex = process.env["APIBARA_ALWAYS_REINDEX"] === "true";
577
587
  let prevFinality;
588
+ const schema = _schema ?? db._.schema ?? {};
589
+ const idColumnMap = {
590
+ "*": typeof idColumn === "string" ? idColumn : "id",
591
+ ...typeof idColumn === "object" ? idColumn : {}
592
+ };
578
593
  try {
579
- tableNames = Object.values(schema ?? db._.schema ?? {}).map(
580
- (table) => table.dbName
581
- );
594
+ tableNames = Object.values(schema).map((table) => table.dbName);
582
595
  } catch (error) {
583
596
  throw new DrizzleStorageError("Failed to get table names from schema", {
584
597
  cause: error
585
598
  });
586
599
  }
600
+ for (const table of Object.values(schema)) {
601
+ const columns = table.columns;
602
+ const tableIdColumn = getIdColumnForTable(table.dbName, idColumnMap);
603
+ const columnExists = Object.values(columns).some(
604
+ (column) => column.name === tableIdColumn
605
+ );
606
+ if (!columnExists) {
607
+ throw new DrizzleStorageError(
608
+ `Column \`"${tableIdColumn}"\` does not exist in table \`"${table.dbName}"\`. Make sure the table has the specified column or provide a valid \`idColumn\` mapping to \`drizzleStorage\`.`
609
+ );
610
+ }
611
+ }
587
612
  indexer$1.hooks.hook("run:before", async () => {
588
613
  const internalContext = plugins$1.useInternalContext();
589
614
  const context = indexer.useIndexerContext();
@@ -591,20 +616,9 @@ function drizzleStorage({
591
616
  context[constants.DRIZZLE_STORAGE_DB_PROPERTY] = db;
592
617
  const { indexerName: indexerFileName, availableIndexers } = internalContext;
593
618
  indexerId = internal.generateIndexerId(indexerFileName, identifier);
594
- if (alwaysReindex) {
595
- logger.warn(
596
- `Reindexing: Deleting all data from tables - ${tableNames.join(", ")}`
597
- );
598
- await withTransaction(db, async (tx) => {
599
- await cleanupStorage(tx, tableNames, indexerId);
600
- if (enablePersistence) {
601
- await resetPersistence({ tx, indexerId });
602
- }
603
- logger.success("Tables have been cleaned up for reindexing");
604
- });
605
- }
606
619
  let retries = 0;
607
620
  let migrationsApplied = false;
621
+ let cleanupApplied = false;
608
622
  while (retries <= MAX_RETRIES) {
609
623
  try {
610
624
  if (migrateOptions && !migrationsApplied) {
@@ -617,6 +631,17 @@ function drizzleStorage({
617
631
  if (enablePersistence) {
618
632
  await initializePersistentState(tx);
619
633
  }
634
+ if (alwaysReindex && !cleanupApplied) {
635
+ logger.warn(
636
+ `Reindexing: Deleting all data from tables - ${tableNames.join(", ")}`
637
+ );
638
+ await cleanupStorage(tx, tableNames, indexerId);
639
+ if (enablePersistence) {
640
+ await resetPersistence({ tx, indexerId });
641
+ }
642
+ cleanupApplied = true;
643
+ logger.success("Tables have been cleaned up for reindexing");
644
+ }
620
645
  });
621
646
  break;
622
647
  } catch (error) {
@@ -659,7 +684,7 @@ function drizzleStorage({
659
684
  return;
660
685
  }
661
686
  await withTransaction(db, async (tx) => {
662
- await invalidate(tx, cursor, idColumn, indexerId);
687
+ await invalidate(tx, cursor, idColumnMap, indexerId);
663
688
  if (enablePersistence) {
664
689
  await invalidateState({ tx, cursor, indexerId });
665
690
  }
@@ -697,7 +722,7 @@ function drizzleStorage({
697
722
  throw new DrizzleStorageError("Invalidate Cursor is undefined");
698
723
  }
699
724
  await withTransaction(db, async (tx) => {
700
- await invalidate(tx, cursor, idColumn, indexerId);
725
+ await invalidate(tx, cursor, idColumnMap, indexerId);
701
726
  if (enablePersistence) {
702
727
  await invalidateState({ tx, cursor, indexerId });
703
728
  }
@@ -713,14 +738,14 @@ function drizzleStorage({
713
738
  await withTransaction(db, async (tx) => {
714
739
  context[constants.DRIZZLE_PROPERTY] = { db: tx };
715
740
  if (prevFinality === "pending") {
716
- await invalidate(tx, cursor, idColumn, indexerId);
741
+ await invalidate(tx, cursor, idColumnMap, indexerId);
717
742
  }
718
743
  if (finality !== "finalized") {
719
744
  await registerTriggers(
720
745
  tx,
721
746
  tableNames,
722
747
  endCursor,
723
- idColumn,
748
+ idColumnMap,
724
749
  indexerId
725
750
  );
726
751
  }
package/dist/index.d.cts CHANGED
@@ -113,6 +113,13 @@ type MigrateOptions = MigrationConfig;
113
113
  */
114
114
  declare function migrate<TSchema extends Record<string, unknown>>(db: PgliteDatabase<TSchema> | NodePgDatabase<TSchema>, options: MigrateOptions): Promise<void>;
115
115
 
116
+ interface IdColumnMap extends Record<string, string> {
117
+ /**
118
+ * Wildcard mapping for all tables.
119
+ */
120
+ "*": string;
121
+ }
122
+
116
123
  type DrizzleStorage<TQueryResult extends PgQueryResultHKT, TFullSchema extends Record<string, unknown> = Record<string, never>, TSchema extends TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>> = {
117
124
  db: PgTransaction<TQueryResult, TFullSchema, TSchema>;
118
125
  };
@@ -135,9 +142,32 @@ interface DrizzleStorageOptions<TQueryResult extends PgQueryResultHKT, TFullSche
135
142
  */
136
143
  schema?: Record<string, unknown>;
137
144
  /**
138
- * The column to use as the id. Defaults to 'id'.
145
+ * The column to use as the primary identifier for each table.
146
+ *
147
+ * This identifier is used for tracking changes during reorgs and rollbacks.
148
+ *
149
+ * Can be specified in two ways:
150
+ *
151
+ * 1. As a single string that applies to all tables:
152
+ * ```ts
153
+ * idColumn: "_id" // Uses "_id" column for all tables
154
+ * ```
155
+ *
156
+ * 2. As an object mapping table names to their ID columns:
157
+ * ```ts
158
+ * idColumn: {
159
+ * transfers: "transaction_hash", // Use "transaction_hash" for transfers table
160
+ * blocks: "block_number", // Use "block_number" for blocks table
161
+ * "*": "_id" // Use "_id" for all other tables | defaults to "id"
162
+ * }
163
+ * ```
164
+ *
165
+ * The special "*" key acts as a fallback for any tables not explicitly mapped.
166
+ *
167
+ * @default "id"
168
+ * @type {string | Partial<IdColumnMap>}
139
169
  */
140
- idColumn?: string;
170
+ idColumn?: string | Partial<IdColumnMap>;
141
171
  /**
142
172
  * The options for the database migration. When provided, the database will automatically run migrations before the indexer runs.
143
173
  */
@@ -154,6 +184,6 @@ interface DrizzleStorageOptions<TQueryResult extends PgQueryResultHKT, TFullSche
154
184
  * @param options.idColumn - The column to use as the id. Defaults to 'id'.
155
185
  * @param options.migrate - The options for the database migration. when provided, the database will automatically run migrations before the indexer runs.
156
186
  */
157
- declare function drizzleStorage<TFilter, TBlock, TQueryResult extends PgQueryResultHKT, TFullSchema extends Record<string, unknown> = Record<string, never>, TSchema extends TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>>({ db, persistState: enablePersistence, indexerName: identifier, schema, idColumn, migrate: migrateOptions, }: DrizzleStorageOptions<TQueryResult, TFullSchema, TSchema>): _apibara_indexer_plugins.IndexerPlugin<TFilter, TBlock>;
187
+ declare function drizzleStorage<TFilter, TBlock, TQueryResult extends PgQueryResultHKT, TFullSchema extends Record<string, unknown> = Record<string, never>, TSchema extends TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>>({ db, persistState: enablePersistence, indexerName: identifier, schema: _schema, idColumn, migrate: migrateOptions, }: DrizzleStorageOptions<TQueryResult, TFullSchema, TSchema>): _apibara_indexer_plugins.IndexerPlugin<TFilter, TBlock>;
158
188
 
159
- export { type Database, type DrizzleOptions, type DrizzleStorage, type DrizzleStorageOptions, type MigrateOptions, type NodePgDatabase, type NodePgDrizzleOptions, type PgliteDatabase, type PgliteDrizzleOptions, drizzle, drizzleStorage, migrate, useDrizzleStorage };
189
+ export { type Database, type DrizzleOptions, type DrizzleStorage, type DrizzleStorageOptions, type IdColumnMap, type MigrateOptions, type NodePgDatabase, type NodePgDrizzleOptions, type PgliteDatabase, type PgliteDrizzleOptions, drizzle, drizzleStorage, migrate, useDrizzleStorage };
package/dist/index.d.mts CHANGED
@@ -113,6 +113,13 @@ type MigrateOptions = MigrationConfig;
113
113
  */
114
114
  declare function migrate<TSchema extends Record<string, unknown>>(db: PgliteDatabase<TSchema> | NodePgDatabase<TSchema>, options: MigrateOptions): Promise<void>;
115
115
 
116
+ interface IdColumnMap extends Record<string, string> {
117
+ /**
118
+ * Wildcard mapping for all tables.
119
+ */
120
+ "*": string;
121
+ }
122
+
116
123
  type DrizzleStorage<TQueryResult extends PgQueryResultHKT, TFullSchema extends Record<string, unknown> = Record<string, never>, TSchema extends TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>> = {
117
124
  db: PgTransaction<TQueryResult, TFullSchema, TSchema>;
118
125
  };
@@ -135,9 +142,32 @@ interface DrizzleStorageOptions<TQueryResult extends PgQueryResultHKT, TFullSche
135
142
  */
136
143
  schema?: Record<string, unknown>;
137
144
  /**
138
- * The column to use as the id. Defaults to 'id'.
145
+ * The column to use as the primary identifier for each table.
146
+ *
147
+ * This identifier is used for tracking changes during reorgs and rollbacks.
148
+ *
149
+ * Can be specified in two ways:
150
+ *
151
+ * 1. As a single string that applies to all tables:
152
+ * ```ts
153
+ * idColumn: "_id" // Uses "_id" column for all tables
154
+ * ```
155
+ *
156
+ * 2. As an object mapping table names to their ID columns:
157
+ * ```ts
158
+ * idColumn: {
159
+ * transfers: "transaction_hash", // Use "transaction_hash" for transfers table
160
+ * blocks: "block_number", // Use "block_number" for blocks table
161
+ * "*": "_id" // Use "_id" for all other tables | defaults to "id"
162
+ * }
163
+ * ```
164
+ *
165
+ * The special "*" key acts as a fallback for any tables not explicitly mapped.
166
+ *
167
+ * @default "id"
168
+ * @type {string | Partial<IdColumnMap>}
139
169
  */
140
- idColumn?: string;
170
+ idColumn?: string | Partial<IdColumnMap>;
141
171
  /**
142
172
  * The options for the database migration. When provided, the database will automatically run migrations before the indexer runs.
143
173
  */
@@ -154,6 +184,6 @@ interface DrizzleStorageOptions<TQueryResult extends PgQueryResultHKT, TFullSche
154
184
  * @param options.idColumn - The column to use as the id. Defaults to 'id'.
155
185
  * @param options.migrate - The options for the database migration. when provided, the database will automatically run migrations before the indexer runs.
156
186
  */
157
- declare function drizzleStorage<TFilter, TBlock, TQueryResult extends PgQueryResultHKT, TFullSchema extends Record<string, unknown> = Record<string, never>, TSchema extends TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>>({ db, persistState: enablePersistence, indexerName: identifier, schema, idColumn, migrate: migrateOptions, }: DrizzleStorageOptions<TQueryResult, TFullSchema, TSchema>): _apibara_indexer_plugins.IndexerPlugin<TFilter, TBlock>;
187
+ declare function drizzleStorage<TFilter, TBlock, TQueryResult extends PgQueryResultHKT, TFullSchema extends Record<string, unknown> = Record<string, never>, TSchema extends TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>>({ db, persistState: enablePersistence, indexerName: identifier, schema: _schema, idColumn, migrate: migrateOptions, }: DrizzleStorageOptions<TQueryResult, TFullSchema, TSchema>): _apibara_indexer_plugins.IndexerPlugin<TFilter, TBlock>;
158
188
 
159
- export { type Database, type DrizzleOptions, type DrizzleStorage, type DrizzleStorageOptions, type MigrateOptions, type NodePgDatabase, type NodePgDrizzleOptions, type PgliteDatabase, type PgliteDrizzleOptions, drizzle, drizzleStorage, migrate, useDrizzleStorage };
189
+ export { type Database, type DrizzleOptions, type DrizzleStorage, type DrizzleStorageOptions, type IdColumnMap, type MigrateOptions, type NodePgDatabase, type NodePgDrizzleOptions, type PgliteDatabase, type PgliteDrizzleOptions, drizzle, drizzleStorage, migrate, useDrizzleStorage };
package/dist/index.d.ts CHANGED
@@ -113,6 +113,13 @@ type MigrateOptions = MigrationConfig;
113
113
  */
114
114
  declare function migrate<TSchema extends Record<string, unknown>>(db: PgliteDatabase<TSchema> | NodePgDatabase<TSchema>, options: MigrateOptions): Promise<void>;
115
115
 
116
+ interface IdColumnMap extends Record<string, string> {
117
+ /**
118
+ * Wildcard mapping for all tables.
119
+ */
120
+ "*": string;
121
+ }
122
+
116
123
  type DrizzleStorage<TQueryResult extends PgQueryResultHKT, TFullSchema extends Record<string, unknown> = Record<string, never>, TSchema extends TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>> = {
117
124
  db: PgTransaction<TQueryResult, TFullSchema, TSchema>;
118
125
  };
@@ -135,9 +142,32 @@ interface DrizzleStorageOptions<TQueryResult extends PgQueryResultHKT, TFullSche
135
142
  */
136
143
  schema?: Record<string, unknown>;
137
144
  /**
138
- * The column to use as the id. Defaults to 'id'.
145
+ * The column to use as the primary identifier for each table.
146
+ *
147
+ * This identifier is used for tracking changes during reorgs and rollbacks.
148
+ *
149
+ * Can be specified in two ways:
150
+ *
151
+ * 1. As a single string that applies to all tables:
152
+ * ```ts
153
+ * idColumn: "_id" // Uses "_id" column for all tables
154
+ * ```
155
+ *
156
+ * 2. As an object mapping table names to their ID columns:
157
+ * ```ts
158
+ * idColumn: {
159
+ * transfers: "transaction_hash", // Use "transaction_hash" for transfers table
160
+ * blocks: "block_number", // Use "block_number" for blocks table
161
+ * "*": "_id" // Use "_id" for all other tables | defaults to "id"
162
+ * }
163
+ * ```
164
+ *
165
+ * The special "*" key acts as a fallback for any tables not explicitly mapped.
166
+ *
167
+ * @default "id"
168
+ * @type {string | Partial<IdColumnMap>}
139
169
  */
140
- idColumn?: string;
170
+ idColumn?: string | Partial<IdColumnMap>;
141
171
  /**
142
172
  * The options for the database migration. When provided, the database will automatically run migrations before the indexer runs.
143
173
  */
@@ -154,6 +184,6 @@ interface DrizzleStorageOptions<TQueryResult extends PgQueryResultHKT, TFullSche
154
184
  * @param options.idColumn - The column to use as the id. Defaults to 'id'.
155
185
  * @param options.migrate - The options for the database migration. when provided, the database will automatically run migrations before the indexer runs.
156
186
  */
157
- declare function drizzleStorage<TFilter, TBlock, TQueryResult extends PgQueryResultHKT, TFullSchema extends Record<string, unknown> = Record<string, never>, TSchema extends TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>>({ db, persistState: enablePersistence, indexerName: identifier, schema, idColumn, migrate: migrateOptions, }: DrizzleStorageOptions<TQueryResult, TFullSchema, TSchema>): _apibara_indexer_plugins.IndexerPlugin<TFilter, TBlock>;
187
+ declare function drizzleStorage<TFilter, TBlock, TQueryResult extends PgQueryResultHKT, TFullSchema extends Record<string, unknown> = Record<string, never>, TSchema extends TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>>({ db, persistState: enablePersistence, indexerName: identifier, schema: _schema, idColumn, migrate: migrateOptions, }: DrizzleStorageOptions<TQueryResult, TFullSchema, TSchema>): _apibara_indexer_plugins.IndexerPlugin<TFilter, TBlock>;
158
188
 
159
- export { type Database, type DrizzleOptions, type DrizzleStorage, type DrizzleStorageOptions, type MigrateOptions, type NodePgDatabase, type NodePgDrizzleOptions, type PgliteDatabase, type PgliteDrizzleOptions, drizzle, drizzleStorage, migrate, useDrizzleStorage };
189
+ export { type Database, type DrizzleOptions, type DrizzleStorage, type DrizzleStorageOptions, type IdColumnMap, type MigrateOptions, type NodePgDatabase, type NodePgDrizzleOptions, type PgliteDatabase, type PgliteDrizzleOptions, drizzle, drizzleStorage, migrate, useDrizzleStorage };
package/dist/index.mjs CHANGED
@@ -40,6 +40,12 @@ function serialize(obj) {
40
40
  function sleep(ms) {
41
41
  return new Promise((resolve) => setTimeout(resolve, ms));
42
42
  }
43
+ const getIdColumnForTable = (tableName, idColumn) => {
44
+ if (idColumn[tableName]) {
45
+ return idColumn[tableName];
46
+ }
47
+ return idColumn["*"];
48
+ };
43
49
 
44
50
  function drizzle(options) {
45
51
  const {
@@ -369,9 +375,10 @@ async function initializeReorgRollbackTable(tx, indexerId) {
369
375
  );
370
376
  }
371
377
  }
372
- async function registerTriggers(tx, tables, endCursor, idColumn, indexerId) {
378
+ async function registerTriggers(tx, tables, endCursor, idColumnMap, indexerId) {
373
379
  try {
374
380
  for (const table of tables) {
381
+ const tableIdColumn = getIdColumnForTable(table, idColumnMap);
375
382
  await tx.execute(
376
383
  sql.raw(
377
384
  `DROP TRIGGER IF EXISTS ${getReorgTriggerName(table, indexerId)} ON ${table};`
@@ -382,7 +389,7 @@ async function registerTriggers(tx, tables, endCursor, idColumn, indexerId) {
382
389
  CREATE CONSTRAINT TRIGGER ${getReorgTriggerName(table, indexerId)}
383
390
  AFTER INSERT OR UPDATE OR DELETE ON ${table}
384
391
  DEFERRABLE INITIALLY DEFERRED
385
- FOR EACH ROW EXECUTE FUNCTION ${SCHEMA_NAME}.reorg_checkpoint('${idColumn}', ${`${Number(endCursor.orderKey)}`}, '${indexerId}');
392
+ FOR EACH ROW EXECUTE FUNCTION ${SCHEMA_NAME}.reorg_checkpoint('${tableIdColumn}', ${Number(endCursor.orderKey)}, '${indexerId}');
386
393
  `)
387
394
  );
388
395
  }
@@ -407,7 +414,7 @@ async function removeTriggers(db, tables, indexerId) {
407
414
  });
408
415
  }
409
416
  }
410
- async function invalidate(tx, cursor, idColumn, indexerId) {
417
+ async function invalidate(tx, cursor, idColumnMap, indexerId) {
411
418
  const { rows: result } = await tx.execute(
412
419
  sql.raw(`
413
420
  WITH deleted AS (
@@ -425,6 +432,7 @@ async function invalidate(tx, cursor, idColumn, indexerId) {
425
432
  );
426
433
  }
427
434
  for (const op of result) {
435
+ const tableIdColumn = getIdColumnForTable(op.table_name, idColumnMap);
428
436
  switch (op.op) {
429
437
  case "I":
430
438
  try {
@@ -434,7 +442,7 @@ async function invalidate(tx, cursor, idColumn, indexerId) {
434
442
  await tx.execute(
435
443
  sql.raw(`
436
444
  DELETE FROM ${op.table_name}
437
- WHERE ${idColumn} = '${op.row_id}'
445
+ WHERE ${tableIdColumn} = '${op.row_id}'
438
446
  `)
439
447
  );
440
448
  } catch (error) {
@@ -474,7 +482,9 @@ async function invalidate(tx, cursor, idColumn, indexerId) {
474
482
  );
475
483
  }
476
484
  const rowValue = typeof op.row_value === "string" ? JSON.parse(op.row_value) : op.row_value;
477
- const nonIdKeys = Object.keys(rowValue).filter((k) => k !== idColumn);
485
+ const nonIdKeys = Object.keys(rowValue).filter(
486
+ (k) => k !== tableIdColumn
487
+ );
478
488
  const fields = nonIdKeys.map((c) => `${c} = prev.${c}`).join(", ");
479
489
  const query = sql.raw(`
480
490
  UPDATE ${op.table_name}
@@ -482,7 +492,7 @@ async function invalidate(tx, cursor, idColumn, indexerId) {
482
492
  FROM (
483
493
  SELECT * FROM json_populate_record(null::${op.table_name}, '${JSON.stringify(op.row_value)}'::json)
484
494
  ) as prev
485
- WHERE ${op.table_name}.${idColumn} = '${op.row_id}'
495
+ WHERE ${op.table_name}.${tableIdColumn} = '${op.row_id}'
486
496
  `);
487
497
  await tx.execute(query);
488
498
  } catch (error) {
@@ -560,8 +570,8 @@ function drizzleStorage({
560
570
  db,
561
571
  persistState: enablePersistence = true,
562
572
  indexerName: identifier = "default",
563
- schema,
564
- idColumn = "id",
573
+ schema: _schema,
574
+ idColumn,
565
575
  migrate: migrateOptions
566
576
  }) {
567
577
  return defineIndexerPlugin((indexer) => {
@@ -569,15 +579,30 @@ function drizzleStorage({
569
579
  let indexerId = "";
570
580
  const alwaysReindex = process.env["APIBARA_ALWAYS_REINDEX"] === "true";
571
581
  let prevFinality;
582
+ const schema = _schema ?? db._.schema ?? {};
583
+ const idColumnMap = {
584
+ "*": typeof idColumn === "string" ? idColumn : "id",
585
+ ...typeof idColumn === "object" ? idColumn : {}
586
+ };
572
587
  try {
573
- tableNames = Object.values(schema ?? db._.schema ?? {}).map(
574
- (table) => table.dbName
575
- );
588
+ tableNames = Object.values(schema).map((table) => table.dbName);
576
589
  } catch (error) {
577
590
  throw new DrizzleStorageError("Failed to get table names from schema", {
578
591
  cause: error
579
592
  });
580
593
  }
594
+ for (const table of Object.values(schema)) {
595
+ const columns = table.columns;
596
+ const tableIdColumn = getIdColumnForTable(table.dbName, idColumnMap);
597
+ const columnExists = Object.values(columns).some(
598
+ (column) => column.name === tableIdColumn
599
+ );
600
+ if (!columnExists) {
601
+ throw new DrizzleStorageError(
602
+ `Column \`"${tableIdColumn}"\` does not exist in table \`"${table.dbName}"\`. Make sure the table has the specified column or provide a valid \`idColumn\` mapping to \`drizzleStorage\`.`
603
+ );
604
+ }
605
+ }
581
606
  indexer.hooks.hook("run:before", async () => {
582
607
  const internalContext = useInternalContext();
583
608
  const context = useIndexerContext();
@@ -585,20 +610,9 @@ function drizzleStorage({
585
610
  context[DRIZZLE_STORAGE_DB_PROPERTY] = db;
586
611
  const { indexerName: indexerFileName, availableIndexers } = internalContext;
587
612
  indexerId = generateIndexerId(indexerFileName, identifier);
588
- if (alwaysReindex) {
589
- logger.warn(
590
- `Reindexing: Deleting all data from tables - ${tableNames.join(", ")}`
591
- );
592
- await withTransaction(db, async (tx) => {
593
- await cleanupStorage(tx, tableNames, indexerId);
594
- if (enablePersistence) {
595
- await resetPersistence({ tx, indexerId });
596
- }
597
- logger.success("Tables have been cleaned up for reindexing");
598
- });
599
- }
600
613
  let retries = 0;
601
614
  let migrationsApplied = false;
615
+ let cleanupApplied = false;
602
616
  while (retries <= MAX_RETRIES) {
603
617
  try {
604
618
  if (migrateOptions && !migrationsApplied) {
@@ -611,6 +625,17 @@ function drizzleStorage({
611
625
  if (enablePersistence) {
612
626
  await initializePersistentState(tx);
613
627
  }
628
+ if (alwaysReindex && !cleanupApplied) {
629
+ logger.warn(
630
+ `Reindexing: Deleting all data from tables - ${tableNames.join(", ")}`
631
+ );
632
+ await cleanupStorage(tx, tableNames, indexerId);
633
+ if (enablePersistence) {
634
+ await resetPersistence({ tx, indexerId });
635
+ }
636
+ cleanupApplied = true;
637
+ logger.success("Tables have been cleaned up for reindexing");
638
+ }
614
639
  });
615
640
  break;
616
641
  } catch (error) {
@@ -653,7 +678,7 @@ function drizzleStorage({
653
678
  return;
654
679
  }
655
680
  await withTransaction(db, async (tx) => {
656
- await invalidate(tx, cursor, idColumn, indexerId);
681
+ await invalidate(tx, cursor, idColumnMap, indexerId);
657
682
  if (enablePersistence) {
658
683
  await invalidateState({ tx, cursor, indexerId });
659
684
  }
@@ -691,7 +716,7 @@ function drizzleStorage({
691
716
  throw new DrizzleStorageError("Invalidate Cursor is undefined");
692
717
  }
693
718
  await withTransaction(db, async (tx) => {
694
- await invalidate(tx, cursor, idColumn, indexerId);
719
+ await invalidate(tx, cursor, idColumnMap, indexerId);
695
720
  if (enablePersistence) {
696
721
  await invalidateState({ tx, cursor, indexerId });
697
722
  }
@@ -707,14 +732,14 @@ function drizzleStorage({
707
732
  await withTransaction(db, async (tx) => {
708
733
  context[DRIZZLE_PROPERTY] = { db: tx };
709
734
  if (prevFinality === "pending") {
710
- await invalidate(tx, cursor, idColumn, indexerId);
735
+ await invalidate(tx, cursor, idColumnMap, indexerId);
711
736
  }
712
737
  if (finality !== "finalized") {
713
738
  await registerTriggers(
714
739
  tx,
715
740
  tableNames,
716
741
  endCursor,
717
- idColumn,
742
+ idColumnMap,
718
743
  indexerId
719
744
  );
720
745
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apibara/plugin-drizzle",
3
- "version": "2.1.0-beta.20",
3
+ "version": "2.1.0-beta.21",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -45,8 +45,8 @@
45
45
  "vitest": "^1.6.0"
46
46
  },
47
47
  "dependencies": {
48
- "@apibara/indexer": "2.1.0-beta.20",
49
- "@apibara/protocol": "2.1.0-beta.20",
48
+ "@apibara/indexer": "2.1.0-beta.21",
49
+ "@apibara/protocol": "2.1.0-beta.21",
50
50
  "postgres-range": "^1.1.4"
51
51
  }
52
52
  }
package/src/index.ts CHANGED
@@ -32,10 +32,18 @@ import {
32
32
  registerTriggers,
33
33
  removeTriggers,
34
34
  } from "./storage";
35
- import { DrizzleStorageError, sleep, withTransaction } from "./utils";
35
+ import {
36
+ DrizzleStorageError,
37
+ type IdColumnMap,
38
+ getIdColumnForTable,
39
+ sleep,
40
+ withTransaction,
41
+ } from "./utils";
36
42
 
37
43
  export * from "./helper";
38
44
 
45
+ export type { IdColumnMap };
46
+
39
47
  const MAX_RETRIES = 5;
40
48
 
41
49
  export type DrizzleStorage<
@@ -89,9 +97,32 @@ export interface DrizzleStorageOptions<
89
97
  */
90
98
  schema?: Record<string, unknown>;
91
99
  /**
92
- * The column to use as the id. Defaults to 'id'.
100
+ * The column to use as the primary identifier for each table.
101
+ *
102
+ * This identifier is used for tracking changes during reorgs and rollbacks.
103
+ *
104
+ * Can be specified in two ways:
105
+ *
106
+ * 1. As a single string that applies to all tables:
107
+ * ```ts
108
+ * idColumn: "_id" // Uses "_id" column for all tables
109
+ * ```
110
+ *
111
+ * 2. As an object mapping table names to their ID columns:
112
+ * ```ts
113
+ * idColumn: {
114
+ * transfers: "transaction_hash", // Use "transaction_hash" for transfers table
115
+ * blocks: "block_number", // Use "block_number" for blocks table
116
+ * "*": "_id" // Use "_id" for all other tables | defaults to "id"
117
+ * }
118
+ * ```
119
+ *
120
+ * The special "*" key acts as a fallback for any tables not explicitly mapped.
121
+ *
122
+ * @default "id"
123
+ * @type {string | Partial<IdColumnMap>}
93
124
  */
94
- idColumn?: string;
125
+ idColumn?: string | Partial<IdColumnMap>;
95
126
  /**
96
127
  * The options for the database migration. When provided, the database will automatically run migrations before the indexer runs.
97
128
  */
@@ -120,8 +151,8 @@ export function drizzleStorage<
120
151
  db,
121
152
  persistState: enablePersistence = true,
122
153
  indexerName: identifier = "default",
123
- schema,
124
- idColumn = "id",
154
+ schema: _schema,
155
+ idColumn,
125
156
  migrate: migrateOptions,
126
157
  }: DrizzleStorageOptions<TQueryResult, TFullSchema, TSchema>) {
127
158
  return defineIndexerPlugin<TFilter, TBlock>((indexer) => {
@@ -129,17 +160,37 @@ export function drizzleStorage<
129
160
  let indexerId = "";
130
161
  const alwaysReindex = process.env["APIBARA_ALWAYS_REINDEX"] === "true";
131
162
  let prevFinality: DataFinality | undefined;
163
+ const schema: TSchema = (_schema as TSchema) ?? db._.schema ?? {};
164
+ const idColumnMap: IdColumnMap = {
165
+ "*": typeof idColumn === "string" ? idColumn : "id",
166
+ ...(typeof idColumn === "object" ? idColumn : {}),
167
+ };
132
168
 
133
169
  try {
134
- tableNames = Object.values((schema as TSchema) ?? db._.schema ?? {}).map(
135
- (table) => table.dbName,
136
- );
170
+ tableNames = Object.values(schema).map((table) => table.dbName);
137
171
  } catch (error) {
138
172
  throw new DrizzleStorageError("Failed to get table names from schema", {
139
173
  cause: error,
140
174
  });
141
175
  }
142
176
 
177
+ // Check if specified idColumn exists in all the tables in schema
178
+ for (const table of Object.values(schema)) {
179
+ const columns = table.columns;
180
+ const tableIdColumn = getIdColumnForTable(table.dbName, idColumnMap);
181
+
182
+ const columnExists = Object.values(columns).some(
183
+ (column) => column.name === tableIdColumn,
184
+ );
185
+
186
+ if (!columnExists) {
187
+ throw new DrizzleStorageError(
188
+ `Column \`"${tableIdColumn}"\` does not exist in table \`"${table.dbName}"\`. ` +
189
+ "Make sure the table has the specified column or provide a valid `idColumn` mapping to `drizzleStorage`.",
190
+ );
191
+ }
192
+ }
193
+
143
194
  indexer.hooks.hook("run:before", async () => {
144
195
  const internalContext = useInternalContext();
145
196
  const context = useIndexerContext();
@@ -153,25 +204,11 @@ export function drizzleStorage<
153
204
 
154
205
  indexerId = generateIndexerId(indexerFileName, identifier);
155
206
 
156
- if (alwaysReindex) {
157
- logger.warn(
158
- `Reindexing: Deleting all data from tables - ${tableNames.join(", ")}`,
159
- );
160
- await withTransaction(db, async (tx) => {
161
- await cleanupStorage(tx, tableNames, indexerId);
162
-
163
- if (enablePersistence) {
164
- await resetPersistence({ tx, indexerId });
165
- }
166
-
167
- logger.success("Tables have been cleaned up for reindexing");
168
- });
169
- }
170
-
171
207
  let retries = 0;
172
208
 
173
209
  // incase the migrations are already applied, we don't want to run them again
174
210
  let migrationsApplied = false;
211
+ let cleanupApplied = false;
175
212
 
176
213
  while (retries <= MAX_RETRIES) {
177
214
  try {
@@ -186,6 +223,22 @@ export function drizzleStorage<
186
223
  if (enablePersistence) {
187
224
  await initializePersistentState(tx);
188
225
  }
226
+
227
+ if (alwaysReindex && !cleanupApplied) {
228
+ logger.warn(
229
+ `Reindexing: Deleting all data from tables - ${tableNames.join(", ")}`,
230
+ );
231
+
232
+ await cleanupStorage(tx, tableNames, indexerId);
233
+
234
+ if (enablePersistence) {
235
+ await resetPersistence({ tx, indexerId });
236
+ }
237
+
238
+ cleanupApplied = true;
239
+
240
+ logger.success("Tables have been cleaned up for reindexing");
241
+ }
189
242
  });
190
243
  break;
191
244
  } catch (error) {
@@ -239,7 +292,8 @@ export function drizzleStorage<
239
292
  }
240
293
 
241
294
  await withTransaction(db, async (tx) => {
242
- await invalidate(tx, cursor, idColumn, indexerId);
295
+ // Use the appropriate idColumn for each table when calling invalidate
296
+ await invalidate(tx, cursor, idColumnMap, indexerId);
243
297
 
244
298
  if (enablePersistence) {
245
299
  await invalidateState({ tx, cursor, indexerId });
@@ -289,7 +343,8 @@ export function drizzleStorage<
289
343
  }
290
344
 
291
345
  await withTransaction(db, async (tx) => {
292
- await invalidate(tx, cursor, idColumn, indexerId);
346
+ // Use the appropriate idColumn for each table when calling invalidate
347
+ await invalidate(tx, cursor, idColumnMap, indexerId);
293
348
 
294
349
  if (enablePersistence) {
295
350
  await invalidateState({ tx, cursor, indexerId });
@@ -319,7 +374,7 @@ export function drizzleStorage<
319
374
 
320
375
  if (prevFinality === "pending") {
321
376
  // invalidate if previous block's finality was "pending"
322
- await invalidate(tx, cursor, idColumn, indexerId);
377
+ await invalidate(tx, cursor, idColumnMap, indexerId);
323
378
  }
324
379
 
325
380
  if (finality !== "finalized") {
@@ -327,7 +382,7 @@ export function drizzleStorage<
327
382
  tx,
328
383
  tableNames,
329
384
  endCursor,
330
- idColumn,
385
+ idColumnMap,
331
386
  indexerId,
332
387
  );
333
388
  }
package/src/storage.ts CHANGED
@@ -16,7 +16,11 @@ import {
16
16
  text,
17
17
  } from "drizzle-orm/pg-core";
18
18
  import { SCHEMA_NAME } from "./constants";
19
- import { DrizzleStorageError } from "./utils";
19
+ import {
20
+ DrizzleStorageError,
21
+ type IdColumnMap,
22
+ getIdColumnForTable,
23
+ } from "./utils";
20
24
 
21
25
  const ROLLBACK_TABLE_NAME = "reorg_rollback";
22
26
 
@@ -125,11 +129,14 @@ export async function registerTriggers<
125
129
  tx: PgTransaction<TQueryResult, TFullSchema, TSchema>,
126
130
  tables: string[],
127
131
  endCursor: Cursor,
128
- idColumn: string,
132
+ idColumnMap: IdColumnMap,
129
133
  indexerId: string,
130
134
  ) {
131
135
  try {
132
136
  for (const table of tables) {
137
+ // Determine the column ID for this specific table
138
+ const tableIdColumn = getIdColumnForTable(table, idColumnMap);
139
+
133
140
  await tx.execute(
134
141
  sql.raw(
135
142
  `DROP TRIGGER IF EXISTS ${getReorgTriggerName(table, indexerId)} ON ${table};`,
@@ -140,7 +147,7 @@ export async function registerTriggers<
140
147
  CREATE CONSTRAINT TRIGGER ${getReorgTriggerName(table, indexerId)}
141
148
  AFTER INSERT OR UPDATE OR DELETE ON ${table}
142
149
  DEFERRABLE INITIALLY DEFERRED
143
- FOR EACH ROW EXECUTE FUNCTION ${SCHEMA_NAME}.reorg_checkpoint('${idColumn}', ${`${Number(endCursor.orderKey)}`}, '${indexerId}');
150
+ FOR EACH ROW EXECUTE FUNCTION ${SCHEMA_NAME}.reorg_checkpoint('${tableIdColumn}', ${Number(endCursor.orderKey)}, '${indexerId}');
144
151
  `),
145
152
  );
146
153
  }
@@ -184,7 +191,7 @@ export async function invalidate<
184
191
  >(
185
192
  tx: PgTransaction<TQueryResult, TFullSchema, TSchema>,
186
193
  cursor: Cursor,
187
- idColumn: string,
194
+ idColumnMap: IdColumnMap,
188
195
  indexerId: string,
189
196
  ) {
190
197
  // Get and delete operations after cursor in one query, ordered by newest first
@@ -208,6 +215,9 @@ export async function invalidate<
208
215
 
209
216
  // Process each operation in reverse order
210
217
  for (const op of result) {
218
+ // Determine the column ID for this specific table
219
+ const tableIdColumn = getIdColumnForTable(op.table_name, idColumnMap);
220
+
211
221
  switch (op.op) {
212
222
  case "I":
213
223
  try {
@@ -218,7 +228,7 @@ export async function invalidate<
218
228
  await tx.execute(
219
229
  sql.raw(`
220
230
  DELETE FROM ${op.table_name}
221
- WHERE ${idColumn} = '${op.row_id}'
231
+ WHERE ${tableIdColumn} = '${op.row_id}'
222
232
  `),
223
233
  );
224
234
  } catch (error) {
@@ -271,7 +281,9 @@ export async function invalidate<
271
281
  ? JSON.parse(op.row_value)
272
282
  : op.row_value;
273
283
 
274
- const nonIdKeys = Object.keys(rowValue).filter((k) => k !== idColumn);
284
+ const nonIdKeys = Object.keys(rowValue).filter(
285
+ (k) => k !== tableIdColumn,
286
+ );
275
287
 
276
288
  const fields = nonIdKeys.map((c) => `${c} = prev.${c}`).join(", ");
277
289
 
@@ -281,7 +293,7 @@ export async function invalidate<
281
293
  FROM (
282
294
  SELECT * FROM json_populate_record(null::${op.table_name}, '${JSON.stringify(op.row_value)}'::json)
283
295
  ) as prev
284
- WHERE ${op.table_name}.${idColumn} = '${op.row_id}'
296
+ WHERE ${op.table_name}.${tableIdColumn} = '${op.row_id}'
285
297
  `);
286
298
 
287
299
  await tx.execute(query);
package/src/utils.ts CHANGED
@@ -48,3 +48,22 @@ export function serialize<T>(obj: T): string {
48
48
  export function sleep(ms: number) {
49
49
  return new Promise((resolve) => setTimeout(resolve, ms));
50
50
  }
51
+
52
+ export interface IdColumnMap extends Record<string, string> {
53
+ /**
54
+ * Wildcard mapping for all tables.
55
+ */
56
+ "*": string;
57
+ }
58
+
59
+ export const getIdColumnForTable = (
60
+ tableName: string,
61
+ idColumn: IdColumnMap,
62
+ ): string => {
63
+ // If there's a specific mapping for this table, use it
64
+ if (idColumn[tableName]) {
65
+ return idColumn[tableName];
66
+ }
67
+ // Default fallback
68
+ return idColumn["*"];
69
+ };