Skip to content

Collapse the digest payload contract, batch the last-visit read, and stop deep-importing document-changes #238

Description

@HMarzban

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

  • One declaration of each digest payload type, owned by packages/email-templates, with
    email.types.ts re-exporting. workspace_id is visible to renderDigestEmail.
  • digestContentChanges.ts takes prepared maps. A digest of N documents issues one
    workspace_members query, not N.
  • Neither email file deep-imports past modules/document-changes/index.ts.
  • bun run test in apps/hocuspocus.server is green, and bun test in
    packages/email-templates is green with no snapshot update.
  • Both typechecks exit 0.

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.

Activity

  1. HMarzban commented on Sep 8, 2026

    @HMarzban
    CollaboratorAuthor

    Part 1 is done. Parts 2 and 3 are not. Leaving this open.

    Committed on main as 6d3aa9f6a..00df9e16f, chiefly 1f620f98b.

    1. The payload contract — done.
    DigestDocument and its family are declared once, in a new
    packages/email-templates/src/types.ts.
    apps/hocuspocus.server/src/types/email.types.ts re-exports them and declares none, and both
    inline copies in engine.ts are gone. grep -c "interface DigestDocument" now returns 1, 0, 0
    across those three files.

    There was a fourth declaration nobody had named: the template preview route in
    src/api/email.ts hand-wrote its own fixture, carrying no content_changes and no
    workspace_id. So the one surface built for eyeballing the mail could not render the change
    digest at all. That fixture is now typed DigestDocument[].

    Two things worth recording from doing it:

    • The consolidation first widened DigestNotification.type from its seven-name union to bare
      string, which silently defeated the typed fixture it was built for. A typo compiled and
      printed "New notification". The union now lives in the package, where the renderer switches on
      it, and a planted typo fails to compile on both the test fixture and the preview fixture.
    • period_start was dead payload — computed as 24 hours even for a weekly digest, carried
      through the queue, and read by no template. It is deleted end to end. period_end stays and
      is live.

    2. Batch the last-visit read — not done.
    readDigestLastVisit in pgmqConsumer.ts is still one query per reader and document.

    3. Stop deep-importing document-changes — not done.
    Both still exist, and the module root exports only init and InitResult:

    • lib/email/pgmqConsumer.ts:9 takes createComputeDocumentChanges from
      domain/computeDocumentChanges
    • lib/email/digestContentChanges.ts:9 takes types from modules/document-changes/types

    The consequence is a split privacy rule, and it is now written down in
    apps/hocuspocus.server/CLAUDE.md: the route wraps compute in requireServiceRole, while the
    digest answers the same question separately through resolveContentChangeAudience. Harden the
    route and the digest keeps its own rule, with no compiler complaint.

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions