You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 56bf27a
Browse filesBrowse the repository at this point in the historyBrowse files
fix(plugin-email): the boot sweep reads stored templates in bulk and no longer rewrites an unchanged row (#22065)
Fixes#22062
Clause-②: no
Measured on the real `ObjectQL` engine over `SqlDriver` (better-sqlite3
`:memory:`), the base sweep (`56c88446ea`) and this branch's sweep on
one harness: a steady boot over 79 unchanged templates went from 316
statements (237 reads, 79 UPDATEs) to 1 read, and over 450 templates
from 1,800 statements (1,350 reads, 450 UPDATEs) to 3 reads and no
write. A first boot pays one bulk read per 200 names on top (79: 158 to
159; 450: 900 to 903), and a boot where one template changed costs 4 and
6 statements instead of 316 and 1,800. Row choice, as measured: the
per-template lookup `find(..., { where: { name, locale }, limit: 1 })`
goes out as `select * from sys_email_template where name = ? and locale
= ? order by id asc limit ?`, because the SQL driver orders every paged
read by `id`; the same read in bulk with no `limit` gets no ORDER BY at
all and walks storage order, and on a `(name, locale)` held by three
organizations and the global row it met `etpl_m` first where the lookup
returns `etpl_c`. So the bulk read is paged too (`limit` of 1,000 rows
per 200 names, no `orderBy`), the very shape of the lookup, and each key
keeps the first row it meets in the read's own order; the draft's
"smallest string id" happens to coincide with that on SQLite but is not
the rule. Miss safety: the preferred shape. A key the bulk read did not
answer (a template new since the last boot, a read cut short at its row
bound, or a failed read) is looked up on its own before anything is
inserted; a steady boot has no such key and pays nothing for it.
## What changed
-
`packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts`
- The boot sweep reads the stored rows for the declared names in `$in`
pages of 200 (`SWEEP_NAMES_PER_READ`), each bounded at 1,000 rows
(`SWEEP_ROWS_PER_READ`). Both constants are exported from the module for
its own tests only; `src/index.ts` does not re-export them.
- `upsertDeclaredEmailTemplate` and the sweep share one write step. That
step does not rewrite a `managed_by: 'package'` row that already holds
every column `mapTemplateToRow` projects, which is exactly the column
set the UPDATE writes. The compare errs toward rewriting: a value is
held only when it is the projected value, `1`/`0` for a projected
boolean, or (for `variables_json` alone) a parsed value whose
`JSON.stringify` is the projected text exactly. An absent or `null`
column is never held.
- Admin-owned and customized rows are never written, as before; a legacy
row with no `managed_by` (or a `platform` one) is rewritten and adopted,
as before.
- After any write the sweep drops that key from its bulk copy. The
registry lists an env-wide overlay after the package entry for the same
slot, so the later item must meet the row as it now is. The card's draft
compared it against the stale copy and would have reverted every
env-wide overlay on each registry-path boot; pinned below (A11).
- `bootstrap-declared-email-templates.test.ts`: 19 new cases. The fake
engine also learns `$in` and now hands out row copies, as a real driver
does: it used to hand out the stored objects, which hid the stale-copy
defect above from every pin.
- `packages/qa/dogfood/test/email-template-boot-sweep.test.ts` (new):
the real-engine pins. They live in dogfood because
`@objectstack/plugin-email` cannot import a driver for its own suite:
`driver-sql` is not its dependency, and its `check:test-source-alias`
ledger entry is shrink-only. The dogfood package already declares
`objectql`, `driver-sql`, `platform-objects` and `plugin-email`.
- `content/docs/permissions/tenant-audit-census.mdx` and
`docs/audits/2026-08-tenant-audit-write-call-sites.counts.md`:
regenerated by `node scripts/tenant-audit-census.mjs --write`, as
`check:tenant-audit-census` prescribes. The two `sys_email_template`
writes moved into a function that takes the object name as a typed
`object: string` parameter, so two sites moved from "some other run-time
expression" (61 to 59) to "an `object: string` parameter" (17 to 19).
The population is unchanged.
- `.changeset/22062-email-template-boot-sweep.md`: `patch` for
`@objectstack/plugin-email`.
## Hypotheses, measured first
1. Truncation. The engine has no default page size: `find` with no
`limit` over 1,500 rows returned 1,500. The only bound is the one this
branch adds, and a key it cuts off falls back to the per-template
lookup.
2. Row choice: above. It is deterministic on the SQL driver for this
object: the driver registers `sys_email_template` as managed on every
boot (with or without schema sync), so `id` is always its tie-breaker.
`TursoDriver` extends `SqlDriver` and overrides neither `findRows` nor
`orderKeysFor`; the hosted half is verified on
objectstack-ai/cloud#2637.
3. Stored shapes on better-sqlite3: `active`, `is_system` and
`customized` read back as JS booleans, `variables_json` as text, an
unset optional column as `null`.
4. `$in` is honoured: `where name in (?)`; 3 named plus 1 absent name
returned 3 rows, 200 names returned 200.
5. Live doors: `email-plugin.ts` calls `upsertDeclaredEmailTemplate` at
two sites (the delete-reveal path and the runtime-write path); neither
reads the return value, and no other caller exists in this repository. A
skip returns `false`, the value its docs already define as "deliberately
skipped".
6. Gain: the first paragraph.
## Pins and their ablations
Each negative pin was put back to its forbidden behaviour through
`scripts/ablation-replace.mjs` (anchor must hit, write and restore
proven by blob against `HEAD`), watched red, and restored. Unit legs ran
at `e1171ca48e`; the real-engine legs rebuilt
`@objectstack/plugin-email` and proved the marker in and then out of
`dist/` with `scripts/ablation-dist-preflight.mjs`.
| leg | forbidden behaviour put back | red |
|:--|:--|:--|
| A1 | no compare before the write | 8 unit cases, incl. the steady-boot
pin and the live door |
| A2 | no bulk read | steady boot (find count), miss lookup order,
failed read |
| A3 | every stored value reads as held | all 7 column-kind controls,
plus 5 more |
| A5 | the last row a key meets wins | unit row choice |
| A6 | a key the bulk read did not answer is taken as no row | miss
lookup order, truncated read (double insert), duplicate slot |
| A6+A7 | a failed bulk read answers an empty set, and a miss is taken
as no row | failed read (double insert), plus the three above |
| A8 | the compare ignores provenance | legacy row is no longer adopted
|
| A9 / A10 | customized / admin-owned rows are written | the existing
seed-not-clobber cases |
| A11 | the bulk copy is never dropped after a write | duplicate slot:
overlay reverted to the package wording |
| D1 | the bulk read is unpaged (no `limit`) | real engine: row choice
(`etpl_m` rewritten) and the steady-boot statement shape |
| D2 | no compare before the write | real engine: steady boot answered
`{ seeded: 450, skipped: 0 }` |
| D3 | the last row a key meets wins | real engine: row choice (`etpl_z`
rewritten) |
A11's first run was green: the fake engine handed out live row objects,
so the "stale" copy was the stored row itself. The fake now returns
copies and A11 is red. Every restore leg: 40 of 40 unit cases and 3 of 3
real-engine cases green.
## Verification (head `ef2a35d986`; the source and test bytes are
identical to `e1171ca48e`, the census commit touches docs only)
- `pnpm --filter @objectstack/plugin-email test`: 31 files, 533 tests
passed. `typecheck`: `tsc --noEmit` and `check:test-typecheck` both
green.
- Dogfood: `email-template-boot-sweep.test.ts` (3),
`email-template-materialization.dogfood.test.ts` (2) and
`email-template-overlay-survives-boot.dogfood.test.ts` (3): 8 passed.
The last is the real-showcase pin that an org-scoped template edit
survives the next cold boot. `pnpm --filter @objectstack/dogfood
typecheck` green.
- Gates: `node scripts/pm/dispatch-gates.mjs` derived 93 families on
this change; all 93 ran and exited 0 at `ef2a35d986`, reconciled with
`--ran` (0 NOT-MEASURED, 0 UNRUN).
- Lint, a declared narrowing: the 3 touched TS files, all inside
`eslint.config.mjs`'s linted population (`--print-config` resolves
each), 0 errors and 0 warnings by `--format json` at `ef2a35d986`. The
config enables no type-aware linting, so this diff cannot move a verdict
on an untouched file. The repo-wide `pnpm lint` is CI's.
## Acceptance notes
- Boundary, not measured: on a case-insensitive MySQL collation, the
bulk map keys `(name, locale)` by exact string while the per-template
lookup matches by collation. A case-variant miss falls back to the
lookup, which is the safe direction; two organizations holding the same
slot in different letter case could resolve to different rows on the two
paths.
- The live doors still log "materialized" after a write that was skipped
because nothing changed, as they already did after an admin or
customized skip.
## References
- objectstack-ai/cloud#2637: the hosted half, verified there by the
`repo:cloud` seat.
- #21785: its contract (an org-scoped template edit survives a boot) is
kept, its unit cases and its dogfood file stay green, and `08adfeade8`
is untouched.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
0 commit comments