refactor(batch): collapse the batch family to one implementation over the store seam - #108
Merged
Conversation
… the store seam
`src/lib/batch.ts` was a facade that read the process-wide deployment mode and
handed `batchSend` to one of two modules. `batch.local.ts` rendered a template
per CSV row, checked suppression, sent through the provider adapters and wrote
the sent-mail ledger. `batch.remote.ts` threw. Same name, same signature,
opposite meanings, with an environment variable deciding whether the operation
existed at all. Both arms are deleted and one implementation remains behind the
unchanged facade path.
What replaces the arm choice is the store's own answer, not a mode word: the
template is read through `EmailStore.templates` with a `name` equality filter.
The local SQLite store filters and serves the row; a store that cannot answer
that question refuses, with the typed refusal's code, status and message, and
`batchSend` throws it loudly. A refusal is never reported as a batch that sent
nothing and hit no errors, because that is the correct answer for an empty CSV.
Deliberately NOT done here, since each would rebuild the axis or do another
phase's work:
* nothing asks WHICH store it holds. `src/store-resolution.ts` says
construction is the only place that answer is visible and that its plan
union is not a runtime label to branch on; a branch on the store kind or
the descriptor would be the same switch respelled.
* the SEND does not go through the seam. `SendIntentsRepository` is gated on
the idempotency-fenced ledger and both stores declare that capability
false, so routing the send there would refuse in every configuration. One
send service is phase 8.
* suppression, the send counters and the template placeholder grammar are not
reimplemented. They stay with the contacts and templates families, each a
phase-4 migration of its own, and are reached through their FACADES rather
than their arms — which is what the deleted `batch.local.ts` did to four
separate families.
The dependency on the axis is therefore transitive, not gone. What is gone is
batch's own mode decision.
Two consumers reached into the arm rather than the facade and are repointed:
`src/cli/commands/misc.local.ts` (the `emails batch` command) and
`src/cli/commands/send-suppression.test.ts`. Both are one-line specifier swaps.
`batchSend` no longer threads an explicit database handle into the contacts,
send and ledger calls. In a local configuration that is behaviour-identical —
the facades default to the same memoised connection — and it stops this module
from opening a local database under a configuration that names an API.
Ratchet, measured over the real `git ls-files` corpus before and after staging
(670/669 files and 8,369,059 chars before; 668/667 and 8,387,638 after, both
well above the floors):
twoArmFamilies 43 -> 42 (-1)
remoteArmModules 43 -> 42 (-1)
routedFacadeDefinitions 30 -> 30
routedCallExpressions 293 -> 293
selfHostedResourceBranches 47 -> 47
selfHostedResourceReferences 203 -> 203
isSelfHostedModeReferences 70 -> 70
getEmailsModeReferences 78 -> 76 (-2)
resolveEmailsModeReferences 73 -> 73
normalizeEmailsModeReferences 16 -> 16
emailsModeEnvReferences 239 -> 239
No metric rose. The dispatch-helper counters do not move because batch was one
of the thirteen facades that dispatched inline and never held the helper.
`src/store/` is byte-identical to main.
andrei-hasna
added a commit
that referenced
this pull request
Jul 27, 2026
`<=` leaves slack a regression can walk back through: a ceiling above the live count is a licence to re-add exactly as many branches as the gap is wide with the guard still green. The delivery-doctor collapse lowers six counts, and two earlier collapses left slack behind — #108 left two structural metrics one wide each and the process-wide-read metric two wide, and #83's note already recorded the tree-wide variable metric three under its ceiling. All eleven ceilings are now exactly the count measured over this commit's git ls-files corpus. Verified two ways, because a ceiling that does not bite is worse than none: the added prose moves no count (the identifiers are named by role and file:line, as the plan requires of anything inside this corpus), and staging a single tracked occurrence of the deployment variable turns the ratchet red at 240 against 239.
This was referenced Jul 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
OPE-MODE-P4, one family:
src/lib/batch. Both arms deleted, one implementation leftbehind the unchanged facade path.
src/lib/batch.tswas a nine-line facade that read the process-wide deployment mode andhanded
batchSendto one of two modules.batch.local.ts(90 lines) rendered a templateper CSV row, checked suppression, sent through the provider adapters and wrote the
sent-mail ledger.
batch.remote.ts(33 lines) threw. Same name, same signature, oppositemeanings — an environment variable decided whether the operation existed at all. That is
the axis this program deletes, and it is now gone from this family.
What replaces the arm choice is the store's own answer. The template is read through
EmailStore.templateswith anameequality filter. The local SQLite store filters andserves the row. A store that cannot answer "the template named X" refuses, and
batchSendthrows that refusal with its code, status and message. A refusal is neverreported as
{ total: n, sent: 0, failed: 0, errors: [] }, because that is the correctanswer for an empty CSV and must not also be the answer for "this installation could not
be asked".
What this PR deliberately does NOT do
Each of these would either rebuild the axis under a new name or do a later phase's work:
src/store-resolution.tsstates thatconstruction is the only place that answer is visible and that its plan union is not a
runtime label for callers to branch on. A branch here on the store kind, or on
descriptor, would be the same switch respelled — so there is none.SendIntentsRepositoryis gated on theidempotency-fenced ledger and both stores declare that capability false
(
src/store-sqlite/index.ts,src/store-http/index.ts);outboundPolicyis false onboth as well. Routing the send there would refuse in every configuration and break
batch sending outright. One send service is phase 8.
reimplemented.
ContactsRepository/TemplatesRepositoryare genericResourceRepository<ResourceRow>CRUD; copying the canonical-address logic or the{{name}}syntax into batch would create a second source of truth for who may bemailed while
src/db/contactsandsrc/db/templatesare still phase-4 families oftheir own.
For (2) and (3) batch imports each family's facade, never an arm — reaching into a
.localmodule is exactly what the deletedbatch.local.tsdid to four separatefamilies. Stated plainly so no reviewer has to infer it: those facades still dispatch on
the axis internally, so this module's dependency on the axis is transitive, not gone.
What is gone is batch's own mode decision. This PR does not touch the axis module, the
dispatch layer, the curl bridge, or any
*.local.*mode-gated branch — all of which arephase 9's, and only once the ratchet reads zero.
Changes outside my family — flagged loudly, as required
Two consumers reached into the arm rather than the facade, so deleting the arm breaks
them. Both are one-line import-specifier swaps and nothing else:
src/cli/commands/misc.local.ts:375— production. Theemails batchcommand'sdynamic import moves from
../../lib/batch.local.jsto../../lib/batch.js. This fileis the
miscfamily (phase 5), not mine and not a sibling agent's. Its printedBatchResultfields are unchanged.src/cli/commands/send-suppression.test.ts:413— the same swap in a test.No other file outside
src/lib/batch.*is touched.src/store/is byte-identical tomain(git diff origin/main -- src/store/produces zero lines).batchSendalso stops threading an explicit database handle into the contacts, send andledger calls. In a local configuration that is behaviour-identical — the facades default
to the same memoised connection — and it stops this module from opening a local SQLite
database under a configuration that names an API.
One additive public-type change:
BatchSendOptionsis now a named export ofsrc/lib/batch.ts(previously the options were an inline type on the function, re-exportedfrom the arm via
export type *).BatchResultand the runtime surface (batchSend,parseCsv) are unchanged, as is the facade's import path.(a) Ratchet delta — all eleven metrics
Measured over the real
git ls-filescorpus, before onorigin/main(2142932) andafter on the staged tree, using
scripts/mode-axis-ratchet-lib.mjsitself:twoArmFamiliesremoteArmModulesroutedFacadeDefinitionsroutedCallExpressionsselfHostedResourceBranchesselfHostedResourceReferencesisSelfHostedModeReferencesgetEmailsModeReferencesresolveEmailsModeReferencesnormalizeEmailsModeReferencesemailsModeEnvReferencesCorpus: 670 tracked / 669 scanned / 8,369,059 chars before; 668 / 667 / 8,387,638 after.
Both are well clear of the 500-file and 5,000,000-char floors.
No metric rose. The two required structural drops are both present. The dispatch-helper
counters do not move because batch was one of the thirteen facades that dispatched
inline rather than through the helper, so it never held a helper definition or a helper
call site; the −2 on the process-wide mode read is batch.ts's import plus its single call.
(b) New tests that FAIL on unmodified main: 9 of 17
Verified the required way: a pristine
origin/mainworktree with onlysrc/lib/batch.test.tscopied in and nothing else changed, thenbun test src/lib/batch.test.ts→ 9 fail / 8 pass.The nine:
The other eight are declared parity coverage, not change detectors: the five
parseCsvcases, "renders the stored template and mails every row over the configuredstore", "skips a suppressed row and counts it", and "reports a template the store does not
hold as not found". They exist to prove the collapse did not lose the local arm's
behaviour, and they are labelled as such rather than counted as evidence of change.
Case 4 is the one that matters most: it asserts the result is not a
BatchResultatall, because a refusal reported as zero sends and zero errors is indistinguishable from an
empty CSV. Case 5 additionally asserts nothing was mailed and nothing was ledgered.
(c) Suite totals
origin/main2142932)bun run testsrc/lib/batch.test.tsAlso clean on this branch:
bunx tsc --noEmit(silent),bun run no-cloud:source(26 pass / 0 fail),
bun run buildthenbun run no-cloud:pack(786 text files /13,055,367 bytes scanned, no markers — well above the pack guard's 100-file and
1,000,000-byte floors).
Adversarial self-review — what it found
Run before opening, hunting the four named classes. It was not empty:
"refuses to guess when a filtered read answers with a row of another name" originally
passed on main. With
_storeinjected but ignored (as main ignores it), the local armlooked
tplup in SQLite, found nothing, and threw the very sameTemplate not found: tplthe test asserted. It now seeds a real, usabletplrow inthe database while the injected store answers with another name's row: on main the batch
finds the real template and sends, so the rejection assertion fails. That moved the
fail-on-main count from 8 to 9.
structural assertion banned every
*.local.jsimport frombatch.tsand immediatelywent red on
./sent-ledger.local.js. That module has no facade and no second arm, soimporting it bypasses no dispatch. Rather than exempt it by name — which would have been
weakening the guard to make the test pass — the predicate now resolves each suffixed
specifier and only flags one whose family also ships a facade, i.e. the actual hazard of
skipping a dispatch other callers go through. It carries positive controls in both
directions (
./batch.local.jsand../db/contacts.local.jsmust be flagged;./sent-ledger.local.jsand./csv.jsmust not), checked against the predicate ratherthan against repo state, per trap feat: add BrowserPlan Mailery address contract #2 in the plan.
okoutcome from the seam throws; there is no path from a refusal to a
BatchResult. Anon-string
subject_templatethrows rather than being coerced, so a stored value thatis not text can never be mailed as its
String(...)rendering.arm's meaning survives without a mode read. Under an API configuration the HTTP
store reads the service's published contract, finds that
/v1/templatesaccepts nonamefilter, and refuses rather than answering with the unfiltered list — so batchfails loudly before any send is attempted. The wording changed (it now names the
missing route instead of saying "not available in the self-hosted client"); nothing in
the tree matched the old string, checked by grep.
repointed to the facade, not adapted.
src/index.ts'sParameters<BatchModule["batchSend"]>re-export still resolves;
src/cli/commands/sequences.ts'sparseCsvimport isuntouched; the three module-list assertions in
src/index.test.tsand the twostartup-contract tests reference the facade path, which is unchanged.
with
FOREIGN KEY constraint failedbecauseemails.provider_idis a real foreign keyand my provider id was invented. The provider is now minted through the seam. That
failure was the loop's per-row error path working correctly.
Not done on purpose
docs/PLAN-MODE-REMOVAL.md§4.3 still lists 13 two-arm families undersrc/lib/(it is now 12). The doc states its numbers were measured on one commit and to re-measure
before trusting them, and three sibling agents are collapsing three other families
concurrently — a four-way edit of the same tables would be conflict for no accuracy gain.
Worth a single follow-up that re-measures the whole table once the parallel P4 batch has
landed.
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.