@apibara/plugin-drizzle 2.1.0-beta.4 → 2.1.0-beta.40

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,36 @@ 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
+ resetPersistence,
23
26
  } from "./persistence";
24
27
  import {
28
+ cleanupStorage,
25
29
  finalize,
26
30
  initializeReorgRollbackTable,
27
31
  invalidate,
28
32
  registerTriggers,
29
33
  removeTriggers,
30
34
  } from "./storage";
31
- import { DrizzleStorageError, sleep, withTransaction } from "./utils";
35
+ import {
36
+ DrizzleStorageError,
37
+ type IdColumnMap,
38
+ getIdColumnForTable,
39
+ sleep,
40
+ withTransaction,
41
+ } from "./utils";
42
+
43
+ export * from "./helper";
44
+
45
+ export type { IdColumnMap };
32
46
 
33
- const DRIZZLE_PROPERTY = "_drizzle";
34
47
  const MAX_RETRIES = 5;
35
48
 
36
49
  export type DrizzleStorage<
@@ -61,17 +74,76 @@ export function useDrizzleStorage<
61
74
  return context[DRIZZLE_PROPERTY];
62
75
  }
63
76
 
77
+ export function useTestDrizzleStorage<
78
+ TQueryResult extends PgQueryResultHKT,
79
+ TFullSchema extends Record<string, unknown> = Record<string, never>,
80
+ TSchema extends
81
+ TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>,
82
+ >(): PgDatabase<TQueryResult, TFullSchema, TSchema> {
83
+ const context = useIndexerContext();
84
+
85
+ if (!context[DRIZZLE_STORAGE_DB_PROPERTY]) {
86
+ throw new DrizzleStorageError(
87
+ "drizzle storage db is not available. Did you register the plugin?",
88
+ );
89
+ }
90
+
91
+ return context[DRIZZLE_STORAGE_DB_PROPERTY];
92
+ }
93
+
64
94
  export interface DrizzleStorageOptions<
65
95
  TQueryResult extends PgQueryResultHKT,
66
96
  TFullSchema extends Record<string, unknown> = Record<string, never>,
67
97
  TSchema extends
68
98
  TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>,
69
99
  > {
100
+ /**
101
+ * The Drizzle database instance.
102
+ */
70
103
  db: PgDatabase<TQueryResult, TFullSchema, TSchema>;
104
+ /**
105
+ * Whether to persist the indexer's state. Defaults to true.
106
+ */
71
107
  persistState?: boolean;
108
+ /**
109
+ * The name of the indexer. Default value is 'default'.
110
+ */
72
111
  indexerName?: string;
112
+ /**
113
+ * The schema of the database.
114
+ */
73
115
  schema?: Record<string, unknown>;
74
- idColumn?: string;
116
+ /**
117
+ * The column to use as the primary identifier for each table.
118
+ *
119
+ * This identifier is used for tracking changes during reorgs and rollbacks.
120
+ *
121
+ * Can be specified in two ways:
122
+ *
123
+ * 1. As a single string that applies to all tables:
124
+ * ```ts
125
+ * idColumn: "_id" // Uses "_id" column for all tables
126
+ * ```
127
+ *
128
+ * 2. As an object mapping table names to their ID columns:
129
+ * ```ts
130
+ * idColumn: {
131
+ * transfers: "transaction_hash", // Use "transaction_hash" for transfers table
132
+ * blocks: "block_number", // Use "block_number" for blocks table
133
+ * "*": "_id" // Use "_id" for all other tables | defaults to "id"
134
+ * }
135
+ * ```
136
+ *
137
+ * The special "*" key acts as a fallback for any tables not explicitly mapped.
138
+ *
139
+ * @default "id"
140
+ * @type {string | Partial<IdColumnMap>}
141
+ */
142
+ idColumn?: string | Partial<IdColumnMap>;
143
+ /**
144
+ * The options for the database migration. When provided, the database will automatically run migrations before the indexer runs.
145
+ */
146
+ migrate?: MigrateOptions;
75
147
  }
76
148
 
77
149
  /**
@@ -83,6 +155,7 @@ export interface DrizzleStorageOptions<
83
155
  * @param options.indexerName - The name of the indexer. Defaults value is 'default'.
84
156
  * @param options.schema - The schema of the database.
85
157
  * @param options.idColumn - The column to use as the id. Defaults to 'id'.
158
+ * @param options.migrate - The options for the database migration. when provided, the database will automatically run migrations before the indexer runs.
86
159
  */
87
160
  export function drizzleStorage<
88
161
  TFilter,
@@ -95,42 +168,101 @@ export function drizzleStorage<
95
168
  db,
96
169
  persistState: enablePersistence = true,
97
170
  indexerName: identifier = "default",
98
- schema,
99
- idColumn = "id",
171
+ schema: _schema,
172
+ idColumn,
173
+ migrate: migrateOptions,
100
174
  }: DrizzleStorageOptions<TQueryResult, TFullSchema, TSchema>) {
101
175
  return defineIndexerPlugin<TFilter, TBlock>((indexer) => {
102
176
  let tableNames: string[] = [];
103
177
  let indexerId = "";
178
+ const alwaysReindex = process.env["APIBARA_ALWAYS_REINDEX"] === "true";
179
+ let prevFinality: DataFinality | undefined;
180
+ const schema: TSchema = (_schema as TSchema) ?? db._.schema ?? {};
181
+ const idColumnMap: IdColumnMap = {
182
+ "*": typeof idColumn === "string" ? idColumn : "id",
183
+ ...(typeof idColumn === "object" ? idColumn : {}),
184
+ };
104
185
 
105
186
  try {
106
- tableNames = Object.values((schema as TSchema) ?? db._.schema ?? {}).map(
107
- (table) => table.dbName,
108
- );
187
+ tableNames = Object.values(schema).map((table) => table.dbName);
109
188
  } catch (error) {
110
189
  throw new DrizzleStorageError("Failed to get table names from schema", {
111
190
  cause: error,
112
191
  });
113
192
  }
114
193
 
115
- indexer.hooks.hook("run:before", async () => {
194
+ // Check if specified idColumn exists in all the tables in schema
195
+ for (const table of Object.values(schema)) {
196
+ const columns = table.columns;
197
+ const tableIdColumn = getIdColumnForTable(table.dbName, idColumnMap);
198
+
199
+ const columnExists = Object.values(columns).some(
200
+ (column) => column.name === tableIdColumn,
201
+ );
202
+
203
+ if (!columnExists) {
204
+ throw new DrizzleStorageError(
205
+ `Column \`"${tableIdColumn}"\` does not exist in table \`"${table.dbName}"\`. ` +
206
+ "Make sure the table has the specified column or provide a valid `idColumn` mapping to `drizzleStorage`.",
207
+ );
208
+ }
209
+ }
210
+
211
+ indexer.hooks.hook("plugins:init", async () => {
212
+ const internalContext = useInternalContext();
213
+ const context = useIndexerContext();
214
+ const logger = useLogger();
215
+
216
+ // For testing purposes using vcr.
217
+ context[DRIZZLE_STORAGE_DB_PROPERTY] = db;
218
+
116
219
  const { indexerName: indexerFileName, availableIndexers } =
117
- useInternalContext();
220
+ internalContext;
118
221
 
119
222
  indexerId = generateIndexerId(indexerFileName, identifier);
120
223
 
121
224
  let retries = 0;
122
225
 
226
+ // incase the migrations are already applied, we don't want to run them again
227
+ let migrationsApplied = false;
228
+ let cleanupApplied = false;
229
+
123
230
  while (retries <= MAX_RETRIES) {
124
231
  try {
232
+ if (migrateOptions && !migrationsApplied) {
233
+ // @ts-ignore type mismatch for db
234
+ await migrate(db, migrateOptions);
235
+ migrationsApplied = true;
236
+ logger.success("Migrations applied");
237
+ }
125
238
  await withTransaction(db, async (tx) => {
126
239
  await initializeReorgRollbackTable(tx, indexerId);
127
240
  if (enablePersistence) {
128
241
  await initializePersistentState(tx);
129
242
  }
243
+
244
+ if (alwaysReindex && !cleanupApplied) {
245
+ logger.warn(
246
+ `Reindexing: Deleting all data from tables - ${tableNames.join(", ")}`,
247
+ );
248
+
249
+ await cleanupStorage(tx, tableNames, indexerId);
250
+
251
+ if (enablePersistence) {
252
+ await resetPersistence({ tx, indexerId });
253
+ }
254
+
255
+ cleanupApplied = true;
256
+
257
+ logger.success("Tables have been cleaned up for reindexing");
258
+ }
130
259
  });
131
260
  break;
132
261
  } catch (error) {
133
262
  if (retries === MAX_RETRIES) {
263
+ if (error instanceof DrizzleStorageError) {
264
+ throw error;
265
+ }
134
266
  throw new DrizzleStorageError(
135
267
  "Initialization failed after 5 retries",
136
268
  {
@@ -177,7 +309,8 @@ export function drizzleStorage<
177
309
  }
178
310
 
179
311
  await withTransaction(db, async (tx) => {
180
- await invalidate(tx, cursor, idColumn, indexerId);
312
+ // Use the appropriate idColumn for each table when calling invalidate
313
+ await invalidate(tx, cursor, idColumnMap, indexerId);
181
314
 
182
315
  if (enablePersistence) {
183
316
  await invalidateState({ tx, cursor, indexerId });
@@ -204,7 +337,7 @@ export function drizzleStorage<
204
337
  });
205
338
 
206
339
  indexer.hooks.hook("message:finalize", async ({ message }) => {
207
- const { cursor } = message.finalize;
340
+ const { cursor } = message;
208
341
 
209
342
  if (!cursor) {
210
343
  throw new DrizzleStorageError("Finalized Cursor is undefined");
@@ -220,14 +353,15 @@ export function drizzleStorage<
220
353
  });
221
354
 
222
355
  indexer.hooks.hook("message:invalidate", async ({ message }) => {
223
- const { cursor } = message.invalidate;
356
+ const { cursor } = message;
224
357
 
225
358
  if (!cursor) {
226
359
  throw new DrizzleStorageError("Invalidate Cursor is undefined");
227
360
  }
228
361
 
229
362
  await withTransaction(db, async (tx) => {
230
- await invalidate(tx, cursor, idColumn, indexerId);
363
+ // Use the appropriate idColumn for each table when calling invalidate
364
+ await invalidate(tx, cursor, idColumnMap, indexerId);
231
365
 
232
366
  if (enablePersistence) {
233
367
  await invalidateState({ tx, cursor, indexerId });
@@ -238,7 +372,8 @@ export function drizzleStorage<
238
372
  indexer.hooks.hook("handler:middleware", async ({ use }) => {
239
373
  use(async (context, next) => {
240
374
  try {
241
- const { endCursor, finality } = context as {
375
+ const { endCursor, finality, cursor } = context as {
376
+ cursor: Cursor;
242
377
  endCursor: Cursor;
243
378
  finality: DataFinality;
244
379
  };
@@ -254,12 +389,17 @@ export function drizzleStorage<
254
389
  TSchema
255
390
  >;
256
391
 
392
+ if (prevFinality === "pending") {
393
+ // invalidate if previous block's finality was "pending"
394
+ await invalidate(tx, cursor, idColumnMap, indexerId);
395
+ }
396
+
257
397
  if (finality !== "finalized") {
258
398
  await registerTriggers(
259
399
  tx,
260
400
  tableNames,
261
401
  endCursor,
262
- idColumn,
402
+ idColumnMap,
263
403
  indexerId,
264
404
  );
265
405
  }
@@ -267,13 +407,15 @@ export function drizzleStorage<
267
407
  await next();
268
408
  delete context[DRIZZLE_PROPERTY];
269
409
 
270
- if (enablePersistence) {
410
+ if (enablePersistence && finality !== "pending") {
271
411
  await persistState({
272
412
  tx,
273
413
  endCursor,
274
414
  indexerId,
275
415
  });
276
416
  }
417
+
418
+ prevFinality = finality;
277
419
  });
278
420
 
279
421
  if (finality !== "finalized") {
@@ -1,24 +1,29 @@
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 { integer, pgSchema, primaryKey, text } from "drizzle-orm/pg-core";
9
+ import { SCHEMA_NAME } from "./constants";
9
10
  import { DrizzleStorageError, deserialize, serialize } from "./utils";
10
11
 
11
- const CHECKPOINTS_TABLE_NAME = "__indexer_checkpoints";
12
- const FILTERS_TABLE_NAME = "__indexer_filters";
13
- const SCHEMA_VERSION_TABLE_NAME = "__indexer_schema_version";
12
+ const CHECKPOINTS_TABLE_NAME = "checkpoints";
13
+ const FILTERS_TABLE_NAME = "filters";
14
+ const SCHEMA_VERSION_TABLE_NAME = "schema_version";
14
15
 
15
- export const checkpoints = pgTable(CHECKPOINTS_TABLE_NAME, {
16
+ const schema = pgSchema(SCHEMA_NAME);
17
+
18
+ /** This table is not used for migrations, its only used for ease of internal operations with drizzle. */
19
+ export const checkpoints = schema.table(CHECKPOINTS_TABLE_NAME, {
16
20
  id: text("id").notNull().primaryKey(),
17
21
  orderKey: integer("order_key").notNull(),
18
22
  uniqueKey: text("unique_key"),
19
23
  });
20
24
 
21
- export const filters = pgTable(
25
+ /** This table is not used for migrations, its only used for ease of internal operations with drizzle. */
26
+ export const filters = schema.table(
22
27
  FILTERS_TABLE_NAME,
23
28
  {
24
29
  id: text("id").notNull(),
@@ -33,7 +38,8 @@ export const filters = pgTable(
33
38
  ],
34
39
  );
35
40
 
36
- export const schemaVersion = pgTable(SCHEMA_VERSION_TABLE_NAME, {
41
+ /** This table is not used for migrations, its only used for ease of internal operations with drizzle. */
42
+ export const schemaVersion = schema.table(SCHEMA_VERSION_TABLE_NAME, {
37
43
  k: integer("k").notNull().primaryKey(),
38
44
  version: integer("version").notNull(),
39
45
  });
@@ -53,13 +59,22 @@ export async function initializePersistentState<
53
59
  TSchema extends
54
60
  TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>,
55
61
  >(tx: PgTransaction<TQueryResult, TFullSchema, TSchema>) {
62
+ // Create schema if it doesn't exist
63
+ await tx.execute(
64
+ sql.raw(`
65
+ CREATE SCHEMA IF NOT EXISTS ${SCHEMA_NAME};
66
+ `),
67
+ );
68
+
56
69
  // Create schema version table
57
- await tx.execute(`
58
- CREATE TABLE IF NOT EXISTS ${SCHEMA_VERSION_TABLE_NAME} (
70
+ await tx.execute(
71
+ sql.raw(`
72
+ CREATE TABLE IF NOT EXISTS ${SCHEMA_NAME}.${SCHEMA_VERSION_TABLE_NAME} (
59
73
  k INTEGER PRIMARY KEY,
60
74
  version INTEGER NOT NULL
61
75
  );
62
- `);
76
+ `),
77
+ );
63
78
 
64
79
  // Get current schema version
65
80
  const versionRows = await tx
@@ -80,23 +95,27 @@ export async function initializePersistentState<
80
95
  try {
81
96
  if (storedVersion === -1) {
82
97
  // First time initialization
83
- await tx.execute(`
84
- CREATE TABLE IF NOT EXISTS ${CHECKPOINTS_TABLE_NAME} (
98
+ await tx.execute(
99
+ sql.raw(`
100
+ CREATE TABLE IF NOT EXISTS ${SCHEMA_NAME}.${CHECKPOINTS_TABLE_NAME} (
85
101
  id TEXT PRIMARY KEY,
86
102
  order_key INTEGER NOT NULL,
87
103
  unique_key TEXT
88
104
  );
89
- `);
105
+ `),
106
+ );
90
107
 
91
- await tx.execute(`
92
- CREATE TABLE IF NOT EXISTS ${FILTERS_TABLE_NAME} (
108
+ await tx.execute(
109
+ sql.raw(`
110
+ CREATE TABLE IF NOT EXISTS ${SCHEMA_NAME}.${FILTERS_TABLE_NAME} (
93
111
  id TEXT NOT NULL,
94
112
  filter TEXT NOT NULL,
95
113
  from_block INTEGER NOT NULL,
96
114
  to_block INTEGER DEFAULT NULL,
97
115
  PRIMARY KEY (id, from_block)
98
116
  );
99
- `);
117
+ `),
118
+ );
100
119
 
101
120
  // Set initial schema version
102
121
  await tx.insert(schemaVersion).values({
@@ -155,7 +174,9 @@ export async function persistState<
155
174
  target: checkpoints.id,
156
175
  set: {
157
176
  orderKey: Number(endCursor.orderKey),
158
- uniqueKey: endCursor.uniqueKey,
177
+ // Explicitly set the unique key to `null` to indicate that it has been deleted
178
+ // Otherwise drizzle will not update its value.
179
+ uniqueKey: endCursor.uniqueKey ? endCursor.uniqueKey : null,
159
180
  },
160
181
  });
161
182
 
@@ -297,3 +318,24 @@ export async function finalizeState<
297
318
  });
298
319
  }
299
320
  }
321
+
322
+ export async function resetPersistence<
323
+ TQueryResult extends PgQueryResultHKT,
324
+ TFullSchema extends Record<string, unknown> = Record<string, never>,
325
+ TSchema extends
326
+ TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>,
327
+ >(props: {
328
+ tx: PgTransaction<TQueryResult, TFullSchema, TSchema>;
329
+ indexerId: string;
330
+ }) {
331
+ const { tx, indexerId } = props;
332
+
333
+ try {
334
+ await tx.delete(checkpoints).where(eq(checkpoints.id, indexerId));
335
+ await tx.delete(filters).where(eq(filters.id, indexerId));
336
+ } catch (error) {
337
+ throw new DrizzleStorageError("Failed to reset persistence state", {
338
+ cause: error,
339
+ });
340
+ }
341
+ }
package/src/storage.ts CHANGED
@@ -11,11 +11,20 @@ import {
11
11
  char,
12
12
  integer,
13
13
  jsonb,
14
- pgTable,
14
+ pgSchema,
15
15
  serial,
16
16
  text,
17
17
  } from "drizzle-orm/pg-core";
18
- import { DrizzleStorageError } from "./utils";
18
+ import { SCHEMA_NAME } from "./constants";
19
+ import {
20
+ DrizzleStorageError,
21
+ type IdColumnMap,
22
+ getIdColumnForTable,
23
+ } from "./utils";
24
+
25
+ const ROLLBACK_TABLE_NAME = "reorg_rollback";
26
+
27
+ const schema = pgSchema(SCHEMA_NAME);
19
28
 
20
29
  function getReorgTriggerName(table: string, indexerId: string) {
21
30
  return `${table}_reorg_${indexerId}`;
@@ -23,7 +32,8 @@ function getReorgTriggerName(table: string, indexerId: string) {
23
32
 
24
33
  export type ReorgOperation = "I" | "U" | "D";
25
34
 
26
- export const reorgRollbackTable = pgTable("__reorg_rollback", {
35
+ /** This table is not used for migrations, its only used for ease of internal operations with drizzle. */
36
+ export const reorgRollbackTable = schema.table(ROLLBACK_TABLE_NAME, {
27
37
  n: serial("n").primaryKey(),
28
38
  op: char("op", { length: 1 }).$type<ReorgOperation>().notNull(),
29
39
  table_name: text("table_name").notNull(),
@@ -42,10 +52,14 @@ export async function initializeReorgRollbackTable<
42
52
  TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>,
43
53
  >(tx: PgTransaction<TQueryResult, TFullSchema, TSchema>, indexerId: string) {
44
54
  try {
55
+ // Create schema if it doesn't exist
56
+ await tx.execute(`
57
+ CREATE SCHEMA IF NOT EXISTS ${SCHEMA_NAME};
58
+ `);
45
59
  // Create the audit log table
46
60
  await tx.execute(
47
61
  sql.raw(`
48
- CREATE TABLE IF NOT EXISTS __reorg_rollback(
62
+ CREATE TABLE IF NOT EXISTS ${SCHEMA_NAME}.${ROLLBACK_TABLE_NAME}(
49
63
  n SERIAL PRIMARY KEY,
50
64
  op CHAR(1) NOT NULL,
51
65
  table_name TEXT NOT NULL,
@@ -59,7 +73,7 @@ export async function initializeReorgRollbackTable<
59
73
 
60
74
  await tx.execute(
61
75
  sql.raw(`
62
- CREATE INDEX IF NOT EXISTS idx_reorg_rollback_indexer_id_cursor ON __reorg_rollback(indexer_id, cursor);
76
+ CREATE INDEX IF NOT EXISTS idx_reorg_rollback_indexer_id_cursor ON ${SCHEMA_NAME}.${ROLLBACK_TABLE_NAME}(indexer_id, cursor);
63
77
  `),
64
78
  );
65
79
  } catch (error) {
@@ -72,24 +86,25 @@ export async function initializeReorgRollbackTable<
72
86
  // Create the trigger function
73
87
  await tx.execute(
74
88
  sql.raw(`
75
- CREATE OR REPLACE FUNCTION reorg_checkpoint()
89
+ CREATE OR REPLACE FUNCTION ${SCHEMA_NAME}.reorg_checkpoint()
76
90
  RETURNS TRIGGER AS $$
77
91
  DECLARE
78
- id_col TEXT := TG_ARGV[0]::TEXT;
79
- order_key INTEGER := TG_ARGV[1]::INTEGER;
80
- indexer_id TEXT := TG_ARGV[2]::TEXT;
92
+ table_name TEXT := TG_ARGV[0]::TEXT;
93
+ id_col TEXT := TG_ARGV[1]::TEXT;
94
+ order_key INTEGER := TG_ARGV[2]::INTEGER;
95
+ indexer_id TEXT := TG_ARGV[3]::TEXT;
81
96
  new_id_value TEXT := row_to_json(NEW.*)->>id_col;
82
97
  old_id_value TEXT := row_to_json(OLD.*)->>id_col;
83
98
  BEGIN
84
99
  IF (TG_OP = 'DELETE') THEN
85
- INSERT INTO __reorg_rollback(op, table_name, cursor, row_id, row_value, indexer_id)
86
- SELECT 'D', TG_TABLE_NAME, order_key, old_id_value, row_to_json(OLD.*), indexer_id;
100
+ INSERT INTO ${SCHEMA_NAME}.${ROLLBACK_TABLE_NAME}(op, table_name, cursor, row_id, row_value, indexer_id)
101
+ SELECT 'D', table_name, order_key, old_id_value, row_to_json(OLD.*), indexer_id;
87
102
  ELSIF (TG_OP = 'UPDATE') THEN
88
- INSERT INTO __reorg_rollback(op, table_name, cursor, row_id, row_value, indexer_id)
89
- SELECT 'U', TG_TABLE_NAME, order_key, new_id_value, row_to_json(OLD.*), indexer_id;
103
+ INSERT INTO ${SCHEMA_NAME}.${ROLLBACK_TABLE_NAME}(op, table_name, cursor, row_id, row_value, indexer_id)
104
+ SELECT 'U', table_name, order_key, new_id_value, row_to_json(OLD.*), indexer_id;
90
105
  ELSIF (TG_OP = 'INSERT') THEN
91
- INSERT INTO __reorg_rollback(op, table_name, cursor, row_id, row_value, indexer_id)
92
- SELECT 'I', TG_TABLE_NAME, order_key, new_id_value, null, indexer_id;
106
+ INSERT INTO ${SCHEMA_NAME}.${ROLLBACK_TABLE_NAME}(op, table_name, cursor, row_id, row_value, indexer_id)
107
+ SELECT 'I', table_name, order_key, new_id_value, null, indexer_id;
93
108
  END IF;
94
109
  RETURN NULL;
95
110
  END;
@@ -115,11 +130,14 @@ export async function registerTriggers<
115
130
  tx: PgTransaction<TQueryResult, TFullSchema, TSchema>,
116
131
  tables: string[],
117
132
  endCursor: Cursor,
118
- idColumn: string,
133
+ idColumnMap: IdColumnMap,
119
134
  indexerId: string,
120
135
  ) {
121
136
  try {
122
137
  for (const table of tables) {
138
+ // Determine the column ID for this specific table
139
+ const tableIdColumn = getIdColumnForTable(table, idColumnMap);
140
+
123
141
  await tx.execute(
124
142
  sql.raw(
125
143
  `DROP TRIGGER IF EXISTS ${getReorgTriggerName(table, indexerId)} ON ${table};`,
@@ -130,7 +148,7 @@ export async function registerTriggers<
130
148
  CREATE CONSTRAINT TRIGGER ${getReorgTriggerName(table, indexerId)}
131
149
  AFTER INSERT OR UPDATE OR DELETE ON ${table}
132
150
  DEFERRABLE INITIALLY DEFERRED
133
- FOR EACH ROW EXECUTE FUNCTION reorg_checkpoint('${idColumn}', ${`${Number(endCursor.orderKey)}`}, '${indexerId}');
151
+ FOR EACH ROW EXECUTE FUNCTION ${SCHEMA_NAME}.reorg_checkpoint('${table}', '${tableIdColumn}', ${Number(endCursor.orderKey)}, '${indexerId}');
134
152
  `),
135
153
  );
136
154
  }
@@ -174,14 +192,14 @@ export async function invalidate<
174
192
  >(
175
193
  tx: PgTransaction<TQueryResult, TFullSchema, TSchema>,
176
194
  cursor: Cursor,
177
- idColumn: string,
195
+ idColumnMap: IdColumnMap,
178
196
  indexerId: string,
179
197
  ) {
180
198
  // Get and delete operations after cursor in one query, ordered by newest first
181
199
  const { rows: result } = (await tx.execute(
182
200
  sql.raw(`
183
201
  WITH deleted AS (
184
- DELETE FROM __reorg_rollback
202
+ DELETE FROM ${SCHEMA_NAME}.${ROLLBACK_TABLE_NAME}
185
203
  WHERE cursor > ${Number(cursor.orderKey)}
186
204
  AND indexer_id = '${indexerId}'
187
205
  RETURNING *
@@ -198,6 +216,9 @@ export async function invalidate<
198
216
 
199
217
  // Process each operation in reverse order
200
218
  for (const op of result) {
219
+ // Determine the column ID for this specific table
220
+ const tableIdColumn = getIdColumnForTable(op.table_name, idColumnMap);
221
+
201
222
  switch (op.op) {
202
223
  case "I":
203
224
  try {
@@ -208,7 +229,7 @@ export async function invalidate<
208
229
  await tx.execute(
209
230
  sql.raw(`
210
231
  DELETE FROM ${op.table_name}
211
- WHERE ${idColumn} = '${op.row_id}'
232
+ WHERE ${tableIdColumn} = '${op.row_id}'
212
233
  `),
213
234
  );
214
235
  } catch (error) {
@@ -261,7 +282,9 @@ export async function invalidate<
261
282
  ? JSON.parse(op.row_value)
262
283
  : op.row_value;
263
284
 
264
- const nonIdKeys = Object.keys(rowValue).filter((k) => k !== idColumn);
285
+ const nonIdKeys = Object.keys(rowValue).filter(
286
+ (k) => k !== tableIdColumn,
287
+ );
265
288
 
266
289
  const fields = nonIdKeys.map((c) => `${c} = prev.${c}`).join(", ");
267
290
 
@@ -271,7 +294,7 @@ export async function invalidate<
271
294
  FROM (
272
295
  SELECT * FROM json_populate_record(null::${op.table_name}, '${JSON.stringify(op.row_value)}'::json)
273
296
  ) as prev
274
- WHERE ${op.table_name}.${idColumn} = '${op.row_id}'
297
+ WHERE ${op.table_name}.${tableIdColumn} = '${op.row_id}'
275
298
  `);
276
299
 
277
300
  await tx.execute(query);
@@ -305,7 +328,7 @@ export async function finalize<
305
328
  try {
306
329
  await tx.execute(
307
330
  sql.raw(`
308
- DELETE FROM __reorg_rollback
331
+ DELETE FROM ${SCHEMA_NAME}.${ROLLBACK_TABLE_NAME}
309
332
  WHERE cursor <= ${Number(cursor.orderKey)}
310
333
  AND indexer_id = '${indexerId}'
311
334
  `),
@@ -316,3 +339,45 @@ export async function finalize<
316
339
  });
317
340
  }
318
341
  }
342
+
343
+ export async function cleanupStorage<
344
+ TQueryResult extends PgQueryResultHKT,
345
+ TFullSchema extends Record<string, unknown> = Record<string, never>,
346
+ TSchema extends
347
+ TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>,
348
+ >(
349
+ tx: PgTransaction<TQueryResult, TFullSchema, TSchema>,
350
+ tables: string[],
351
+ indexerId: string,
352
+ ) {
353
+ try {
354
+ for (const table of tables) {
355
+ await tx.execute(
356
+ sql.raw(
357
+ `DROP TRIGGER IF EXISTS ${getReorgTriggerName(table, indexerId)} ON ${table};`,
358
+ ),
359
+ );
360
+ }
361
+
362
+ await tx.execute(
363
+ sql.raw(`
364
+ DELETE FROM ${SCHEMA_NAME}.${ROLLBACK_TABLE_NAME}
365
+ WHERE indexer_id = '${indexerId}'
366
+ `),
367
+ );
368
+
369
+ for (const table of tables) {
370
+ try {
371
+ await tx.execute(sql.raw(`TRUNCATE TABLE ${table} CASCADE;`));
372
+ } catch (error) {
373
+ throw new DrizzleStorageError(`Failed to truncate table ${table}`, {
374
+ cause: error,
375
+ });
376
+ }
377
+ }
378
+ } catch (error) {
379
+ throw new DrizzleStorageError("Failed to clean up storage", {
380
+ cause: error,
381
+ });
382
+ }
383
+ }