You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Start the digest Change window at the carrier's save, and leave the reader out of the contributor count #452
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.
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.
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.
Summary
resolveDigestSincepicks where the digest's Change window starts. Two flaws follow from it: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.tsresolveDigestSince(51-65): with no last visit, the window starts at now minus 24 hours (daily) or minus 7 days (weekly).withSections(152) never readsdoc.content_changes.since. Line 186 overwrites it.summary.contributorswith no filter onoptions.recipientId.Other files
apps/hocuspocus.server/src/lib/email/digestDocuments.ts:73-81: the block keeps the earliest carriercreated_atassince. An unparseablecreated_atcan survive there.apps/hocuspocus.server/src/modules/document-changes/infra/changesStore.ts:17-22:resolveAnchortakes the newest row at or beforesince.apps/hocuspocus.server/src/modules/document-changes/domain/computeDocumentChanges.ts:147: returnssame-anchorwithchanged: false.apps/hocuspocus.server/src/lib/email/digestMessage.ts:79andapps/hocuspocus.server/src/lib/email/pgmqConsumer.ts:221-224: an empty digest is markedskippedwith "No digest content".apps/hocuspocus.server/src/lib/queue.ts:556-566: the fan-out runs insetImmediateright after the version INSERT.apps/hocuspocus.server/src/extensions/document-occupancy.extension.ts:advanceReading(61) setsreadAtfrom inbound messages,applyReading('heard')(214) feeds it, andreleaseAndStamp(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 afterreadAt.packages/supabase/scripts/07-5-email-notifications-pgmq.sql:328-348: animmediaterow 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 whoselast_connection_closed_atis null. The third is a failed last-visit read (apps/hocuspocus.server/src/lib/email/pgmqConsumer.ts:138-143).same-anchor. The block drops, and the row is markedskipped. The real change is never mailed.B: the reader's own edits.
READ_DWELL_MSstamps nothing, so every row that visit saved also lands in R's next window.Fix
withSections, readdoc.content_changes.sincebefore line 186 overwrites it. Parse it withDate.parse. An unparseable value becomesnull.resolveDigestSinceas a new optional fifth parameter,carrierSince: Date | null = null. The window rule stays in one pure function.lastVisitis null andcarrierSinceis set, start at the earlier of (now minus the frequency window) and (carrierSinceminus 60 s). Otherwise keep today's rule.DAY_MS, not exported. Keep the retention floor clamp and thenowcap. Add one line on the carrier rule to the JSDoc above the function.outcome.result.summary.contributors.filter((p) => p.id !== options.recipientId).length.ProfileLite.id(apps/hocuspocus.server/src/lib/profiles.ts:9-16) is thepublic.usersid, the same value asrecipientId. Do not add an option tocomputeChanges: theGET /changesroute answers a different question and must keep the full list.apps/hocuspocus.server/CLAUDE.md§Digest Email Links And Counts, the contributor-count bullet sayswithSectionsreadssummary.contributors.length. Add that it leaves out the recipient.Out of scope
sincepast the reader's own leading rows. It narrows the window, andDocuments.contributorsis 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.apps/hocuspocus.server/CLAUDE.mdcalls a window that widens "the safe direction".content_changecarrier once the last carrier's digest is sent or skipped #451.Acceptance criteria
enrichDigestDocumentspasses compute asinceof the carriersinceminus 60 s. This holds when that is earlier than now minus the frequency window.since,sinceis now minus the frequency window, as today.resolveDigestSincetests and the retention clamp case inenrichDigestDocumentspass unchanged.summary.contributorsholds the recipient and one other person,contributorCountis 1. When it holds only the recipient,contributorCountis absent.sinceandfromLastLeftas today.Verify
In
apps/hocuspocus.server/tests/unit/contentChangeDigest.test.ts, underdescribe('enrichDigestDocuments'):enrichableDoc()carriersinceis2026-09-02T10:00:00.000Z,NOWis2026-09-04T00:00:00.000Z, and the visit is null. Capturerequest.sinceincomputeChanges. Expect2026-09-02T09:59:00.000Z. Today'smaingives2026-09-03T00:00:00.000Z.changesResultto acceptcontributors. Return two profiles, one withid: OWNER(the fixturerecipientId). ExpectcontributorCount1. With only theOWNERprofile, expect nocontributorCount.Both cases must fail on today's
main. Runbun test tests/unit/contentChangeDigest.test.tsinapps/hocuspocus.server.