Repository navigation
fix(plugin-email): the boot sweep reads stored templates in bulk and no longer rewrites an unchanged row - #22065
Conversation
…skips a row that already holds its projection The declared-template boot sweep looked every effective template up on its own and rewrote its row unconditionally: one lookup, one UPDATE and the engine's two read-backs per template, on every boot. It now reads the stored rows for the declared names in bounded `$in` pages, looks a key the bulk read did not answer up on its own before inserting, and leaves a package-managed row that already holds the projected columns unwritten. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…ite controls and miss fallback Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…ady-boot cost on the real SQL driver Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…a real driver does Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…e-write (patch) Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…ed write step The two sys_email_template writes moved into a function that takes the object name as a typed string parameter, so the census now places them under that row. The population is unchanged. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check11 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d1c2852c369c36024518af04d8dee54d1871a85d && git checkout d1c2852c369c36024518af04d8dee54d1871a85d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5cfd8661c4deef4716d1895883633003a54a9db7 ef2a35d986890908b26df16365f3a6afbe2af724 && git checkout -B drift-repro 5cfd8661c4deef4716d1895883633003a54a9db7 && git merge --no-ff ef2a35d986890908b26df16365f3a6afbe2af724
node scripts/docs-audit/affected-docs.mjs --json 5cfd8661c4deef4716d1895883633003a54a9db7 |
Fixes #22062
Clause-②: no
Measured on the real
ObjectQLengine overSqlDriver(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 lookupfind(..., { where: { name, locale }, limit: 1 })goes out asselect * from sys_email_template where name = ? and locale = ? order by id asc limit ?, because the SQL driver orders every paged read byid; the same read in bulk with nolimitgets no ORDER BY at all and walks storage order, and on a(name, locale)held by three organizations and the global row it metetpl_mfirst where the lookup returnsetpl_c. So the bulk read is paged too (limitof 1,000 rows per 200 names, noorderBy), 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$inpages 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.tsdoes not re-export them.upsertDeclaredEmailTemplateand the sweep share one write step. That step does not rewrite amanaged_by: 'package'row that already holds every columnmapTemplateToRowprojects, 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/0for a projected boolean, or (forvariables_jsonalone) a parsed value whoseJSON.stringifyis the projected text exactly. An absent ornullcolumn is never held.managed_by(or aplatformone) is rewritten and adopted, as before.bootstrap-declared-email-templates.test.ts: 19 new cases. The fake engine also learns$inand 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-emailcannot import a driver for its own suite:driver-sqlis not its dependency, and itscheck:test-source-aliasledger entry is shrink-only. The dogfood package already declaresobjectql,driver-sql,platform-objectsandplugin-email.content/docs/permissions/tenant-audit-census.mdxanddocs/audits/2026-08-tenant-audit-write-call-sites.counts.md: regenerated bynode scripts/tenant-audit-census.mjs --write, ascheck:tenant-audit-censusprescribes. The twosys_email_templatewrites moved into a function that takes the object name as a typedobject: stringparameter, so two sites moved from "some other run-time expression" (61 to 59) to "anobject: stringparameter" (17 to 19). The population is unchanged..changeset/22062-email-template-boot-sweep.md:patchfor@objectstack/plugin-email.Hypotheses, measured first
findwith nolimitover 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.sys_email_templateas managed on every boot (with or without schema sync), soidis always its tie-breaker.TursoDriverextendsSqlDriverand overrides neitherfindRowsnororderKeysFor; the hosted half is verified on objectstack-ai/cloud#2637.active,is_systemandcustomizedread back as JS booleans,variables_jsonas text, an unset optional column asnull.$inis honoured:where name in (?); 3 named plus 1 absent name returned 3 rows, 200 names returned 200.email-plugin.tscallsupsertDeclaredEmailTemplateat 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 returnsfalse, the value its docs already define as "deliberately skipped".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 againstHEAD), watched red, and restored. Unit legs ran ate1171ca48e; the real-engine legs rebuilt@objectstack/plugin-emailand proved the marker in and then out ofdist/withscripts/ablation-dist-preflight.mjs.limit)etpl_mrewritten) and the steady-boot statement shape{ seeded: 450, skipped: 0 }etpl_zrewritten)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 toe1171ca48e, the census commit touches docs only)pnpm --filter @objectstack/plugin-email test: 31 files, 533 tests passed.typecheck:tsc --noEmitandcheck:test-typecheckboth green.email-template-boot-sweep.test.ts(3),email-template-materialization.dogfood.test.ts(2) andemail-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 typecheckgreen.node scripts/pm/dispatch-gates.mjsderived 93 families on this change; all 93 ran and exited 0 atef2a35d986, reconciled with--ran(0 NOT-MEASURED, 0 UNRUN).eslint.config.mjs's linted population (--print-configresolves each), 0 errors and 0 warnings by--format jsonatef2a35d986. The config enables no type-aware linting, so this diff cannot move a verdict on an untouched file. The repo-widepnpm lintis CI's.Acceptance notes
(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.References
repo:cloudseat.08adfeade8is untouched.Generated by Claude Code