@thru/indexer 0.1.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,477 @@
1
+ # @thru/indexer
2
+
3
+ A reusable blockchain indexing framework for building backends that index Thru chain data.
4
+
5
+ ## Features
6
+
7
+ - **Event Streams** - Index historical, immutable event data
8
+ - **Account Streams** - Track current on-chain account state with slot-aware upserts
9
+ - **Type-Safe Schema Builder** - Fluent API with full TypeScript inference
10
+ - **Auto-Generated REST API** - Hono + OpenAPI routes with pagination
11
+ - **Resumable Indexing** - Checkpoint-based recovery after restarts
12
+ - **Drizzle ORM** - PostgreSQL with type-safe queries and migrations
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pnpm add @thru/indexer @thru/replay @thru/helpers postgres drizzle-orm hono @hono/zod-openapi
18
+ pnpm add -D drizzle-kit tsx typescript
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ ### 1. Define an Event Stream
24
+
25
+ ```typescript
26
+ // src/streams/transfers.ts
27
+ import { create } from "@bufbuild/protobuf";
28
+ import { decodeAddress, encodeAddress, encodeSignature } from "@thru/helpers";
29
+ import { defineEventStream, t } from "@thru/indexer";
30
+ import { FilterSchema, FilterParamValueSchema, type Event } from "@thru/replay";
31
+ import { TokenEvent } from "../abi/token";
32
+
33
+ const TOKEN_PROGRAM = "taAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKqq";
34
+
35
+ const transfers = defineEventStream({
36
+ name: "transfers",
37
+ description: "Token transfer events",
38
+
39
+ schema: {
40
+ id: t.text().primaryKey(),
41
+ slot: t.bigint().notNull().index(),
42
+ txnSignature: t.text().notNull(),
43
+ source: t.text().notNull().index(),
44
+ dest: t.text().notNull().index(),
45
+ amount: t.bigint().notNull(),
46
+ indexedAt: t.timestamp().notNull().defaultNow(),
47
+ },
48
+
49
+ // Lazy filter for drizzle-kit compatibility
50
+ filterFactory: () => {
51
+ const programBytes = new Uint8Array(decodeAddress(TOKEN_PROGRAM));
52
+ return create(FilterSchema, {
53
+ expression: "event.program.value == params.address",
54
+ params: {
55
+ address: create(FilterParamValueSchema, {
56
+ kind: { case: "bytesValue", value: programBytes },
57
+ }),
58
+ },
59
+ });
60
+ },
61
+
62
+ // Parse raw event into table row (return null to skip)
63
+ parse: (event: Event) => {
64
+ const payload = event.payload;
65
+ if (!payload || payload[0] !== 2) return null;
66
+
67
+ const tokenEvent = TokenEvent.from_array(payload);
68
+ const transfer = tokenEvent?.payload()?.asTransfer();
69
+ if (!transfer) return null;
70
+
71
+ return {
72
+ id: event.eventId,
73
+ slot: event.slot!,
74
+ txnSignature: encodeSignature(event.transactionSignature?.value ?? new Uint8Array()),
75
+ source: encodeAddress(new Uint8Array(transfer.source.get_bytes())),
76
+ dest: encodeAddress(new Uint8Array(transfer.dest.get_bytes())),
77
+ amount: transfer.amount,
78
+ indexedAt: new Date(),
79
+ };
80
+ },
81
+
82
+ api: { filters: ["source", "dest"] },
83
+ });
84
+
85
+ // Export table for Drizzle migrations
86
+ export const transferEvents = transfers.table;
87
+ export default transfers;
88
+ ```
89
+
90
+ ### 2. Define an Account Stream
91
+
92
+ ```typescript
93
+ // src/account-streams/token-accounts.ts
94
+ import { decodeAddress, encodeAddress } from "@thru/helpers";
95
+ import { defineAccountStream, t } from "@thru/indexer";
96
+ import { TokenAccount } from "../abi/token";
97
+
98
+ const TOKEN_PROGRAM = "taAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKqq";
99
+
100
+ const tokenAccounts = defineAccountStream({
101
+ name: "token-accounts",
102
+ description: "Token account balances",
103
+
104
+ ownerProgramFactory: () => new Uint8Array(decodeAddress(TOKEN_PROGRAM)),
105
+ expectedSize: 73,
106
+
107
+ schema: {
108
+ address: t.text().primaryKey(),
109
+ mint: t.text().notNull().index(),
110
+ owner: t.text().notNull().index(),
111
+ amount: t.bigint().notNull(),
112
+ slot: t.bigint().notNull(),
113
+ seq: t.bigint().notNull(),
114
+ updatedAt: t.timestamp().notNull().defaultNow(),
115
+ },
116
+
117
+ parse: (account) => {
118
+ if (account.data.length !== 73) return null;
119
+
120
+ const parsed = TokenAccount.from_array(account.data);
121
+ if (!parsed) return null;
122
+
123
+ return {
124
+ address: encodeAddress(account.address),
125
+ mint: encodeAddress(new Uint8Array(parsed.mint.get_bytes())),
126
+ owner: encodeAddress(new Uint8Array(parsed.owner.get_bytes())),
127
+ amount: parsed.amount,
128
+ slot: account.slot,
129
+ seq: account.seq,
130
+ updatedAt: new Date(),
131
+ };
132
+ },
133
+
134
+ api: { filters: ["mint", "owner"], idField: "address" },
135
+ });
136
+
137
+ export const tokenAccountsTable = tokenAccounts.table;
138
+ export default tokenAccounts;
139
+ ```
140
+
141
+ ### 3. Set Up Database Schema
142
+
143
+ ```typescript
144
+ // src/db/schema.ts
145
+ export { checkpointTable } from "@thru/indexer";
146
+ export { transferEvents } from "../streams/transfers";
147
+ export { tokenAccountsTable } from "../account-streams/token-accounts";
148
+ ```
149
+
150
+ ```typescript
151
+ // drizzle.config.ts
152
+ import { defineConfig } from "drizzle-kit";
153
+
154
+ export default defineConfig({
155
+ schema: "./src/db/schema.ts",
156
+ out: "./drizzle",
157
+ dialect: "postgresql",
158
+ dbCredentials: {
159
+ url: process.env.DATABASE_URL!,
160
+ },
161
+ });
162
+ ```
163
+
164
+ ```typescript
165
+ // src/db/index.ts
166
+ import { drizzle } from "drizzle-orm/postgres-js";
167
+ import postgres from "postgres";
168
+
169
+ const client = postgres(process.env.DATABASE_URL!);
170
+ export const db = drizzle(client);
171
+ ```
172
+
173
+ ### 4. Create Indexer
174
+
175
+ ```typescript
176
+ // src/indexer.ts
177
+ import { ChainClient } from "@thru/replay";
178
+ import { Indexer } from "@thru/indexer";
179
+ import { db } from "./db";
180
+ import transfers from "./streams/transfers";
181
+ import tokenAccounts from "./account-streams/token-accounts";
182
+
183
+ const indexer = new Indexer({
184
+ db,
185
+ clientFactory: () => new ChainClient({ baseUrl: process.env.CHAIN_RPC_URL! }),
186
+ eventStreams: [transfers],
187
+ accountStreams: [tokenAccounts],
188
+ defaultStartSlot: 0n,
189
+ safetyMargin: 64,
190
+ pageSize: 512,
191
+ logLevel: "info",
192
+ });
193
+
194
+ process.on("SIGINT", () => indexer.stop());
195
+ process.on("SIGTERM", () => indexer.stop());
196
+
197
+ indexer.start().then((result) => {
198
+ console.log("Indexer finished:", result);
199
+ });
200
+ ```
201
+
202
+ ### 5. Create API Server
203
+
204
+ ```typescript
205
+ // src/api.ts
206
+ import { serve } from "@hono/node-server";
207
+ import { OpenAPIHono } from "@hono/zod-openapi";
208
+ import { mountStreamRoutes } from "@thru/indexer";
209
+ import { db } from "./db";
210
+ import transfers from "./streams/transfers";
211
+ import tokenAccounts from "./account-streams/token-accounts";
212
+
213
+ const app = new OpenAPIHono();
214
+
215
+ mountStreamRoutes(app, {
216
+ db,
217
+ basePath: "/api/v1",
218
+ eventStreams: [transfers],
219
+ accountStreams: [tokenAccounts],
220
+ });
221
+
222
+ serve({ fetch: app.fetch, port: 3000 }, (info) => {
223
+ console.log(`API server running on http://localhost:${info.port}`);
224
+ });
225
+ ```
226
+
227
+ ### 6. Run
228
+
229
+ ```bash
230
+ # Generate and apply migrations
231
+ pnpm drizzle-kit generate
232
+ pnpm drizzle-kit push
233
+
234
+ # Start indexer
235
+ pnpm tsx src/indexer.ts
236
+
237
+ # Start API (separate terminal)
238
+ pnpm tsx src/api.ts
239
+ ```
240
+
241
+ ## API Reference
242
+
243
+ ### Schema Builder
244
+
245
+ The `t` object provides a fluent API for defining columns:
246
+
247
+ ```typescript
248
+ import { t } from "@thru/indexer";
249
+
250
+ const schema = {
251
+ id: t.text().primaryKey(),
252
+ slot: t.bigint().notNull().index(),
253
+ name: t.text(), // nullable by default
254
+ count: t.integer().notNull(),
255
+ active: t.boolean().notNull().default(true),
256
+ createdAt: t.timestamp().notNull().defaultNow(),
257
+ mintId: t.text().notNull().references(mintsTable, "id"),
258
+ };
259
+ ```
260
+
261
+ **Column Types:**
262
+ - `t.text()` - VARCHAR/TEXT
263
+ - `t.bigint()` - BIGINT (for slots, amounts)
264
+ - `t.integer()` - INTEGER
265
+ - `t.boolean()` - BOOLEAN
266
+ - `t.timestamp()` - TIMESTAMP WITH TIME ZONE
267
+
268
+ **Modifiers:**
269
+ - `.notNull()` - NOT NULL constraint
270
+ - `.primaryKey()` - Primary key (implies NOT NULL)
271
+ - `.index()` - Create index
272
+ - `.unique()` - Unique constraint
273
+ - `.default(value)` - Default value
274
+ - `.defaultNow()` - Default to current timestamp
275
+ - `.references(table, column)` - Foreign key
276
+
277
+ ### Event Stream Options
278
+
279
+ ```typescript
280
+ defineEventStream({
281
+ name: string; // Unique stream name
282
+ description?: string; // Human-readable description
283
+ schema: { ... }; // Column definitions
284
+ filter?: Filter; // Direct CEL filter
285
+ filterFactory?: () => Filter; // Lazy filter (for drizzle-kit)
286
+ parse: (event: Event) => Row | null;
287
+ api?: {
288
+ filters?: string[]; // Filterable columns
289
+ };
290
+ filterBatch?: (events, ctx) => Promise<events>; // Pre-commit filter
291
+ onCommit?: (batch, ctx) => Promise<void>; // Post-commit hook
292
+ });
293
+ ```
294
+
295
+ ### Account Stream Options
296
+
297
+ ```typescript
298
+ defineAccountStream({
299
+ name: string;
300
+ description?: string;
301
+ ownerProgram?: Uint8Array; // Direct program address
302
+ ownerProgramFactory?: () => Uint8Array; // Lazy (for drizzle-kit)
303
+ expectedSize?: number; // Filter by data size
304
+ dataSizes?: number[]; // Multiple valid sizes
305
+ schema: { ... };
306
+ parse: (account: AccountState) => Row | null;
307
+ api?: {
308
+ filters?: string[];
309
+ idField?: string; // Primary key field name
310
+ };
311
+ });
312
+ ```
313
+
314
+ ### Indexer Options
315
+
316
+ ```typescript
317
+ new Indexer({
318
+ db: DatabaseClient; // Drizzle database client
319
+ clientFactory: () => ChainClient; // Factory for RPC connections
320
+ eventStreams?: EventStream[];
321
+ accountStreams?: AccountStream[];
322
+ defaultStartSlot?: bigint; // Start slot if no checkpoint
323
+ safetyMargin?: number; // Slots behind tip (default: 64)
324
+ pageSize?: number; // Events per page (default: 512)
325
+ logLevel?: "debug" | "info" | "warn" | "error";
326
+ validateParse?: boolean; // Validate parse output with Zod (dev mode)
327
+ });
328
+ ```
329
+
330
+ ### Hooks
331
+
332
+ **`filterBatch`** - Filter events before database commit:
333
+
334
+ ```typescript
335
+ filterBatch: async (events, { db }) => {
336
+ // Only keep transfers involving registered users
337
+ const users = await db.select().from(usersTable);
338
+ const userAddresses = new Set(users.map(u => u.address));
339
+
340
+ return events.filter(e =>
341
+ userAddresses.has(e.source) || userAddresses.has(e.dest)
342
+ );
343
+ }
344
+ ```
345
+
346
+ **`onCommit`** - Side effects after commit:
347
+
348
+ ```typescript
349
+ onCommit: async (batch, { db }) => {
350
+ // Queue notifications for transfer recipients
351
+ await queueNotifications(db, batch.events);
352
+ }
353
+ ```
354
+
355
+ ## Migrations
356
+
357
+ The library uses Drizzle Kit for migrations. Tables are automatically created from stream schemas.
358
+
359
+ ```bash
360
+ # Generate migration from schema changes
361
+ pnpm drizzle-kit generate
362
+
363
+ # Apply migrations
364
+ pnpm drizzle-kit migrate
365
+
366
+ # Push schema directly (development)
367
+ pnpm drizzle-kit push
368
+
369
+ # Open Drizzle Studio
370
+ pnpm drizzle-kit studio
371
+ ```
372
+
373
+ ### Why `filterFactory` / `ownerProgramFactory`?
374
+
375
+ Drizzle Kit imports your schema files to generate migrations. If those files load config at import time, it fails:
376
+
377
+ ```typescript
378
+ // Breaks drizzle-kit (config not available at import time)
379
+ filter: create(FilterSchema, {
380
+ params: { address: decodeAddress(loadConfig().TOKEN_PROGRAM) }
381
+ })
382
+
383
+ // Works (lazy loading, only called at runtime)
384
+ filterFactory: () => {
385
+ const config = loadConfig();
386
+ return create(FilterSchema, { ... });
387
+ }
388
+ ```
389
+
390
+ ### Schema Helper
391
+
392
+ Use `getSchemaExports()` to collect all tables for your Drizzle schema file:
393
+
394
+ ```typescript
395
+ // db/schema.ts
396
+ import { getSchemaExports } from "@thru/indexer";
397
+ import transfers from "../streams/transfers";
398
+ import tokenAccounts from "../account-streams/token-accounts";
399
+
400
+ // Export all tables for Drizzle migrations
401
+ export const { checkpointTable, transfersTable, tokenAccountsTable } = getSchemaExports({
402
+ eventStreams: [transfers],
403
+ accountStreams: [tokenAccounts],
404
+ });
405
+ ```
406
+
407
+ ### Runtime Validation
408
+
409
+ Enable `validateParse` to validate parse function output at runtime using Zod schemas. This is useful during development to catch type mismatches early:
410
+
411
+ ```typescript
412
+ const indexer = new Indexer({
413
+ db,
414
+ clientFactory: () => new ChainClient({ baseUrl: RPC_URL }),
415
+ eventStreams: [transfers],
416
+ validateParse: process.env.NODE_ENV !== "production", // Enable in dev
417
+ });
418
+ ```
419
+
420
+ When validation fails, the indexer logs detailed error messages:
421
+
422
+ ```
423
+ [transfers] Stream "transfers" parse returned invalid data:
424
+ - amount: Expected bigint, received number
425
+ - source: Required
426
+ ```
427
+
428
+ ## Exports
429
+
430
+ ```typescript
431
+ // Schema
432
+ export { t, columnBuilder } from "@thru/indexer";
433
+ export type { ColumnDef, SchemaDefinition, InferRow, InferInsert } from "@thru/indexer";
434
+
435
+ // Validation (for development)
436
+ export { generateZodSchema, validateParsedData } from "@thru/indexer";
437
+
438
+ // Streams
439
+ export { defineEventStream, defineAccountStream } from "@thru/indexer";
440
+ export type { EventStream, AccountStream } from "@thru/indexer";
441
+
442
+ // Checkpoint
443
+ export { checkpointTable, getCheckpoint, updateCheckpoint, getSchemaExports } from "@thru/indexer";
444
+
445
+ // API
446
+ export { mountStreamRoutes, generateSchemas } from "@thru/indexer";
447
+ export { paginate, parseCursor, paginationQuerySchema } from "@thru/indexer";
448
+
449
+ // Runtime
450
+ export { Indexer } from "@thru/indexer";
451
+ export type { IndexerConfig, IndexerResult } from "@thru/indexer";
452
+
453
+ // Types
454
+ export type { ApiConfig, StreamBatch, HookContext } from "@thru/indexer";
455
+ ```
456
+
457
+ ## Example Project Structure
458
+
459
+ ```
460
+ my-indexer/
461
+ ├── src/
462
+ │ ├── abi/ # ABI type definitions
463
+ │ │ └── token.ts
464
+ │ ├── streams/ # Event stream definitions
465
+ │ │ └── transfers.ts
466
+ │ ├── account-streams/ # Account stream definitions
467
+ │ │ └── token-accounts.ts
468
+ │ ├── db/
469
+ │ │ ├── index.ts # Database client
470
+ │ │ └── schema.ts # Drizzle schema exports
471
+ │ ├── indexer.ts # Indexer entry point
472
+ │ └── api.ts # API entry point
473
+ ├── drizzle/ # Generated migrations
474
+ ├── drizzle.config.ts
475
+ ├── package.json
476
+ └── tsconfig.json
477
+ ```