@secondlayer/subgraphs 3.12.0 → 3.13.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 (34) hide show
  1. package/dist/src/index.d.ts +7 -0
  2. package/dist/src/index.js +531 -180
  3. package/dist/src/index.js.map +10 -10
  4. package/dist/src/runtime/block-processor.d.ts +31 -1
  5. package/dist/src/runtime/block-processor.js +501 -72
  6. package/dist/src/runtime/block-processor.js.map +9 -8
  7. package/dist/src/runtime/catchup.d.ts +7 -0
  8. package/dist/src/runtime/catchup.js +520 -118
  9. package/dist/src/runtime/catchup.js.map +10 -9
  10. package/dist/src/runtime/context.d.ts +65 -3
  11. package/dist/src/runtime/context.js +390 -8
  12. package/dist/src/runtime/context.js.map +6 -4
  13. package/dist/src/runtime/processor.js +576 -229
  14. package/dist/src/runtime/processor.js.map +12 -12
  15. package/dist/src/runtime/reindex.d.ts +7 -0
  16. package/dist/src/runtime/reindex.js +531 -180
  17. package/dist/src/runtime/reindex.js.map +10 -10
  18. package/dist/src/runtime/reorg.d.ts +7 -0
  19. package/dist/src/runtime/reorg.js +521 -73
  20. package/dist/src/runtime/reorg.js.map +10 -9
  21. package/dist/src/runtime/replay.js.map +2 -2
  22. package/dist/src/runtime/runner.d.ts +70 -2
  23. package/dist/src/runtime/runner.js +56 -58
  24. package/dist/src/runtime/runner.js.map +3 -3
  25. package/dist/src/runtime/source-matcher.d.ts +2 -0
  26. package/dist/src/runtime/source-matcher.js.map +2 -2
  27. package/dist/src/schema/index.d.ts +7 -0
  28. package/dist/src/schema/index.js +18 -1
  29. package/dist/src/schema/index.js.map +3 -3
  30. package/dist/src/service.js +576 -229
  31. package/dist/src/service.js.map +12 -12
  32. package/dist/src/types.d.ts +7 -0
  33. package/dist/src/validate.d.ts +7 -0
  34. package/package.json +1 -1
@@ -5,6 +5,134 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
5
5
  import { logger } from "@secondlayer/shared/logger";
6
6
  import { formatUnits } from "@secondlayer/stacks/utils";
7
7
  import { sql } from "kysely";
8
+
9
+ // src/schema/generator.ts
10
+ import { createHash } from "node:crypto";
11
+
12
+ // src/schema/utils.ts
13
+ import { pgSchemaName } from "@secondlayer/shared/db/queries/subgraphs";
14
+
15
+ // src/schema/generator.ts
16
+ var TYPE_MAP = {
17
+ text: "TEXT",
18
+ uint: "NUMERIC",
19
+ int: "NUMERIC",
20
+ principal: "TEXT",
21
+ boolean: "BOOLEAN",
22
+ timestamp: "TIMESTAMPTZ",
23
+ jsonb: "JSONB"
24
+ };
25
+ function escapeLiteralDefault(value) {
26
+ if (value === null || value === undefined)
27
+ return "NULL";
28
+ if (typeof value === "number" || typeof value === "bigint")
29
+ return String(value);
30
+ if (typeof value === "boolean")
31
+ return value ? "TRUE" : "FALSE";
32
+ return `'${String(value).replace(/'/g, "''")}'`;
33
+ }
34
+ function tableNeedsTrgm(tableDef) {
35
+ return Object.values(tableDef.columns).some((col) => col.search);
36
+ }
37
+ function emitTableDDL(schemaName, tableName, tableDef) {
38
+ const qualifiedName = `${schemaName}.${tableName}`;
39
+ const statements = [];
40
+ const columnDefs = [
41
+ "_id BIGSERIAL PRIMARY KEY",
42
+ "_block_height BIGINT NOT NULL",
43
+ "_tx_id TEXT NOT NULL",
44
+ "_created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()"
45
+ ];
46
+ for (const [colName, col] of Object.entries(tableDef.columns)) {
47
+ const sqlType = TYPE_MAP[col.type];
48
+ const nullable = col.nullable ? "" : " NOT NULL";
49
+ let colDef = `${colName} ${sqlType}${nullable}`;
50
+ if (col.default !== undefined) {
51
+ colDef += ` DEFAULT ${escapeLiteralDefault(col.default)}`;
52
+ }
53
+ if (col.type === "uint") {
54
+ colDef += ` CHECK (${colName} >= 0)`;
55
+ }
56
+ columnDefs.push(colDef);
57
+ }
58
+ statements.push(`CREATE TABLE IF NOT EXISTS ${qualifiedName} (
59
+ ${columnDefs.join(`,
60
+ `)}
61
+ )`);
62
+ statements.push(`CREATE INDEX IF NOT EXISTS idx_${schemaName}_${tableName}_block_height ON ${qualifiedName} (_block_height)`);
63
+ statements.push(`CREATE INDEX IF NOT EXISTS idx_${schemaName}_${tableName}_tx_id ON ${qualifiedName} (_tx_id)`);
64
+ for (const [colName, col] of Object.entries(tableDef.columns)) {
65
+ if (col.indexed) {
66
+ statements.push(`CREATE INDEX IF NOT EXISTS idx_${schemaName}_${tableName}_${colName} ON ${qualifiedName} (${colName})`);
67
+ }
68
+ }
69
+ for (const [colName, col] of Object.entries(tableDef.columns)) {
70
+ if (col.search) {
71
+ statements.push(`CREATE INDEX IF NOT EXISTS idx_${schemaName}_${tableName}_${colName}_trgm ON ${qualifiedName} USING gin (${colName} gin_trgm_ops)`);
72
+ }
73
+ }
74
+ if (tableDef.indexes) {
75
+ for (let i = 0;i < tableDef.indexes.length; i++) {
76
+ const cols = tableDef.indexes[i];
77
+ const idxName = `idx_${schemaName}_${tableName}_composite_${i}`;
78
+ statements.push(`CREATE INDEX IF NOT EXISTS ${idxName} ON ${qualifiedName} (${cols.join(", ")})`);
79
+ }
80
+ }
81
+ if (tableDef.uniqueKeys) {
82
+ for (let i = 0;i < tableDef.uniqueKeys.length; i++) {
83
+ const cols = tableDef.uniqueKeys[i];
84
+ const constraintName = `uq_${schemaName}_${tableName}_${cols.join("_")}`;
85
+ statements.push(`ALTER TABLE ${qualifiedName} ADD CONSTRAINT ${constraintName} UNIQUE (${cols.join(", ")})`);
86
+ }
87
+ }
88
+ return statements;
89
+ }
90
+ function emitJournalDDL(schemaName) {
91
+ return [
92
+ `CREATE TABLE IF NOT EXISTS ${schemaName}._journal (
93
+ _jid BIGSERIAL PRIMARY KEY,
94
+ block_height BIGINT NOT NULL,
95
+ table_name TEXT NOT NULL,
96
+ row_key JSONB NOT NULL,
97
+ prev_row JSONB,
98
+ _created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
99
+ )`,
100
+ `CREATE INDEX IF NOT EXISTS idx_${schemaName}_journal_height ON ${schemaName}._journal (block_height)`
101
+ ];
102
+ }
103
+ function emitForeignKeyDDL(schemaName, tableName, tableDef) {
104
+ return (tableDef.relations ?? []).map((rel) => {
105
+ const constraintName = `fk_${schemaName}_${tableName}_${rel.name}`;
106
+ return `ALTER TABLE ${schemaName}.${tableName} ADD CONSTRAINT ${constraintName} ` + `FOREIGN KEY (${rel.fields.join(", ")}) ` + `REFERENCES ${schemaName}.${rel.references} (${rel.referencedColumns.join(", ")})`;
107
+ });
108
+ }
109
+ function generateSubgraphSQL(def, schemaNameOverride) {
110
+ const schemaName = schemaNameOverride ?? pgSchemaName(def.name);
111
+ const statements = [];
112
+ const needsTrgm = Object.values(def.schema).some((table) => Object.values(table.columns).some((col) => col.search));
113
+ if (needsTrgm) {
114
+ statements.push("CREATE EXTENSION IF NOT EXISTS pg_trgm");
115
+ }
116
+ statements.push(`CREATE SCHEMA IF NOT EXISTS ${schemaName}`);
117
+ for (const [tableName, tableDef] of Object.entries(def.schema)) {
118
+ statements.push(...emitTableDDL(schemaName, tableName, tableDef));
119
+ }
120
+ statements.push(...emitJournalDDL(schemaName));
121
+ for (const [tableName, tableDef] of Object.entries(def.schema)) {
122
+ statements.push(...emitForeignKeyDDL(schemaName, tableName, tableDef));
123
+ }
124
+ const hashInput = JSON.stringify({
125
+ name: def.name,
126
+ schema: def.schema,
127
+ sources: def.sources
128
+ }, (_key, value) => typeof value === "bigint" ? value.toString() : value);
129
+ const hash = createHash("sha256").update(hashInput).digest("hex");
130
+ return { statements, hash };
131
+ }
132
+
133
+ // src/runtime/context.ts
134
+ var JOURNAL_RETENTION_BLOCKS = 300;
135
+ var journalEnsured = new Set;
8
136
  function validateColumnName(name) {
9
137
  if (!/^[a-z_][a-z0-9_]*$/i.test(name)) {
10
138
  throw new Error(`Invalid column name: ${name}`);
@@ -19,13 +147,15 @@ class SubgraphContext {
19
147
  subgraphSchema;
20
148
  ops = [];
21
149
  byo;
22
- constructor(db, pgSchemaName, subgraphSchema, block, tx, byo = false) {
150
+ journal;
151
+ constructor(db, pgSchemaName2, subgraphSchema, block, tx, byo = false, journal = false) {
23
152
  this.db = db;
24
- this.pgSchemaName = pgSchemaName;
153
+ this.pgSchemaName = pgSchemaName2;
25
154
  this.subgraphSchema = subgraphSchema;
26
155
  this.block = block;
27
156
  this._tx = tx;
28
157
  this.byo = byo;
158
+ this.journal = journal;
29
159
  }
30
160
  get tx() {
31
161
  return this._tx;
@@ -81,6 +211,43 @@ class SubgraphContext {
81
211
  this.validateTable(table);
82
212
  this.ops.push({ kind: "delete", table, data: where });
83
213
  }
214
+ increment(table, key, deltas) {
215
+ this.validateTable(table);
216
+ const tableDef = this.subgraphSchema[table];
217
+ const keyColumns = Object.keys(key);
218
+ const hasUniqueConstraint = tableDef?.uniqueKeys?.some((uk) => uk.length === keyColumns.length && uk.every((c) => keyColumns.includes(c)));
219
+ if (!hasUniqueConstraint) {
220
+ throw new Error(`increment("${table}") requires a uniqueKeys constraint on [${keyColumns.join(", ")}]`);
221
+ }
222
+ for (const [col, v] of Object.entries(deltas)) {
223
+ validateColumnName(col);
224
+ if (keyColumns.includes(col)) {
225
+ throw new Error(`increment("${table}"): "${col}" is a key column`);
226
+ }
227
+ if (typeof v !== "bigint" && typeof v !== "number") {
228
+ throw new Error(`increment("${table}"): delta for "${col}" must be bigint or number`);
229
+ }
230
+ }
231
+ this.ops.push({
232
+ kind: "increment",
233
+ table,
234
+ data: {
235
+ ...key,
236
+ _block_height: this.block.height,
237
+ _tx_id: this._tx.txId,
238
+ _upsert_keys: keyColumns
239
+ },
240
+ set: { ...deltas }
241
+ });
242
+ }
243
+ opsCheckpoint() {
244
+ return this.ops.length;
245
+ }
246
+ rollbackTo(checkpoint) {
247
+ if (checkpoint < 0 || checkpoint > this.ops.length)
248
+ return;
249
+ this.ops.length = checkpoint;
250
+ }
84
251
  patch(table, where, set) {
85
252
  this.update(table, where, set);
86
253
  }
@@ -102,7 +269,7 @@ class SubgraphContext {
102
269
  const query = `SELECT * FROM ${qualifiedTable} WHERE ${clause} LIMIT 1`;
103
270
  const { rows } = await sql.raw(query).execute(this.db);
104
271
  const row = rows[0] ?? null;
105
- return row ? this.coerceRow(table, row) : null;
272
+ return this.overlayOne(table, where, row ? this.coerceRow(table, row) : null);
106
273
  }
107
274
  async findMany(table, where) {
108
275
  this.validateTable(table);
@@ -110,7 +277,85 @@ class SubgraphContext {
110
277
  const { clause } = buildWhereClause(where);
111
278
  const query = `SELECT * FROM ${qualifiedTable} WHERE ${clause}`;
112
279
  const { rows } = await sql.raw(query).execute(this.db);
113
- return rows.map((r) => this.coerceRow(table, r));
280
+ const dbRows = rows.map((r) => this.coerceRow(table, r));
281
+ return this.overlayMany(table, where, dbRows);
282
+ }
283
+ overlayOne(table, where, dbRow) {
284
+ let row = dbRow;
285
+ for (const op of this.ops) {
286
+ if (op.table !== table)
287
+ continue;
288
+ row = this.applyOpToRow(op, row, where);
289
+ }
290
+ return row;
291
+ }
292
+ overlayMany(table, where, dbRows) {
293
+ let result = [...dbRows];
294
+ for (const op of this.ops) {
295
+ if (op.table !== table)
296
+ continue;
297
+ if (op.kind === "update") {
298
+ result = result.map((r) => rowMatches(r, op.data) ? { ...r, ...op.set ?? {} } : r);
299
+ } else if (op.kind === "delete") {
300
+ result = result.filter((r) => !rowMatches(r, op.data));
301
+ } else {
302
+ const upsertKeys = op.data._upsert_keys;
303
+ const clean = stripControlKeys(op.data);
304
+ const idx = upsertKeys ? result.findIndex((r) => upsertKeys.every((k) => valEq(r[k], clean[k]))) : -1;
305
+ if (idx >= 0) {
306
+ result[idx] = this.applyOpToRow(op, result[idx], where) ?? result[idx];
307
+ } else {
308
+ const created = this.applyOpToRow(op, null, where);
309
+ if (created)
310
+ result.push(created);
311
+ }
312
+ }
313
+ }
314
+ return result;
315
+ }
316
+ applyOpToRow(op, row, where) {
317
+ const upsertKeys = op.data._upsert_keys;
318
+ const clean = stripControlKeys(op.data);
319
+ switch (op.kind) {
320
+ case "insert": {
321
+ if (row) {
322
+ if (upsertKeys?.every((k) => valEq(row[k], clean[k]))) {
323
+ const merged = { ...row };
324
+ for (const [k, v] of Object.entries(clean)) {
325
+ if (!upsertKeys.includes(k) && !k.startsWith("_"))
326
+ merged[k] = v;
327
+ }
328
+ return merged;
329
+ }
330
+ return row;
331
+ }
332
+ return rowMatches(clean, where) ? { ...clean } : null;
333
+ }
334
+ case "increment": {
335
+ const deltas = op.set ?? {};
336
+ if (row) {
337
+ if (upsertKeys.every((k) => valEq(row[k], clean[k]))) {
338
+ const merged = { ...row };
339
+ for (const [col, d] of Object.entries(deltas)) {
340
+ merged[col] = toBigIntOr0(merged[col]) + toBigIntOr0(d);
341
+ }
342
+ return merged;
343
+ }
344
+ return row;
345
+ }
346
+ if (!rowMatches(clean, where))
347
+ return null;
348
+ const created = { ...clean };
349
+ for (const [col, d] of Object.entries(deltas)) {
350
+ created[col] = toBigIntOr0(d);
351
+ }
352
+ return created;
353
+ }
354
+ case "update":
355
+ return row && rowMatches(row, op.data) ? { ...row, ...op.set ?? {} } : row;
356
+ case "delete":
357
+ return row && rowMatches(row, op.data) ? null : row;
358
+ }
114
359
  }
115
360
  async count(table, where) {
116
361
  this.validateTable(table);
@@ -171,6 +416,7 @@ class SubgraphContext {
171
416
  async flush() {
172
417
  if (this.ops.length === 0)
173
418
  return { count: 0, writes: [] };
419
+ await this.ensureJournalTable();
174
420
  const opsToFlush = [...this.ops];
175
421
  this.ops.length = 0;
176
422
  const statements = this.buildStatements(opsToFlush);
@@ -188,12 +434,12 @@ class SubgraphContext {
188
434
  const writes = opsToFlush.map((op, rowIndex) => {
189
435
  const blockHeight = op.data._block_height ?? this.block.height;
190
436
  const txId = op.data._tx_id ?? this._tx.txId;
191
- const baseRow = op.kind === "update" ? { ...op.data, ...op.set ?? {} } : { ...op.data };
437
+ const baseRow = op.kind === "update" || op.kind === "increment" ? { ...op.data, ...op.set ?? {} } : { ...op.data };
192
438
  baseRow._upsert_keys = undefined;
193
439
  baseRow._upsert_fallback_keys = undefined;
194
440
  baseRow._upsert_fallback_set = undefined;
195
441
  return {
196
- op: op.kind,
442
+ op: op.kind === "increment" ? "update" : op.kind,
197
443
  table: op.table,
198
444
  row: jsonSafe(baseRow),
199
445
  pk: { blockHeight, txId, rowIndex }
@@ -218,6 +464,35 @@ class SubgraphContext {
218
464
  const batchKey = `${op.table}:${[...cols].sort().join(",")}:${upsertKeys ? [...upsertKeys].sort().join(",") : ""}`;
219
465
  return { data, cols, vals, upsertKeys, batchKey };
220
466
  }
467
+ async ensureJournalTable() {
468
+ if (!this.journal || journalEnsured.has(this.pgSchemaName))
469
+ return;
470
+ const { rows } = await sql.raw(`SELECT to_regclass('"${this.pgSchemaName}"."_journal"') AS r`).execute(this.db);
471
+ if (rows[0]?.r) {
472
+ journalEnsured.add(this.pgSchemaName);
473
+ return;
474
+ }
475
+ for (const stmt of emitJournalDDL(this.pgSchemaName)) {
476
+ await sql.raw(stmt).execute(this.db);
477
+ }
478
+ }
479
+ columnSqlType(table, col) {
480
+ const def = this.subgraphSchema[table]?.columns?.[col];
481
+ return def ? TYPE_MAP[def.type] : undefined;
482
+ }
483
+ journalCaptureSQL(table, keyCols, keyLiteralRows) {
484
+ const cast = (col, expr) => {
485
+ const t = this.columnSqlType(table, col);
486
+ return t ? `CAST(${expr} AS ${t})` : expr;
487
+ };
488
+ const keyObj = keyCols.map((k) => `'${k}', ${cast(k, `v."${k}"`)}`).join(", ");
489
+ const joinCond = keyCols.map((k) => `t."${k}" = ${cast(k, `v."${k}"`)}`).join(" AND ");
490
+ const valuesList = keyLiteralRows.map((r) => `(${r.join(", ")})`).join(", ");
491
+ return `INSERT INTO "${this.pgSchemaName}"."_journal" ("block_height", "table_name", "row_key", "prev_row") ` + `SELECT ${this.block.height}, '${table}', jsonb_build_object(${keyObj}), to_jsonb(t.*) ` + `FROM (VALUES ${valuesList}) AS v(${keyCols.map((k) => `"${k}"`).join(", ")}) ` + `LEFT JOIN "${this.pgSchemaName}"."${table}" t ON ${joinCond}`;
492
+ }
493
+ journalCaptureByWhereSQL(table, clause) {
494
+ return `INSERT INTO "${this.pgSchemaName}"."_journal" ("block_height", "table_name", "row_key", "prev_row") ` + `SELECT ${this.block.height}, '${table}', jsonb_build_object('_id', t."_id"), to_jsonb(t.*) ` + `FROM "${this.pgSchemaName}"."${table}" t WHERE ${clause}`;
495
+ }
221
496
  buildStatements(ops) {
222
497
  const statements = [];
223
498
  if (this.byo) {
@@ -231,6 +506,38 @@ class SubgraphContext {
231
506
  }
232
507
  let currentBatch = null;
233
508
  let currentBatchKey = "";
509
+ let incBatch = null;
510
+ let incBatchKey = "";
511
+ const flushIncrementBatch = () => {
512
+ if (!incBatch)
513
+ return;
514
+ const batch = incBatch;
515
+ const qualifiedTable = `"${this.pgSchemaName}"."${batch.table}"`;
516
+ const cols = [
517
+ ...batch.keyCols,
518
+ ...batch.deltaCols,
519
+ "_block_height",
520
+ "_tx_id",
521
+ "_created_at"
522
+ ];
523
+ const valuesList = Array.from(batch.rows.values()).map((r) => {
524
+ const vals = [
525
+ ...batch.keyCols.map((k) => escapeLiteral(r.keys[k])),
526
+ ...batch.deltaCols.map((c) => String(r.deltas[c] ?? 0n)),
527
+ escapeLiteral(r.meta.blockHeight),
528
+ escapeLiteral(r.meta.txId),
529
+ "NOW()"
530
+ ];
531
+ return `(${vals.join(", ")})`;
532
+ }).join(", ");
533
+ const setClauses = batch.deltaCols.map((c) => `"${c}" = COALESCE("${batch.table}"."${c}", 0) + EXCLUDED."${c}"`);
534
+ if (this.journal) {
535
+ statements.push(this.journalCaptureSQL(batch.table, batch.keyCols, Array.from(batch.rows.values()).map((r) => batch.keyCols.map((k) => escapeLiteral(r.keys[k])))));
536
+ }
537
+ statements.push(`INSERT INTO ${qualifiedTable} (${cols.map((c) => `"${c}"`).join(", ")}) VALUES ${valuesList} ` + `ON CONFLICT (${batch.keyCols.map((k) => `"${k}"`).join(", ")}) DO UPDATE SET ${setClauses.join(", ")}`);
538
+ incBatch = null;
539
+ incBatchKey = "";
540
+ };
234
541
  const flushInsertBatch = () => {
235
542
  if (!currentBatch)
236
543
  return;
@@ -252,6 +559,11 @@ class SubgraphContext {
252
559
  }
253
560
  const valuesList = rows.map((r) => `(${r.join(", ")})`).join(", ");
254
561
  let stmt = `INSERT INTO ${qualifiedTable} (${colList}) VALUES ${valuesList}`;
562
+ if (this.journal && batch.upsertKeys && batch.upsertKeys.length > 0) {
563
+ const uKeys = batch.upsertKeys;
564
+ const keyIndices = uKeys.map((k) => batch.cols.indexOf(k));
565
+ statements.push(this.journalCaptureSQL(batch.table, uKeys, rows.map((r) => keyIndices.map((ki) => r[ki]))));
566
+ }
255
567
  if (batch.upsertKeys && batch.upsertKeys.length > 0) {
256
568
  const batchKeys = batch.upsertKeys;
257
569
  const updateCols = batch.cols.filter((c) => !batchKeys.includes(c) && !c.startsWith("_"));
@@ -269,6 +581,7 @@ class SubgraphContext {
269
581
  for (const op of ops) {
270
582
  const qualifiedTable = `"${this.pgSchemaName}"."${op.table}"`;
271
583
  if (op.kind === "insert") {
584
+ flushIncrementBatch();
272
585
  const { cols, vals, upsertKeys, batchKey } = this.prepareInsert(op);
273
586
  if (batchKey === currentBatchKey && currentBatch) {
274
587
  currentBatch.rows.push(vals);
@@ -277,22 +590,60 @@ class SubgraphContext {
277
590
  currentBatch = { table: op.table, cols, rows: [vals], upsertKeys };
278
591
  currentBatchKey = batchKey;
279
592
  }
593
+ } else if (op.kind === "increment") {
594
+ flushInsertBatch();
595
+ const keyCols = [...op.data._upsert_keys].sort();
596
+ const deltaCols = Object.keys(op.set ?? {}).sort();
597
+ const batchKey = `inc:${op.table}:${keyCols.join(",")}:${deltaCols.join(",")}`;
598
+ if (batchKey !== incBatchKey || !incBatch) {
599
+ flushIncrementBatch();
600
+ incBatch = { table: op.table, keyCols, deltaCols, rows: new Map };
601
+ incBatchKey = batchKey;
602
+ }
603
+ const clean = stripControlKeys(op.data);
604
+ const keySig = keyCols.map((k) => escapeLiteral(clean[k])).join("\x00");
605
+ const existing = incBatch.rows.get(keySig);
606
+ if (existing) {
607
+ for (const c of deltaCols) {
608
+ existing.deltas[c] = (existing.deltas[c] ?? 0n) + toBigIntOr0(op.set?.[c]);
609
+ }
610
+ } else {
611
+ const deltas = {};
612
+ for (const c of deltaCols)
613
+ deltas[c] = toBigIntOr0(op.set?.[c]);
614
+ incBatch.rows.set(keySig, {
615
+ keys: clean,
616
+ deltas,
617
+ meta: {
618
+ blockHeight: op.data._block_height ?? this.block.height,
619
+ txId: op.data._tx_id ?? this._tx.txId
620
+ }
621
+ });
622
+ }
280
623
  } else {
281
624
  flushInsertBatch();
625
+ flushIncrementBatch();
282
626
  if (op.kind === "update") {
283
627
  const setEntries = Object.entries(op.set ?? {});
284
628
  for (const [k] of setEntries)
285
629
  validateColumnName(k);
286
630
  const setClauses = setEntries.map(([k, v]) => `"${k}" = ${escapeLiteral(v)}`);
287
631
  const { clause } = buildWhereClause(op.data);
632
+ if (this.journal) {
633
+ statements.push(this.journalCaptureByWhereSQL(op.table, clause));
634
+ }
288
635
  statements.push(`UPDATE ${qualifiedTable} SET ${setClauses.join(", ")} WHERE ${clause}`);
289
636
  } else if (op.kind === "delete") {
290
637
  const { clause } = buildWhereClause(op.data);
638
+ if (this.journal) {
639
+ statements.push(this.journalCaptureByWhereSQL(op.table, clause));
640
+ }
291
641
  statements.push(`DELETE FROM ${qualifiedTable} WHERE ${clause}`);
292
642
  }
293
643
  }
294
644
  }
295
645
  flushInsertBatch();
646
+ flushIncrementBatch();
296
647
  return statements;
297
648
  }
298
649
  validateTable(table) {
@@ -301,6 +652,36 @@ class SubgraphContext {
301
652
  }
302
653
  }
303
654
  }
655
+ function stripControlKeys(data) {
656
+ const {
657
+ _upsert_keys: _a,
658
+ _upsert_fallback_keys: _b,
659
+ _upsert_fallback_set: _c,
660
+ ...clean
661
+ } = data;
662
+ return clean;
663
+ }
664
+ function valEq(a, b) {
665
+ if (a === b)
666
+ return true;
667
+ if (a == null || b == null)
668
+ return false;
669
+ return String(a) === String(b);
670
+ }
671
+ function rowMatches(row, where) {
672
+ return Object.entries(where).every(([k, v]) => valEq(row[k], v));
673
+ }
674
+ function toBigIntOr0(v) {
675
+ if (typeof v === "bigint")
676
+ return v;
677
+ if (v == null)
678
+ return 0n;
679
+ try {
680
+ return BigInt(String(v));
681
+ } catch {
682
+ return 0n;
683
+ }
684
+ }
304
685
  function jsonSafe(row) {
305
686
  const out = {};
306
687
  for (const [k, v] of Object.entries(row)) {
@@ -619,7 +1000,25 @@ async function runHandlers(subgraph, matched, ctx, opts) {
619
1000
  filterLookup.set(name, filter);
620
1001
  }
621
1002
  }
1003
+ const units = [];
622
1004
  for (const { tx, events, sourceName } of matched) {
1005
+ if (events.length === 0) {
1006
+ units.push({ tx, sourceName, event: null });
1007
+ } else {
1008
+ for (const event of events)
1009
+ units.push({ tx, sourceName, event });
1010
+ }
1011
+ }
1012
+ units.sort((a, b) => (a.tx.tx_index ?? 0) - (b.tx.tx_index ?? 0) || (a.event?.event_index ?? -1) - (b.event?.event_index ?? -1));
1013
+ for (const { tx, event, sourceName } of units) {
1014
+ if (errors >= threshold) {
1015
+ logger2.error("Subgraph error threshold reached, skipping remaining events", {
1016
+ subgraph: subgraph.name,
1017
+ errors,
1018
+ threshold
1019
+ });
1020
+ return { processed, errors };
1021
+ }
623
1022
  const handler = subgraph.handlers[sourceName] ?? subgraph.handlers["*"] ?? null;
624
1023
  if (!handler) {
625
1024
  logger2.warn("No handler found for source", {
@@ -638,9 +1037,29 @@ async function runHandlers(subgraph, matched, ctx, opts) {
638
1037
  functionName: tx.function_name ?? null
639
1038
  });
640
1039
  const filter = filterLookup.get(sourceName);
641
- if (events.length === 0) {
642
- try {
643
- const payload = filter ? buildEventPayload(filter, tx, null) : {
1040
+ const checkpoint = ctx.opsCheckpoint();
1041
+ try {
1042
+ let payload;
1043
+ if (event === null) {
1044
+ payload = filter ? buildEventPayload(filter, tx, null) : {
1045
+ tx: {
1046
+ txId: tx.tx_id,
1047
+ sender: tx.sender,
1048
+ type: tx.type,
1049
+ status: tx.status,
1050
+ contractId: tx.contract_id,
1051
+ functionName: tx.function_name
1052
+ }
1053
+ };
1054
+ } else if (filter) {
1055
+ payload = buildEventPayload(filter, tx, event);
1056
+ } else {
1057
+ const decoded = decodeEventData(event.data);
1058
+ payload = {
1059
+ ...decoded,
1060
+ _eventId: event.id,
1061
+ _eventType: event.type,
1062
+ _eventIndex: event.event_index,
644
1063
  tx: {
645
1064
  txId: tx.tx_id,
646
1065
  sender: tx.sender,
@@ -650,62 +1069,22 @@ async function runHandlers(subgraph, matched, ctx, opts) {
650
1069
  functionName: tx.function_name
651
1070
  }
652
1071
  };
653
- await handler(payload, ctx);
654
- processed++;
655
- } catch (err) {
656
- errors++;
657
- logger2.error("Subgraph handler error", {
658
- subgraph: subgraph.name,
659
- sourceName,
660
- txId: tx.tx_id,
661
- error: getErrorMessage(err)
662
- });
663
- }
664
- continue;
665
- }
666
- for (const event of events) {
667
- if (errors >= threshold) {
668
- logger2.error("Subgraph error threshold reached, skipping remaining events", {
669
- subgraph: subgraph.name,
670
- errors,
671
- threshold
672
- });
673
- return { processed, errors };
674
1072
  }
675
- try {
676
- const payload = filter ? buildEventPayload(filter, tx, event) : (() => {
677
- const decoded = decodeEventData(event.data);
678
- return {
679
- ...decoded,
680
- _eventId: event.id,
681
- _eventType: event.type,
682
- _eventIndex: event.event_index,
683
- tx: {
684
- txId: tx.tx_id,
685
- sender: tx.sender,
686
- type: tx.type,
687
- status: tx.status,
688
- contractId: tx.contract_id,
689
- functionName: tx.function_name
690
- }
691
- };
692
- })();
693
- if (filter?.type === "print_event" && filter.topic && payload.topic !== filter.topic) {
694
- continue;
695
- }
696
- await handler(payload, ctx);
697
- processed++;
698
- } catch (err) {
699
- errors++;
700
- logger2.error("Subgraph handler error", {
701
- subgraph: subgraph.name,
702
- sourceName,
703
- txId: tx.tx_id,
704
- eventId: event.id,
705
- eventType: event.type,
706
- error: getErrorMessage(err)
707
- });
1073
+ if (event !== null && filter?.type === "print_event" && filter.topic && payload.topic !== filter.topic) {
1074
+ continue;
708
1075
  }
1076
+ await handler(payload, ctx);
1077
+ processed++;
1078
+ } catch (err) {
1079
+ ctx.rollbackTo(checkpoint);
1080
+ errors++;
1081
+ logger2.error("Subgraph handler error", {
1082
+ subgraph: subgraph.name,
1083
+ sourceName,
1084
+ txId: tx.tx_id,
1085
+ ...event !== null ? { eventId: event.id, eventType: event.type } : {},
1086
+ error: getErrorMessage(err)
1087
+ });
709
1088
  }
710
1089
  }
711
1090
  return { processed, errors };
@@ -962,9 +1341,6 @@ import {
962
1341
  import { logger as logger5 } from "@secondlayer/shared/logger";
963
1342
  import { sql as sql3 } from "kysely";
964
1343
 
965
- // src/schema/utils.ts
966
- import { pgSchemaName } from "@secondlayer/shared/db/queries/subgraphs";
967
-
968
1344
  // src/runtime/block-source.ts
969
1345
  import { getSourceDb } from "@secondlayer/shared/db";
970
1346
  import { IndexHttpClient } from "@secondlayer/shared/index-http";
@@ -1310,7 +1686,7 @@ function resolveBlockSource(subgraph) {
1310
1686
  }
1311
1687
 
1312
1688
  // src/runtime/outbox-emit.ts
1313
- import { createHash } from "node:crypto";
1689
+ import { createHash as createHash2 } from "node:crypto";
1314
1690
  import { logger as logger4 } from "@secondlayer/shared/logger";
1315
1691
  var loggedKillSwitch = false;
1316
1692
  var OP_VERB = {
@@ -1323,7 +1699,7 @@ function isEmitOutboxEnabled() {
1323
1699
  }
1324
1700
  function dedupKey(subgraphName, tableName, blockHeight, txId, rowIndex, row) {
1325
1701
  const canonical = `${subgraphName}:${tableName}:${blockHeight}:${txId}:${rowIndex}:${stableStringify(row)}`;
1326
- return createHash("sha256").update(canonical).digest("hex").slice(0, 32);
1702
+ return createHash2("sha256").update(canonical).digest("hex").slice(0, 32);
1327
1703
  }
1328
1704
  function stableStringify(obj) {
1329
1705
  const keys = Object.keys(obj).sort();
@@ -1575,6 +1951,32 @@ async function resolveTraitContracts(subgraph, blockHeight, db) {
1575
1951
  }
1576
1952
  return resolved;
1577
1953
  }
1954
+ var BLOCK_RETRY_DELAYS_MS = [500, 2000, 5000];
1955
+ function journalEnabled(opts) {
1956
+ return !opts?.skipProgressUpdate;
1957
+ }
1958
+ async function processBlockWithRetry(subgraph, subgraphName, blockHeight, opts, retryDelaysMs = BLOCK_RETRY_DELAYS_MS) {
1959
+ let lastError;
1960
+ for (let attempt = 0;attempt <= retryDelaysMs.length; attempt++) {
1961
+ try {
1962
+ return await processBlock(subgraph, subgraphName, blockHeight, opts);
1963
+ } catch (err) {
1964
+ lastError = err;
1965
+ const delay = retryDelaysMs[attempt];
1966
+ if (delay === undefined)
1967
+ break;
1968
+ logger5.warn("Block processing failed, retrying", {
1969
+ subgraph: subgraphName,
1970
+ blockHeight,
1971
+ attempt: attempt + 1,
1972
+ retryInMs: delay,
1973
+ error: err instanceof Error ? err.message : String(err)
1974
+ });
1975
+ await new Promise((r) => setTimeout(r, delay));
1976
+ }
1977
+ }
1978
+ throw lastError;
1979
+ }
1578
1980
  async function processBlock(subgraph, subgraphName, blockHeight, opts) {
1579
1981
  const targetDb = getTargetDb();
1580
1982
  const blockStart = performance.now();
@@ -1642,10 +2044,17 @@ async function processBlock(subgraph, subgraphName, blockHeight, opts) {
1642
2044
  }
1643
2045
  };
1644
2046
  if (route.byo) {
2047
+ if (opts?.atomicProgress) {
2048
+ const row = await targetDb.selectFrom("subgraphs").select("last_processed_block").where("name", "=", subgraphName).executeTakeFirst();
2049
+ if (row && Number(row.last_processed_block) >= blockHeight) {
2050
+ result.skipped = true;
2051
+ return result;
2052
+ }
2053
+ }
1645
2054
  let runResult = { processed: 0, errors: 0 };
1646
2055
  let manifest;
1647
2056
  await route.dataDb.transaction().execute(async (tx) => {
1648
- const ctx = new SubgraphContext(tx, schemaName, subgraph.schema, blockMeta, initialTx, true);
2057
+ const ctx = new SubgraphContext(tx, schemaName, subgraph.schema, blockMeta, initialTx, true, journalEnabled(opts));
1649
2058
  const handlerStart = performance.now();
1650
2059
  runResult = await runHandlers(subgraph, matched, ctx);
1651
2060
  handlerMs = performance.now() - handlerStart;
@@ -1661,24 +2070,39 @@ async function processBlock(subgraph, subgraphName, blockHeight, opts) {
1661
2070
  if (manifest && manifest.count > 0) {
1662
2071
  await emitSubscriptionOutbox(tx, subgraphName, manifest, matcher, block.height);
1663
2072
  }
2073
+ if (opts?.atomicProgress && manifest && manifest.count > 0) {
2074
+ await updateSubgraphStatus(tx, subgraphName, opts.atomicProgress.status, blockHeight);
2075
+ }
1664
2076
  await applyProgress(tx, runResult);
1665
2077
  });
1666
2078
  } else {
1667
2079
  await targetDb.transaction().execute(async (tx) => {
1668
- const ctx = new SubgraphContext(tx, schemaName, subgraph.schema, blockMeta, initialTx);
2080
+ if (opts?.atomicProgress) {
2081
+ const row = await tx.selectFrom("subgraphs").select("last_processed_block").where("name", "=", subgraphName).executeTakeFirst();
2082
+ if (row && Number(row.last_processed_block) >= blockHeight) {
2083
+ result.skipped = true;
2084
+ return;
2085
+ }
2086
+ }
2087
+ const ctx = new SubgraphContext(tx, schemaName, subgraph.schema, blockMeta, initialTx, false, journalEnabled(opts));
1669
2088
  const handlerStart = performance.now();
1670
2089
  const runResult = await runHandlers(subgraph, matched, ctx);
1671
2090
  handlerMs = performance.now() - handlerStart;
1672
2091
  result.processed = runResult.processed;
1673
2092
  result.errors = runResult.errors;
2093
+ let flushedWrites = false;
1674
2094
  if (ctx.pendingOps > 0) {
1675
2095
  const flushStart = performance.now();
1676
2096
  const manifest = await ctx.flush();
2097
+ flushedWrites = manifest.count > 0;
1677
2098
  if (manifest.count > 0) {
1678
2099
  await emitSubscriptionOutbox(tx, subgraphName, manifest, matcher, block.height);
1679
2100
  }
1680
2101
  flushMs = performance.now() - flushStart;
1681
2102
  }
2103
+ if (opts?.atomicProgress && flushedWrites) {
2104
+ await updateSubgraphStatus(tx, subgraphName, opts.atomicProgress.status, blockHeight);
2105
+ }
1682
2106
  await applyProgress(tx, runResult);
1683
2107
  });
1684
2108
  }
@@ -1708,6 +2132,9 @@ async function processBlock(subgraph, subgraphName, blockHeight, opts) {
1708
2132
  error: err instanceof Error ? err.message : String(err)
1709
2133
  });
1710
2134
  }
2135
+ if (journalEnabled(opts)) {
2136
+ await sql3.raw(`DELETE FROM "${schemaName}"."_journal" WHERE "block_height" < ${blockHeight - JOURNAL_RETENTION_BLOCKS}`).execute(route.dataDb).catch(() => {});
2137
+ }
1711
2138
  }
1712
2139
  return result;
1713
2140
  }
@@ -1788,113 +2215,10 @@ import {
1788
2215
  updateSubgraphStatus as updateSubgraphStatus2
1789
2216
  } from "@secondlayer/shared/db/queries/subgraphs";
1790
2217
  import { logger as logger6 } from "@secondlayer/shared/logger";
1791
-
1792
- // src/schema/generator.ts
1793
- import { createHash as createHash2 } from "node:crypto";
1794
- var TYPE_MAP = {
1795
- text: "TEXT",
1796
- uint: "NUMERIC",
1797
- int: "NUMERIC",
1798
- principal: "TEXT",
1799
- boolean: "BOOLEAN",
1800
- timestamp: "TIMESTAMPTZ",
1801
- jsonb: "JSONB"
1802
- };
1803
- function escapeLiteralDefault(value) {
1804
- if (value === null || value === undefined)
1805
- return "NULL";
1806
- if (typeof value === "number" || typeof value === "bigint")
1807
- return String(value);
1808
- if (typeof value === "boolean")
1809
- return value ? "TRUE" : "FALSE";
1810
- return `'${String(value).replace(/'/g, "''")}'`;
1811
- }
1812
- function tableNeedsTrgm(tableDef) {
1813
- return Object.values(tableDef.columns).some((col) => col.search);
1814
- }
1815
- function emitTableDDL(schemaName, tableName, tableDef) {
1816
- const qualifiedName = `${schemaName}.${tableName}`;
1817
- const statements = [];
1818
- const columnDefs = [
1819
- "_id BIGSERIAL PRIMARY KEY",
1820
- "_block_height BIGINT NOT NULL",
1821
- "_tx_id TEXT NOT NULL",
1822
- "_created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()"
1823
- ];
1824
- for (const [colName, col] of Object.entries(tableDef.columns)) {
1825
- const sqlType = TYPE_MAP[col.type];
1826
- const nullable = col.nullable ? "" : " NOT NULL";
1827
- let colDef = `${colName} ${sqlType}${nullable}`;
1828
- if (col.default !== undefined) {
1829
- colDef += ` DEFAULT ${escapeLiteralDefault(col.default)}`;
1830
- }
1831
- columnDefs.push(colDef);
1832
- }
1833
- statements.push(`CREATE TABLE IF NOT EXISTS ${qualifiedName} (
1834
- ${columnDefs.join(`,
1835
- `)}
1836
- )`);
1837
- statements.push(`CREATE INDEX IF NOT EXISTS idx_${schemaName}_${tableName}_block_height ON ${qualifiedName} (_block_height)`);
1838
- statements.push(`CREATE INDEX IF NOT EXISTS idx_${schemaName}_${tableName}_tx_id ON ${qualifiedName} (_tx_id)`);
1839
- for (const [colName, col] of Object.entries(tableDef.columns)) {
1840
- if (col.indexed) {
1841
- statements.push(`CREATE INDEX IF NOT EXISTS idx_${schemaName}_${tableName}_${colName} ON ${qualifiedName} (${colName})`);
1842
- }
1843
- }
1844
- for (const [colName, col] of Object.entries(tableDef.columns)) {
1845
- if (col.search) {
1846
- statements.push(`CREATE INDEX IF NOT EXISTS idx_${schemaName}_${tableName}_${colName}_trgm ON ${qualifiedName} USING gin (${colName} gin_trgm_ops)`);
1847
- }
1848
- }
1849
- if (tableDef.indexes) {
1850
- for (let i = 0;i < tableDef.indexes.length; i++) {
1851
- const cols = tableDef.indexes[i];
1852
- const idxName = `idx_${schemaName}_${tableName}_composite_${i}`;
1853
- statements.push(`CREATE INDEX IF NOT EXISTS ${idxName} ON ${qualifiedName} (${cols.join(", ")})`);
1854
- }
1855
- }
1856
- if (tableDef.uniqueKeys) {
1857
- for (let i = 0;i < tableDef.uniqueKeys.length; i++) {
1858
- const cols = tableDef.uniqueKeys[i];
1859
- const constraintName = `uq_${schemaName}_${tableName}_${cols.join("_")}`;
1860
- statements.push(`ALTER TABLE ${qualifiedName} ADD CONSTRAINT ${constraintName} UNIQUE (${cols.join(", ")})`);
1861
- }
1862
- }
1863
- return statements;
1864
- }
1865
- function emitForeignKeyDDL(schemaName, tableName, tableDef) {
1866
- return (tableDef.relations ?? []).map((rel) => {
1867
- const constraintName = `fk_${schemaName}_${tableName}_${rel.name}`;
1868
- return `ALTER TABLE ${schemaName}.${tableName} ADD CONSTRAINT ${constraintName} ` + `FOREIGN KEY (${rel.fields.join(", ")}) ` + `REFERENCES ${schemaName}.${rel.references} (${rel.referencedColumns.join(", ")})`;
1869
- });
1870
- }
1871
- function generateSubgraphSQL(def, schemaNameOverride) {
1872
- const schemaName = schemaNameOverride ?? pgSchemaName(def.name);
1873
- const statements = [];
1874
- const needsTrgm = Object.values(def.schema).some((table) => Object.values(table.columns).some((col) => col.search));
1875
- if (needsTrgm) {
1876
- statements.push("CREATE EXTENSION IF NOT EXISTS pg_trgm");
1877
- }
1878
- statements.push(`CREATE SCHEMA IF NOT EXISTS ${schemaName}`);
1879
- for (const [tableName, tableDef] of Object.entries(def.schema)) {
1880
- statements.push(...emitTableDDL(schemaName, tableName, tableDef));
1881
- }
1882
- for (const [tableName, tableDef] of Object.entries(def.schema)) {
1883
- statements.push(...emitForeignKeyDDL(schemaName, tableName, tableDef));
1884
- }
1885
- const hashInput = JSON.stringify({
1886
- name: def.name,
1887
- schema: def.schema,
1888
- sources: def.sources
1889
- }, (_key, value) => typeof value === "bigint" ? value.toString() : value);
1890
- const hash = createHash2("sha256").update(hashInput).digest("hex");
1891
- return { statements, hash };
1892
- }
1893
-
1894
- // src/runtime/reindex.ts
1895
2218
  var LOG_INTERVAL = 1000;
1896
2219
  var HEALTH_FLUSH_INTERVAL = 1000;
1897
2220
  var PROGRESS_FLUSH_INTERVAL_MS = 5000;
2221
+ var EMPTY_BATCH_HALT_THRESHOLD = 3;
1898
2222
  var STANDARD_REINDEX_BATCH_CONFIG = {
1899
2223
  defaultBatchSize: 500,
1900
2224
  minBatchSize: 100,
@@ -1970,6 +2294,7 @@ async function processBlockRange(def, opts) {
1970
2294
  let batchSize = batchConfig.defaultBatchSize;
1971
2295
  let currentHeight = fromBlock;
1972
2296
  let aborted = false;
2297
+ let consecutiveEmptyBatches = 0;
1973
2298
  const sparse = Boolean(source.nextDataHeight && canSparseScan(def));
1974
2299
  const flushHealth = async () => {
1975
2300
  if (pendingEventsProcessed === 0 && pendingErrors === 0)
@@ -1981,6 +2306,13 @@ async function processBlockRange(def, opts) {
1981
2306
  lastHealthFlushBlock = blocksProcessed;
1982
2307
  lastHealthFlushAt = Date.now();
1983
2308
  };
2309
+ const haltRange = async (errorMsg, height) => {
2310
+ pendingErrors++;
2311
+ pendingLastError = errorMsg;
2312
+ await flushHealth().catch(() => {});
2313
+ await updateSubgraphStatus2(targetDb, subgraphName, "error").catch(() => {});
2314
+ throw new Error(`${subgraphName}: halted at block ${height}: ${errorMsg}`);
2315
+ };
1984
2316
  let nextBatchEnd = Math.min(currentHeight + batchSize - 1, toBlock);
1985
2317
  let nextBatchPromise = source.loadBlockRange(currentHeight, nextBatchEnd);
1986
2318
  while (currentHeight <= toBlock) {
@@ -1995,6 +2327,14 @@ async function processBlockRange(def, opts) {
1995
2327
  }
1996
2328
  const batch = await nextBatchPromise;
1997
2329
  const batchEnd = nextBatchEnd;
2330
+ if (batch.size === 0 && batchEnd >= currentHeight) {
2331
+ consecutiveEmptyBatches++;
2332
+ if (consecutiveEmptyBatches >= EMPTY_BATCH_HALT_THRESHOLD) {
2333
+ await haltRange(`block source returned ${consecutiveEmptyBatches} consecutive empty batches (ending ${currentHeight}..${batchEnd}) — source degraded`, currentHeight);
2334
+ }
2335
+ } else {
2336
+ consecutiveEmptyBatches = 0;
2337
+ }
1998
2338
  const nextStart = batchEnd + 1;
1999
2339
  if (nextStart <= toBlock) {
2000
2340
  nextBatchEnd = Math.min(nextStart + batchSize - 1, toBlock);
@@ -2002,28 +2342,39 @@ async function processBlockRange(def, opts) {
2002
2342
  }
2003
2343
  const batchFailedBlocks = [];
2004
2344
  let batchMatched = 0;
2345
+ const atomicProgress = status === "reindexing" ? { status } : undefined;
2005
2346
  for (let height = currentHeight;height <= batchEnd; height++) {
2006
- const blockData = batch.get(height);
2347
+ let blockData = batch.get(height);
2007
2348
  if (!blockData) {
2349
+ blockData = (await source.loadBlockRange(height, height)).get(height);
2350
+ }
2351
+ if (!blockData) {
2352
+ if (status === "reindexing") {
2353
+ const errorMsg = `block ${height} missing from source — halting reindex (cursor stays at ${height - 1})`;
2354
+ await haltRange(errorMsg, height);
2355
+ }
2008
2356
  batchFailedBlocks.push({ height, reason: "block_missing" });
2009
2357
  blocksProcessed++;
2010
2358
  continue;
2011
2359
  }
2012
2360
  let result;
2013
2361
  try {
2014
- result = await processBlock(def, subgraphName, height, {
2362
+ result = await processBlockWithRetry(def, subgraphName, height, {
2015
2363
  skipProgressUpdate: true,
2364
+ atomicProgress,
2016
2365
  preloaded: blockData
2017
2366
  });
2018
2367
  } catch (err) {
2019
- const errorMsg = err instanceof Error ? err.message : String(err);
2020
- logger6.error("Block processing error", {
2368
+ const errorMsg = getErrorMessage2(err);
2369
+ logger6.error("Block processing failed persistently", {
2021
2370
  subgraph: subgraphName,
2022
2371
  blockHeight: height,
2023
2372
  error: errorMsg
2024
2373
  });
2374
+ if (status === "reindexing") {
2375
+ await haltRange(`block ${height} failed persistently: ${errorMsg}`, height);
2376
+ }
2025
2377
  batchFailedBlocks.push({ height, reason: "processing_error" });
2026
- await updateSubgraphStatus2(targetDb, subgraphName, status, height).catch(() => {});
2027
2378
  blocksProcessed++;
2028
2379
  totalErrors++;
2029
2380
  pendingErrors++;
@@ -2326,9 +2677,6 @@ async function backfillSubgraph(def, opts) {
2326
2677
 
2327
2678
  // src/runtime/catchup.ts
2328
2679
  import { getTargetDb as getTargetDb3 } from "@secondlayer/shared/db";
2329
- import {
2330
- recordGapBatch as recordGapBatch2
2331
- } from "@secondlayer/shared/db/queries/subgraph-gaps";
2332
2680
  import { getSubgraph } from "@secondlayer/shared/db/queries/subgraphs";
2333
2681
  import { logger as logger7 } from "@secondlayer/shared/logger";
2334
2682
  var LOG_INTERVAL2 = 1000;
@@ -2367,28 +2715,6 @@ function resolveCatchupBatchConfig(env = process.env) {
2367
2715
  prefetch: parseBoolean(env.SUBGRAPH_CATCHUP_PREFETCH) ?? base.prefetch
2368
2716
  };
2369
2717
  }
2370
- function coalesceGaps(blocks) {
2371
- if (blocks.length === 0)
2372
- return [];
2373
- blocks.sort((a, b) => a.height - b.height);
2374
- const ranges = [];
2375
- let start = blocks[0].height;
2376
- let end = blocks[0].height;
2377
- let reason = blocks[0].reason;
2378
- for (let i = 1;i < blocks.length; i++) {
2379
- const b = blocks[i];
2380
- if (b.height === end + 1 && b.reason === reason) {
2381
- end = b.height;
2382
- } else {
2383
- ranges.push({ start, end, reason });
2384
- start = b.height;
2385
- end = b.height;
2386
- reason = b.reason;
2387
- }
2388
- }
2389
- ranges.push({ start, end, reason });
2390
- return ranges;
2391
- }
2392
2718
  function adjustBatchSize(current, avgEvents, config) {
2393
2719
  if (avgEvents > 50)
2394
2720
  return Math.max(Math.round(current * 0.5), config.minBatchSize);
@@ -2451,30 +2777,37 @@ async function catchUpSubgraph(subgraph, subgraphName) {
2451
2777
  batchEnd = Math.min(currentHeight + batchSize - 1, chainTip);
2452
2778
  batch = await source.loadBlockRange(currentHeight, batchEnd);
2453
2779
  }
2454
- const batchFailedBlocks = [];
2780
+ let stopCatchup = false;
2455
2781
  for (let height = currentHeight;height <= batchEnd; height++) {
2456
- const blockData = batch.get(height);
2782
+ let blockData = batch.get(height);
2457
2783
  if (!blockData) {
2458
- batchFailedBlocks.push({ height, reason: "block_missing" });
2459
- processed++;
2460
- continue;
2784
+ blockData = (await source.loadBlockRange(height, height)).get(height);
2785
+ }
2786
+ if (!blockData) {
2787
+ logger7.warn("Block missing during catch-up, deferring to next tick", {
2788
+ subgraph: subgraphName,
2789
+ blockHeight: height
2790
+ });
2791
+ stopCatchup = true;
2792
+ break;
2461
2793
  }
2462
2794
  let result;
2463
2795
  try {
2464
- result = await processBlock(subgraph, subgraphName, height, {
2796
+ result = await processBlockWithRetry(subgraph, subgraphName, height, {
2465
2797
  preloaded: blockData
2466
2798
  });
2467
2799
  } catch (err) {
2468
- logger7.error("Block processing error during catch-up", {
2800
+ const errorMsg = err instanceof Error ? err.message : String(err);
2801
+ logger7.error("Block processing failed persistently during catch-up", {
2469
2802
  subgraph: subgraphName,
2470
2803
  blockHeight: height,
2471
- error: err instanceof Error ? err.message : String(err)
2804
+ error: errorMsg
2472
2805
  });
2473
- batchFailedBlocks.push({ height, reason: "processing_error" });
2474
- const { updateSubgraphStatus: updateSubgraphStatus3 } = await import("@secondlayer/shared/db/queries/subgraphs");
2475
- await updateSubgraphStatus3(targetDb, subgraphName, "active", height).catch(() => {});
2476
- processed++;
2477
- continue;
2806
+ const { updateSubgraphStatus: updateSubgraphStatus3, recordSubgraphProcessed: recordSubgraphProcessed3 } = await import("@secondlayer/shared/db/queries/subgraphs");
2807
+ await recordSubgraphProcessed3(targetDb, subgraphName, 0, 1, `catch-up halted at block ${height}: ${errorMsg}`).catch(() => {});
2808
+ await updateSubgraphStatus3(targetDb, subgraphName, "error").catch(() => {});
2809
+ stopCatchup = true;
2810
+ break;
2478
2811
  }
2479
2812
  processed++;
2480
2813
  if (result.timing) {
@@ -2493,15 +2826,8 @@ async function catchUpSubgraph(subgraph, subgraphName) {
2493
2826
  });
2494
2827
  }
2495
2828
  }
2496
- if (batchFailedBlocks.length > 0) {
2497
- const gaps = coalesceGaps(batchFailedBlocks);
2498
- await recordGapBatch2(targetDb, subgraphRow.id, subgraphName, gaps).catch((err) => {
2499
- logger7.warn("Failed to record subgraph gaps", {
2500
- subgraph: subgraphName,
2501
- error: err instanceof Error ? err.message : String(err)
2502
- });
2503
- });
2504
- }
2829
+ if (stopCatchup)
2830
+ break;
2505
2831
  const avg = avgEventsPerBlock(batch);
2506
2832
  batchSize = adjustBatchSize(batchSize, avg, batchConfig);
2507
2833
  currentHeight = batchEnd + 1;
@@ -2548,8 +2874,29 @@ async function handleSubgraphReorg(blockHeight, loadSubgraphDef) {
2548
2874
  if (rows.length > 0)
2549
2875
  revertedByTable[tableName] = rows;
2550
2876
  }
2551
- for (const tableName of tableNames) {
2552
- await client.unsafe(`DELETE FROM "${schemaName}"."${tableName}" WHERE "_block_height" >= $1`, [blockHeight]);
2877
+ const hasJournal = (await client.unsafe(`SELECT to_regclass('"${schemaName}"."_journal"') AS r`))[0]?.r;
2878
+ if (hasJournal) {
2879
+ await client.begin(async (tx) => {
2880
+ for (const tableName of tableNames) {
2881
+ const earliest = `
2882
+ SELECT DISTINCT ON (row_key) row_key, prev_row
2883
+ FROM "${schemaName}"."_journal"
2884
+ WHERE block_height >= $1 AND table_name = $2
2885
+ ORDER BY row_key, _jid ASC`;
2886
+ await tx.unsafe(`DELETE FROM "${schemaName}"."${tableName}" t USING (${earliest}) e WHERE to_jsonb(t.*) @> e.row_key`, [blockHeight, tableName]);
2887
+ await tx.unsafe(`DELETE FROM "${schemaName}"."${tableName}" WHERE "_block_height" >= $1`, [blockHeight]);
2888
+ await tx.unsafe(`INSERT INTO "${schemaName}"."${tableName}"
2889
+ SELECT r.* FROM (${earliest}) e
2890
+ CROSS JOIN LATERAL jsonb_populate_record(NULL::"${schemaName}"."${tableName}", e.prev_row) r
2891
+ WHERE e.prev_row IS NOT NULL`, [blockHeight, tableName]);
2892
+ }
2893
+ await tx.unsafe(`DELETE FROM "${schemaName}"."_journal" WHERE block_height >= $1`, [blockHeight]);
2894
+ });
2895
+ } else {
2896
+ logger8.warn("Subgraph has no revert journal — falling back to height delete (accumulator rows may lose history)", { subgraph: sg.name, blockHeight });
2897
+ for (const tableName of tableNames) {
2898
+ await client.unsafe(`DELETE FROM "${schemaName}"."${tableName}" WHERE "_block_height" >= $1`, [blockHeight]);
2899
+ }
2553
2900
  }
2554
2901
  for (const [tableName, rows] of Object.entries(revertedByTable)) {
2555
2902
  if (rows.length === 0)
@@ -3068,5 +3415,5 @@ export {
3068
3415
  startSubgraphOperationRunner
3069
3416
  };
3070
3417
 
3071
- //# debugId=CA67CC38C44D43DF64756E2164756E21
3418
+ //# debugId=9E7A5CE05C0F87F064756E2164756E21
3072
3419
  //# sourceMappingURL=processor.js.map