Skip to content

Add the document change digest compute service and its REST route #200

Description

@HMarzban

Problem

A person opens a document they have not read for a week. Nothing tells them what changed.

The version store already holds every snapshot. No code turns two snapshots into a per-section answer. document-changes and computeDocumentChanges match nothing in the tree today.

Without this service the email digest can never mention document edits. It reports chat only.

Part of #169.

What to do

Two earlier design decisions are wrong. Fix both before you write the code.

(a) A zero-change result must not be reclassified as unchanged.

An earlier design called this a "self-healing guard". It hides real edits.

Classification is a JSON deep-compare after stripping the volatile toc-id attribute. Magnitude is a second, separate step, and it uses @tiptap/pm/changeset. That library's default token encoder keys a character by its character code and a node by its type name. So it sees neither marks nor attributes.

These three real edits therefore deep-compare as different, then return zero simplified changes:

  • bold added to existing words
  • a changed link href
  • a heading level 2 to 3 with identical text

Each one keeps its heading text. So the guard's second condition, "equal heading text", saves none of them. The guard would call all three unchanged, and the digest would silently omit a real edit.

Keep status: 'modified' with magnitude: null. That is exactly what the throw branch already does.

(b) The canonical compare must cover the section's level and headingText, not only its node list.

A heading opens a section. Only the following non-heading top-level nodes append to that section's nodes array. The heading node itself is never inside nodes.

So a heading level 2 to 3 change leaves both node lists identical. It is invisible before the guard is even consulted. Compare level and headingText as part of the canonical form.

Then build apps/hocuspocus.server/src/modules/document-changes/, beside the five existing modules.

  • types.ts — Section, SectionPair, SectionStatus (added | removed | modified | unchanged), SectionMagnitude, SectionNode, ChangeSummary, DocumentChangesResult, ChangesStore.
  • Four pure domain files: segmentSections.ts, pairSections.ts, diffSections.ts, buildSectionTree.ts.
  • domain/computeDocumentChanges.ts — the orchestrator, as a factory over a deps subset { prisma, logger, getOwnerProfiles }.
  • infra/changesStore.ts — anchor resolution and row reads. Projection-only, apart from the two snapshot rows.
  • http/schema.ts, http/controller.ts, http/router.ts, plus module.ts and index.ts.
  • Unit tests beside the domain files, with literal ProseMirror JSON and no mocks. Integration tests under __tests__/integration/.

The route is GET /api/documents/:documentId/changes?since&until&scope. Service-role bearer. since is required, until defaults to now, and scope is summary (default) or headings. Mount it in apps/hocuspocus.server/src/index.ts after the existing mounts.

module.ts returns { router } only, like the InitResult the other modules export. The email worker is a separate operating-system process, so it will import the compute function by deep path instead.

Reuse these, do not rewrite them:

  • The block canonicalizer that already strips toc-id. The filter is at apps/hocuspocus.server/src/modules/document-versions/domain/canonicalizeBlock.ts:10, and the volatile set it imports is named at apps/hocuspocus.server/src/modules/document-versions/types.ts:221.
  • The lazy schema memo at apps/hocuspocus.server/src/modules/document-versions/domain/diffBlocks.ts:15.
  • readContent at apps/hocuspocus.server/src/modules/document-content/domain/readContent.ts:38.
  • documentIdSchema at apps/hocuspocus.server/src/modules/document-content/http/schema.ts:14.
  • The byte-compare idiom already shipped at apps/hocuspocus.server/src/modules/document-versions/http/controller.ts:249:
    if (before && Buffer.compare(before.data, after.data) === 0) {

An earlier design said Buffer.equals. That method does not exist on a Prisma Bytes value. Use Buffer.compare.

Do not add a test path list anywhere. apps/hocuspocus.server/package.json:16 is "test": "bun test",, so a bare bun test picks up the new __tests__ directory with no wiring. The comment at .github/workflows/backend-ci.yml:35-36 records why the old enumerated list was removed.

Acceptance

  • Three named unit fixtures classify modified, not unchanged. They are bold added, a changed link href, and a heading level 2 to 3 with identical text.
  • Each of those three asserts magnitude: null, because the changeset reports zero simplified changes.
  • A fixture whose two sides differ only by toc-id classifies unchanged.
  • bun run test in apps/hocuspocus.server is green, and its output names the new test files, with no edit to package.json or backend-ci.yml.
  • A request with the service-role bearer returns 200 for scope=summary, and 200 with a sections tree for scope=headings.
  • The same request with no bearer returns 401.
  • A malformed documentId returns 400 before any database call, pinned by a mock that asserts zero queries.
  • An until that predates the first version row returns 200 with changed: false, null baseline and head, and runs no attribution query.
  • Two different version rows holding identical bytes return changed: false with a truthful non-zero versions count and zero decodes.
  • A null baseline with an existing head reads the whole document as added.

Notes

A version row's createdAt is @default(now()) at apps/hocuspocus.server/prisma/schema.prisma:21. The row is inserted by the persistence worker, so that stamp is commit time, not edit time. A window ending at now can miss the last minute of typing. Day-scale digest windows are not affected.

apps/hocuspocus.server/package.json:66 is "@tiptap/pm": "catalog:",, so the version comes from the workspace catalog. Re-check the changeset call after any dependency bump.

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions