@secondlayer/subgraphs 3.21.0 → 3.23.0

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.
Files changed (42) hide show
  1. package/dist/src/index.d.ts +238 -17
  2. package/dist/src/index.js +389 -52
  3. package/dist/src/index.js.map +12 -11
  4. package/dist/src/runtime/block-processor.d.ts +111 -10
  5. package/dist/src/runtime/block-processor.js +263 -31
  6. package/dist/src/runtime/block-processor.js.map +9 -8
  7. package/dist/src/runtime/catchup.d.ts +111 -10
  8. package/dist/src/runtime/catchup.js +263 -31
  9. package/dist/src/runtime/catchup.js.map +9 -8
  10. package/dist/src/runtime/context.d.ts +111 -11
  11. package/dist/src/runtime/context.js +166 -12
  12. package/dist/src/runtime/context.js.map +7 -6
  13. package/dist/src/runtime/processor.js +272 -35
  14. package/dist/src/runtime/processor.js.map +10 -9
  15. package/dist/src/runtime/reindex.d.ts +111 -10
  16. package/dist/src/runtime/reindex.js +263 -31
  17. package/dist/src/runtime/reindex.js.map +9 -8
  18. package/dist/src/runtime/reorg.d.ts +111 -10
  19. package/dist/src/runtime/reorg.js +268 -31
  20. package/dist/src/runtime/reorg.js.map +10 -9
  21. package/dist/src/runtime/replay.d.ts +3 -3
  22. package/dist/src/runtime/replay.js +30 -6
  23. package/dist/src/runtime/replay.js.map +4 -4
  24. package/dist/src/runtime/runner.d.ts +191 -17
  25. package/dist/src/runtime/source-matcher.d.ts +68 -8
  26. package/dist/src/runtime/source-matcher.js +30 -6
  27. package/dist/src/runtime/source-matcher.js.map +3 -3
  28. package/dist/src/runtime/stats.d.ts +3 -3
  29. package/dist/src/schema/index.d.ts +114 -13
  30. package/dist/src/schema/index.js +133 -20
  31. package/dist/src/schema/index.js.map +5 -5
  32. package/dist/src/service.js +272 -35
  33. package/dist/src/service.js.map +10 -9
  34. package/dist/src/testing/index.d.ts +667 -0
  35. package/dist/src/testing/index.js +1463 -0
  36. package/dist/src/testing/index.js.map +17 -0
  37. package/dist/src/triggers/index.d.ts +57 -6
  38. package/dist/src/types.d.ts +112 -11
  39. package/dist/src/validate.d.ts +129 -11
  40. package/dist/src/validate.js +117 -18
  41. package/dist/src/validate.js.map +3 -3
  42. package/package.json +9 -3
@@ -1,3 +1,62 @@
1
+ import { UnwrapResponse } from "@secondlayer/stacks/actions";
2
+ import { AbiContract, ExtractFunctionArgs, ExtractFunctionOutput, ExtractReadOnlyFunctions, ToCamelCase } from "@secondlayer/stacks/clarity";
3
+ /**
4
+ * How long a read stays valid.
5
+ *
6
+ * - `per-block` (default): pinned to the block being processed, cached under
7
+ * its `index_block_hash`. Sound for any read-only function — reindexing the
8
+ * same block always returns the same value, and a reorg-replaced block has a
9
+ * different id so it can't inherit the orphaned answer.
10
+ * - `contract-constant`: the caller asserts the value cannot change for this
11
+ * contract and these args (SIP-010 `get-decimals`/`get-symbol`), so it is
12
+ * resolved ONCE instead of once per block. This is the mode that makes a
13
+ * backfill affordable — under `per-block` every block is a distinct key and
14
+ * therefore a distinct RPC. Declaring it on a value that does change pins the
15
+ * first answer forever, so it is opt-in and never inferred.
16
+ */
17
+ type ChainReadCacheMode = "per-block" | "contract-constant";
18
+ type ChainReadOptions = {
19
+ cache?: ChainReadCacheMode
20
+ };
21
+ /**
22
+ * The chain-read surface, fully typed by an ABI.
23
+ *
24
+ * Shaped like `getContract` from `@secondlayer/stacks` — the same ABI in, the
25
+ * same camelCased read methods out — minus `call`/`buildCall`: a handler
26
+ * indexes the chain, it never writes to it.
27
+ *
28
+ * Reached through {@link readContractAt} rather than off `ctx.client`
29
+ * directly: an ABI-generic method living on the context interface makes every
30
+ * structural comparison of a subgraph definition instantiate it, which exceeds
31
+ * TS's depth budget. At a call site the ABI is already concrete and it costs
32
+ * nothing.
33
+ */
34
+ interface ChainReadClient {
35
+ /**
36
+ * Read-only methods of a contract, evaluated against the exact block being
37
+ * processed.
38
+ *
39
+ * Every call is pinned to the block's `index_block_hash`, so a handler is a
40
+ * pure function of its block: reindexing a range produces byte-identical
41
+ * rows. Reading at the node's tip instead would make the same reindex
42
+ * return different values — that is the corruption this refuses to allow,
43
+ * so a block whose id was never persisted throws rather than falling back.
44
+ *
45
+ * Results are memoized in Postgres, so a value is fetched once per block
46
+ * rather than once per event (or once per contract with
47
+ * `cache: "contract-constant"`).
48
+ */
49
+ contract<const C extends AbiContract>(contractId: string, abi: C, options?: ChainReadOptions): {
50
+ read: ChainReadMethods<C>
51
+ };
52
+ }
53
+ /**
54
+ * The camelCased read methods of an ABI. Spelled out rather than reused from
55
+ * `ContractInstance<C>["read"]`: that type is a conditional over the codegen
56
+ * brand, and evaluating it wherever a subgraph definition is structurally
57
+ * compared pushes TS past its instantiation depth.
58
+ */
59
+ type ChainReadMethods<C extends AbiContract> = { [N in ExtractReadOnlyFunctions<C> as ToCamelCase<N>] : (args: ExtractFunctionArgs<C, N>) => Promise<UnwrapResponse<ExtractFunctionOutput<C, N>>> };
1
60
  /** Supported column types for subgraph schemas */
2
61
  type ColumnType = "text" | "uint" | "int" | "principal" | "boolean" | "timestamp" | "jsonb";
3
62
  /** Column definition in a subgraph table */
@@ -24,15 +83,24 @@ interface SubgraphRelation {
24
83
  /** Target column(s) the fields point at (a uniqueKeys entry on the target). */
25
84
  referencedColumns: string[];
26
85
  }
27
- /** Table definition within a subgraph schema */
86
+ /**
87
+ * Table definition within a subgraph schema.
88
+ *
89
+ * The array fields are `readonly` so a schema literal can be hoisted out of
90
+ * the `defineSubgraph()` call with `as const` (or {@link defineSchema}) and
91
+ * still typecheck — `as const` produces `readonly [readonly ["holder"]]`,
92
+ * which a mutable `string[][]` rejects. Without that, extracting a helper
93
+ * that takes `ctx` meant losing the entire typed surface, which is why the
94
+ * shipped starter reached for `ctx: any`.
95
+ */
28
96
  interface SubgraphTable {
29
97
  columns: Record<string, SubgraphColumn>;
30
98
  /** Composite indexes (each entry is an array of column names) */
31
- indexes?: string[][];
99
+ indexes?: readonly (readonly string[])[];
32
100
  /** Unique key constraints (each entry is an array of column names). Required for upsert. */
33
- uniqueKeys?: string[][];
101
+ uniqueKeys?: readonly (readonly string[])[];
34
102
  /** Foreign-key relations to other tables (for typed ORM joins). */
35
- relations?: SubgraphRelation[];
103
+ relations?: readonly SubgraphRelation[];
36
104
  }
37
105
  /** Subgraph schema — maps table names to table definitions */
38
106
  type SubgraphSchema = Record<string, SubgraphTable>;
@@ -40,12 +108,19 @@ type SubgraphSchema = Record<string, SubgraphTable>;
40
108
  type RowValue = string | number | bigint | boolean | null | undefined | Record<string, unknown> | unknown[];
41
109
  /** Value or computed function that receives existing row */
42
110
  type ComputedValue = RowValue | ((existing: Record<string, unknown> | null) => unknown);
43
- import { Database } from "@secondlayer/shared/db";
44
- import { Kysely, Transaction } from "kysely";
45
- type AnyDb = Kysely<Database> | Transaction<Database>;
111
+ import { Database as Database2 } from "@secondlayer/shared/db";
112
+ import { Kysely as Kysely2, Transaction } from "kysely";
113
+ type AnyDb = Kysely2<Database2> | Transaction<Database2>;
46
114
  /** Reorg journal entries older than this many blocks are prunable — far past
47
115
  * Stacks finality (observed reorg depth is single digits). */
48
116
  declare const JOURNAL_RETENTION_BLOCKS = 300;
117
+ interface WriteOp {
118
+ kind: "insert" | "update" | "delete" | "increment";
119
+ table: string;
120
+ data: Record<string, unknown>;
121
+ /** For update: SET clause. For increment: column → signed delta. */
122
+ set?: Record<string, unknown>;
123
+ }
49
124
  interface FlushWrite {
50
125
  op: "insert" | "update" | "delete";
51
126
  table: string;
@@ -67,6 +142,13 @@ interface BlockMeta {
67
142
  hash: string;
68
143
  timestamp: number;
69
144
  burnBlockHeight: number;
145
+ /**
146
+ * Nakamoto StacksBlockId — the identifier `?tip=` accepts, and the only way
147
+ * to pin a read-only call to THIS block. Null on rows ingested before it
148
+ * was persisted; `ctx.client.readOnly` refuses to run rather than silently
149
+ * reading at the node's tip.
150
+ */
151
+ indexBlockHash?: string | null;
70
152
  }
71
153
  interface TxMeta2 {
72
154
  txId: string;
@@ -92,7 +174,10 @@ declare class SubgraphContext2 {
92
174
  private readonly db;
93
175
  private readonly pgSchemaName;
94
176
  private readonly subgraphSchema;
95
- private readonly ops;
177
+ /** Pending writes for this block. `protected` so the in-memory test
178
+ * context (`@secondlayer/subgraphs/testing`) can materialize them through
179
+ * the SAME overlay this class uses — never a second implementation. */
180
+ protected readonly ops: WriteOp[];
96
181
  /**
97
182
  * BYO data plane: handler writes land in a user-owned DB whose flush can't
98
183
  * share the managed block transaction, so a crash replays the block. When
@@ -108,7 +193,15 @@ declare class SubgraphContext2 {
108
193
  * journal would just be churn the pruner deletes.
109
194
  */
110
195
  private readonly journal;
196
+ /** Memoized per block; see the `client` getter. */
197
+ protected _client?: ChainReadClient;
111
198
  constructor(db: AnyDb, pgSchemaName: string, subgraphSchema: SubgraphSchema, block: BlockMeta, tx: TxMeta2, byo?: boolean, journal?: boolean);
199
+ /**
200
+ * Chain reads pinned to THIS block. Lazily built so a handler that never
201
+ * reads never touches the node config, and so the in-memory test context
202
+ * can override it without a node.
203
+ */
204
+ get client(): ChainReadClient;
112
205
  get tx(): TxMeta2;
113
206
  /** Update the current transaction context (used by runner between events) */
114
207
  setTx(tx: TxMeta2): void;
@@ -143,6 +236,13 @@ declare class SubgraphContext2 {
143
236
  patchOrInsert(table: string, key: Record<string, unknown>, row: Record<string, ComputedValue>): Promise<void>;
144
237
  /** Format a bigint amount with decimal places */
145
238
  formatUnits(value: bigint, decimals: number): string;
239
+ /**
240
+ * Committed rows matching `where`, BEFORE the pending-ops overlay. The one
241
+ * seam a different backing store replaces (the in-memory test context);
242
+ * everything above it — validation, overlay, op queueing, flush semantics
243
+ * — stays here so the two can't diverge.
244
+ */
245
+ protected readRows(table: string, where: Record<string, unknown>, limit?: number): Promise<Record<string, unknown>[]>;
146
246
  findOne(table: string, where: Record<string, unknown>): Promise<Record<string, unknown> | null>;
147
247
  findMany(table: string, where: Record<string, unknown>): Promise<Record<string, unknown>[]>;
148
248
  /**
@@ -152,10 +252,10 @@ declare class SubgraphContext2 {
152
252
  * apply by where-match. Overlaid rows synthesized from pending inserts
153
253
  * lack DB-generated columns (_id, _created_at).
154
254
  */
155
- private overlayOne;
156
- private overlayMany;
255
+ protected overlayOne(table: string, where: Record<string, unknown>, dbRow: Record<string, unknown> | null): Record<string, unknown> | null;
256
+ protected overlayMany(table: string, where: Record<string, unknown>, dbRows: Record<string, unknown>[]): Record<string, unknown>[];
157
257
  /** Apply one pending op to a candidate row state (null = row absent). */
158
- private applyOpToRow;
258
+ protected applyOpToRow(op: WriteOp, row: Record<string, unknown> | null, where: Record<string, unknown>): Record<string, unknown> | null;
159
259
  count(table: string, where?: Record<string, unknown>): Promise<number>;
160
260
  sum(table: string, column: string, where?: Record<string, unknown>): Promise<bigint>;
161
261
  min(table: string, column: string, where?: Record<string, unknown>): Promise<bigint | null>;
@@ -100,6 +100,18 @@ function emitJournalDDL(schemaName) {
100
100
  `CREATE INDEX IF NOT EXISTS idx_${schemaName}_journal_height ON ${schemaName}._journal (block_height)`
101
101
  ];
102
102
  }
103
+ function emitFactoryDDL(schemaName) {
104
+ return [
105
+ `CREATE TABLE IF NOT EXISTS ${schemaName}._factory_addresses (
106
+ source_name TEXT NOT NULL,
107
+ address TEXT NOT NULL,
108
+ block_height BIGINT NOT NULL,
109
+ _created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
110
+ PRIMARY KEY (source_name, address)
111
+ )`,
112
+ `CREATE INDEX IF NOT EXISTS idx_${schemaName}_factory_height ON ${schemaName}._factory_addresses (block_height)`
113
+ ];
114
+ }
103
115
  function emitForeignKeyDDL(schemaName, tableName, tableDef) {
104
116
  return (tableDef.relations ?? []).map((rel) => {
105
117
  const constraintName = `fk_${schemaName}_${tableName}_${rel.name}`;
@@ -118,6 +130,9 @@ function generateSubgraphSQL(def, schemaNameOverride) {
118
130
  statements.push(...emitTableDDL(schemaName, tableName, tableDef));
119
131
  }
120
132
  statements.push(...emitJournalDDL(schemaName));
133
+ if (Object.values(def.sources ?? {}).some((src) => src.factory !== undefined)) {
134
+ statements.push(...emitFactoryDDL(schemaName));
135
+ }
121
136
  for (const [tableName, tableDef] of Object.entries(def.schema)) {
122
137
  statements.push(...emitForeignKeyDDL(schemaName, tableName, tableDef));
123
138
  }
@@ -130,6 +145,139 @@ function generateSubgraphSQL(def, schemaNameOverride) {
130
145
  return { statements, hash };
131
146
  }
132
147
 
148
+ // src/runtime/chain-read.ts
149
+ import { getSourceDb } from "@secondlayer/shared/db";
150
+ import { http, createPublicClient } from "@secondlayer/stacks";
151
+ import {
152
+ ContractResponseError,
153
+ buildFunctionArgs,
154
+ isResponseOutput,
155
+ readContract
156
+ } from "@secondlayer/stacks/actions";
157
+ import {
158
+ clarityValueToJSUntyped,
159
+ deserializeCV,
160
+ normalizeAbi,
161
+ serializeCV,
162
+ toCamelCase
163
+ } from "@secondlayer/stacks/clarity";
164
+ function readContractAt(ctx, contractId, abi, options) {
165
+ return ctx.client.contract(contractId, abi, options);
166
+ }
167
+
168
+ class ChainReadError extends Error {
169
+ name = "ChainReadError";
170
+ }
171
+ function nodeClient(rpcUrl) {
172
+ return createPublicClient({
173
+ transport: http(rpcUrl, { retryCount: 0, timeout: 1e4 })
174
+ });
175
+ }
176
+
177
+ class ConcurrencyGate {
178
+ limit;
179
+ active = 0;
180
+ queue = [];
181
+ constructor(limit) {
182
+ this.limit = limit;
183
+ }
184
+ async run(fn) {
185
+ if (this.active >= this.limit) {
186
+ await new Promise((resolve) => this.queue.push(resolve));
187
+ }
188
+ this.active++;
189
+ try {
190
+ return await fn();
191
+ } finally {
192
+ this.active--;
193
+ this.queue.shift()?.();
194
+ }
195
+ }
196
+ }
197
+ var DEFAULT_MAX_CONCURRENT_READS = 4;
198
+ function maxConcurrentReads() {
199
+ const raw = process.env.SUBGRAPH_CHAIN_READ_CONCURRENCY;
200
+ const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
201
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_CONCURRENT_READS;
202
+ }
203
+ var gate = new ConcurrencyGate(maxConcurrentReads());
204
+ function hashArgs(args) {
205
+ const serialized = args.map((arg) => serializeCV(arg)).join(",");
206
+ return Bun.hash(serialized).toString(16);
207
+ }
208
+ function findFunction(abi, name) {
209
+ const fn = abi.functions.find((f) => f.name === name);
210
+ if (!fn) {
211
+ throw new ChainReadError(`No function "${name}" in the supplied ABI.`);
212
+ }
213
+ if (fn.access !== "read-only") {
214
+ throw new ChainReadError(`"${name}" is ${fn.access}, not read-only — a handler cannot call it.`);
215
+ }
216
+ return fn;
217
+ }
218
+ function createChainReadClient(ctx) {
219
+ const readValue = async (contractId, abi, functionName, args, options) => {
220
+ const rpcUrl = ctx.rpcUrl ?? process.env.STACKS_NODE_RPC_URL;
221
+ if (!rpcUrl) {
222
+ throw new ChainReadError("ctx.client needs a Stacks node: set STACKS_NODE_RPC_URL on the subgraph processor.");
223
+ }
224
+ const constant = options.cache === "contract-constant";
225
+ if (!constant && !ctx.indexBlockHash) {
226
+ throw new ChainReadError(`Block ${ctx.blockHeight} has no index_block_hash, so a read cannot be pinned to it. Reading at the node's tip would make this handler non-deterministic (the same reindex would produce different rows). Re-ingest the block, or pass cache: "contract-constant" if the value genuinely cannot change.`);
227
+ }
228
+ const fn = findFunction(abi, functionName);
229
+ const clarityArgs = buildFunctionArgs(fn, args);
230
+ const db = ctx.db ?? getSourceDb();
231
+ const argsHash = hashArgs(clarityArgs);
232
+ const pinnedTo = constant ? null : ctx.indexBlockHash;
233
+ const cached = await db.selectFrom("chain_read_cache").select("result_hex").where("contract_id", "=", contractId).where("function_name", "=", fn.name).where("args_hash", "=", argsHash).where((eb) => pinnedTo === null ? eb("index_block_hash", "is", null) : eb("index_block_hash", "=", pinnedTo)).executeTakeFirst();
234
+ const resultHex = cached?.result_hex ?? await gate.run(async () => {
235
+ const value2 = await readContract(nodeClient(rpcUrl), {
236
+ contract: contractId,
237
+ functionName: fn.name,
238
+ args: clarityArgs,
239
+ tip: ctx.indexBlockHash ?? undefined
240
+ });
241
+ return serializeCV(value2);
242
+ });
243
+ if (!cached) {
244
+ await db.insertInto("chain_read_cache").values({
245
+ contract_id: contractId,
246
+ function_name: fn.name,
247
+ args_hash: argsHash,
248
+ index_block_hash: pinnedTo,
249
+ block_height: constant ? null : ctx.blockHeight,
250
+ result_hex: resultHex
251
+ }).onConflict((oc) => oc.doNothing()).execute();
252
+ }
253
+ const value = deserializeCV(resultHex);
254
+ const js = clarityValueToJSUntyped(fn.outputs, value);
255
+ if (isResponseOutput(fn.outputs)) {
256
+ const response = js;
257
+ if ("ok" in response)
258
+ return response.ok;
259
+ throw new ContractResponseError(`${contractId}::${fn.name} returned an err response.`, response.err);
260
+ }
261
+ return js;
262
+ };
263
+ return {
264
+ contract(contractId, rawAbi, options = {}) {
265
+ const abi = normalizeAbi(rawAbi);
266
+ const camelToKebab = new Map;
267
+ for (const fn of abi.functions) {
268
+ camelToKebab.set(toCamelCase(fn.name), fn.name);
269
+ }
270
+ const read = new Proxy({}, {
271
+ get(_target, prop) {
272
+ const fnName = camelToKebab.get(prop) ?? prop;
273
+ return (args = {}) => readValue(contractId, abi, fnName, args, options);
274
+ }
275
+ });
276
+ return { read };
277
+ }
278
+ };
279
+ }
280
+
133
281
  // src/runtime/context.ts
134
282
  var JOURNAL_RETENTION_BLOCKS = 300;
135
283
  var journalEnsured = new Set;
@@ -148,6 +296,7 @@ class SubgraphContext {
148
296
  ops = [];
149
297
  byo;
150
298
  journal;
299
+ _client;
151
300
  constructor(db, pgSchemaName2, subgraphSchema, block, tx, byo = false, journal = false) {
152
301
  this.db = db;
153
302
  this.pgSchemaName = pgSchemaName2;
@@ -157,6 +306,13 @@ class SubgraphContext {
157
306
  this.byo = byo;
158
307
  this.journal = journal;
159
308
  }
309
+ get client() {
310
+ this._client ??= createChainReadClient({
311
+ blockHeight: this.block.height,
312
+ indexBlockHash: this.block.indexBlockHash
313
+ });
314
+ return this._client;
315
+ }
160
316
  get tx() {
161
317
  return this._tx;
162
318
  }
@@ -262,23 +418,21 @@ class SubgraphContext {
262
418
  formatUnits(value, decimals) {
263
419
  return formatUnits(value, decimals);
264
420
  }
265
- async findOne(table, where) {
266
- this.validateTable(table);
421
+ async readRows(table, where, limit) {
267
422
  const qualifiedTable = `"${this.pgSchemaName}"."${table}"`;
268
423
  const { clause } = buildWhereClause(where);
269
- const query = `SELECT * FROM ${qualifiedTable} WHERE ${clause} LIMIT 1`;
424
+ const query = `SELECT * FROM ${qualifiedTable} WHERE ${clause}${limit !== undefined ? ` LIMIT ${limit}` : ""}`;
270
425
  const { rows } = await sql.raw(query).execute(this.db);
271
- const row = rows[0] ?? null;
272
- return this.overlayOne(table, where, row ? this.coerceRow(table, row) : null);
426
+ return rows.map((r) => this.coerceRow(table, r));
427
+ }
428
+ async findOne(table, where) {
429
+ this.validateTable(table);
430
+ const row = (await this.readRows(table, where, 1))[0] ?? null;
431
+ return this.overlayOne(table, where, row);
273
432
  }
274
433
  async findMany(table, where) {
275
434
  this.validateTable(table);
276
- const qualifiedTable = `"${this.pgSchemaName}"."${table}"`;
277
- const { clause } = buildWhereClause(where);
278
- const query = `SELECT * FROM ${qualifiedTable} WHERE ${clause}`;
279
- const { rows } = await sql.raw(query).execute(this.db);
280
- const dbRows = rows.map((r) => this.coerceRow(table, r));
281
- return this.overlayMany(table, where, dbRows);
435
+ return this.overlayMany(table, where, await this.readRows(table, where));
282
436
  }
283
437
  overlayOne(table, where, dbRow) {
284
438
  if (this.ops.length === 0)
@@ -723,5 +877,5 @@ export {
723
877
  JOURNAL_RETENTION_BLOCKS
724
878
  };
725
879
 
726
- //# debugId=E55C45575AA28BA564756E2164756E21
880
+ //# debugId=943E34F234EEBFC564756E2164756E21
727
881
  //# sourceMappingURL=context.js.map