@secondlayer/subgraphs 1.0.0-alpha.0 → 1.0.0-beta.1

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.
@@ -168,7 +168,7 @@ class SubgraphContext {
168
168
  }
169
169
  async flush() {
170
170
  if (this.ops.length === 0)
171
- return 0;
171
+ return { count: 0, writes: [] };
172
172
  const opsToFlush = [...this.ops];
173
173
  this.ops.length = 0;
174
174
  const statements = this.buildStatements(opsToFlush);
@@ -183,7 +183,21 @@ class SubgraphContext {
183
183
  }
184
184
  });
185
185
  }
186
- return opsToFlush.length;
186
+ const writes = opsToFlush.map((op, rowIndex) => {
187
+ const blockHeight = op.data._block_height ?? this.block.height;
188
+ const txId = op.data._tx_id ?? this._tx.txId;
189
+ const baseRow = op.kind === "update" ? { ...op.data, ...op.set ?? {} } : { ...op.data };
190
+ delete baseRow._upsert_keys;
191
+ delete baseRow._upsert_fallback_keys;
192
+ delete baseRow._upsert_fallback_set;
193
+ return {
194
+ op: op.kind,
195
+ table: op.table,
196
+ row: jsonSafe(baseRow),
197
+ pk: { blockHeight, txId, rowIndex }
198
+ };
199
+ });
200
+ return { count: opsToFlush.length, writes };
187
201
  }
188
202
  prepareInsert(op) {
189
203
  const upsertKeys = op.data._upsert_keys;
@@ -276,6 +290,13 @@ class SubgraphContext {
276
290
  }
277
291
  }
278
292
  }
293
+ function jsonSafe(row) {
294
+ const out = {};
295
+ for (const [k, v] of Object.entries(row)) {
296
+ out[k] = typeof v === "bigint" ? v.toString() : v;
297
+ }
298
+ return out;
299
+ }
279
300
  function escapeLiteral(value) {
280
301
  if (value === null || value === undefined)
281
302
  return "NULL";
@@ -857,12 +878,200 @@ import {
857
878
  recordSubgraphProcessed,
858
879
  updateSubgraphStatus
859
880
  } from "@secondlayer/shared/db/queries/subgraphs";
860
- import { logger as logger3 } from "@secondlayer/shared/logger";
861
- import { sql as sql2 } from "kysely";
881
+ import { logger as logger4 } from "@secondlayer/shared/logger";
882
+ import { sql as sql3 } from "kysely";
862
883
 
863
884
  // src/schema/utils.ts
864
885
  import { pgSchemaName } from "@secondlayer/shared/db/queries/subgraphs";
865
886
 
887
+ // src/runtime/outbox-emit.ts
888
+ import { createHash } from "node:crypto";
889
+ import { logger as logger3 } from "@secondlayer/shared/logger";
890
+ var loggedKillSwitch = false;
891
+ function isEmitOutboxEnabled() {
892
+ return process.env.SECONDLAYER_EMIT_OUTBOX !== "false";
893
+ }
894
+ function dedupKey(subgraphName, tableName, blockHeight, txId, rowIndex, row) {
895
+ const canonical = `${subgraphName}:${tableName}:${blockHeight}:${txId}:${rowIndex}:${stableStringify(row)}`;
896
+ return createHash("sha256").update(canonical).digest("hex").slice(0, 32);
897
+ }
898
+ function stableStringify(obj) {
899
+ const keys = Object.keys(obj).sort();
900
+ return JSON.stringify(keys.reduce((acc, k) => {
901
+ acc[k] = obj[k];
902
+ return acc;
903
+ }, {}));
904
+ }
905
+ async function emitSubscriptionOutbox(tx, subgraphName, manifest, matcher, blockHeight) {
906
+ if (!isEmitOutboxEnabled()) {
907
+ if (!loggedKillSwitch) {
908
+ logger3.warn("SECONDLAYER_EMIT_OUTBOX=false — outbox emission bypassed");
909
+ loggedKillSwitch = true;
910
+ }
911
+ return 0;
912
+ }
913
+ loggedKillSwitch = false;
914
+ if (manifest.count === 0 || matcher.size() === 0)
915
+ return 0;
916
+ const rows = [];
917
+ for (const write of manifest.writes) {
918
+ if (write.op !== "insert")
919
+ continue;
920
+ const subs = matcher.match(subgraphName, write.table, write.row);
921
+ if (subs.length === 0)
922
+ continue;
923
+ const eventType = `${subgraphName}.${write.table}.created`;
924
+ for (const s of subs) {
925
+ rows.push({
926
+ subscription_id: s.id,
927
+ subgraph_name: subgraphName,
928
+ table_name: write.table,
929
+ block_height: blockHeight,
930
+ tx_id: write.pk.txId || null,
931
+ row_pk: write.pk,
932
+ event_type: eventType,
933
+ payload: write.row,
934
+ dedup_key: dedupKey(subgraphName, write.table, write.pk.blockHeight, write.pk.txId, write.pk.rowIndex, write.row)
935
+ });
936
+ }
937
+ }
938
+ if (rows.length === 0)
939
+ return 0;
940
+ await tx.insertInto("subscription_outbox").values(rows).onConflict((oc) => oc.columns(["subscription_id", "dedup_key"]).doNothing()).execute();
941
+ return rows.length;
942
+ }
943
+
944
+ // src/runtime/subscription-state.ts
945
+ import { listSubscriptions } from "@secondlayer/shared/db/queries/subscriptions";
946
+ import { sql as sql2 } from "kysely";
947
+
948
+ // src/runtime/emitter-matcher.ts
949
+ function isPrimitive(v) {
950
+ const t = typeof v;
951
+ return t === "string" || t === "number" || t === "boolean";
952
+ }
953
+ function coerceNumeric(v) {
954
+ if (typeof v === "number" && Number.isFinite(v))
955
+ return v;
956
+ if (typeof v === "bigint")
957
+ return Number(v);
958
+ if (typeof v === "string" && v !== "" && !Number.isNaN(Number(v))) {
959
+ return Number(v);
960
+ }
961
+ return null;
962
+ }
963
+ function matchClause(rowValue, clause) {
964
+ if (isPrimitive(clause)) {
965
+ if (typeof clause === "number" || typeof rowValue === "number") {
966
+ const a = coerceNumeric(rowValue);
967
+ const b = coerceNumeric(clause);
968
+ if (a !== null && b !== null)
969
+ return a === b;
970
+ }
971
+ return rowValue === clause || String(rowValue) === String(clause);
972
+ }
973
+ if (clause === null || typeof clause !== "object" || Array.isArray(clause)) {
974
+ return false;
975
+ }
976
+ const keys = Object.keys(clause);
977
+ if (keys.length !== 1)
978
+ return false;
979
+ const op = keys[0];
980
+ const c = clause;
981
+ switch (op) {
982
+ case "eq":
983
+ return matchClause(rowValue, c.eq);
984
+ case "neq":
985
+ return !matchClause(rowValue, c.neq);
986
+ case "gt":
987
+ case "gte":
988
+ case "lt":
989
+ case "lte": {
990
+ const a = coerceNumeric(rowValue);
991
+ const b = coerceNumeric(c[op]);
992
+ if (a === null || b === null)
993
+ return false;
994
+ if (op === "gt")
995
+ return a > b;
996
+ if (op === "gte")
997
+ return a >= b;
998
+ if (op === "lt")
999
+ return a < b;
1000
+ return a <= b;
1001
+ }
1002
+ case "in": {
1003
+ const list = c.in;
1004
+ if (!Array.isArray(list))
1005
+ return false;
1006
+ return list.some((item) => isPrimitive(item) && matchClause(rowValue, item));
1007
+ }
1008
+ default:
1009
+ return false;
1010
+ }
1011
+ }
1012
+ function matchesFilter(filter, row) {
1013
+ if (!filter || Object.keys(filter).length === 0)
1014
+ return true;
1015
+ for (const [col, clause] of Object.entries(filter)) {
1016
+ if (!matchClause(row[col], clause))
1017
+ return false;
1018
+ }
1019
+ return true;
1020
+ }
1021
+ function key(subgraphName, tableName) {
1022
+ return `${subgraphName}\x00${tableName}`;
1023
+ }
1024
+
1025
+ class SubscriptionMatcher {
1026
+ byKey = new Map;
1027
+ byId = new Map;
1028
+ setAll(subs) {
1029
+ this.byKey.clear();
1030
+ this.byId.clear();
1031
+ for (const sub of subs) {
1032
+ if (sub.status !== "active")
1033
+ continue;
1034
+ this.byId.set(sub.id, sub);
1035
+ const k = key(sub.subgraph_name, sub.table_name);
1036
+ const arr = this.byKey.get(k);
1037
+ if (arr)
1038
+ arr.push(sub);
1039
+ else
1040
+ this.byKey.set(k, [sub]);
1041
+ }
1042
+ }
1043
+ match(subgraphName, tableName, row) {
1044
+ const bucket = this.byKey.get(key(subgraphName, tableName));
1045
+ if (!bucket)
1046
+ return [];
1047
+ const hits = [];
1048
+ for (const sub of bucket) {
1049
+ if (matchesFilter(sub.filter, row))
1050
+ hits.push(sub);
1051
+ }
1052
+ return hits;
1053
+ }
1054
+ has(subgraphName, tableName) {
1055
+ return this.byKey.has(key(subgraphName, tableName));
1056
+ }
1057
+ size() {
1058
+ return this.byId.size;
1059
+ }
1060
+ get(id) {
1061
+ return this.byId.get(id);
1062
+ }
1063
+ }
1064
+
1065
+ // src/runtime/subscription-state.ts
1066
+ var matcher = new SubscriptionMatcher;
1067
+ async function refreshMatcher(db) {
1068
+ const rows = await sql2`
1069
+ SELECT * FROM subscriptions WHERE status = 'active'
1070
+ `.execute(db);
1071
+ matcher.setAll(rows.rows);
1072
+ return matcher.size();
1073
+ }
1074
+
866
1075
  // src/runtime/block-processor.ts
867
1076
  var schemaNameCache = new Map;
868
1077
  async function processBlock(subgraph, subgraphName, blockHeight, opts) {
@@ -886,7 +1095,7 @@ async function processBlock(subgraph, subgraphName, blockHeight, opts) {
886
1095
  } else {
887
1096
  block = await sourceDb.selectFrom("blocks").selectAll().where("height", "=", blockHeight).executeTakeFirst();
888
1097
  if (!block) {
889
- logger3.warn("Block not found for subgraph processing", {
1098
+ logger4.warn("Block not found for subgraph processing", {
890
1099
  subgraph: subgraphName,
891
1100
  blockHeight
892
1101
  });
@@ -894,7 +1103,7 @@ async function processBlock(subgraph, subgraphName, blockHeight, opts) {
894
1103
  return result;
895
1104
  }
896
1105
  if (!block.canonical) {
897
- logger3.debug("Skipping non-canonical block", {
1106
+ logger4.debug("Skipping non-canonical block", {
898
1107
  subgraph: subgraphName,
899
1108
  blockHeight
900
1109
  });
@@ -941,7 +1150,10 @@ async function processBlock(subgraph, subgraphName, blockHeight, opts) {
941
1150
  result.errors = runResult.errors;
942
1151
  if (ctx.pendingOps > 0) {
943
1152
  const flushStart = performance.now();
944
- await ctx.flush();
1153
+ const manifest = await ctx.flush();
1154
+ if (manifest.count > 0) {
1155
+ await emitSubscriptionOutbox(tx, subgraphName, manifest, matcher, block.height);
1156
+ }
945
1157
  flushMs = performance.now() - flushStart;
946
1158
  }
947
1159
  if (!opts?.skipProgressUpdate) {
@@ -963,10 +1175,10 @@ async function processBlock(subgraph, subgraphName, blockHeight, opts) {
963
1175
  try {
964
1176
  const tables = Object.keys(subgraph.schema);
965
1177
  for (const table of tables) {
966
- const { rows } = await sql2.raw(`SELECT n_live_tup AS count FROM pg_stat_user_tables WHERE schemaname = '${schemaName}' AND relname = '${table}'`).execute(targetDb);
1178
+ const { rows } = await sql3.raw(`SELECT n_live_tup AS count FROM pg_stat_user_tables WHERE schemaname = '${schemaName}' AND relname = '${table}'`).execute(targetDb);
967
1179
  const count = Number(rows[0]?.count ?? 0);
968
1180
  if (count >= 1e7) {
969
- logger3.warn("Subgraph table exceeds 10M rows (estimate)", {
1181
+ logger4.warn("Subgraph table exceeds 10M rows (estimate)", {
970
1182
  subgraph: subgraphName,
971
1183
  table,
972
1184
  count
@@ -1047,7 +1259,7 @@ import {
1047
1259
  recordGapBatch
1048
1260
  } from "@secondlayer/shared/db/queries/subgraph-gaps";
1049
1261
  import { getSubgraph } from "@secondlayer/shared/db/queries/subgraphs";
1050
- import { logger as logger4 } from "@secondlayer/shared/logger";
1262
+ import { logger as logger5 } from "@secondlayer/shared/logger";
1051
1263
 
1052
1264
  // src/runtime/batch-loader.ts
1053
1265
  async function loadBlockRange(db, fromHeight, toHeight) {
@@ -1146,7 +1358,7 @@ async function catchUpSubgraph(subgraph, subgraphName) {
1146
1358
  const subgraphStart = Number(subgraphRow.start_block) || 1;
1147
1359
  const startBlock = Math.max(lastProcessedBlock + 1, subgraphStart);
1148
1360
  const totalBlocks = chainTip - lastProcessedBlock;
1149
- logger4.info("Subgraph catch-up starting", {
1361
+ logger5.info("Subgraph catch-up starting", {
1150
1362
  subgraph: subgraphName,
1151
1363
  from: startBlock,
1152
1364
  to: chainTip,
@@ -1161,7 +1373,7 @@ async function catchUpSubgraph(subgraph, subgraphName) {
1161
1373
  while (currentHeight <= chainTip) {
1162
1374
  const currentRow = await getSubgraph(targetDb, subgraphName);
1163
1375
  if (!currentRow || currentRow.status !== "active") {
1164
- logger4.info("Subgraph status changed, stopping catch-up", {
1376
+ logger5.info("Subgraph status changed, stopping catch-up", {
1165
1377
  subgraph: subgraphName,
1166
1378
  status: currentRow?.status ?? "deleted"
1167
1379
  });
@@ -1188,7 +1400,7 @@ async function catchUpSubgraph(subgraph, subgraphName) {
1188
1400
  preloaded: blockData
1189
1401
  });
1190
1402
  } catch (err) {
1191
- logger4.error("Block processing error during catch-up", {
1403
+ logger5.error("Block processing error during catch-up", {
1192
1404
  subgraph: subgraphName,
1193
1405
  blockHeight: height,
1194
1406
  error: err instanceof Error ? err.message : String(err)
@@ -1207,7 +1419,7 @@ async function catchUpSubgraph(subgraph, subgraphName) {
1207
1419
  }
1208
1420
  }
1209
1421
  if (processed % LOG_INTERVAL === 0) {
1210
- logger4.info("Subgraph catch-up progress", {
1422
+ logger5.info("Subgraph catch-up progress", {
1211
1423
  subgraph: subgraphName,
1212
1424
  processed,
1213
1425
  total: totalBlocks,
@@ -1219,7 +1431,7 @@ async function catchUpSubgraph(subgraph, subgraphName) {
1219
1431
  if (batchFailedBlocks.length > 0) {
1220
1432
  const gaps = coalesceGaps(batchFailedBlocks);
1221
1433
  await recordGapBatch(targetDb, subgraphRow.id, subgraphName, gaps).catch((err) => {
1222
- logger4.warn("Failed to record subgraph gaps", {
1434
+ logger5.warn("Failed to record subgraph gaps", {
1223
1435
  subgraph: subgraphName,
1224
1436
  error: err instanceof Error ? err.message : String(err)
1225
1437
  });
@@ -1230,7 +1442,7 @@ async function catchUpSubgraph(subgraph, subgraphName) {
1230
1442
  currentHeight = batchEnd + 1;
1231
1443
  }
1232
1444
  await stats.flush(targetDb);
1233
- logger4.info("Subgraph catch-up complete", {
1445
+ logger5.info("Subgraph catch-up complete", {
1234
1446
  subgraph: subgraphName,
1235
1447
  processed
1236
1448
  });
@@ -1244,14 +1456,14 @@ async function catchUpSubgraph(subgraph, subgraphName) {
1244
1456
  import { getErrorMessage as getErrorMessage2 } from "@secondlayer/shared";
1245
1457
  import { getRawClient, getTargetDb as getTargetDb3 } from "@secondlayer/shared/db";
1246
1458
  import { listSubgraphs } from "@secondlayer/shared/db/queries/subgraphs";
1247
- import { logger as logger5 } from "@secondlayer/shared/logger";
1459
+ import { logger as logger6 } from "@secondlayer/shared/logger";
1248
1460
  async function handleSubgraphReorg(blockHeight, loadSubgraphDef) {
1249
1461
  const targetDb = getTargetDb3();
1250
1462
  const client = getRawClient("target");
1251
1463
  const activeSubgraphs = (await listSubgraphs(targetDb)).filter((v) => v.status === "active");
1252
1464
  if (activeSubgraphs.length === 0)
1253
1465
  return;
1254
- logger5.info("Propagating reorg to subgraphs", {
1466
+ logger6.info("Propagating reorg to subgraphs", {
1255
1467
  blockHeight,
1256
1468
  subgraphCount: activeSubgraphs.length
1257
1469
  });
@@ -1264,18 +1476,18 @@ async function handleSubgraphReorg(blockHeight, loadSubgraphDef) {
1264
1476
  for (const tableName of Object.keys(schema)) {
1265
1477
  await client.unsafe(`DELETE FROM "${schemaName}"."${tableName}" WHERE "_block_height" = $1`, [blockHeight]);
1266
1478
  }
1267
- logger5.info("Subgraph reorg cleanup done", {
1479
+ logger6.info("Subgraph reorg cleanup done", {
1268
1480
  subgraph: sg.name,
1269
1481
  blockHeight
1270
1482
  });
1271
1483
  const def = await loadSubgraphDef(sg);
1272
1484
  await processBlock(def, sg.name, blockHeight);
1273
- logger5.info("Subgraph reorg reprocessed", {
1485
+ logger6.info("Subgraph reorg reprocessed", {
1274
1486
  subgraph: sg.name,
1275
1487
  blockHeight
1276
1488
  });
1277
1489
  } catch (err) {
1278
- logger5.error("Subgraph reorg handling failed", {
1490
+ logger6.error("Subgraph reorg handling failed", {
1279
1491
  subgraph: sg.name,
1280
1492
  blockHeight,
1281
1493
  error: getErrorMessage2(err)
@@ -1286,13 +1498,286 @@ async function handleSubgraphReorg(blockHeight, loadSubgraphDef) {
1286
1498
 
1287
1499
  // src/runtime/processor.ts
1288
1500
  import { getErrorMessage as getErrorMessage3 } from "@secondlayer/shared";
1289
- import { getTargetDb as getTargetDb4 } from "@secondlayer/shared/db";
1501
+ import { getTargetDb as getTargetDb5 } from "@secondlayer/shared/db";
1290
1502
  import {
1291
1503
  listSubgraphs as listSubgraphs2,
1292
1504
  updateSubgraphStatus as updateSubgraphStatus2
1293
1505
  } from "@secondlayer/shared/db/queries/subgraphs";
1294
- import { logger as logger6 } from "@secondlayer/shared/logger";
1506
+ import { logger as logger8 } from "@secondlayer/shared/logger";
1507
+ import { listen as listen2 } from "@secondlayer/shared/queue/listener";
1508
+
1509
+ // src/runtime/emitter.ts
1510
+ import {
1511
+ getTargetDb as getTargetDb4
1512
+ } from "@secondlayer/shared/db";
1513
+ import {
1514
+ getSubscriptionSigningSecret
1515
+ } from "@secondlayer/shared/db/queries/subscriptions";
1516
+ import { logger as logger7 } from "@secondlayer/shared/logger";
1295
1517
  import { listen } from "@secondlayer/shared/queue/listener";
1518
+ import { sql as sql4 } from "kysely";
1519
+
1520
+ // src/runtime/formats/standard-webhooks.ts
1521
+ import { sign } from "@secondlayer/shared/crypto/standard-webhooks";
1522
+ function buildStandardWebhooks(outboxRow, signingSecret) {
1523
+ const payload = {
1524
+ type: outboxRow.event_type,
1525
+ timestamp: new Date(outboxRow.created_at).toISOString(),
1526
+ data: outboxRow.payload
1527
+ };
1528
+ const body = JSON.stringify(payload);
1529
+ const sigHeaders = sign(body, signingSecret, {
1530
+ id: outboxRow.id,
1531
+ timestampSeconds: Math.floor(new Date(outboxRow.created_at).getTime() / 1000)
1532
+ });
1533
+ return {
1534
+ body,
1535
+ headers: {
1536
+ "content-type": "application/json",
1537
+ ...sigHeaders
1538
+ }
1539
+ };
1540
+ }
1541
+
1542
+ // src/runtime/emitter.ts
1543
+ var BATCH_SIZE = 50;
1544
+ var BACKOFF_SECONDS = [30, 120, 600, 3600, 21600, 86400, 259200];
1545
+ var CIRCUIT_THRESHOLD = 20;
1546
+ function nextDelaySeconds(attempt) {
1547
+ return BACKOFF_SECONDS[Math.min(attempt, BACKOFF_SECONDS.length - 1)];
1548
+ }
1549
+ async function dispatchOne(db, outboxRow, sub) {
1550
+ const { body, headers } = buildStandardWebhooks(outboxRow, getSubscriptionSigningSecret(sub));
1551
+ const start = performance.now();
1552
+ let statusCode = null;
1553
+ let error = null;
1554
+ let ok = false;
1555
+ let responseBody = "";
1556
+ let responseHeaders = {};
1557
+ try {
1558
+ const res = await fetch(sub.url, {
1559
+ method: "POST",
1560
+ headers,
1561
+ body,
1562
+ signal: AbortSignal.timeout(sub.timeout_ms)
1563
+ });
1564
+ statusCode = res.status;
1565
+ ok = res.ok;
1566
+ const buf = await res.arrayBuffer();
1567
+ const truncated = buf.byteLength > 8192 ? buf.slice(0, 8192) : buf;
1568
+ responseBody = Buffer.from(truncated).toString("utf8");
1569
+ responseHeaders = Object.fromEntries(res.headers.entries());
1570
+ } catch (err) {
1571
+ error = err instanceof Error ? err.message : String(err);
1572
+ }
1573
+ const durationMs = Math.round(performance.now() - start);
1574
+ const attempt = outboxRow.attempt + 1;
1575
+ await db.insertInto("subscription_deliveries").values({
1576
+ outbox_id: outboxRow.id,
1577
+ subscription_id: outboxRow.subscription_id,
1578
+ attempt,
1579
+ status_code: statusCode,
1580
+ response_headers: responseHeaders,
1581
+ response_body: responseBody || null,
1582
+ error_message: error,
1583
+ duration_ms: durationMs
1584
+ }).execute();
1585
+ return { ok, statusCode, error, durationMs };
1586
+ }
1587
+ async function settleDelivered(db, outboxRow) {
1588
+ await db.transaction().execute(async (tx) => {
1589
+ await tx.updateTable("subscription_outbox").set({
1590
+ status: "delivered",
1591
+ delivered_at: new Date,
1592
+ attempt: outboxRow.attempt + 1,
1593
+ locked_by: null,
1594
+ locked_until: null
1595
+ }).where("id", "=", outboxRow.id).execute();
1596
+ await tx.updateTable("subscriptions").set({
1597
+ last_delivery_at: new Date,
1598
+ last_success_at: new Date,
1599
+ circuit_failures: 0,
1600
+ last_error: null,
1601
+ updated_at: new Date
1602
+ }).where("id", "=", outboxRow.subscription_id).execute();
1603
+ });
1604
+ }
1605
+ async function settleFailed(db, outboxRow, sub, errText) {
1606
+ const attempt = outboxRow.attempt + 1;
1607
+ const isDead = attempt >= sub.max_retries;
1608
+ const nextAt = isDead ? null : new Date(Date.now() + nextDelaySeconds(outboxRow.attempt) * 1000);
1609
+ await db.transaction().execute(async (tx) => {
1610
+ await tx.updateTable("subscription_outbox").set({
1611
+ attempt,
1612
+ next_attempt_at: nextAt ?? new Date,
1613
+ status: isDead ? "dead" : "pending",
1614
+ failed_at: isDead ? new Date : null,
1615
+ locked_by: null,
1616
+ locked_until: null
1617
+ }).where("id", "=", outboxRow.id).execute();
1618
+ const newFailures = sub.circuit_failures + 1;
1619
+ const shouldTripCircuit = newFailures >= CIRCUIT_THRESHOLD;
1620
+ await tx.updateTable("subscriptions").set({
1621
+ last_delivery_at: new Date,
1622
+ last_error: errText.slice(0, 500),
1623
+ circuit_failures: newFailures,
1624
+ ...shouldTripCircuit ? { status: "paused", circuit_opened_at: new Date } : {},
1625
+ updated_at: new Date
1626
+ }).where("id", "=", outboxRow.subscription_id).execute();
1627
+ if (shouldTripCircuit) {
1628
+ logger7.warn("Subscription circuit tripped — paused after consecutive failures", {
1629
+ subscription: sub.name,
1630
+ failures: newFailures
1631
+ });
1632
+ }
1633
+ });
1634
+ }
1635
+ async function claimAndDrain(db, state, emitterId) {
1636
+ if (state.claimInFlight)
1637
+ return 0;
1638
+ state.claimInFlight = true;
1639
+ try {
1640
+ const claimed = await db.transaction().execute(async (tx) => {
1641
+ const rows = await sql4`
1642
+ SELECT * FROM subscription_outbox
1643
+ WHERE status = 'pending' AND next_attempt_at <= NOW()
1644
+ ORDER BY next_attempt_at ASC
1645
+ FOR UPDATE SKIP LOCKED
1646
+ LIMIT ${sql4.lit(BATCH_SIZE)}
1647
+ `.execute(tx);
1648
+ if (rows.rows.length === 0)
1649
+ return [];
1650
+ const now = new Date;
1651
+ const lockUntil = new Date(now.getTime() + 60000);
1652
+ await tx.updateTable("subscription_outbox").set({ locked_by: emitterId, locked_until: lockUntil }).where("id", "in", rows.rows.map((r) => r.id)).execute();
1653
+ return rows.rows;
1654
+ });
1655
+ if (claimed.length === 0)
1656
+ return 0;
1657
+ const bySubId = new Map;
1658
+ for (const row of claimed) {
1659
+ const arr = bySubId.get(row.subscription_id);
1660
+ if (arr)
1661
+ arr.push(row);
1662
+ else
1663
+ bySubId.set(row.subscription_id, [row]);
1664
+ }
1665
+ const subIds = Array.from(bySubId.keys());
1666
+ const subs = await db.selectFrom("subscriptions").selectAll().where("id", "in", subIds).execute();
1667
+ const subById = new Map(subs.map((s) => [s.id, s]));
1668
+ await Promise.all(subIds.map((subId) => drainForSub(db, state, subById.get(subId), bySubId.get(subId))));
1669
+ return claimed.length;
1670
+ } finally {
1671
+ state.claimInFlight = false;
1672
+ }
1673
+ }
1674
+ async function drainForSub(db, state, sub, rows) {
1675
+ const cap = sub.concurrency || 4;
1676
+ const counter = () => state.inFlightBySub.get(sub.id) ?? 0;
1677
+ const inc = () => state.inFlightBySub.set(sub.id, counter() + 1);
1678
+ const dec = () => state.inFlightBySub.set(sub.id, Math.max(0, counter() - 1));
1679
+ const queue = [...rows];
1680
+ const workers = [];
1681
+ const slots = Math.min(cap, queue.length);
1682
+ for (let i = 0;i < slots; i++) {
1683
+ workers.push((async () => {
1684
+ while (state.running && queue.length > 0) {
1685
+ const row = queue.shift();
1686
+ if (!row)
1687
+ break;
1688
+ inc();
1689
+ try {
1690
+ const result = await dispatchOne(db, row, sub);
1691
+ if (result.ok) {
1692
+ await settleDelivered(db, row);
1693
+ } else {
1694
+ const err = result.error ?? `HTTP ${result.statusCode ?? "?"}`;
1695
+ await settleFailed(db, row, sub, err);
1696
+ }
1697
+ } catch (err) {
1698
+ logger7.error("Emitter dispatch crashed", {
1699
+ outboxId: row.id,
1700
+ error: err instanceof Error ? err.message : String(err)
1701
+ });
1702
+ await settleFailed(db, row, sub, err instanceof Error ? err.message : String(err));
1703
+ } finally {
1704
+ dec();
1705
+ }
1706
+ }
1707
+ })());
1708
+ }
1709
+ await Promise.all(workers);
1710
+ }
1711
+ async function runRetention(db) {
1712
+ await sql4`
1713
+ DELETE FROM subscription_outbox
1714
+ WHERE status = 'delivered' AND delivered_at < NOW() - interval '7 days'
1715
+ `.execute(db);
1716
+ await sql4`
1717
+ DELETE FROM subscription_deliveries
1718
+ WHERE dispatched_at < NOW() - interval '30 days'
1719
+ `.execute(db);
1720
+ await sql4`
1721
+ DELETE FROM subscription_outbox
1722
+ WHERE status = 'dead' AND failed_at < NOW() - interval '90 days'
1723
+ `.execute(db);
1724
+ }
1725
+ async function startEmitter(opts) {
1726
+ const emitterId = `emitter-${Math.random().toString(36).slice(2, 10)}`;
1727
+ const db = getTargetDb4();
1728
+ const state = {
1729
+ running: true,
1730
+ inFlightBySub: new Map,
1731
+ claimInFlight: false
1732
+ };
1733
+ const pollIntervalMs = opts?.pollIntervalMs ?? 120000;
1734
+ const retentionIntervalMs = opts?.retentionIntervalMs ?? 60 * 60000;
1735
+ logger7.info("[emitter] started", { id: emitterId });
1736
+ await refreshMatcher(db).catch((err) => {
1737
+ logger7.error("[emitter] initial matcher refresh failed", {
1738
+ error: err instanceof Error ? err.message : String(err)
1739
+ });
1740
+ });
1741
+ const stopNew = await listen("subscriptions:new_outbox", () => {
1742
+ if (!state.running)
1743
+ return;
1744
+ claimAndDrain(db, state, emitterId).catch((err) => logger7.error("[emitter] claim failed", {
1745
+ error: err instanceof Error ? err.message : String(err)
1746
+ }));
1747
+ });
1748
+ const stopChanged = await listen("subscriptions:changed", () => {
1749
+ if (!state.running)
1750
+ return;
1751
+ refreshMatcher(db).catch((err) => logger7.error("[emitter] matcher refresh failed", {
1752
+ error: err instanceof Error ? err.message : String(err)
1753
+ }));
1754
+ });
1755
+ const poll = setInterval(() => {
1756
+ if (!state.running)
1757
+ return;
1758
+ claimAndDrain(db, state, emitterId).catch((err) => logger7.error("[emitter] poll claim failed", {
1759
+ error: err instanceof Error ? err.message : String(err)
1760
+ }));
1761
+ }, pollIntervalMs);
1762
+ claimAndDrain(db, state, emitterId);
1763
+ const retention = setInterval(() => {
1764
+ if (!state.running)
1765
+ return;
1766
+ runRetention(db).catch((err) => logger7.error("[emitter] retention failed", {
1767
+ error: err instanceof Error ? err.message : String(err)
1768
+ }));
1769
+ }, retentionIntervalMs);
1770
+ return async () => {
1771
+ state.running = false;
1772
+ clearInterval(poll);
1773
+ clearInterval(retention);
1774
+ await stopNew();
1775
+ await stopChanged();
1776
+ logger7.info("[emitter] stopped", { id: emitterId });
1777
+ };
1778
+ }
1779
+
1780
+ // src/runtime/processor.ts
1296
1781
  var CHANNEL_NEW_BLOCK = "indexer:new_block";
1297
1782
  var DEFAULT_CONCURRENCY = 5;
1298
1783
  var POLL_INTERVAL_MS = 5000;
@@ -1326,7 +1811,7 @@ async function loadSubgraphDefinition(sg) {
1326
1811
  knownVersions.set(sg.name, sg.version);
1327
1812
  definitionCache.set(sg.name, def);
1328
1813
  if (prevVersion && prevVersion !== sg.version) {
1329
- logger6.info("Subgraph handler reloaded", {
1814
+ logger8.info("Subgraph handler reloaded", {
1330
1815
  subgraph: sg.name,
1331
1816
  from: prevVersion,
1332
1817
  to: sg.version
@@ -1346,8 +1831,8 @@ function cleanupCaches(active) {
1346
1831
  async function startSubgraphProcessor(opts) {
1347
1832
  const concurrency = opts?.concurrency ?? DEFAULT_CONCURRENCY;
1348
1833
  let running = true;
1349
- logger6.info("Starting subgraph processor", { concurrency });
1350
- const targetDb = getTargetDb4();
1834
+ logger8.info("Starting subgraph processor", { concurrency });
1835
+ const targetDb = getTargetDb5();
1351
1836
  const activeSubgraphs = (await listSubgraphs2(targetDb)).filter((v) => v.status === "active");
1352
1837
  for (const sg of activeSubgraphs) {
1353
1838
  try {
@@ -1358,16 +1843,16 @@ async function startSubgraphProcessor(opts) {
1358
1843
  if (isHandlerNotFoundError(err)) {
1359
1844
  await updateSubgraphStatus2(targetDb, sg.name, "error");
1360
1845
  }
1361
- logger6.error("Subgraph catch-up failed on startup", {
1846
+ logger8.error("Subgraph catch-up failed on startup", {
1362
1847
  subgraph: sg.name,
1363
1848
  error: msg
1364
1849
  });
1365
1850
  }
1366
1851
  }
1367
- const stopListening = await listen(CHANNEL_NEW_BLOCK, async () => {
1852
+ const stopListening = await listen2(CHANNEL_NEW_BLOCK, async () => {
1368
1853
  if (!running)
1369
1854
  return;
1370
- const db = getTargetDb4();
1855
+ const db = getTargetDb5();
1371
1856
  const subgraphs = (await listSubgraphs2(db)).filter((v) => v.status === "active");
1372
1857
  cleanupCaches(subgraphs);
1373
1858
  for (const sg of subgraphs) {
@@ -1379,14 +1864,14 @@ async function startSubgraphProcessor(opts) {
1379
1864
  if (isHandlerNotFoundError(err)) {
1380
1865
  await updateSubgraphStatus2(db, sg.name, "error");
1381
1866
  }
1382
- logger6.error("Subgraph processing failed", {
1867
+ logger8.error("Subgraph processing failed", {
1383
1868
  subgraph: sg.name,
1384
1869
  error: msg
1385
1870
  });
1386
1871
  }
1387
1872
  }
1388
1873
  }, { connectionString: sourceListenerUrl() });
1389
- const stopReorgListening = await listen("subgraph_reorg", async (payload) => {
1874
+ const stopReorgListening = await listen2("subgraph_reorg", async (payload) => {
1390
1875
  if (!running)
1391
1876
  return;
1392
1877
  try {
@@ -1396,7 +1881,7 @@ async function startSubgraphProcessor(opts) {
1396
1881
  await handleSubgraphReorg(blockHeight, loadSubgraphDefinition);
1397
1882
  }
1398
1883
  } catch (err) {
1399
- logger6.error("Subgraph reorg handling failed", {
1884
+ logger8.error("Subgraph reorg handling failed", {
1400
1885
  error: getErrorMessage3(err)
1401
1886
  });
1402
1887
  }
@@ -1404,7 +1889,7 @@ async function startSubgraphProcessor(opts) {
1404
1889
  const pollInterval = setInterval(async () => {
1405
1890
  if (!running)
1406
1891
  return;
1407
- const db = getTargetDb4();
1892
+ const db = getTargetDb5();
1408
1893
  const subgraphs = (await listSubgraphs2(db)).filter((v) => v.status === "active");
1409
1894
  cleanupCaches(subgraphs);
1410
1895
  for (const sg of subgraphs) {
@@ -1412,35 +1897,37 @@ async function startSubgraphProcessor(opts) {
1412
1897
  const def = await loadSubgraphDefinition(sg);
1413
1898
  await catchUpSubgraph(def, sg.name);
1414
1899
  } catch (err) {
1415
- logger6.error("Subgraph poll processing failed", {
1900
+ logger8.error("Subgraph poll processing failed", {
1416
1901
  subgraph: sg.name,
1417
1902
  error: getErrorMessage3(err)
1418
1903
  });
1419
1904
  }
1420
1905
  }
1421
1906
  }, POLL_INTERVAL_MS);
1422
- logger6.info("Subgraph processor ready");
1907
+ const stopEmitter = await startEmitter();
1908
+ logger8.info("Subgraph processor ready");
1423
1909
  return async () => {
1424
1910
  running = false;
1425
1911
  clearInterval(pollInterval);
1426
1912
  await stopListening();
1427
1913
  await stopReorgListening();
1428
- logger6.info("Subgraph processor stopped");
1914
+ await stopEmitter();
1915
+ logger8.info("Subgraph processor stopped");
1429
1916
  };
1430
1917
  }
1431
1918
 
1432
1919
  // src/service.ts
1433
- import { logger as logger7 } from "@secondlayer/shared/logger";
1920
+ import { logger as logger9 } from "@secondlayer/shared/logger";
1434
1921
  var processor = await startSubgraphProcessor({
1435
1922
  concurrency: Number.parseInt(process.env.SUBGRAPH_CONCURRENCY ?? "5")
1436
1923
  });
1437
1924
  var shutdown = async () => {
1438
- logger7.info("Shutting down subgraph processor...");
1925
+ logger9.info("Shutting down subgraph processor...");
1439
1926
  await processor();
1440
1927
  process.exit(0);
1441
1928
  };
1442
1929
  process.on("SIGINT", shutdown);
1443
1930
  process.on("SIGTERM", shutdown);
1444
1931
 
1445
- //# debugId=403B5FB854EE82EB64756E2164756E21
1932
+ //# debugId=831A226FB3756A4564756E2164756E21
1446
1933
  //# sourceMappingURL=service.js.map