Skip to content

Start the digest Change window at the carrier's save, and leave the reader out of the contributor count #452

Description

@HMarzban

Summary

resolveDigestSince picks where the digest's Change window starts. Two flaws follow from it:

  • A. For a reader with no Last left, the window can start after the save that made the carrier. The digest then reads "no change" and is skipped.
  • B. For a reader with a Last left, the window can include the reader's own final save. The mail then counts the reader in "N people contributed".

Both flaws are read from code, not measured.

Shares withSections (apps/hocuspocus.server/src/lib/email/digestContentChanges.ts) with #448. Land that issue first. #448 shifts the line numbers below, so find each line by its symbol.

Where

apps/hocuspocus.server/src/lib/email/digestContentChanges.ts

  • resolveDigestSince (51-65): with no last visit, the window starts at now minus 24 hours (daily) or minus 7 days (weekly).
  • withSections (152) never reads doc.content_changes.since. Line 186 overwrites it.
  • Lines 178 and 193 count summary.contributors with no filter on options.recipientId.

Other files

  • apps/hocuspocus.server/src/lib/email/digestDocuments.ts:73-81: the block keeps the earliest carrier created_at as since. An unparseable created_at can survive there.
  • apps/hocuspocus.server/src/modules/document-changes/infra/changesStore.ts:17-22: resolveAnchor takes the newest row at or before since.
  • apps/hocuspocus.server/src/modules/document-changes/domain/computeDocumentChanges.ts:147: returns same-anchor with changed: false.
  • apps/hocuspocus.server/src/lib/email/digestMessage.ts:79 and apps/hocuspocus.server/src/lib/email/pgmqConsumer.ts:221-224: an empty digest is marked skipped with "No digest content".
  • apps/hocuspocus.server/src/lib/queue.ts:556-566: the fan-out runs in setImmediate right after the version INSERT.
  • apps/hocuspocus.server/src/extensions/document-occupancy.extension.ts: advanceReading (61) sets readAt from inbound messages, applyReading('heard') (214) feeds it, and releaseAndStamp (171-178) stamps Last left from it.
  • apps/hocuspocus.server/src/config/hocuspocus.config.ts:400-401: saves debounce 10 s, max 60 s, so the reader's final row lands after readAt.
  • packages/supabase/scripts/07-5-email-notifications-pgmq.sql:328-348: an immediate row is scheduled 15 minutes out. A daily row is scheduled for 09:00 local the next day, and a weekly row for 09:00 on the next Monday.
  • apps/hocuspocus.server/src/lib/contentChangeFanout.ts:73-79: a reader's own save does not notify them, but a later save by someone else does.

What happens

A: a reader with no Last left. It happens in three cases. The first is an owner who never joined, reached through the owner branch of notify_document_content_change. The second is a member whose last_connection_closed_at is null. The third is a failed last-visit read (apps/hocuspocus.server/src/lib/email/pgmqConsumer.ts:138-143).

  • M saves at 08:00 local on day 1, and the carrier is made at 08:00. The digest runs at 09:00 on day 2, so the window starts at 09:00 on day 1.
  • The 08:00 row is both baseline and head, so the result is same-anchor. The block drops, and the row is marked skipped. The real change is never mailed.
  • Variant: M also saves after 09:00 on day 1. The mail then shows only the later edits and silently leaves out the earlier ones.
  • This hits daily carriers made between 00:00 and 09:00 local, and weekly carriers made on Monday before 09:00. Immediate readers are not affected: their row sends 15 minutes later, and their 24-hour window covers it.

B: the reader's own edits.

  • Reader R types and closes the tab within about one debounce. Last left is about R's last keystroke, and R's final row commits a few seconds later.
  • Later another member saves, so R gets a carrier. R's window holds R's own final row.
  • The mail shows R's own text under "Changed since you left" and counts R in "N people contributed".
  • A visit shorter than READ_DWELL_MS stamps nothing, so every row that visit saved also lands in R's next window.

Fix

  • A. In withSections, read doc.content_changes.since before line 186 overwrites it. Parse it with Date.parse. An unparseable value becomes null.
    • Pass it to resolveDigestSince as a new optional fifth parameter, carrierSince: Date | null = null. The window rule stays in one pure function.
    • When lastVisit is null and carrierSince is set, start at the earlier of (now minus the frequency window) and (carrierSince minus 60 s). Otherwise keep today's rule.
    • The fan-out runs right after the version INSERT, so the carrier follows its row closely. The 60 s is a margin for clock skew and worker latency, not the debounce.
    • Hold the 60 s in a module-local constant beside DAY_MS, not exported. Keep the retention floor clamp and the now cap. Add one line on the carrier rule to the JSDoc above the function.
  • B. At line 178, count outcome.result.summary.contributors.filter((p) => p.id !== options.recipientId).length. ProfileLite.id (apps/hocuspocus.server/src/lib/profiles.ts:9-16) is the public.users id, the same value as recipientId. Do not add an option to computeChanges: the GET /changes route answers a different question and must keep the full list.
  • Docs. In apps/hocuspocus.server/CLAUDE.md §Digest Email Links And Counts, the contributor-count bullet says withSections reads summary.contributors.length. Add that it leaves out the recipient.

Out of scope

  • Moving since past the reader's own leading rows. It narrows the window, and Documents.contributors is per-replica and a floor. So a row that looks like the reader's own can hold another person's edit. File it after the count fix ships, if readers still report their own text.
  • Moving the window start forward for any reason. apps/hocuspocus.server/CLAUDE.md calls a window that widens "the safe direction".
  • Stamping Last left after the reader's final flush. That touches the occupancy rule.
  • The carrier dedupe after a send. See the issue Allow a new content_change carrier once the last carrier's digest is sent or skipped #451.

Acceptance criteria

  • With a null Last left, enrichDigestDocuments passes compute a since of the carrier since minus 60 s. This holds when that is earlier than now minus the frequency window.
  • With a null Last left and an unparseable carrier since, since is now minus the frequency window, as today.
  • The existing resolveDigestSince tests and the retention clamp case in enrichDigestDocuments pass unchanged.
  • When summary.contributors holds the recipient and one other person, contributorCount is 1. When it holds only the recipient, contributorCount is absent.
  • A Last left that is set still gives the same since and fromLastLeft as today.

Verify

In apps/hocuspocus.server/tests/unit/contentChangeDigest.test.ts, under describe('enrichDigestDocuments'):

  • Window case: use the defaults. The enrichableDoc() carrier since is 2026-09-02T10:00:00.000Z, NOW is 2026-09-04T00:00:00.000Z, and the visit is null. Capture request.since in computeChanges. Expect 2026-09-02T09:59:00.000Z. Today's main gives 2026-09-03T00:00:00.000Z.
  • Count case: extend changesResult to accept contributors. Return two profiles, one with id: OWNER (the fixture recipientId). Expect contributorCount 1. With only the OWNER profile, expect no contributorCount.

Both cases must fail on today's main. Run bun test tests/unit/contentChangeDigest.test.ts in apps/hocuspocus.server.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions