Skip to content

fix(plugin-email): the boot sweep reads stored templates in bulk and no longer rewrites an unchanged row - #22065

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22062-email-template-boot-sweep
Oct 7, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22062-email-template-boot-sweep

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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


Generated by Claude Code

claude added 6 commits October 7, 2026 06:53
…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>
…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 7, 2026
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

11 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
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5cfd8661c4deef4716d1895883633003a54a9db7 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d1c2852c369c36024518af04d8dee54d1871a85d — the merge of head ef2a35d986890908b26df16365f3a6afbe2af724 into base 5cfd8661c4deef4716d1895883633003a54a9db7, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 7, 2026 08:20
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 7, 2026 08:21
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 7, 2026
Merged via the queue into main with commit 56bf27a Oct 7, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22062-email-template-boot-sweep branch October 7, 2026 08:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants