diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 6a4f9b39..8fc154fd 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -1253,7 +1253,7 @@ ## Function: indexZapReceipt - **Purpose:** Validate provider pubkey (case-insensitive hex) and add sats once per receipt id. Callers verify the Nostr signature first. Persists a `nostr_zap_ingest` row (`indexed`, or `rejected` with reason `pubkey` / `amount` / `duplicate`); store throw logs `nostr.zap.ingest.record_failed` and does not change the boolean result. -- **Ingest dedupe:** One `nostr_zap_ingest` row is written per receipt per decision change per process (memory is per store instance and empty after a restart, so the first tick after boot may write one `rejected`/`duplicate` row per known receipt). A repeated identical `outcome:reason` is not written again. Receipts whose remembered decision is already terminal never reach this function: `indexOpenZapReceipts` skips them before validation. +- **Ingest dedupe:** One `nostr_zap_ingest` row is written per receipt per decision change per process (memory is per store instance and empty after a restart, so the first tick after boot may write one `rejected`/`duplicate` row per known receipt). A repeated identical `outcome:reason` is normally not written again, because the memory is consulted before the write; that is not a guarantee, since the memory is set only after the write resolves, worker ticks are not serialised, and a failed write leaves it untouched. Receipts whose remembered decision is already terminal never reach this function: `indexOpenZapReceipts` skips them before validation. - **Inputs:** store, messageId, receipt, providerPubkey, amountSats; optional receiptEvent / noteEventId for debug rows. - **Returns / side effects:** boolean; logs indexed/rejected; records ingest. - **Used by:** `indexOpenZapReceipts` (worker tick). diff --git a/src/lib/conversation-store.ts b/src/lib/conversation-store.ts index 8ebdbffb..01367c7f 100644 --- a/src/lib/conversation-store.ts +++ b/src/lib/conversation-store.ts @@ -115,7 +115,7 @@ export interface ConversationStore { updatePublishState(id: string, state: NostrPublishState): Promise; } -/** Idempotent SQL for conversation tables (DDL plus boot-time unwrap of `nostr_event` values stored as jsonb string scalars in `conversation_message`; matches `docs/schema/conversation.sql`). */ +/** Idempotent SQL for conversation tables (DDL plus boot-time unwrap of `nostr_event` values stored as jsonb string scalars in `conversation_message`; `docs/schema/conversation.sql` mirrors the DDL and documents the boot repair statement by comment, the `DO $unwrap$` block lives only in this array). */ export const CONVERSATION_SCHEMA_SQL: readonly string[] = [ `CREATE TABLE IF NOT EXISTS conversation ( id uuid PRIMARY KEY, diff --git a/src/lib/db-change.ts b/src/lib/db-change.ts index 04a3fe8a..d40dbcd6 100644 --- a/src/lib/db-change.ts +++ b/src/lib/db-change.ts @@ -13,7 +13,7 @@ import type { SqlClient } from '@/lib/auth/sql'; -/** Idempotent SQL for the append-only change log (DDL plus one-time live `view_key` rewrite; matches `docs/schema/db_change.sql`). */ +/** Idempotent SQL for the append-only change log (DDL, the `log_db_change` trigger body that on UPDATE stores an unchanged and unredacted bytea column as a `{unchanged, sha256, bytes}` reference instead of the full value, and the one-time live `view_key` rewrite; matches `docs/schema/db_change.sql`). */ export const DB_CHANGE_SCHEMA_SQL: readonly string[] = [ `CREATE EXTENSION IF NOT EXISTS pgcrypto;`, `CREATE TABLE IF NOT EXISTS db_change ( diff --git a/src/lib/message-store.ts b/src/lib/message-store.ts index a46539e2..ea57160d 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -326,7 +326,7 @@ export interface ZapIngestRow { receipt: Record; } -/** Idempotent SQL for the forum table (DDL plus boot-time unwrap of `nostr_event` values stored as jsonb string scalars; matches `docs/schema/message.sql`). */ +/** Idempotent SQL for the forum table (DDL plus boot-time unwrap of `nostr_event` values stored as jsonb string scalars; `docs/schema/message.sql` mirrors the DDL and documents the boot repair statement by comment, the `DO $unwrap$` block lives only in this array). */ export const MESSAGE_SCHEMA_SQL: readonly string[] = [ `CREATE TABLE IF NOT EXISTS message ( id uuid PRIMARY KEY, diff --git a/src/lib/nostr/zap-index.ts b/src/lib/nostr/zap-index.ts index f161dae4..1c33cc6b 100644 --- a/src/lib/nostr/zap-index.ts +++ b/src/lib/nostr/zap-index.ts @@ -33,7 +33,14 @@ const providerPubkeyCache = new Map(); /** * Last persisted ingest `outcome:reason` per receipt id, keyed by message store. - * Empty after process restart; first tick may rewrite one row per known receipt. + * Empty after process restart; the first tick may then re-persist a forgotten + * decision, but only for the receipts that tick still queries. A receipt whose + * message has aged out of `listLatest` is never asked for again. + * + * Note the asymmetry with `MessageStore.deleteById`: both store adapters forget + * the receipt id when the message goes away and would record it again, but this + * map does not, so a terminal decision here keeps suppressing that write until + * the process restarts. */ const zapDecisions = new WeakMap>(); @@ -107,8 +114,9 @@ function receiptFrame(event: NostrEventFrame): Record { /** * Persist an ingest decision without failing the tick. - * Skips the write when this process already persisted the same outcome:reason - * for the receipt id on this store instance. + * Skips the write when the memory already holds the same outcome:reason for the + * receipt id on this store instance. The memory is set only after the write + * resolves, so two overlapping ticks can both pass this check. * * @param store - Forum store. * @param row - Ingest row. @@ -162,6 +170,13 @@ function zapIngestRow(args: { * The provider pubkey check is case-insensitive hex. Callers must already * have verified the Nostr signature (`verifyEvent`). * + * A repeated identical `outcome:reason` is normally not written again, because + * the memory is consulted before the write. That is not a guarantee: the memory + * is set only after the write resolves, worker ticks are not serialised, and a + * failed write leaves the memory untouched, so two overlapping ticks or a retry + * can still produce a second identical row. A later, different decision for that + * receipt always writes another ingest row. + * * @param store - Forum store. * @param messageId - Forum row id. * @param receipt - Kind 9735. @@ -264,6 +279,15 @@ export async function indexZapReceipt(args: { * Query zap relays for kind:9735 receipts on recent forum notes and index * validated ones. * + * Receipts whose terminal decision this process already persisted are skipped, + * that is `indexed`, or `rejected` with reason `duplicate`. Every other + * rejection reason is re-validated on each tick and writes again whenever the + * decision changes, so a steady state writes no ingest rows only while the + * decisions themselves are stable. The memory is process-local, so the first + * tick after a restart may re-persist decisions it has forgotten, bounded by the + * receipts that tick queries at all. Ticks are not serialised, so the bound is + * per tick, not per receipt across concurrent ticks. + * * @param args - Store, auth, querier, relay urls, timeout, clock, fetch. * @returns Resolves when the tick's ingest pass finishes. */ @@ -337,6 +361,11 @@ export async function indexOpenZapReceipts(args: { /** * Validate and index one candidate receipt event. * + * Returns right after id validation when this process already persisted a + * terminal decision for the receipt id on this store instance, that is + * `indexed`, or `rejected` with reason `duplicate`. Every other rejection + * reason is re-validated on each call. + * * @param event - Queried frame. * @param args - Ingest collaborators. */