From 617f8fa2b1b4855683148a00c7310fa36f4c9e8c Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Sun, 30 Aug 2026 18:57:52 +0200 Subject: [PATCH 1/2] 01a052b6 - Match Damus hashtags as tokens, not prefixes (#81) * 01a052b6 - Match Damus hashtags as tokens, not prefixes #bitcoiners is not #bitcoin. Postgres missing-hashtag scan uses the same token rule. Unsigned claim requires pending, and a lease that expires at claimed_until is reclaimable in both stores. * 01a052b6 - Fix lease-expiry and token-regex assertions * 01a052b6 - Document hashtag matching as a token boundary --------- Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- docs/handbook/functions.md | 12 +++++----- src/__tests__/lib/message-store.test.ts | 30 +++++++++++++++++++++++-- src/__tests__/lib/nostr/event.test.ts | 13 +++++++++++ src/lib/message-store.ts | 28 ++++++++++++++--------- src/lib/nostr/event.ts | 29 +++++++++++++++++++----- 5 files changed, 89 insertions(+), 23 deletions(-) diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 57cca663..ad44db0e 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -156,7 +156,7 @@ ## Function: PostgresMessageStore -- **Purpose:** Durable `MessageStore` over Postgres (`message` table plus `message_invoice` and `nostr_zap_ingest`). `listLatest` selects Nostr columns plus `(photo IS NOT NULL) AS has_photo` and never the `photo` bytea column (HTTP window newest-first; product UX is a messenger group — clients reverse); `create` inserts optional photo bytes; `getPhoto` loads bytes by id; `getById`; `getByEventId` (`WHERE event_id`); `claimUnsigned`/`claimUnpublished` lease rows; `listPendingSigned` returns pending rows whose kind:1 lacks `t=bitcoin` (`created_at ASC, id ASC`); `clearSignedEvent` nulls `event_id` / `nostr_event` / `claimed_until` only while `pending` and `event_id` still matches the listed id; `listSignedMissingPhoto` returns published rows with a photo whose kind:1 content lacks `/messages/:id/photo.` plus an image extension (`sats = 0`, pending excluded so fan-out is not starved, `created_at ASC, id ASC`); `listSignedMissingHashtags` returns published unpaid rows whose kind:1 content lacks `#bitcoin` or `#21gifts` (`sats = 0`, pending excluded so fan-out is not starved, includes null / non-string content, `created_at ASC, id ASC`); `resetSignedEvent` nulls `event_id` / `nostr_event` / `claimed_until`, parks `pending`, and clears the epoch only when `event_id` still matches and `sats` is 0; `updateSignedEvent` (false on `event_id` collision); `updatePublishState`; `addSats`; `recordZapReceipt` (one statement: `INSERT nostr_zap_receipt ON CONFLICT DO NOTHING` plus `UPDATE message.sats`); `recordInvoiceAttempt` / `listInvoiceAttempts`; `recordZapIngest` / `listZapIngests`. +- **Purpose:** Durable `MessageStore` over Postgres (`message` table plus `message_invoice` and `nostr_zap_ingest`). `listLatest` selects Nostr columns plus `(photo IS NOT NULL) AS has_photo` and never the `photo` bytea column (HTTP window newest-first; product UX is a messenger group — clients reverse); `create` inserts optional photo bytes; `getPhoto` loads bytes by id; `getById`; `getByEventId` (`WHERE event_id`); `claimUnsigned`/`claimUnpublished` lease rows (`claimed_until <= now` is expired; unsigned requires `pending` + null `event_id`); `listPendingSigned` returns pending rows whose kind:1 lacks `t=bitcoin` (`created_at ASC, id ASC`); `clearSignedEvent` nulls `event_id` / `nostr_event` / `claimed_until` only while `pending` and `event_id` still matches the listed id; `listSignedMissingPhoto` returns published rows with a photo whose kind:1 content lacks `/messages/:id/photo.` plus an image extension (`sats = 0`, pending excluded so fan-out is not starved, `created_at ASC, id ASC`); `listSignedMissingHashtags` returns published unpaid rows whose kind:1 content lacks a `#bitcoin` or `#21gifts` token (next character must not be `[A-Za-z0-9_]`; `sats = 0`, pending excluded so fan-out is not starved, includes null / non-string content, `created_at ASC, id ASC`); `resetSignedEvent` nulls `event_id` / `nostr_event` / `claimed_until`, parks `pending`, and clears the epoch only when `event_id` still matches and `sats` is 0; `updateSignedEvent` (false on `event_id` collision); `updatePublishState`; `addSats`; `recordZapReceipt` (one statement: `INSERT nostr_zap_receipt ON CONFLICT DO NOTHING` plus `UPDATE message.sats`); `recordInvoiceAttempt` / `listInvoiceAttempts`; `recordZapIngest` / `listZapIngests`. - **Inputs:** Constructor takes a shared boot `SqlClient` (already migrated). - **Returns / side effects:** Parameter-bound SQL; maps snake_case rows to `MessageRow` / `ForumPhoto` / invoice and ingest rows. Claim uses `FOR UPDATE SKIP LOCKED`. Errors propagate to the route (503) except invoice/ingest persist failures which are caught by callers. - **Used by:** `openBootStores` when `DATABASE_URL` is set. @@ -366,7 +366,7 @@ ## Function: InMemoryMessageStore -- **Purpose:** Process-local `MessageStore` for the public member forum. Default empty so the process boots without a database. Photos live in a private map, not on listed rows. Same port as Postgres: `getById`, `getByEventId`, claim/sign/publish, `listPendingSigned` (pending, no `t=bitcoin`, oldest-first), `clearSignedEvent` (pending and `eventId` still matches `expectedEventId`, then nulls `eventId` / `nostrEvent` / `claimedUntil`), `listSignedMissingPhoto` (published + photo, kind:1 content lacks `/messages/:id/photo.` plus extension, oldest-first, `sats === 0`, pending excluded), `listSignedMissingHashtags` (published unpaid, kind:1 content lacks `#bitcoin` or `#21gifts`, oldest-first, `sats === 0`, pending excluded so fan-out is not starved), `resetSignedEvent` (nulls `eventId` / `nostrEvent` / `claimedUntil`, parks `pending`, no-op unless `eventId` still matches and `sats` is 0), `addSats`, `recordZapReceipt` (duplicate receipt id does not add sats), `recordInvoiceAttempt` / `listInvoiceAttempts`, `recordZapIngest` / `listZapIngests`; `updateSignedEvent` returns false on duplicate `eventId`. Store/HTTP order is newest-first; product UX is a messenger group (clients reverse). +- **Purpose:** Process-local `MessageStore` for the public member forum. Default empty so the process boots without a database. Photos live in a private map, not on listed rows. Same port as Postgres: `getById`, `getByEventId`, claim/sign/publish (`claimUnsigned` is pending + null `eventId`; lease expires at `claimedUntil`), `listPendingSigned` (pending, no `t=bitcoin`, oldest-first), `clearSignedEvent` (pending and `eventId` still matches `expectedEventId`, then nulls `eventId` / `nostrEvent` / `claimedUntil`), `listSignedMissingPhoto` (published + photo, kind:1 content lacks `/messages/:id/photo.` plus extension, oldest-first, `sats === 0`, pending excluded), `listSignedMissingHashtags` (published unpaid, kind:1 content lacks a `#bitcoin` or `#21gifts` token, oldest-first, `sats === 0`, pending excluded so fan-out is not starved), `resetSignedEvent` (nulls `eventId` / `nostrEvent` / `claimedUntil`, parks `pending`, no-op unless `eventId` still matches and `sats` is 0), `addSats`, `recordZapReceipt` (duplicate receipt id does not add sats), `recordInvoiceAttempt` / `listInvoiceAttempts`, `recordZapIngest` / `listZapIngests`; `updateSignedEvent` returns false on duplicate `eventId`. Store/HTTP order is newest-first; product UX is a messenger group (clients reverse). - **Inputs:** Optional seed `MessageRow[]` (copied; `hasPhoto` defaults false). `listLatest(limit)` sorts newest `createdAt` then `id` DESC and caps at `limit`. `create(row, photo?)` appends a copy; `getPhoto(id)` returns a photo copy or null. - **Returns / side effects:** Promise of row/photo copies; mutating results does not change the store. Listed objects never expose bytes. No I/O. - **Used by:** `createApp` default `messageStore`. @@ -884,14 +884,14 @@ ## Function: kind1HasHashtag -- **Purpose:** Case-insensitive check that kind:1 content already contains `#name` as a hashtag token (the `#` prefix distinguishes `#21gifts` from `https://21.gifts`). +- **Purpose:** Case-insensitive check that kind:1 content already contains `#name` as a hashtag token (next character must not be `[A-Za-z0-9_]`; the `#` prefix distinguishes `#21gifts` from `https://21.gifts`). - **Inputs:** content string, hashtag name without `#`. -- **Returns / side effects:** boolean. +- **Returns / side effects:** True when the token is present; otherwise false. - **Used by:** `kind1ContentWithHashtags`. ## Function: kind1ContentWithHashtags -- **Purpose:** Append any missing Damus-visible `#bitcoin` / `#21gifts` to Nostr kind:1 content (forum DB `text` stays unchanged). Empty → `"#bitcoin #21gifts"`; non-empty strips trailing newlines then appends `\n\n` + missing tags in fixed order; already-present tags (any case) are not duplicated. +- **Purpose:** Append any missing Damus-visible `#bitcoin` / `#21gifts` tokens to Nostr kind:1 content (forum DB `text` stays unchanged). Empty → `"#bitcoin #21gifts"`; non-empty strips trailing newlines then appends `\n\n` + missing tags in fixed order; a tag is present when `kind1HasHashtag` matches (`#bitcoiners` is not `#bitcoin`). - **Inputs:** content string. - **Returns / side effects:** content with missing hashtags appended. - **Used by:** `buildKind1Event`; `listSignedMissingHashtags` (in-memory helper). @@ -905,7 +905,7 @@ ## Function: buildKind1Event -- **Purpose:** Unsigned top-level kind:1 for a forum line. Optional photo appends the public image URL to content and a NIP-92 `imeta` tag. Always appends Damus-visible `#bitcoin` / `#21gifts` via `kind1ContentWithHashtags` (forum row `text` is not modified). +- **Purpose:** Unsigned top-level kind:1 for a forum line. Optional photo appends the public image URL to content and a NIP-92 `imeta` tag. Always ensures Damus-visible `#bitcoin` / `#21gifts` via `kind1ContentWithHashtags`, appending only missing tokens (forum row `text` is not modified). - **Inputs:** content, unix created_at, optional `{ url, mime }`. - **Returns / side effects:** Unsigned fields. - **Used by:** Worker sign path. diff --git a/src/__tests__/lib/message-store.test.ts b/src/__tests__/lib/message-store.test.ts index 22d4e91e..3bb3aae0 100644 --- a/src/__tests__/lib/message-store.test.ts +++ b/src/__tests__/lib/message-store.test.ts @@ -197,6 +197,20 @@ describe('InMemoryMessageStore', () => { expect(one).toHaveLength(1); }); + it('reclaims an unsigned row at the exact lease expiry', async () => { + const store = new InMemoryMessageStore(); + await store.create(EARLY); + expect((await store.claimUnsigned(10, 1_000, 60_000)).map((row) => row.id)).toEqual(['a']); + expect(await store.claimUnsigned(10, 60_999, 60_000)).toEqual([]); + expect((await store.claimUnsigned(10, 61_000, 60_000)).map((row) => row.id)).toEqual(['a']); + }); + + it('claimUnsigned skips published rows even when eventId is null', async () => { + const store = new InMemoryMessageStore(); + await store.create({ ...EARLY, nostrPublishState: 'published' }); + expect(await store.claimUnsigned(10, 1_000, 60_000)).toEqual([]); + }); + it('getByEventId returns the row for a stored eventId and undefined when missing', async () => { const store = new InMemoryMessageStore(); const eventId = 'ee'.repeat(32); @@ -402,11 +416,20 @@ describe('InMemoryMessageStore', () => { eventId: '66'.repeat(32), nostrEvent: { content: 'pending without hashtags' }, }); + await store.create({ + ...EARLY, + id: 'prefix', + createdAt: new Date('2026-08-16T00:00:00.000Z'), + eventId: '77'.repeat(32), + nostrEvent: { content: 'hello #bitcoiners' }, + }); + await store.updatePublishState('prefix', 'published', 'space'); expect((await store.listSignedMissingHashtags(10)).map((row) => row.id)).toEqual([ 'n', 'a', 'p', 'q', + 'prefix', 'z', ]); expect((await store.listSignedMissingHashtags(2)).map((row) => row.id)).toEqual(['n', 'a']); @@ -416,6 +439,7 @@ describe('InMemoryMessageStore', () => { 'a', 'p', 'q', + 'prefix', ]); await store.resetSignedEvent('a', 'ab'.repeat(32)); expect((await store.getById('a'))?.eventId).toBeNull(); @@ -427,6 +451,7 @@ describe('InMemoryMessageStore', () => { 'n', 'p', 'q', + 'prefix', ]); }); @@ -764,6 +789,7 @@ describe('PostgresMessageStore', () => { sql.nextRows = []; expect(await store.claimUnsigned(5, 1_000, 60_000)).toEqual([]); expect(await store.claimUnpublished(5, 1_000, 60_000)).toEqual([]); + expect(sql.queries.some((q) => /claimed_until <= \$2/.test(q.text))).toBe(true); expect(await store.updateSignedEvent('m1', 'ee'.repeat(32), { id: 'x' })).toBe(false); await store.updatePublishState('m1', 'published', 'public'); await store.addSats('m1', 7); @@ -936,8 +962,8 @@ describe('PostgresMessageStore', () => { expect(listSql).toMatch(/sats = 0/); expect(listSql).toMatch(/nostr_publish_state = 'published'/); expect(listSql).toMatch(/jsonb_typeof\(nostr_event->'content'\) IS DISTINCT FROM 'string'/); - expect(listSql).toMatch(/NOT LIKE '%#21gifts%'/); - expect(listSql).toMatch(/NOT LIKE '%#bitcoin%'/); + expect(listSql).toContain('#21gifts([^a-z0-9_]|$)'); + expect(listSql).toContain('#bitcoin([^a-z0-9_]|$)'); expect(listSql).toMatch(/ORDER BY created_at ASC,\s*id ASC/); }); diff --git a/src/__tests__/lib/nostr/event.test.ts b/src/__tests__/lib/nostr/event.test.ts index ce74c58d..fcc386d0 100644 --- a/src/__tests__/lib/nostr/event.test.ts +++ b/src/__tests__/lib/nostr/event.test.ts @@ -64,6 +64,19 @@ describe('kind1', () => { expect(kind1ContentWithHashtags('hello #21Gifts')).toBe('hello #21Gifts\n\n#bitcoin'); expect(kind1HasHashtag('note #Bitcoin here', 'bitcoin')).toBe(true); }); + + it('does not treat #bitcoiners or #21giftshop as the Damus tokens', () => { + expect(kind1HasHashtag('hello #bitcoiners', 'bitcoin')).toBe(false); + expect(kind1HasHashtag('shop #21giftshop', '21gifts')).toBe(false); + expect(kind1ContentWithHashtags('hello #bitcoiners')).toBe( + 'hello #bitcoiners\n\n#bitcoin #21gifts', + ); + expect(kind1ContentWithHashtags('shop #21giftshop')).toBe( + 'shop #21giftshop\n\n#bitcoin #21gifts', + ); + expect(kind1HasHashtag('#bitcoin.', 'bitcoin')).toBe(true); + expect(kind1HasHashtag('#21gifts', '21gifts')).toBe(true); + }); }); describe('kind0', () => { diff --git a/src/lib/message-store.ts b/src/lib/message-store.ts index e8871c91..fd50715c 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -122,11 +122,12 @@ export interface MessageStore { listSignedMissingPhoto(limit: number): Promise; /** - * Published rows whose kind:1 content lacks `#21gifts` or `#bitcoin` (case-insensitive). - * `sats = 0` only (zapped rows keep their event id). Pending rows are left - * for fan-out — resetting them renews the sign lease and they never EVENT. - * Oldest `createdAt` then `id` first. Includes `nostrEvent === null` and - * non-string content. + * Published rows whose kind:1 content lacks a `#21gifts` or `#bitcoin` token + * (case-insensitive; next character must not be `[A-Za-z0-9_]`, so + * `#bitcoiners` still lacks `#bitcoin`). `sats = 0` only (zapped rows keep + * their event id). Pending rows are left for fan-out — resetting them + * renews the sign lease and they never EVENT. Oldest `createdAt` then `id` + * first. Includes `nostrEvent === null` and non-string content. * * @param limit - Max rows. */ @@ -424,7 +425,14 @@ export class InMemoryMessageStore implements MessageStore { } claimUnsigned(limit: number, nowMs: number, leaseMs: number): Promise { - return Promise.resolve(this.#claim((row) => row.eventId === null, limit, nowMs, leaseMs)); + return Promise.resolve( + this.#claim( + (row) => row.eventId === null && row.nostrPublishState === 'pending', + limit, + nowMs, + leaseMs, + ), + ); } claimUnpublished(limit: number, nowMs: number, leaseMs: number): Promise { @@ -779,7 +787,7 @@ export class PostgresMessageStore implements MessageStore { WHERE id IN ( SELECT id FROM message WHERE event_id IS NULL AND nostr_publish_state = 'pending' - AND (claimed_until IS NULL OR claimed_until < $2) + AND (claimed_until IS NULL OR claimed_until <= $2) ORDER BY created_at ASC, id ASC LIMIT $3 FOR UPDATE SKIP LOCKED @@ -797,7 +805,7 @@ export class PostgresMessageStore implements MessageStore { WHERE id IN ( SELECT id FROM message WHERE event_id IS NOT NULL AND nostr_publish_state = 'pending' - AND (claimed_until IS NULL OR claimed_until < $2) + AND (claimed_until IS NULL OR claimed_until <= $2) ORDER BY created_at ASC, id ASC LIMIT $3 FOR UPDATE SKIP LOCKED @@ -868,8 +876,8 @@ export class PostgresMessageStore implements MessageStore { AND ( nostr_event IS NULL OR jsonb_typeof(nostr_event->'content') IS DISTINCT FROM 'string' - OR LOWER(COALESCE(nostr_event->>'content', '')) NOT LIKE '%#21gifts%' - OR LOWER(COALESCE(nostr_event->>'content', '')) NOT LIKE '%#bitcoin%' + OR NOT (LOWER(COALESCE(nostr_event->>'content', '')) ~ '#21gifts([^a-z0-9_]|$)') + OR NOT (LOWER(COALESCE(nostr_event->>'content', '')) ~ '#bitcoin([^a-z0-9_]|$)') ) ORDER BY created_at ASC, id ASC LIMIT $1`, diff --git a/src/lib/nostr/event.ts b/src/lib/nostr/event.ts index 2cdc4b85..9309b12e 100644 --- a/src/lib/nostr/event.ts +++ b/src/lib/nostr/event.ts @@ -74,13 +74,31 @@ export function kind1Tags(): string[][] { } /** - * True when `content` already contains `#name` as a hashtag (case-insensitive). + * True when `content` already contains `#name` as a hashtag token + * (case-insensitive). The next character must not be `[A-Za-z0-9_]`, so + * `#bitcoiners` is not `#bitcoin`. The `#` prefix distinguishes `#21gifts` + * from `https://21.gifts`. * * @param content - Kind:1 content body. * @param name - Hashtag name without `#` (e.g. `bitcoin`). + * @returns True when content contains the requested hashtag token; otherwise false. */ export function kind1HasHashtag(content: string, name: string): boolean { - return content.toLowerCase().includes(`#${name.toLowerCase()}`); + const needle = `#${name.toLowerCase()}`; + const lower = content.toLowerCase(); + let from = 0; + while (from < lower.length) { + const index = lower.indexOf(needle, from); + if (index === -1) { + return false; + } + const after = lower[index + needle.length]; + if (after === undefined || !/[a-z0-9_]/.test(after)) { + return true; + } + from = index + 1; + } + return false; } /** @@ -92,7 +110,7 @@ export function kind1HasHashtag(content: string, name: string): boolean { * Already-present tags (any case, e.g. `#21Gifts`) are not duplicated; only missing ones are appended, still in KIND1_CONTENT_HASHTAGS order. * * @param content - Forum text and optional photo URL already composed. - * @returns Content with any missing hashtags appended. + * @returns Content with any missing hashtag tokens appended (unchanged when both are already present). */ export function kind1ContentWithHashtags(content: string): string { const missing = KIND1_CONTENT_HASHTAGS.filter((tag) => !kind1HasHashtag(content, tag.slice(1))); @@ -121,8 +139,9 @@ export interface UnsignedKind1 { /** * Build an unsigned top-level kind:1 for a forum message. * - * Content is plaintext (no name prefix) plus Damus-visible `#bitcoin` / - * `#21gifts`. Tags are frozen — no `e`/`p`/`q`. + * Content is plaintext (no name prefix). `kind1ContentWithHashtags` ensures + * Damus-visible `#bitcoin` / `#21gifts` tokens (appends only missing ones). + * Tags are frozen — no `e`/`p`/`q`. * * @param content - Already-normalised forum text (may be empty when `photo` is set). * @param createdAtUnix - Unix seconds for the event. From 1509037aaf771f89c4cfc69e7e8fc2489a864060 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Sun, 30 Aug 2026 18:58:11 +0200 Subject: [PATCH 2/2] Skip already-delivered Web Push endpoints on outbox retry (#80) One outbox row still notifies an account. Successful endpoints are recorded so a mixed-device retry does not send the same payload twice. Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- CONTRIBUTING.md | 2 +- FLOWS.md | 5 +- docs/handbook/functions.md | 14 ++-- docs/schema/db_change.sql | 4 +- docs/schema/push.sql | 4 +- src/__tests__/lib/db-change.test.ts | 1 + src/__tests__/lib/push-store.test.ts | 109 ++++++++++++++++++++++++- src/__tests__/lib/push-worker.test.ts | 57 ++++++++++++- src/lib/db-change.ts | 2 +- src/lib/push-store.ts | 111 +++++++++++++++++++++++++- src/lib/push-worker.ts | 15 ++++ 11 files changed, 304 insertions(+), 20 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad706435..bede2d7f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -319,7 +319,7 @@ gap. Reviewers enforce this; `migrateDbChangeSchema` in `src/lib/db-change.ts` / still match a live `account.view_key`, then recreates it. Rows whose key no longer matches a live account are left unchanged. - In the stored JSON, secret columns `token`, `challenge`, `nostr_nsec_ciphertext`, - `nonce`, `view_key`, `endpoint`, `p256dh`, and `auth` are SHA-256 hex of the column text. All other columns, including + `nonce`, `view_key`, `endpoint`, `p256dh`, `auth`, and `delivered_endpoints` are SHA-256 hex of the column text. All other columns, including `name`, stay plaintext. Do not omit those secret keys from the JSON (rotation **must** still be visible as a hash change). - Compare OLD vs NEW **before** redaction diff --git a/FLOWS.md b/FLOWS.md index fee1bf62..4b3bceea 100644 --- a/FLOWS.md +++ b/FLOWS.md @@ -191,8 +191,9 @@ The api enqueues (does not send inline): - a **forum** payload when someone else posts (`tag: forum`) - a **zap** payload when a zap receipt is newly indexed onto the author's note -The worker sends when VAPID is configured. Open focused tabs skip a second -banner (service worker). Do not invent preference HTTP in v1. +The worker sends when VAPID is configured. On outbox retry it does not re-send +an endpoint that already succeeded for that outbox row. Open focused tabs skip +a second banner (service worker). Do not invent preference HTTP in v1. HTTP cited: `/push/vapid-public`, `/me/push-subscriptions`, `/debug/push-ping`. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index ad44db0e..7f068ec3 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -121,7 +121,7 @@ ## Function: migratePushSchema -- **Purpose:** Applies `PUSH_SCHEMA_SQL` in order (`CREATE TABLE IF NOT EXISTS` for `push_subscription` and `push_outbox` plus supporting indexes). +- **Purpose:** Applies `PUSH_SCHEMA_SQL` in order (`CREATE TABLE IF NOT EXISTS` for `push_subscription` and `push_outbox` with `delivered_endpoints`, supporting indexes, then `ALTER TABLE … ADD COLUMN IF NOT EXISTS delivered_endpoints`). - **Inputs:** `SqlClient` already opened by boot. - **Returns / side effects:** Void; idempotent DDL matching `docs/schema/push.sql`. Does not attach `db_change` triggers (that runs later via `migrateDbChangeSchema`). - **Used by:** `openBootStores` when SQL opens, after `migrateContactSchema` and before `migrateDbChangeSchema`. @@ -137,7 +137,7 @@ - **Purpose:** Ordered idempotent SQL that creates the append-only `db_change` log, secret-redacting helpers, immutability guard (including a one-time live `view_key` rewrite in that same `DO`), and per-table `trg_db_change` triggers on every public table except `db_change`. - **Inputs:** None (readonly string array constant). -- **Returns / side effects:** Statement texts only; executed by `migrateDbChangeSchema`. Secrets `token`, `challenge`, `nostr_nsec_ciphertext`, `nonce`, `view_key`, `endpoint`, `p256dh`, and `auth` become SHA-256 hex in logged JSON; other columns including `name` stay plaintext. The guard `DO` hashes JSON `view_key` that still equals a live `account.view_key` and leaves other rows unchanged. +- **Returns / side effects:** Statement texts only; executed by `migrateDbChangeSchema`. Secrets `token`, `challenge`, `nostr_nsec_ciphertext`, `nonce`, `view_key`, `endpoint`, `p256dh`, `auth`, and `delivered_endpoints` become SHA-256 hex in logged JSON; other columns including `name` stay plaintext. The guard `DO` hashes JSON `view_key` that still equals a live `account.view_key` and leaves other rows unchanged. - **Used by:** `migrateDbChangeSchema`; documented mirror in `docs/schema/db_change.sql`. ## Function: InMemoryBtcUsdStore @@ -283,15 +283,15 @@ ## Function: InMemoryPushStore - **Purpose:** Process-local `PushStore` for Web Push subscriptions and the outbox. Default empty so the process boots without a database. -- **Inputs:** Constructor none. Methods match `PushStore` (`upsertSubscription` keeps original `createdAt` on endpoint conflict; `claimPending` leases oldest pending; `markFailed` fails at 8 attempts). -- **Returns / side effects:** Caller-owned copies; mutating results does not change the store. No I/O. +- **Inputs:** Constructor none. Methods match `PushStore` (`upsertSubscription` keeps original `createdAt` on endpoint conflict; `claimPending` leases oldest pending; `markFailed` fails at 8 attempts; `recordDelivered` unions unique endpoint URLs onto the outbox row). +- **Returns / side effects:** Caller-owned copies including `deliveredEndpoints` slices; mutating results does not change the store. No I/O. - **Used by:** `createApp` default `pushStore`; memory `src/index.ts` when boot omits SQL push. ## Function: PostgresPushStore -- **Purpose:** Durable `PushStore` over Postgres (`push_subscription`, `push_outbox`). Same port semantics as the in-memory adapter, including claim leases and attempt counting. +- **Purpose:** Durable `PushStore` over Postgres (`push_subscription`, `push_outbox`). Same port semantics as the in-memory adapter, including claim leases, attempt counting, and `recordDelivered` for successful endpoint URLs. - **Inputs:** Constructor takes a shared boot `SqlClient` (already migrated via `migratePushSchema`). -- **Returns / side effects:** Parameter-bound SQL; maps snake_case rows to domain objects. Errors propagate to callers. +- **Returns / side effects:** Parameter-bound SQL; maps snake_case rows to domain objects including `delivered_endpoints` JSON. Errors propagate to callers. - **Used by:** `openBootStores` when `DATABASE_URL` is set. ## Function: enqueueForumPushes @@ -319,7 +319,7 @@ - **Purpose:** Claim a batch of pending outbox rows and deliver each payload to every subscription for the recipient account. - **Inputs:** `PushWorkerDeps` (`store`, `sender`, `now`). Batch size and lease from module constants. -- **Returns / side effects:** No-op when `sender.isConfigured()` is false. Deletes gone subscriptions; `markFailed` on fail; `markSent` when all gone / any ok / no subs. +- **Returns / side effects:** No-op when `sender.isConfigured()` is false. Records successful endpoints via `recordDelivered` and does not resend them on retry; deletes gone subscriptions without recording them; `markFailed` on fail after recording successes; `markSent` when remaining sends succeed / all gone / no subs left to try. - **Used by:** `startPushWorker` interval; unit tests. ## Function: startPushWorker diff --git a/docs/schema/db_change.sql b/docs/schema/db_change.sql index 9f75e266..4fd08529 100644 --- a/docs/schema/db_change.sql +++ b/docs/schema/db_change.sql @@ -1,7 +1,7 @@ -- Append-only row-change log. AFTER INSERT/UPDATE/DELETE triggers on every -- public table (except db_change) write redacted before/after JSON. Secret -- columns token, challenge, nostr_nsec_ciphertext, nonce, view_key, endpoint, --- p256dh, and auth are stored as SHA-256 hex; other columns including name stay +-- p256dh, auth, and delivered_endpoints are stored as SHA-256 hex; other columns including name stay -- plaintext. The log itself -- rejects UPDATE, DELETE, and TRUNCATE at runtime. migrateDbChangeSchema may -- drop that trigger once to hash view_key values that still match a live @@ -32,7 +32,7 @@ BEGIN IF j IS NULL THEN RETURN NULL; END IF; - FOREACH k IN ARRAY ARRAY['token', 'challenge', 'nostr_nsec_ciphertext', 'nonce', 'view_key', 'endpoint', 'p256dh', 'auth'] + FOREACH k IN ARRAY ARRAY['token', 'challenge', 'nostr_nsec_ciphertext', 'nonce', 'view_key', 'endpoint', 'p256dh', 'auth', 'delivered_endpoints'] LOOP IF outj ? k AND jsonb_typeof(outj -> k) IS DISTINCT FROM 'null' THEN outj := jsonb_set( diff --git a/docs/schema/push.sql b/docs/schema/push.sql index d140be88..c470bf54 100644 --- a/docs/schema/push.sql +++ b/docs/schema/push.sql @@ -20,6 +20,8 @@ CREATE TABLE IF NOT EXISTS push_outbox ( status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')), attempts integer NOT NULL DEFAULT 0, claimed_until timestamptz, - created_at timestamptz NOT NULL + created_at timestamptz NOT NULL, + delivered_endpoints text NOT NULL DEFAULT '[]' ); CREATE INDEX IF NOT EXISTS push_outbox_pending_idx ON push_outbox (created_at, id) WHERE status = 'pending'; +ALTER TABLE push_outbox ADD COLUMN IF NOT EXISTS delivered_endpoints text NOT NULL DEFAULT '[]'; diff --git a/src/__tests__/lib/db-change.test.ts b/src/__tests__/lib/db-change.test.ts index 77b1314a..ddde2b4e 100644 --- a/src/__tests__/lib/db-change.test.ts +++ b/src/__tests__/lib/db-change.test.ts @@ -38,6 +38,7 @@ describe('DB_CHANGE_SCHEMA_SQL', () => { expect(joined).toMatch(/endpoint/); expect(joined).toMatch(/p256dh/); expect(joined).toMatch(/auth/); + expect(joined).toMatch(/delivered_endpoints/); const guardBlock = joined.slice(joined.indexOf('$guard$')); const dropAt = guardBlock.indexOf('DROP TRIGGER IF EXISTS db_change_immutable'); const afterMatchAt = guardBlock.indexOf("a.view_key = d.after ->> 'view_key'"); diff --git a/src/__tests__/lib/push-store.test.ts b/src/__tests__/lib/push-store.test.ts index aa7a71b0..2922f45c 100644 --- a/src/__tests__/lib/push-store.test.ts +++ b/src/__tests__/lib/push-store.test.ts @@ -51,17 +51,20 @@ function pending( attempts: 0, claimedUntil: null, createdAt: new Date('2026-08-01T00:00:00.000Z'), + deliveredEndpoints: [], ...overrides, }; } describe('PUSH_SCHEMA_SQL', () => { it('creates push_subscription and push_outbox with indexes', () => { - expect(PUSH_SCHEMA_SQL).toHaveLength(4); + expect(PUSH_SCHEMA_SQL).toHaveLength(5); expect(PUSH_SCHEMA_SQL[0]).toMatch(/CREATE TABLE IF NOT EXISTS push_subscription/i); expect(PUSH_SCHEMA_SQL[1]).toMatch(/push_subscription_account_id_idx/i); expect(PUSH_SCHEMA_SQL[2]).toMatch(/CREATE TABLE IF NOT EXISTS push_outbox/i); + expect(PUSH_SCHEMA_SQL[2]).toMatch(/delivered_endpoints/); expect(PUSH_SCHEMA_SQL[3]).toMatch(/push_outbox_pending_idx/i); + expect(PUSH_SCHEMA_SQL[4]).toMatch(/ADD COLUMN IF NOT EXISTS delivered_endpoints/i); }); }); @@ -193,6 +196,26 @@ describe('InMemoryPushStore', () => { await store.markFailed('y'); expect(await store.claimPending(10, 1, 1000)).toEqual([]); }); + + it('recordDelivered unions unique endpoints and isolates claimed copies', async () => { + const store = new InMemoryPushStore(); + await store.enqueue(pending({ id: 'o', accountId: 'a' })); + await store.recordDelivered('missing', ['https://push.example/x']); + await store.recordDelivered('o', ['https://push.example/a', 'https://push.example/a', '']); + await store.recordDelivered('o', ['https://push.example/b']); + const claimed = await store.claimPending(10, 1, 1000); + expect(claimed[0]?.deliveredEndpoints).toEqual([ + 'https://push.example/a', + 'https://push.example/b', + ]); + claimed[0]?.deliveredEndpoints.push('https://push.example/mutated'); + await store.markFailed('o'); + const again = await store.claimPending(10, 1, 1000); + expect(again[0]?.deliveredEndpoints).toEqual([ + 'https://push.example/a', + 'https://push.example/b', + ]); + }); }); describe('PostgresPushStore', () => { @@ -260,6 +283,8 @@ describe('PostgresPushStore', () => { await store.enqueue(pending({ id: 'o1', accountId: 'acc-a' })); expect(sql.executes.at(-1)?.text).toMatch(/INSERT INTO push_outbox/i); + expect(sql.executes.at(-1)?.text).toMatch(/delivered_endpoints/); + expect(sql.executes.at(-1)?.params.at(-1)).toBe('[]'); sql.nextRows = [ { @@ -272,10 +297,12 @@ describe('PostgresPushStore', () => { attempts: 0, claimed_until: '2026-08-03T00:01:00.000Z', created_at: '2026-08-01T00:00:00.000Z', + delivered_endpoints: '[]', }, ]; const claimed = await store.claimPending(5, Date.parse('2026-08-03T00:00:00.000Z'), 60_000); expect(claimed[0]?.claimedUntil).toBeInstanceOf(Date); + expect(claimed[0]?.deliveredEndpoints).toEqual([]); expect(sql.queries.at(-1)?.text).toMatch(/FOR UPDATE SKIP LOCKED/); await store.markSent('o1'); @@ -284,6 +311,23 @@ describe('PostgresPushStore', () => { expect(sql.executes.at(-1)?.text).toMatch(/attempts = attempts \+ 1/); }); + it('recordDelivered selects and updates delivered_endpoints; unknown id is a no-op', async () => { + const sql = new MockSql(); + const store = new PostgresPushStore(sql); + sql.nextRows = []; + await store.recordDelivered('missing', ['https://push.example/a']); + expect(sql.queries.at(-1)?.text).toMatch(/delivered_endpoints/); + expect(sql.executes).toHaveLength(0); + + sql.nextRows = [{ delivered_endpoints: '["https://push.example/a"]' }]; + await store.recordDelivered('o1', ['https://push.example/a', 'https://push.example/b']); + expect(sql.queries.at(-1)?.text).toMatch(/delivered_endpoints/); + expect(sql.executes.at(-1)?.text).toMatch(/delivered_endpoints/); + expect(sql.executes.at(-1)?.params[0]).toBe( + JSON.stringify(['https://push.example/a', 'https://push.example/b']), + ); + }); + it('throws when upsert RETURNING is empty', async () => { const sql = new MockSql(); const store = new PostgresPushStore(sql); @@ -305,12 +349,14 @@ describe('PostgresPushStore', () => { attempts: 1, claimed_until: null, created_at: new Date('2026-08-01T00:00:00.000Z'), + delivered_endpoints: null, }, ]; const claimed = await store.claimPending(1, 1, 1000); expect(claimed[0]?.type).toBe('forum'); expect(claimed[0]?.status).toBe('pending'); expect(claimed[0]?.claimedUntil).toBeNull(); + expect(claimed[0]?.deliveredEndpoints).toEqual([]); }); it('maps a Date claimed_until from Postgres without wrapping twice', async () => { @@ -328,10 +374,71 @@ describe('PostgresPushStore', () => { attempts: 0, claimed_until: until, created_at: new Date('2026-08-01T00:00:00.000Z'), + delivered_endpoints: '["https://push.example/a"]', }, ]; const claimed = await store.claimPending(1, 1, 1000); expect(claimed[0]?.claimedUntil).toBeInstanceOf(Date); expect(claimed[0]?.claimedUntil?.toISOString()).toBe(until.toISOString()); + expect(claimed[0]?.deliveredEndpoints).toEqual(['https://push.example/a']); + }); + + it('maps invalid delivered_endpoints JSON to an empty array', async () => { + const sql = new MockSql(); + const store = new PostgresPushStore(sql); + sql.nextRows = [ + { + id: 'o4', + account_id: 'acc-a', + type: 'forum', + message_id: 'msg', + payload: '{}', + status: 'pending', + attempts: 0, + claimed_until: null, + created_at: new Date('2026-08-01T00:00:00.000Z'), + delivered_endpoints: 'not-json', + }, + ]; + const claimed = await store.claimPending(1, 1, 1000); + expect(claimed[0]?.deliveredEndpoints).toEqual([]); + }); + + it('maps non-array and dirty delivered_endpoints entries via parse rules', async () => { + const sql = new MockSql(); + const store = new PostgresPushStore(sql); + sql.nextRows = [ + { + id: 'o5', + account_id: 'acc-a', + type: 'forum', + message_id: 'msg', + payload: '{}', + status: 'pending', + attempts: 0, + claimed_until: null, + created_at: new Date('2026-08-01T00:00:00.000Z'), + delivered_endpoints: '{}', + }, + ]; + expect((await store.claimPending(1, 1, 1000))[0]?.deliveredEndpoints).toEqual([]); + + sql.nextRows = [ + { + id: 'o6', + account_id: 'acc-a', + type: 'forum', + message_id: 'msg', + payload: '{}', + status: 'pending', + attempts: 0, + claimed_until: null, + created_at: new Date('2026-08-01T00:00:00.000Z'), + delivered_endpoints: '[1,"","https://push.example/a","https://push.example/a",null]', + }, + ]; + expect((await store.claimPending(1, 1, 1000))[0]?.deliveredEndpoints).toEqual([ + 'https://push.example/a', + ]); }); }); diff --git a/src/__tests__/lib/push-worker.test.ts b/src/__tests__/lib/push-worker.test.ts index cb048605..c03c8e7b 100644 --- a/src/__tests__/lib/push-worker.test.ts +++ b/src/__tests__/lib/push-worker.test.ts @@ -71,6 +71,7 @@ describe('enqueueForumPushes', () => { expect(claimed).toHaveLength(1); expect(claimed[0]?.accountId).toBe('other'); expect(claimed[0]?.type).toBe('forum'); + expect(claimed[0]?.deliveredEndpoints).toEqual([]); expect(JSON.parse(claimed[0]?.payload ?? '{}')).toMatchObject({ type: 'forum', tag: 'forum' }); }); @@ -90,6 +91,7 @@ describe('enqueueZapPush', () => { const claimed = await store.claimPending(10, 5, 1000); expect(claimed).toHaveLength(1); expect(claimed[0]?.type).toBe('zap'); + expect(claimed[0]?.deliveredEndpoints).toEqual([]); expect(JSON.parse(claimed[0]?.payload ?? '{}').tag).toBe('zap:msg-9'); }); @@ -108,6 +110,7 @@ describe('enqueueDebugPush', () => { expect(await enqueueDebugPush(store, 'author', 2)).toBe(1); const claimed = await store.claimPending(10, 2, 1000); expect(claimed[0]?.messageId).toBeNull(); + expect(claimed[0]?.deliveredEndpoints).toEqual([]); expect(JSON.parse(claimed[0]?.payload ?? '{}')).toMatchObject({ type: 'zap', tag: 'debug', @@ -139,6 +142,7 @@ describe('runPushWorkerTick', () => { attempts: 0, claimedUntil: null, createdAt: new Date(1), + deliveredEndpoints: [], }; await store.enqueue(row); const sender = new FakeSender(true); @@ -177,7 +181,9 @@ describe('runPushWorkerTick', () => { await enqueueForumPushes(store, 'author', 'm', 1); const sender = new FakeSender(true, [{ ok: true }, { ok: false, reason: 'gone' }]); await runPushWorkerTick({ store, sender, now: () => 1 }); - expect(await store.listByAccount('other')).toHaveLength(1); + const remaining = await store.listByAccount('other'); + expect(remaining).toHaveLength(1); + expect(remaining[0]?.endpoint).toBe('https://push.example/b'); expect(await store.claimPending(10, 1, 1000)).toEqual([]); }); @@ -190,6 +196,55 @@ describe('runPushWorkerTick', () => { const again = await store.claimPending(10, 1, 1000); expect(again[0]?.attempts).toBe(1); }); + + it('skips already-delivered endpoints on mixed-device retry', async () => { + const store = new InMemoryPushStore(); + await store.upsertSubscription({ + ...SUB_B, + endpoint: 'https://push.example/a', + }); + await store.upsertSubscription({ + ...SUB_B, + endpoint: 'https://push.example/b', + }); + await enqueueForumPushes(store, 'author', 'm', 1); + const sender = new FakeSender(true, [{ ok: true }, { ok: false, reason: 'fail' }]); + await runPushWorkerTick({ store, sender, now: () => 1 }); + expect(sender.calls).toHaveLength(2); + const afterFail = await store.claimPending(10, 1, 1000); + expect(afterFail).toHaveLength(1); + expect(afterFail[0]?.attempts).toBe(1); + expect(afterFail[0]?.deliveredEndpoints).toEqual(['https://push.example/a']); + + sender.results = [{ ok: true }]; + // Previous claimPending held a lease; advance past it so the retry can claim. + await runPushWorkerTick({ store, sender, now: () => 2_000 }); + expect(sender.calls).toHaveLength(3); + expect(sender.calls[2]?.endpoint).toBe('https://push.example/b'); + expect(await store.claimPending(10, 2_000, 1000)).toEqual([]); + }); + + it('marks sent without sending when every remaining subscription was already delivered', async () => { + const store = new InMemoryPushStore(); + await store.upsertSubscription({ + ...SUB_B, + endpoint: 'https://push.example/a', + }); + await store.upsertSubscription({ + ...SUB_B, + endpoint: 'https://push.example/b', + }); + await enqueueForumPushes(store, 'author', 'm', 1); + const sender = new FakeSender(true, [{ ok: true }, { ok: false, reason: 'fail' }]); + await runPushWorkerTick({ store, sender, now: () => 1 }); + expect(sender.calls).toHaveLength(2); + await store.deleteSubscription('other', 'https://push.example/b'); + const callsBefore = sender.calls.length; + sender.results = [{ ok: true }]; + await runPushWorkerTick({ store, sender, now: () => 2 }); + expect(sender.calls).toHaveLength(callsBefore); + expect(await store.claimPending(10, 2, 1000)).toEqual([]); + }); }); describe('startPushWorker', () => { diff --git a/src/lib/db-change.ts b/src/lib/db-change.ts index 7b9ddccd..52e021b7 100644 --- a/src/lib/db-change.ts +++ b/src/lib/db-change.ts @@ -27,7 +27,7 @@ BEGIN IF j IS NULL THEN RETURN NULL; END IF; - FOREACH k IN ARRAY ARRAY['token', 'challenge', 'nostr_nsec_ciphertext', 'nonce', 'view_key', 'endpoint', 'p256dh', 'auth'] + FOREACH k IN ARRAY ARRAY['token', 'challenge', 'nostr_nsec_ciphertext', 'nonce', 'view_key', 'endpoint', 'p256dh', 'auth', 'delivered_endpoints'] LOOP IF outj ? k AND jsonb_typeof(outj -> k) IS DISTINCT FROM 'null' THEN outj := jsonb_set( diff --git a/src/lib/push-store.ts b/src/lib/push-store.ts index 35295598..b195acdc 100644 --- a/src/lib/push-store.ts +++ b/src/lib/push-store.ts @@ -41,6 +41,8 @@ export interface PushOutboxRow { claimedUntil: Date | null; /** Enqueue time. */ createdAt: Date; + /** Endpoints that already received this payload (url strings). */ + deliveredEndpoints: string[]; } /** @@ -109,6 +111,15 @@ export interface PushStore { * @param id - Outbox id. */ markFailed(id: string): Promise; + + /** + * Union `endpoints` into the stored delivered list for this outbox row. + * Duplicate strings are stored once. Unknown id is a no-op. + * + * @param id - Outbox id. + * @param endpoints - Endpoint URLs that received the payload. + */ + recordDelivered(id: string, endpoints: readonly string[]): Promise; } /** Idempotent DDL for push tables (matches `docs/schema/push.sql`). */ @@ -130,9 +141,11 @@ export const PUSH_SCHEMA_SQL: readonly string[] = [ status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')), attempts integer NOT NULL DEFAULT 0, claimed_until timestamptz, - created_at timestamptz NOT NULL + created_at timestamptz NOT NULL, + delivered_endpoints text NOT NULL DEFAULT '[]' )`, `CREATE INDEX IF NOT EXISTS push_outbox_pending_idx ON push_outbox (created_at, id) WHERE status = 'pending'`, + `ALTER TABLE push_outbox ADD COLUMN IF NOT EXISTS delivered_endpoints text NOT NULL DEFAULT '[]'`, ]; /** @@ -147,6 +160,52 @@ export async function migratePushSchema(sql: SqlClient): Promise { } } +/** Parse `delivered_endpoints` JSON text into unique non-empty strings. */ +function parseDeliveredEndpoints(raw: unknown): string[] { + if (typeof raw !== 'string') { + return []; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(parsed)) { + return []; + } + const out: string[] = []; + const seen = new Set(); + for (const item of parsed) { + if (typeof item !== 'string' || item === '') { + continue; + } + if (seen.has(item)) { + continue; + } + seen.add(item); + out.push(item); + } + return out; +} + +/** Union unique endpoint strings onto an existing list (first-seen order). */ +function unionDeliveredEndpoints( + current: readonly string[], + endpoints: readonly string[], +): string[] { + const out = current.slice(); + const seen = new Set(out); + for (const endpoint of endpoints) { + if (typeof endpoint !== 'string' || endpoint === '' || seen.has(endpoint)) { + continue; + } + seen.add(endpoint); + out.push(endpoint); + } + return out; +} + /** Copy a subscription so callers cannot mutate store state. */ function copySub(row: PushSubscriptionRecord): PushSubscriptionRecord { return { @@ -161,6 +220,7 @@ function copyOutbox(row: PushOutboxRow): PushOutboxRow { ...row, createdAt: new Date(row.createdAt.getTime()), claimedUntil: row.claimedUntil === null ? null : new Date(row.claimedUntil.getTime()), + deliveredEndpoints: row.deliveredEndpoints.slice(), }; } @@ -318,6 +378,21 @@ export class InMemoryPushStore implements PushStore { } return Promise.resolve(); } + + /** + * Union unique endpoints onto the in-memory delivered list. + * + * @param id - Outbox id. + * @param endpoints - Newly delivered endpoint URLs. + */ + recordDelivered(id: string, endpoints: readonly string[]): Promise { + const row = this.#outbox.find((item) => item.id === id); + if (row === undefined) { + return Promise.resolve(); + } + row.deliveredEndpoints = unionDeliveredEndpoints(row.deliveredEndpoints, endpoints); + return Promise.resolve(); + } } /** Row shape selected from `push_subscription`. */ @@ -340,6 +415,7 @@ interface PushOutboxSqlRow { attempts: number; claimed_until: Date | string | null; created_at: Date | string; + delivered_endpoints: string | null; } function mapSub(row: PushSubSqlRow): PushSubscriptionRecord { @@ -373,11 +449,12 @@ function mapOutbox(row: PushOutboxSqlRow): PushOutboxRow { ? row.claimed_until : new Date(row.claimed_until), createdAt: row.created_at instanceof Date ? row.created_at : new Date(row.created_at), + deliveredEndpoints: parseDeliveredEndpoints(row.delivered_endpoints), }; } const OUTBOX_SELECT = - 'id, account_id, type, message_id, payload, status, attempts, claimed_until, created_at'; + 'id, account_id, type, message_id, payload, status, attempts, claimed_until, created_at, delivered_endpoints'; /** * Durable {@link PushStore} backed by Postgres. @@ -479,8 +556,8 @@ export class PostgresPushStore implements PushStore { async enqueue(row: PushOutboxRow): Promise { await this.#sql.execute( `INSERT INTO push_outbox - (id, account_id, type, message_id, payload, status, attempts, claimed_until, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + (id, account_id, type, message_id, payload, status, attempts, claimed_until, created_at, delivered_endpoints) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, [ row.id, row.accountId, @@ -491,6 +568,7 @@ export class PostgresPushStore implements PushStore { row.attempts, row.claimedUntil, row.createdAt, + JSON.stringify(row.deliveredEndpoints), ], ); } @@ -545,4 +623,29 @@ export class PostgresPushStore implements PushStore { [id], ); } + + /** + * Read current delivered JSON, union `endpoints`, write back. + * + * @param id - Outbox id. + * @param endpoints - Newly delivered endpoint URLs. + */ + async recordDelivered(id: string, endpoints: readonly string[]): Promise { + const rows = await this.#sql.query<{ delivered_endpoints: string | null }>( + `SELECT delivered_endpoints FROM push_outbox WHERE id = $1`, + [id], + ); + const existing = rows[0]; + if (existing === undefined) { + return; + } + const next = unionDeliveredEndpoints( + parseDeliveredEndpoints(existing.delivered_endpoints), + endpoints, + ); + await this.#sql.execute(`UPDATE push_outbox SET delivered_endpoints = $1 WHERE id = $2`, [ + JSON.stringify(next), + id, + ]); + } } diff --git a/src/lib/push-worker.ts b/src/lib/push-worker.ts index 7122e8e7..c870a3d1 100644 --- a/src/lib/push-worker.ts +++ b/src/lib/push-worker.ts @@ -57,6 +57,7 @@ export async function enqueueForumPushes( attempts: 0, claimedUntil: null, createdAt, + deliveredEndpoints: [], }; await store.enqueue(row); } @@ -90,6 +91,7 @@ export async function enqueueZapPush( attempts: 0, claimedUntil: null, createdAt: new Date(nowMs), + deliveredEndpoints: [], }; await store.enqueue(row); } @@ -121,6 +123,7 @@ export async function enqueueDebugPush( attempts: 0, claimedUntil: null, createdAt: new Date(nowMs), + deliveredEndpoints: [], }; await store.enqueue(row); return 1; @@ -138,6 +141,8 @@ export interface PushWorkerDeps { /** * Claim a batch and deliver each row to every subscription for its account. + * Skips endpoints already recorded on the outbox row so retries do not + * re-send a payload that succeeded on a previous tick. * * @param deps - Store, sender, clock. */ @@ -153,10 +158,17 @@ export async function runPushWorkerTick(deps: PushWorkerDeps): Promise { await deps.store.markSent(row.id); continue; } + const delivered = new Set(row.deliveredEndpoints); + const newlyDelivered: string[] = []; let anyFail = false; for (const sub of subs) { + if (delivered.has(sub.endpoint)) { + continue; + } const result = await deps.sender.send(sub, row.payload); if (result.ok) { + newlyDelivered.push(sub.endpoint); + delivered.add(sub.endpoint); continue; } if (result.reason === 'gone') { @@ -165,6 +177,9 @@ export async function runPushWorkerTick(deps: PushWorkerDeps): Promise { } anyFail = true; } + if (newlyDelivered.length > 0) { + await deps.store.recordDelivered(row.id, newlyDelivered); + } if (anyFail) { await deps.store.markFailed(row.id); } else {