diff --git a/.gitignore b/.gitignore index 814ad38..49eeb9c 100644 --- a/.gitignore +++ b/.gitignore @@ -76,4 +76,4 @@ typings/ .serverless # FuseBox cache -.fusebox/ \ No newline at end of file +.fusebox/src/config/fresh.js diff --git a/src/config/fresh.js b/src/config/fresh.js deleted file mode 100644 index ce9126c..0000000 --- a/src/config/fresh.js +++ /dev/null @@ -1,31 +0,0 @@ -// Scratch config for replay-bug reproduction: syncs the local nodeos from -// genesis into a FRESH, schema-only `cambiatus_fresh` Postgres DB. -// NOT committed — reproduction tooling only. -// Run with: NODE_ENV=fresh node src/app.js -console.log('Loaded Fresh configs') -module.exports = { - blockchain: { - contract: { - token: 'cambiatus.tk', - community: 'cambiatus.cm', - escrow: 'cambiatus.es' - }, - initialBlock: 1, - url: 'http://127.0.0.1:8888' - }, - db: { - user: 'postgres', - host: 'localhost', - port: 5432, - database: 'cambiatus_fresh', - schema: 'public' - }, - http: { - port: 3002 - }, - sentry: { - dsn: '', - environment: 'fresh', - attachStacktrace: true - } -} diff --git a/src/updaters/community.js b/src/updaters/community.js index 6a5c682..178cba1 100644 --- a/src/updaters/community.js +++ b/src/updaters/community.js @@ -54,6 +54,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, + toUTC(blockInfo.timestamp) + ] + ) + const roleData = { community_id: symbol, name: 'member', @@ -303,16 +322,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: toUTC(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) @@ -321,6 +341,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: toUTC(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 @@ -396,10 +425,6 @@ function upsertAction (db, payload, blockInfo, _context) { verifications: payload.data.verifications, verification_type: payload.data.verification_type, deadline: payload.data.deadline > 0 ? toUTC(deadlineDateTime) : null, - created_block: blockInfo.blockNumber, - created_tx: payload.transactionId, - created_at: toUTC(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, @@ -407,13 +432,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: toUTC(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