From e9827942a28d4c5ad5cca5b5f1e15a86cb5767fb Mon Sep 17 00:00:00 2001 From: Julien Lucca Date: Wed, 5 Aug 2026 14:31:04 +0200 Subject: [PATCH 1/2] fix(community): upsert creator into users on createCommunity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The creator's network row FK-references users(account), but createCommunity never created that row — prod only worked because backend sign-ups had pre-populated users. On a from-genesis index of a fresh DB the network insert violated network_account_id_fkey, the swallowed rollback dropped the whole community, and the next netlink crashed the process (network_community_id_fkey -> unhandledRejection -> exit -> crash-loop). Mirror netlink's idempotent INSERT ... ON CONFLICT DO NOTHING inside the same transaction, before the network/role inserts. --- src/updaters/community.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/updaters/community.js b/src/updaters/community.js index dbf06f7..10c9bc7 100644 --- a/src/updaters/community.js +++ b/src/updaters/community.js @@ -53,6 +53,25 @@ async function createCommunity (db, payload, blockInfo) { // create community await tx.communities.insert(communityData) + // The creator's network row (below) FK-references users(account), so the creator + // MUST exist in users first. On a from-genesis index of a fresh DB nobody else has + // created that row (prod historically relied on backend sign-ups pre-populating + // users), the network insert violated network_account_id_fkey, and the swallowed + // rollback left the community missing entirely — the next netlink then crash-looped + // the process. Same idempotent upsert as netlink (relies on the users(account) PK). + await tx.instance.none( + `INSERT INTO users (account, created_block, created_tx, created_eos_account, created_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT DO NOTHING`, + [ + payload.data.creator, + blockInfo.blockNumber, + payload.transactionId, + payload.authorization[0].actor, + blockInfo.timestamp + ] + ) + const roleData = { community_id: symbol, name: 'member', From b120dd40c3853c4d14983a5557e60abea27c0b11 Mon Sep 17 00:00:00 2001 From: Julien Lucca Date: Wed, 5 Aug 2026 14:31:18 +0200 Subject: [PATCH 2/2] fix(updaters): stop rewriting created_* provenance on objective/action edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upsertObjective/upsertAction update paths carried created_block / created_tx / created_at / created_eos_account into the save() UPDATE, so every edit rewrote the row's create provenance with the edit's values. That destroyed the create-path replay guard (count({created_tx})): after any create+edit, replaying the range no longer recognized the create and re-executed it — resolveCreated*Id found no unknown chain id, fell back to the serial, and the insert hit community_objectives_pkey, aborting the block transaction and crash-looping the indexer. Keep the created_* fields create-only; update paths now touch only mutable fields. The backend 20260702120000 migration comment cited the rewrite only as the reason no unique index on created_tx was added — it documents the old behavior, it is not a rationale for keeping it. Verified end-to-end on the local chain: create+edit leaves created_tx / created_block untouched, and a ledger-cleared replay of the range skips the creates via the guard (row counts and created_tx unchanged). --- src/updaters/community.js | 41 ++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/src/updaters/community.js b/src/updaters/community.js index 10c9bc7..428c66f 100644 --- a/src/updaters/community.js +++ b/src/updaters/community.js @@ -307,16 +307,17 @@ async function upsertObjective (db, payload, blockInfo, _context) { let data = { community_id: payload.data.community_id, creator_id: payload.data.editor, - description: payload.data.description, - created_block: blockInfo.blockNumber, - created_tx: payload.transactionId, - created_at: blockInfo.timestamp, - created_eos_account: payload.authorization[0].actor + description: payload.data.description } if (payload.data.objective_id > 0) { // Update path: an idempotent upsert by id (massive's save() emits an UPDATE - // when the pk is present). + // when the pk is present). It must NOT touch the created_* provenance: the + // create-path replay guard below keys on created_tx, so rewriting it on edit + // would make a later replay re-execute the create (duplicate row / pkey + // crash). The 20260702120000 migration comment documents the old rewrite + // only to explain why no unique index on created_tx was added — it is not a + // rationale for keeping it. data = Object.assign(data, { id: payload.data.objective_id }) return db.objectives .save(data) @@ -325,6 +326,15 @@ async function upsertObjective (db, payload, blockInfo, _context) { ) } + // Create-path provenance — written once, on the create, and never rewritten + // by edits, so the replay guard below keeps recognizing this create. + data = Object.assign(data, { + created_block: blockInfo.blockNumber, + created_tx: payload.transactionId, + created_at: blockInfo.timestamp, + created_eos_account: payload.authorization[0].actor + }) + // Idempotency (create path only): with no objective_id the payload carries no key, so // inserting without a guard would add a fresh row on every replay, duplicating the // objective. Keyed on created_tx — a create runs in exactly one on-chain tx, so a @@ -400,10 +410,6 @@ function upsertAction (db, payload, blockInfo, _context) { verifications: payload.data.verifications, verification_type: payload.data.verification_type, deadline: payload.data.deadline > 0 ? deadlineDateTime : null, - created_block: blockInfo.blockNumber, - created_tx: payload.transactionId, - created_at: blockInfo.timestamp, - created_eos_account: payload.authorization[0].actor, has_proof_photo: payload.data.has_proof_photo === 1, has_proof_code: payload.data.has_proof_code === 1, photo_proof_instructions: payload.data.photo_proof_instructions === '' ? null : payload.data.photo_proof_instructions, @@ -411,13 +417,26 @@ function upsertAction (db, payload, blockInfo, _context) { } if (payload.data.action_id > 0) { - // Update + // Update — deliberately does NOT touch the created_* provenance: the + // create-path replay guard below keys on created_tx, so rewriting it on + // edit would make a later replay re-execute the create (duplicate row / + // pkey crash). See upsertObjective for the full rationale. data = Object.assign(data, { id: payload.data.action_id, usages_left: payload.data.usages_left, is_completed: payload.data.is_completed === 1 }) } else { + // Create-path provenance — written once, on the create, and never + // rewritten by edits, so the replay guard below keeps recognizing this + // create. + data = Object.assign(data, { + created_block: blockInfo.blockNumber, + created_tx: payload.transactionId, + created_at: blockInfo.timestamp, + created_eos_account: payload.authorization[0].actor + }) + // Idempotency (create path only): with no action_id the payload carries no key, so // inserting without a guard would add a fresh row on every replay, duplicating the // action (the audit found action ids 405+406 sharing one created_tx). Keyed on