Skip to content

[Architecture] refactor: per-domain modules with service layers — pure motion, zero new deps (stacked on #294) - #295

Draft
amal66 wants to merge 3 commits into
Open-Legal-Products:mainfrom
amal66:olp-pr/service-layers
Draft

[Architecture] refactor: per-domain modules with service layers — pure motion, zero new deps (stacked on #294)#295
amal66 wants to merge 3 commits into
Open-Legal-Products:mainfrom
amal66:olp-pr/service-layers

Conversation

@amal66

@amal66 amal66 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Row amal66#42 of the reference index (#205) — the backend reorganization the index says to take last. Stacked on #294 (durable queues): that PR rewrote routes/tabular.ts, so this one builds on it rather than conflicting with it; merge #294 first and this diff reduces to the reorganization alone.

Design (ADR)

Context. The route files are monoliths — routes/tabular.ts (1,649 lines), routes/documents.ts (1,504), routes/projects.ts (1,139), routes/user.ts (1,132) — each interleaving HTTP parsing, auth/access checks, storage IO, and business logic inline in a dozen unrelated handlers. Handlers can't be unit-tested without a live HTTP stack, and every change lands in a file where the blast radius is unclear.

Decision. Decompose each route file into a module under src/modules/<domain>/: a thin <name>.routes.ts (HTTP concerns: parse, validate, map typed results onto status codes) delegating to <name>.service.ts functions (business logic + data access, explicit db parameter, typed results — discriminated unions, never req/res). Large domains split the service into topic files behind a named-re-export facade so intra-module helpers can't leak: documents (access/upload/versions/download/edits), projects (crud/folders/documents/chats), user (profile/mfa/apiKeys/mcp/account/export), tabular (reviews/rows/extract/extractRow/generate/generateStream/chats). Streaming endpoints keep their SSE loops in the routes file — stream lifetime and client-abort handling are HTTP concerns; only their non-streaming prepare/persist logic moved. src/lib/ keeps cross-domain infrastructure only (storage, llm, chat, queue, access…); the tabular extraction files from #294 move from lib/tabular/ into modules/tabular/ so the domain lives in one place.

Consequences. Reviewability (each concern is a few-hundred-line file with one job), testability (service functions run against a fake db, no server), and a stable seam: the async extraction worker and the SSE route already call the same service functions, and future features get a module, not a bigger monolith. Cost: many more files and a big one-time diff — which is why this row is sequenced last and stacked.

Alternatives considered. Status quo (rejected: the monoliths are where changes go to be risky); splitting only the worst files (rejected: two conventions coexisting indefinitely); adopting the fork's full stack including pino/zod/OTel in the same change (rejected: organization should be reviewable as pure motion; each new dependency deserves its own decision).

Summary

src/routes/*.ts (11 files, 7,853 lines) → src/modules/{chat, project-chat, projects, documents, tabular, user, workflows, library, downloads, case-law, models}/ (56 files changed; small route files are recognized by git as renames). Endpoint surface, behavior, and dependencies are unchanged.

Changes

  • 11 domain modules, each *.routes.ts + services as above; downloads, case-law, models are routes-only moves (too small for a service split — git shows them as 84–91% renames).
  • app.ts — only the 11 import lines change; middleware, limiter, and mount order untouched.
  • modules/tabular/ absorbs lib/tabular/ from [Architecture] feat: durable job queues (BullMQ) for conversion and tabular extraction — off by default, no Redis unless enabled #294 (git-tracked renames, 96–100% similarity) plus the route file's remaining inline helpers: tabular.reviews.ts (row building/rebuild/cell sync) and tabular.chats.ts (citation parsing + chat message building). The extraction worker's imports follow.
  • Within-domain DRY only: helpers duplicated across handlers of the same domain now have one copy in their service (normalizeSharedWith in projects, the doc-fetch + access-guard sequence in documents, findSystemWorkflow in workflows). Similar-but-not-identical code was left alone rather than force-merged.
  • Zero new dependencies, no logging/validation/observability frameworks, no behavior changes.

Why

For an open-source repo, file organization is contributor UX: small files with one concern are the difference between a drive-by contributor shipping a fix and giving up. This also completes the seam #294 introduced — the worker and the route provably share one extraction core because both import the same module functions.

Testing

Pure motion, verified three ways:

  • Endpoint parity: the method+path multiset extracted from both trees is byte-identical — 68 endpoints (67 original + the GET /single-documents/:documentId added by [Architecture] feat: durable job queues (BullMQ) for conversion and tabular extraction — off by default, no Redis unless enabled #294's second commit), same registration order and middleware chains per module (order matters: /create before /:chatId, /ids and /prompt before /:reviewId, both verified).
  • npm run build (tsc) clean.
  • npx vitest run → 42 files, 523 tests passing — unchanged from the base, including the 11 route-level integration suites that exercise the real express app end-to-end, which now run through the module tree.

Handler bodies moved verbatim; the only rewrites are the mechanical seam (res.status(...) inside moved code became typed returns mapped back to the identical status/JSON in the route — all user-visible strings preserved, several verified by string-literal diff).

Provenance

Re-derived from the fork's service-layer refactor (amal66#42, running in the amal66 fork): its module boundaries, routes/service contract, and facade pattern are followed; every body comes from this repo's current code (post-#294), not the fork. Deliberately not ported from the fork's version: pino logging + request-id context, zod validation schemas, OTel hooks, magic-byte upload validation, and its supertest suites (this repo already has its own integration suites, which pass unchanged). Where fork shape and current code disagreed, current code won (kept: ?include=documents batching, spotlight-nonce chat flow, get_chats_overview RPC, manifest-signing export endpoint — none exist in the fork's version).

Sequencing note

This is the index's "merge last" row made concrete: open route-touching PRs (#247, #267/#268, #271, #285/#290, #292) will need re-porting onto the module layout once this lands — the handler bodies they touch are unchanged, so each re-port is a file-move, and I'm happy to do all of them myself within 48h of merge. If you'd rather not adopt the module layout, closing this costs nothing — nothing else depends on it.

Maintenance

Same commitment as the other index rows: I maintain what I land — 48h triage on anything this breaks; if I go dark for 60 days, revert freely.

🤖 Generated with Claude Code

…xtraction

WHY THIS MATTERS
Two workloads in Mike are expensive and can outlive the HTTP request that
started them: DOCX -> PDF conversion (LibreOffice) and tabular-review cell
extraction (one LLM call per row). Today both run inline on the request
thread, so a closed laptop lid, a dropped connection, or a server restart
mid-run silently loses the work — the review grid is left with spinners
that never resolve, and a large upload blocks its request on LibreOffice.

WHAT IS A DURABLE JOB QUEUE
A job queue moves work out of the request/response cycle: the request
records WHAT should happen (a small JSON payload in Redis) and returns; a
worker process picks the job up, runs it, and retries it with exponential
backoff if it fails. "Durable" means the job survives the death of the
thing that created it — the queue (BullMQ on Redis) holds the job until a
worker finishes it, no matter what happens to the original HTTP request or
even the server process (BullMQ re-queues jobs whose worker crashed via
its stalled-job detection).

The classic hazard of queues is the DOUBLE SUBMIT: a client that
reconnects and re-POSTs would enqueue the same work twice. This design
pushes correctness into the queue's identity model — every job's id is
derived deterministically from the work itself (`convert:<versionId>`,
`extract:<reviewId>:<rowId>`), so BullMQ collapses a duplicate submit into
the already-in-flight job. Durable STATE lives only in Postgres
(documents.status, tabular_cells); jobs re-read that state when they run
and skip columns already done, which is what makes retries idempotent.

HOW IT WORKS
- Both queues are OFF by default and opt-in per deployment:
  ASYNC_DOCUMENT_CONVERSION / ASYNC_TABULAR_EXTRACTION (default "false").
  With the flags off the server never dials Redis — the queue connection is
  created lazily and only reached via enqueue/startWorkers, so a fresh
  clone still runs fully synchronously with zero new infrastructure.
- lib/queue/: a shared lazy Redis connection (maxRetriesPerRequest: null,
  which BullMQ's blocking commands require), the two queues with
  deterministic jobIds + retry/backoff, and runProgress — a Redis pub/sub
  bridge that carries per-cell progress frames from workers to any HTTP
  request that is watching.
- workers/: conversionWorker (DOCX->PDF off the request thread; conversion
  failure finalizes the document without a PDF rendition, matching the
  sync path) and extractionWorker (throws on incomplete extraction so
  BullMQ retries; after the last retry a permanent-failure handler flips
  surviving cells to "error" so the grid never shows an eternal spinner).
  A declarative registry + startWorkers()/stopWorkers() lifecycle, started
  from index.ts only when a flag is on, with graceful SIGTERM/SIGINT
  drain.
- lib/tabular/: the extraction core factored out of routes/tabular.ts so
  the synchronous route and the async worker share ONE loop
  (extractRowColumns). The unit of work is the review ROW — one document,
  or a folder of source documents extracted together — matching the row
  model main adopted for folder-grouped reviews. tabular.rows.ts carries
  the row loaders (loadReviewRows / loadRowDocumentText) that both the
  routes and the worker need.
- POST /:reviewId/generate keeps its exact synchronous behavior by
  default; with the flag on it enqueues one job per row, subscribes to the
  review's progress channel BEFORE enqueuing (so a fast worker cannot
  publish into the void), and forwards the same cell_update SSE frames the
  sync path emits. A 3-second DB-poll backstop reconciles any missed
  pub/sub frame, so a dropped message can never hang the stream. A new
  GET /:reviewId/generate/stream lets a disconnected client reattach to a
  running generation without re-triggering work.

Ported from amal66/mike (upstream-pr/durable-queues, #40) and
re-derived against current main: the extraction core is row-based (not
document-based) to match the folder-grouped row model (Open-Legal-Products#274) and db
pagination (Open-Legal-Products#263) that landed after the original branch, and the moved
helper bodies match main's current copies byte-for-byte (multi-document
citation prompts, Ollama key exemption).

Tests: 510 passing (was 499), including queue jobId determinism, worker
idempotency/retry/permanent-failure policy, row extraction core, and the
pending-cell targeting used by the reconnectable stream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@amal66
amal66 marked this pull request as draft August 6, 2026 03:31
pdfStoragePath = pdfKey;
} catch (err) {
console.error(
`[upload] Office→PDF conversion failed for ${filename}:`,
pdfStoragePath = pdfKey;
} catch (err) {
console.error(
`[versions/copy] Office→PDF conversion failed for ${filename}:`,
pdfStoragePath = pdfKey;
} catch (err) {
console.error(
`[versions/upload] Office→PDF conversion failed for ${file.originalname}:`,
pdfStoragePath = pdfKey;
} catch (err) {
console.error(
`[versions/replace] Office→PDF conversion failed for ${file.originalname}:`,
pdfStoragePath = pdfKey;
} catch (err) {
console.error(
`[upload] Office→PDF conversion failed for ${filename}:`,
Comment on lines +98 to +102
console.error("[user/mcp-connectors] update failed", {
userId,
connectorId,
error: detail,
});
Comment on lines +170 to +174
console.error("[user/mcp-connectors] refresh failed", {
userId,
connectorId,
error: detail,
});
Comment on lines +200 to +205
console.error("[user/mcp-connectors] tool toggle failed", {
userId,
connectorId,
toolId,
error: detail,
});
Comment on lines +42 to +46
return (
process.env.API_PUBLIC_URL ||
process.env.BACKEND_URL ||
`${req.protocol}://${req.get("host")}`
).replace(/\/+$/, "");
Comment on lines +391 to +402
console.error("[user/mcp-connectors] oauth callback failed", {
error: detail,
stateHash: shortHash(state),
hasCode: !!code,
hasError: !!error,
issuer:
typeof req.query.iss === "string" ? req.query.iss : undefined,
scope:
typeof req.query.scope === "string"
? req.query.scope
: undefined,
});
…enerate-cell, stale-work reaper, and a frontend that can consume it

WHY THIS MATTERS
The first commit made two workloads durable, but a feature flag is only
real if flipping it on produces a working product. Three gaps stood in
the way. First, ASYNC_DOCUMENT_CONVERSION covered one of the five places
that spawn LibreOffice — project uploads, added versions, replaced
versions and document-to-version copies still blocked their requests for
seconds to minutes. Second, regenerate-cell ran a full LLM extraction
inline even with the extraction queue enabled, and a crash mid-call
stranded the cell in "generating" forever. Third, the frontend had no
way to see async results: nothing polled a "processing" document, and
the reconnectable generate stream had zero callers — enabling the flags
produced spinners that never resolved.

WHAT IS AN ORPHANED TRANSIENT STATE
Transient statuses ("processing", "generating") encode a promise: some
running code will eventually write a terminal state. A crash in the
window between the transient write and the terminal write breaks the
promise, and because nothing else owns the row, the lie persists
forever. The fix has two halves: narrow the windows (hand the work to a
queue that retries and survives restarts) and add an owner of last
resort (a reaper that flips provably-orphaned rows to "error").

HOW IT WORKS
- Conversion queue covers all five LibreOffice call sites. Version flows
  pass a per-version pdfKey (renditions of different versions must not
  collide on the document-level key) and finalizeDocumentStatus: false —
  their document is already "ready", so a rendition failure must not
  flip a healthy document to "error"; only the initial-upload flow parks
  the document "processing" and lets the worker finalize it. Terminal
  conversion jobs are now removed immediately (same rationale as
  extraction): replace-file reuses the versionId, and a lingering
  completed job record would silently dedupe the re-conversion.
- Regenerate-cell becomes a single-cell job: payload gains columnIndex,
  jobId gains a column suffix (extract:<review>:<row>:<col>) so it never
  dedupes against a full-row job, and the worker narrows to that one
  column. The route keeps its synchronous JSON contract by waiting on
  the cell's terminal state (pub/sub + DB-poll backstop); if the wait
  budget elapses it answers 202 {status:"generating"} — the job keeps
  running and the client catches up through the resume stream. The
  disconnect-divergence bug (client marks error, backend later writes
  done) is gone: the DB is the only authority.
- Stale-work reaper (lib/maintenance/staleWork.ts, swept at boot + every
  10 min): documents "processing" past a 30-minute age gate with no live
  conversion job flip to "error"; "generating" cells with no live job
  flip to "error" (async mode only — cells have no timestamp column, so
  in sync mode a live inline run is indistinguishable from an orphan).
  Job existence is the liveness signal, which immediate job removal
  makes trustworthy.
- Frontend catch-up: GET /single-documents/:documentId exists so the
  client can poll one document instead of refetching the collection;
  DocTable polls pending/processing rows every 3s and merges status
  changes through the existing update path. The tabular view now aborts
  its generate stream on unmount, reconnects once through
  GET /generate/stream on a dropped stream, resumes an in-flight run
  found at mount (cells still "generating"), and treats regenerate's
  202 as "keep the skeleton, tail the stream" instead of an error.
- The GET stream view no longer dials Redis in synchronous deployments
  (the subscribe is flag-gated; the DB-poll backstop does the resolving
  there), so the no-Redis-by-default invariant holds for every new path.

Tests: backend 523 passing (+13: payload passthrough, per-version pdf
keys, finalize semantics, single-cell narrowing in worker + failure
handler, reaper liveness/age-gate/no-op cases); frontend 174 passing,
tsc and production build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ice layers

WHY THIS MATTERS
The backend's route files were monoliths — routes/tabular.ts (1,649
lines), routes/documents.ts (1,504), routes/projects.ts (1,139),
routes/user.ts (1,132) — each interleaving HTTP parsing, auth checks,
storage IO, and business logic inline in a dozen unrelated handlers.
In a monolith, every change lands in a giant file where the blast
radius is unclear, business logic can only be exercised through a live
HTTP stack, and a new contributor cannot tell which lines are "the
endpoint" and which are "the feature". For an open-source repo this is
the difference between a drive-by contributor shipping a fix and giving
up: small files with one concern each are reviewable; 1,600-line route
files are not.

WHAT IS A SERVICE LAYER
A service layer separates WHAT the application does from HOW it is
reached. The route (HTTP layer) owns request parsing, validation, and
mapping results onto status codes; the service owns business logic and
data access, takes its database handle as an explicit parameter, returns
typed results (discriminated unions like { ok: false, kind: "not_found" }
instead of writing to `res`), and never touches the HTTP request or
response. That inversion is what makes logic unit-testable (call the
function with a fake db — no server needed), reusable (the async
extraction worker calls the exact same functions the SSE route calls),
and safe to change (the compiler knows every result shape a route must
handle).

HOW IT WORKS
- src/routes/*.ts (11 files, 7,853 lines) is replaced by
  src/modules/<domain>/ — chat, project-chat, projects, documents,
  tabular, user, workflows, library, downloads, case-law, models —
  each a thin <name>.routes.ts plus <name>.service.ts. Large domains
  split the service into topic files behind a named-re-export facade
  (documents: access/upload/versions/download/edits; projects:
  crud/folders/documents/chats; user: profile/mfa/apiKeys/mcp/account/
  export; tabular: reviews/rows/extract/extractRow/generate/
  generateStream/chats) so intra-module helpers cannot leak.
- lib/tabular/* (from the durable-queues change this builds on) moves
  into modules/tabular/ — the domain's extraction core, row loaders and
  route layer now live together; src/lib/ keeps only cross-domain
  infrastructure (storage, llm, chat, queue, access...).
- Streaming endpoints keep their SSE loops in the routes file; only
  their non-streaming prepare/persist logic moved into services —
  streaming lifetime and client-abort handling are HTTP concerns.
- Pure motion, verified three ways: the endpoint inventory
  (method+path multiset, 67 endpoints) is byte-identical before and
  after; tsc is clean; the full suite — 510 tests, including the 11
  route-level integration suites that exercise the real express app —
  passes unchanged. Handler bodies moved verbatim; the only rewrites
  are the mechanical seam (res.status(...) inside moved code became
  typed returns mapped back to the identical status/JSON in the route).
- DRY within domains only: helpers duplicated across handlers in the
  same domain (shared_with normalization in projects, the doc-access
  guard sequence in documents, findSystemWorkflow) now have one copy in
  their service; similar-but-not-identical code was left alone rather
  than force-merged.
- Zero new dependencies. No logging framework, no validation framework,
  no observability hooks — organization only, so the diff is reviewable
  as motion and each future concern can be its own decision.

Re-derived against this branch's code from the fork's service-layer
refactor (#42, running in the amal66 fork), whose module
boundaries and routes/service contract this follows; the fork's
pino/OTel/zod adoption was deliberately NOT ported to keep this
dependency-free pure motion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@amal66
amal66 force-pushed the olp-pr/service-layers branch from 8d09c31 to adaf14e Compare August 6, 2026 17:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants