Problem
Three follow-ups from the review of #201 and #204. Each is real and each was deferred because it
touches files outside that change set. None blocks anything shipped.
1. The digest payload contract is declared three times
DigestDocument and its block live in apps/hocuspocus.server/src/types/email.types.ts:79.
packages/email-templates/src/engine.ts re-declares the same shape inline twice: once at :79 for
renderDigestEmail, and once at :64 as content_changes?: unknown for countDigestItems.
The drift is already here. DigestDocument carries workspace_id; the engine copy does not.
grep -c workspace_id returns 1 for the backend type and 0 for engine.ts. sender.ts passes
documents: payload.documents, a variable, so no excess-property check applies. The field reaches
the template at runtime while being absent from the type that renders it.
This is the exact mechanism that let the item count and the renderer separate earlier in this epic:
the count treated a content_changes block as one item while no template drew it, and no compiler
error was possible.
What to do. Add packages/email-templates/src/types.ts owning DigestDocument,
DigestChannel, DigestNotification, DigestContentChanges, DigestChangedSection and
DigestFrequency. Export them from packages/email-templates/index.ts. Have renderDigestEmail
and countDigestItems take them, and have email.types.ts re-export rather than re-declare.
The backend already imports from the package (src/lib/email/templates.ts:6), and the package
imports nothing from the backend, so there is no cycle. That deletes roughly thirty lines of inline
structural type and removes the drift class.
2. Two injected ports could be two prepared maps
apps/hocuspocus.server/src/lib/email/digestContentChanges.ts takes two function ports:
ReadDigestMetadata (:23) has one production implementation, and it is
async (documentId) => metaById.get(documentId) ?? null in pgmqConsumer.ts. An async wrapper
over a Map that the same call site already passes a second time as new Set(metaById.keys()).
ReadDigestLastVisit (:26) is awaited at :139 inside the per-document loop, and its
implementation is a single-row workspace_members select. So a 20-document digest issues 20
queries where one .in('workspace_id', ids) would do.
The sibling Prisma read one function above was batched for exactly this reason. The serial-loop
comment at :188 defends computeChanges on heap grounds, which does not cover this query.
What to do. Read every last visit once beside the metadata read, then pass two prepared maps:
metaById: Map<string, DigestDocumentMeta> and lastVisitById: Map<string, Date>. Delete
ReadDigestMetadata, ReadDigestLastVisit, the recipientId field, both closures at the call site,
and two awaits. Leave the serial compute loop alone — its heap decision is documented and
stands.
Keep computeChanges injected. It is what lets a test pass a throwing function with no mocking,
which AGENTS.md §Test Policy requires.
3. The email consumer deep-imports the document-changes module
apps/hocuspocus.server/src/modules/document-changes/index.ts is two lines and exports init and
InitResult only. Two files bypass it:
src/lib/email/pgmqConsumer.ts:9 → ../../modules/document-changes/domain/computeDocumentChanges
src/lib/email/digestContentChanges.ts:9 → ../../modules/document-changes/types
apps/hocuspocus.server/CLAUDE.md §HTTP Modules fixes the module layout and names initWsApply as
the one precedent for a second named entry point.
What to do. Widen the surface rather than going around it. Add to the barrel:
export { createComputeDocumentChanges } from './domain/computeDocumentChanges'
export type { ComputeDocumentChanges, ComputeOutcome, SectionNode } from './types'
Then import both files from '../../modules/document-changes'.
Note the constraint this must respect. The module's own rule says the external seam is the
route, and the deep import exists because the worker is a separate process that must not make an
HTTP self-call. Widening the barrel keeps that property while naming the surface. Do not move the
compute into lib/, and do not add an HTTP hop.
Acceptance
Notes
Nothing here changes behaviour. Item 2 is the only one with a measurable effect, and it is a
round-trip reduction, not a semantic change. Item 1 is the highest value: it removes a class of
silent drift that has already cost this epic one defect.
Problem
Three follow-ups from the review of #201 and #204. Each is real and each was deferred because it
touches files outside that change set. None blocks anything shipped.
1. The digest payload contract is declared three times
DigestDocumentand its block live inapps/hocuspocus.server/src/types/email.types.ts:79.packages/email-templates/src/engine.tsre-declares the same shape inline twice: once at:79forrenderDigestEmail, and once at:64ascontent_changes?: unknownforcountDigestItems.The drift is already here.
DigestDocumentcarriesworkspace_id; the engine copy does not.grep -c workspace_idreturns1for the backend type and0forengine.ts.sender.tspassesdocuments: payload.documents, a variable, so no excess-property check applies. The field reachesthe template at runtime while being absent from the type that renders it.
This is the exact mechanism that let the item count and the renderer separate earlier in this epic:
the count treated a
content_changesblock as one item while no template drew it, and no compilererror was possible.
What to do. Add
packages/email-templates/src/types.tsowningDigestDocument,DigestChannel,DigestNotification,DigestContentChanges,DigestChangedSectionandDigestFrequency. Export them frompackages/email-templates/index.ts. HaverenderDigestEmailand
countDigestItemstake them, and haveemail.types.tsre-export rather than re-declare.The backend already imports from the package (
src/lib/email/templates.ts:6), and the packageimports nothing from the backend, so there is no cycle. That deletes roughly thirty lines of inline
structural type and removes the drift class.
2. Two injected ports could be two prepared maps
apps/hocuspocus.server/src/lib/email/digestContentChanges.tstakes two function ports:ReadDigestMetadata(:23) has one production implementation, and it isasync (documentId) => metaById.get(documentId) ?? nullinpgmqConsumer.ts. An async wrapperover a
Mapthat the same call site already passes a second time asnew Set(metaById.keys()).ReadDigestLastVisit(:26) is awaited at:139inside the per-document loop, and itsimplementation is a single-row
workspace_membersselect. So a 20-document digest issues 20queries where one
.in('workspace_id', ids)would do.The sibling Prisma read one function above was batched for exactly this reason. The serial-loop
comment at
:188defendscomputeChangeson heap grounds, which does not cover this query.What to do. Read every last visit once beside the metadata read, then pass two prepared maps:
metaById: Map<string, DigestDocumentMeta>andlastVisitById: Map<string, Date>. DeleteReadDigestMetadata,ReadDigestLastVisit, therecipientIdfield, both closures at the call site,and two
awaits. Leave the serial compute loop alone — its heap decision is documented andstands.
Keep
computeChangesinjected. It is what lets a test pass a throwing function with no mocking,which
AGENTS.md§Test Policy requires.3. The email consumer deep-imports the document-changes module
apps/hocuspocus.server/src/modules/document-changes/index.tsis two lines and exportsinitandInitResultonly. Two files bypass it:src/lib/email/pgmqConsumer.ts:9→../../modules/document-changes/domain/computeDocumentChangessrc/lib/email/digestContentChanges.ts:9→../../modules/document-changes/typesapps/hocuspocus.server/CLAUDE.md§HTTP Modules fixes the module layout and namesinitWsApplyasthe one precedent for a second named entry point.
What to do. Widen the surface rather than going around it. Add to the barrel:
Then import both files from
'../../modules/document-changes'.Note the constraint this must respect. The module's own rule says the external seam is the
route, and the deep import exists because the worker is a separate process that must not make an
HTTP self-call. Widening the barrel keeps that property while naming the surface. Do not move the
compute into
lib/, and do not add an HTTP hop.Acceptance
packages/email-templates, withemail.types.tsre-exporting.workspace_idis visible torenderDigestEmail.digestContentChanges.tstakes prepared maps. A digest of N documents issues oneworkspace_membersquery, not N.modules/document-changes/index.ts.bun run testinapps/hocuspocus.serveris green, andbun testinpackages/email-templatesis green with no snapshot update.Notes
Nothing here changes behaviour. Item 2 is the only one with a measurable effect, and it is a
round-trip reduction, not a semantic change. Item 1 is the highest value: it removes a class of
silent drift that has already cost this epic one defect.