@secondlayer/subgraphs 3.12.0 → 3.14.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 (36) hide show
  1. package/dist/src/index.d.ts +54 -1
  2. package/dist/src/index.js +952 -199
  3. package/dist/src/index.js.map +12 -11
  4. package/dist/src/runtime/block-processor.d.ts +36 -1
  5. package/dist/src/runtime/block-processor.js +568 -86
  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 +587 -132
  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 +665 -248
  14. package/dist/src/runtime/processor.js.map +13 -13
  15. package/dist/src/runtime/reindex.d.ts +7 -0
  16. package/dist/src/runtime/reindex.js +618 -198
  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 +588 -87
  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 +20 -2
  29. package/dist/src/schema/index.js.map +4 -4
  30. package/dist/src/service.js +665 -248
  31. package/dist/src/service.js.map +13 -13
  32. package/dist/src/types.d.ts +7 -0
  33. package/dist/src/validate.d.ts +7 -0
  34. package/dist/src/validate.js +3 -2
  35. package/dist/src/validate.js.map +3 -3
  36. package/package.json +2 -2
@@ -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 };
@@ -953,6 +1332,7 @@ function matchSources(sources, transactions, events, traitContracts = new Map) {
953
1332
  // src/runtime/block-processor.ts
954
1333
  import { getTargetDb } from "@secondlayer/shared/db";
955
1334
  import { resolveTraitContractIds } from "@secondlayer/shared/db/queries/contracts";
1335
+ import { advanceOperationCursor } from "@secondlayer/shared/db/queries/subgraph-operations";
956
1336
  import {
957
1337
  isByoSubgraph,
958
1338
  recordSubgraphProcessed,
@@ -962,9 +1342,6 @@ import {
962
1342
  import { logger as logger5 } from "@secondlayer/shared/logger";
963
1343
  import { sql as sql3 } from "kysely";
964
1344
 
965
- // src/schema/utils.ts
966
- import { pgSchemaName } from "@secondlayer/shared/db/queries/subgraphs";
967
-
968
1345
  // src/runtime/block-source.ts
969
1346
  import { getSourceDb } from "@secondlayer/shared/db";
970
1347
  import { IndexHttpClient } from "@secondlayer/shared/index-http";
@@ -1310,7 +1687,7 @@ function resolveBlockSource(subgraph) {
1310
1687
  }
1311
1688
 
1312
1689
  // src/runtime/outbox-emit.ts
1313
- import { createHash } from "node:crypto";
1690
+ import { createHash as createHash2 } from "node:crypto";
1314
1691
  import { logger as logger4 } from "@secondlayer/shared/logger";
1315
1692
  var loggedKillSwitch = false;
1316
1693
  var OP_VERB = {
@@ -1323,7 +1700,7 @@ function isEmitOutboxEnabled() {
1323
1700
  }
1324
1701
  function dedupKey(subgraphName, tableName, blockHeight, txId, rowIndex, row) {
1325
1702
  const canonical = `${subgraphName}:${tableName}:${blockHeight}:${txId}:${rowIndex}:${stableStringify(row)}`;
1326
- return createHash("sha256").update(canonical).digest("hex").slice(0, 32);
1703
+ return createHash2("sha256").update(canonical).digest("hex").slice(0, 32);
1327
1704
  }
1328
1705
  function stableStringify(obj) {
1329
1706
  const keys = Object.keys(obj).sort();
@@ -1575,6 +1952,47 @@ async function resolveTraitContracts(subgraph, blockHeight, db) {
1575
1952
  }
1576
1953
  return resolved;
1577
1954
  }
1955
+
1956
+ class CursorRaceLostError extends Error {
1957
+ constructor(operationId, height) {
1958
+ super(`op ${operationId} lost cursor race at block ${height}`);
1959
+ this.name = "CursorRaceLostError";
1960
+ }
1961
+ }
1962
+ function opCursorMode(opts) {
1963
+ const ap = opts?.atomicProgress;
1964
+ return ap && "operationId" in ap ? ap : undefined;
1965
+ }
1966
+ function statusMode(opts) {
1967
+ const ap = opts?.atomicProgress;
1968
+ return ap && "status" in ap ? ap : undefined;
1969
+ }
1970
+ var BLOCK_RETRY_DELAYS_MS = [500, 2000, 5000];
1971
+ function journalEnabled(opts) {
1972
+ return !opts?.skipProgressUpdate;
1973
+ }
1974
+ async function processBlockWithRetry(subgraph, subgraphName, blockHeight, opts, retryDelaysMs = BLOCK_RETRY_DELAYS_MS) {
1975
+ let lastError;
1976
+ for (let attempt = 0;attempt <= retryDelaysMs.length; attempt++) {
1977
+ try {
1978
+ return await processBlock(subgraph, subgraphName, blockHeight, opts);
1979
+ } catch (err) {
1980
+ lastError = err;
1981
+ const delay = retryDelaysMs[attempt];
1982
+ if (delay === undefined)
1983
+ break;
1984
+ logger5.warn("Block processing failed, retrying", {
1985
+ subgraph: subgraphName,
1986
+ blockHeight,
1987
+ attempt: attempt + 1,
1988
+ retryInMs: delay,
1989
+ error: err instanceof Error ? err.message : String(err)
1990
+ });
1991
+ await new Promise((r) => setTimeout(r, delay));
1992
+ }
1993
+ }
1994
+ throw lastError;
1995
+ }
1578
1996
  async function processBlock(subgraph, subgraphName, blockHeight, opts) {
1579
1997
  const targetDb = getTargetDb();
1580
1998
  const blockStart = performance.now();
@@ -1642,10 +2060,24 @@ async function processBlock(subgraph, subgraphName, blockHeight, opts) {
1642
2060
  }
1643
2061
  };
1644
2062
  if (route.byo) {
2063
+ if (statusMode(opts)) {
2064
+ const row = await targetDb.selectFrom("subgraphs").select("last_processed_block").where("name", "=", subgraphName).executeTakeFirst();
2065
+ if (row && Number(row.last_processed_block) >= blockHeight) {
2066
+ result.skipped = true;
2067
+ return result;
2068
+ }
2069
+ } else if (opCursorMode(opts)) {
2070
+ const om = opCursorMode(opts);
2071
+ const row = await targetDb.selectFrom("subgraph_operations").select("cursor_block").where("id", "=", om.operationId).executeTakeFirst();
2072
+ if (row?.cursor_block != null && Number(row.cursor_block) >= blockHeight) {
2073
+ result.skipped = true;
2074
+ return result;
2075
+ }
2076
+ }
1645
2077
  let runResult = { processed: 0, errors: 0 };
1646
2078
  let manifest;
1647
2079
  await route.dataDb.transaction().execute(async (tx) => {
1648
- const ctx = new SubgraphContext(tx, schemaName, subgraph.schema, blockMeta, initialTx, true);
2080
+ const ctx = new SubgraphContext(tx, schemaName, subgraph.schema, blockMeta, initialTx, true, journalEnabled(opts));
1649
2081
  const handlerStart = performance.now();
1650
2082
  runResult = await runHandlers(subgraph, matched, ctx);
1651
2083
  handlerMs = performance.now() - handlerStart;
@@ -1661,26 +2093,71 @@ async function processBlock(subgraph, subgraphName, blockHeight, opts) {
1661
2093
  if (manifest && manifest.count > 0) {
1662
2094
  await emitSubscriptionOutbox(tx, subgraphName, manifest, matcher, block.height);
1663
2095
  }
2096
+ const byoSm = statusMode(opts);
2097
+ const byoOm = opCursorMode(opts);
2098
+ if (byoSm && manifest && manifest.count > 0) {
2099
+ await updateSubgraphStatus(tx, subgraphName, byoSm.status, blockHeight);
2100
+ } else if (byoOm && manifest && manifest.count > 0) {
2101
+ await advanceOperationCursor(tx, byoOm.operationId, blockHeight);
2102
+ }
1664
2103
  await applyProgress(tx, runResult);
1665
2104
  });
1666
2105
  } else {
1667
- await targetDb.transaction().execute(async (tx) => {
1668
- const ctx = new SubgraphContext(tx, schemaName, subgraph.schema, blockMeta, initialTx);
1669
- const handlerStart = performance.now();
1670
- const runResult = await runHandlers(subgraph, matched, ctx);
1671
- handlerMs = performance.now() - handlerStart;
1672
- result.processed = runResult.processed;
1673
- result.errors = runResult.errors;
1674
- if (ctx.pendingOps > 0) {
1675
- const flushStart = performance.now();
1676
- const manifest = await ctx.flush();
1677
- if (manifest.count > 0) {
1678
- await emitSubscriptionOutbox(tx, subgraphName, manifest, matcher, block.height);
2106
+ try {
2107
+ await targetDb.transaction().execute(async (tx) => {
2108
+ const opMode = opCursorMode(opts);
2109
+ if (statusMode(opts)) {
2110
+ const row = await tx.selectFrom("subgraphs").select("last_processed_block").where("name", "=", subgraphName).executeTakeFirst();
2111
+ if (row && Number(row.last_processed_block) >= blockHeight) {
2112
+ result.skipped = true;
2113
+ return;
2114
+ }
2115
+ } else if (opMode) {
2116
+ const row = await tx.selectFrom("subgraph_operations").select("cursor_block").where("id", "=", opMode.operationId).executeTakeFirst();
2117
+ if (row?.cursor_block != null && Number(row.cursor_block) >= blockHeight) {
2118
+ result.skipped = true;
2119
+ return;
2120
+ }
1679
2121
  }
1680
- flushMs = performance.now() - flushStart;
2122
+ const ctx = new SubgraphContext(tx, schemaName, subgraph.schema, blockMeta, initialTx, false, journalEnabled(opts));
2123
+ const handlerStart = performance.now();
2124
+ const runResult = await runHandlers(subgraph, matched, ctx);
2125
+ handlerMs = performance.now() - handlerStart;
2126
+ result.processed = runResult.processed;
2127
+ result.errors = runResult.errors;
2128
+ let flushedWrites = false;
2129
+ if (ctx.pendingOps > 0) {
2130
+ const flushStart = performance.now();
2131
+ const manifest = await ctx.flush();
2132
+ flushedWrites = manifest.count > 0;
2133
+ if (manifest.count > 0) {
2134
+ await emitSubscriptionOutbox(tx, subgraphName, manifest, matcher, block.height);
2135
+ }
2136
+ flushMs = performance.now() - flushStart;
2137
+ }
2138
+ const sm = statusMode(opts);
2139
+ if (sm && flushedWrites) {
2140
+ await updateSubgraphStatus(tx, subgraphName, sm.status, blockHeight);
2141
+ } else if (opMode && flushedWrites) {
2142
+ const advanced = await advanceOperationCursor(tx, opMode.operationId, blockHeight);
2143
+ if (!advanced) {
2144
+ throw new CursorRaceLostError(opMode.operationId, blockHeight);
2145
+ }
2146
+ }
2147
+ await applyProgress(tx, runResult);
2148
+ });
2149
+ } catch (err) {
2150
+ if (err instanceof CursorRaceLostError) {
2151
+ logger5.warn("cursor race lost — block already covered", {
2152
+ subgraph: subgraphName,
2153
+ blockHeight,
2154
+ error: err.message
2155
+ });
2156
+ result.skipped = true;
2157
+ return result;
1681
2158
  }
1682
- await applyProgress(tx, runResult);
1683
- });
2159
+ throw err;
2160
+ }
1684
2161
  }
1685
2162
  const totalMs = performance.now() - blockStart;
1686
2163
  result.timing = {
@@ -1708,6 +2185,9 @@ async function processBlock(subgraph, subgraphName, blockHeight, opts) {
1708
2185
  error: err instanceof Error ? err.message : String(err)
1709
2186
  });
1710
2187
  }
2188
+ if (journalEnabled(opts)) {
2189
+ await sql3.raw(`DELETE FROM "${schemaName}"."_journal" WHERE "block_height" < ${blockHeight - JOURNAL_RETENTION_BLOCKS}`).execute(route.dataDb).catch(() => {});
2190
+ }
1711
2191
  }
1712
2192
  return result;
1713
2193
  }
@@ -1782,119 +2262,19 @@ import {
1782
2262
  recordGapBatch,
1783
2263
  resolveGaps
1784
2264
  } from "@secondlayer/shared/db/queries/subgraph-gaps";
1785
- import { updateOperationProcessedEvents } from "@secondlayer/shared/db/queries/subgraph-operations";
2265
+ import {
2266
+ advanceOperationCursor as advanceOperationCursor2,
2267
+ updateOperationProcessedEvents
2268
+ } from "@secondlayer/shared/db/queries/subgraph-operations";
1786
2269
  import {
1787
2270
  recordSubgraphProcessed as recordSubgraphProcessed2,
1788
2271
  updateSubgraphStatus as updateSubgraphStatus2
1789
2272
  } from "@secondlayer/shared/db/queries/subgraphs";
1790
2273
  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
2274
  var LOG_INTERVAL = 1000;
1896
2275
  var HEALTH_FLUSH_INTERVAL = 1000;
1897
2276
  var PROGRESS_FLUSH_INTERVAL_MS = 5000;
2277
+ var EMPTY_BATCH_HALT_THRESHOLD = 3;
1898
2278
  var STANDARD_REINDEX_BATCH_CONFIG = {
1899
2279
  defaultBatchSize: 500,
1900
2280
  minBatchSize: 100,
@@ -1958,6 +2338,7 @@ async function processBlockRange(def, opts) {
1958
2338
  const totalBlocks = toBlock - fromBlock + 1;
1959
2339
  const stats = new StatsAccumulator(subgraphName, opts.isCatchup);
1960
2340
  let blocksProcessed = 0;
2341
+ let blocksSkippedByCursor = 0;
1961
2342
  let totalEventsProcessed = 0;
1962
2343
  let totalErrors = 0;
1963
2344
  let pendingEventsProcessed = 0;
@@ -1970,6 +2351,7 @@ async function processBlockRange(def, opts) {
1970
2351
  let batchSize = batchConfig.defaultBatchSize;
1971
2352
  let currentHeight = fromBlock;
1972
2353
  let aborted = false;
2354
+ let consecutiveEmptyBatches = 0;
1973
2355
  const sparse = Boolean(source.nextDataHeight && canSparseScan(def));
1974
2356
  const flushHealth = async () => {
1975
2357
  if (pendingEventsProcessed === 0 && pendingErrors === 0)
@@ -1981,6 +2363,13 @@ async function processBlockRange(def, opts) {
1981
2363
  lastHealthFlushBlock = blocksProcessed;
1982
2364
  lastHealthFlushAt = Date.now();
1983
2365
  };
2366
+ const haltRange = async (errorMsg, height) => {
2367
+ pendingErrors++;
2368
+ pendingLastError = errorMsg;
2369
+ await flushHealth().catch(() => {});
2370
+ await updateSubgraphStatus2(targetDb, subgraphName, "error").catch(() => {});
2371
+ throw new Error(`${subgraphName}: halted at block ${height}: ${errorMsg}`);
2372
+ };
1984
2373
  let nextBatchEnd = Math.min(currentHeight + batchSize - 1, toBlock);
1985
2374
  let nextBatchPromise = source.loadBlockRange(currentHeight, nextBatchEnd);
1986
2375
  while (currentHeight <= toBlock) {
@@ -1995,6 +2384,14 @@ async function processBlockRange(def, opts) {
1995
2384
  }
1996
2385
  const batch = await nextBatchPromise;
1997
2386
  const batchEnd = nextBatchEnd;
2387
+ if (batch.size === 0 && batchEnd >= currentHeight) {
2388
+ consecutiveEmptyBatches++;
2389
+ if (consecutiveEmptyBatches >= EMPTY_BATCH_HALT_THRESHOLD) {
2390
+ await haltRange(`block source returned ${consecutiveEmptyBatches} consecutive empty batches (ending ${currentHeight}..${batchEnd}) — source degraded`, currentHeight);
2391
+ }
2392
+ } else {
2393
+ consecutiveEmptyBatches = 0;
2394
+ }
1998
2395
  const nextStart = batchEnd + 1;
1999
2396
  if (nextStart <= toBlock) {
2000
2397
  nextBatchEnd = Math.min(nextStart + batchSize - 1, toBlock);
@@ -2002,28 +2399,40 @@ async function processBlockRange(def, opts) {
2002
2399
  }
2003
2400
  const batchFailedBlocks = [];
2004
2401
  let batchMatched = 0;
2402
+ const opCursor = status === "active" && opts.operationId ? { operationId: opts.operationId } : undefined;
2403
+ const atomicProgress = status === "reindexing" ? { status } : opCursor;
2005
2404
  for (let height = currentHeight;height <= batchEnd; height++) {
2006
- const blockData = batch.get(height);
2405
+ let blockData = batch.get(height);
2007
2406
  if (!blockData) {
2407
+ blockData = (await source.loadBlockRange(height, height)).get(height);
2408
+ }
2409
+ if (!blockData) {
2410
+ if (status === "reindexing") {
2411
+ const errorMsg = `block ${height} missing from source — halting reindex (cursor stays at ${height - 1})`;
2412
+ await haltRange(errorMsg, height);
2413
+ }
2008
2414
  batchFailedBlocks.push({ height, reason: "block_missing" });
2009
2415
  blocksProcessed++;
2010
2416
  continue;
2011
2417
  }
2012
2418
  let result;
2013
2419
  try {
2014
- result = await processBlock(def, subgraphName, height, {
2420
+ result = await processBlockWithRetry(def, subgraphName, height, {
2015
2421
  skipProgressUpdate: true,
2422
+ atomicProgress,
2016
2423
  preloaded: blockData
2017
2424
  });
2018
2425
  } catch (err) {
2019
- const errorMsg = err instanceof Error ? err.message : String(err);
2020
- logger6.error("Block processing error", {
2426
+ const errorMsg = getErrorMessage2(err);
2427
+ logger6.error("Block processing failed persistently", {
2021
2428
  subgraph: subgraphName,
2022
2429
  blockHeight: height,
2023
2430
  error: errorMsg
2024
2431
  });
2432
+ if (status === "reindexing") {
2433
+ await haltRange(`block ${height} failed persistently: ${errorMsg}`, height);
2434
+ }
2025
2435
  batchFailedBlocks.push({ height, reason: "processing_error" });
2026
- await updateSubgraphStatus2(targetDb, subgraphName, status, height).catch(() => {});
2027
2436
  blocksProcessed++;
2028
2437
  totalErrors++;
2029
2438
  pendingErrors++;
@@ -2031,6 +2440,8 @@ async function processBlockRange(def, opts) {
2031
2440
  continue;
2032
2441
  }
2033
2442
  blocksProcessed++;
2443
+ if (result.skipped)
2444
+ blocksSkippedByCursor++;
2034
2445
  batchMatched += result.matched;
2035
2446
  totalEventsProcessed += result.processed;
2036
2447
  totalErrors += result.errors;
@@ -2048,7 +2459,11 @@ async function processBlockRange(def, opts) {
2048
2459
  const now = Date.now();
2049
2460
  const shouldFlushProgress = blocksProcessed % 100 === 0 || now - lastProgressFlushAt >= PROGRESS_FLUSH_INTERVAL_MS;
2050
2461
  if (shouldFlushProgress) {
2051
- await updateSubgraphStatus2(targetDb, subgraphName, status, height);
2462
+ if (opCursor) {
2463
+ await advanceOperationCursor2(targetDb, opCursor.operationId, height);
2464
+ } else {
2465
+ await updateSubgraphStatus2(targetDb, subgraphName, status, height);
2466
+ }
2052
2467
  if (opts.operationId) {
2053
2468
  await updateOperationProcessedEvents(targetDb, opts.operationId, totalEventsProcessed).catch(() => {});
2054
2469
  }
@@ -2060,7 +2475,8 @@ async function processBlockRange(def, opts) {
2060
2475
  processed: blocksProcessed,
2061
2476
  total: totalBlocks,
2062
2477
  currentBlock: height,
2063
- pct: Math.round(blocksProcessed / totalBlocks * 100)
2478
+ pct: Math.round(blocksProcessed / totalBlocks * 100),
2479
+ ...blocksSkippedByCursor > 0 ? { skippedByCursor: blocksSkippedByCursor } : {}
2064
2480
  });
2065
2481
  }
2066
2482
  }
@@ -2086,7 +2502,11 @@ async function processBlockRange(def, opts) {
2086
2502
  if (jumpTo > batchEnd + 1) {
2087
2503
  const skipped = Math.min(jumpTo, toBlock + 1) - (batchEnd + 1);
2088
2504
  blocksProcessed += skipped;
2089
- await updateSubgraphStatus2(targetDb, subgraphName, status, jumpTo - 1);
2505
+ if (opCursor) {
2506
+ await advanceOperationCursor2(targetDb, opCursor.operationId, jumpTo - 1);
2507
+ } else {
2508
+ await updateSubgraphStatus2(targetDb, subgraphName, status, jumpTo - 1);
2509
+ }
2090
2510
  logger6.info("Sparse skip", {
2091
2511
  subgraph: subgraphName,
2092
2512
  from: batchEnd + 1,
@@ -2326,9 +2746,6 @@ async function backfillSubgraph(def, opts) {
2326
2746
 
2327
2747
  // src/runtime/catchup.ts
2328
2748
  import { getTargetDb as getTargetDb3 } from "@secondlayer/shared/db";
2329
- import {
2330
- recordGapBatch as recordGapBatch2
2331
- } from "@secondlayer/shared/db/queries/subgraph-gaps";
2332
2749
  import { getSubgraph } from "@secondlayer/shared/db/queries/subgraphs";
2333
2750
  import { logger as logger7 } from "@secondlayer/shared/logger";
2334
2751
  var LOG_INTERVAL2 = 1000;
@@ -2367,28 +2784,6 @@ function resolveCatchupBatchConfig(env = process.env) {
2367
2784
  prefetch: parseBoolean(env.SUBGRAPH_CATCHUP_PREFETCH) ?? base.prefetch
2368
2785
  };
2369
2786
  }
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
2787
  function adjustBatchSize(current, avgEvents, config) {
2393
2788
  if (avgEvents > 50)
2394
2789
  return Math.max(Math.round(current * 0.5), config.minBatchSize);
@@ -2451,30 +2846,37 @@ async function catchUpSubgraph(subgraph, subgraphName) {
2451
2846
  batchEnd = Math.min(currentHeight + batchSize - 1, chainTip);
2452
2847
  batch = await source.loadBlockRange(currentHeight, batchEnd);
2453
2848
  }
2454
- const batchFailedBlocks = [];
2849
+ let stopCatchup = false;
2455
2850
  for (let height = currentHeight;height <= batchEnd; height++) {
2456
- const blockData = batch.get(height);
2851
+ let blockData = batch.get(height);
2457
2852
  if (!blockData) {
2458
- batchFailedBlocks.push({ height, reason: "block_missing" });
2459
- processed++;
2460
- continue;
2853
+ blockData = (await source.loadBlockRange(height, height)).get(height);
2854
+ }
2855
+ if (!blockData) {
2856
+ logger7.warn("Block missing during catch-up, deferring to next tick", {
2857
+ subgraph: subgraphName,
2858
+ blockHeight: height
2859
+ });
2860
+ stopCatchup = true;
2861
+ break;
2461
2862
  }
2462
2863
  let result;
2463
2864
  try {
2464
- result = await processBlock(subgraph, subgraphName, height, {
2865
+ result = await processBlockWithRetry(subgraph, subgraphName, height, {
2465
2866
  preloaded: blockData
2466
2867
  });
2467
2868
  } catch (err) {
2468
- logger7.error("Block processing error during catch-up", {
2869
+ const errorMsg = err instanceof Error ? err.message : String(err);
2870
+ logger7.error("Block processing failed persistently during catch-up", {
2469
2871
  subgraph: subgraphName,
2470
2872
  blockHeight: height,
2471
- error: err instanceof Error ? err.message : String(err)
2873
+ error: errorMsg
2472
2874
  });
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;
2875
+ const { updateSubgraphStatus: updateSubgraphStatus3, recordSubgraphProcessed: recordSubgraphProcessed3 } = await import("@secondlayer/shared/db/queries/subgraphs");
2876
+ await recordSubgraphProcessed3(targetDb, subgraphName, 0, 1, `catch-up halted at block ${height}: ${errorMsg}`).catch(() => {});
2877
+ await updateSubgraphStatus3(targetDb, subgraphName, "error").catch(() => {});
2878
+ stopCatchup = true;
2879
+ break;
2478
2880
  }
2479
2881
  processed++;
2480
2882
  if (result.timing) {
@@ -2493,15 +2895,8 @@ async function catchUpSubgraph(subgraph, subgraphName) {
2493
2895
  });
2494
2896
  }
2495
2897
  }
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
- }
2898
+ if (stopCatchup)
2899
+ break;
2505
2900
  const avg = avgEventsPerBlock(batch);
2506
2901
  batchSize = adjustBatchSize(batchSize, avg, batchConfig);
2507
2902
  currentHeight = batchEnd + 1;
@@ -2548,8 +2943,29 @@ async function handleSubgraphReorg(blockHeight, loadSubgraphDef) {
2548
2943
  if (rows.length > 0)
2549
2944
  revertedByTable[tableName] = rows;
2550
2945
  }
2551
- for (const tableName of tableNames) {
2552
- await client.unsafe(`DELETE FROM "${schemaName}"."${tableName}" WHERE "_block_height" >= $1`, [blockHeight]);
2946
+ const hasJournal = (await client.unsafe(`SELECT to_regclass('"${schemaName}"."_journal"') AS r`))[0]?.r;
2947
+ if (hasJournal) {
2948
+ await client.begin(async (tx) => {
2949
+ for (const tableName of tableNames) {
2950
+ const earliest = `
2951
+ SELECT DISTINCT ON (row_key) row_key, prev_row
2952
+ FROM "${schemaName}"."_journal"
2953
+ WHERE block_height >= $1 AND table_name = $2
2954
+ ORDER BY row_key, _jid ASC`;
2955
+ await tx.unsafe(`DELETE FROM "${schemaName}"."${tableName}" t USING (${earliest}) e WHERE to_jsonb(t.*) @> e.row_key`, [blockHeight, tableName]);
2956
+ await tx.unsafe(`DELETE FROM "${schemaName}"."${tableName}" WHERE "_block_height" >= $1`, [blockHeight]);
2957
+ await tx.unsafe(`INSERT INTO "${schemaName}"."${tableName}"
2958
+ SELECT r.* FROM (${earliest}) e
2959
+ CROSS JOIN LATERAL jsonb_populate_record(NULL::"${schemaName}"."${tableName}", e.prev_row) r
2960
+ WHERE e.prev_row IS NOT NULL`, [blockHeight, tableName]);
2961
+ }
2962
+ await tx.unsafe(`DELETE FROM "${schemaName}"."_journal" WHERE block_height >= $1`, [blockHeight]);
2963
+ });
2964
+ } else {
2965
+ logger8.warn("Subgraph has no revert journal — falling back to height delete (accumulator rows may lose history)", { subgraph: sg.name, blockHeight });
2966
+ for (const tableName of tableNames) {
2967
+ await client.unsafe(`DELETE FROM "${schemaName}"."${tableName}" WHERE "_block_height" >= $1`, [blockHeight]);
2968
+ }
2553
2969
  }
2554
2970
  for (const [tableName, rows] of Object.entries(revertedByTable)) {
2555
2971
  if (rows.length === 0)
@@ -2833,8 +3249,9 @@ async function runSubgraphOperation(operation, signal) {
2833
3249
  if (operation.from_block == null || operation.to_block == null) {
2834
3250
  throw new Error("Backfill operation is missing from_block or to_block");
2835
3251
  }
3252
+ const resumeFrom = operation.cursor_block != null ? Math.max(Number(operation.from_block), Number(operation.cursor_block) + 1) : Number(operation.from_block);
2836
3253
  const result2 = await backfillSubgraph(def, {
2837
- fromBlock: Number(operation.from_block),
3254
+ fromBlock: resumeFrom,
2838
3255
  toBlock: Number(operation.to_block),
2839
3256
  schemaName,
2840
3257
  operationId: operation.id,
@@ -3094,5 +3511,5 @@ var shutdown = async () => {
3094
3511
  process.on("SIGINT", shutdown);
3095
3512
  process.on("SIGTERM", shutdown);
3096
3513
 
3097
- //# debugId=3945FD567AE4CFB864756E2164756E21
3514
+ //# debugId=B3292FF11E73449C64756E2164756E21
3098
3515
  //# sourceMappingURL=service.js.map