Skip to content

Commit 56bf27a

Browse 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>
1 parent 77a94d8 commit 56bf27a

6 files changed

Lines changed: 590 additions & 19 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/plugin-email': patch
3+
---
4+
5+
The declared-email-template boot sweep reads the stored rows in bulk and no longer rewrites a template that has not changed
6+
7+
Clause-②: no
8+
9+
Every boot used to look each effective email template up on its own and rewrite its
10+
`sys_email_template` row unconditionally: one lookup, one UPDATE and the engine's two read-backs
11+
per template, whether or not anything had changed. Measured on `ObjectQL` over `SqlDriver`
12+
(better-sqlite3), a steady boot over 450 unchanged templates sent 1,800 statements; it now sends 3
13+
reads and no write (79 templates: 316 statements, now 1).
14+
15+
- The sweep reads the stored rows for the declared names in `$in` pages of 200 names. Each
16+
`(name, locale)` takes the first row the read returns, in the order the driver gives the
17+
per-template lookup, so a slot several organizations hold resolves to the same row as before.
18+
- A template the bulk read did not answer (new since the last boot, a read cut short at its row
19+
bound, or a failed read) is looked up on its own before anything is inserted, as before.
20+
- A `managed_by: 'package'` row that already holds every column the template projects is not
21+
rewritten. `upsertDeclaredEmailTemplate` now returns `false` for it, and
22+
`bootstrapDeclaredEmailTemplates` counts it under `skipped`, the value both already document for
23+
a row deliberately not written. A row with any other provenance is handled exactly as before:
24+
admin-owned and customized rows are never written, and a legacy row with no `managed_by` is
25+
rewritten and adopted.
26+
27+
An org-scoped template edit still survives the next boot. Signatures and accepted input are unchanged.

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,8 @@ cannot read, and they are neither in nor out.
246246

247247
| object name spelled inline | 104 |
248248
| object name spelled through a `const` | 51 |
249-
| object name is an `object: string` parameter | 17 |
250-
| object name is some other run-time expression | 61 |
249+
| object name is an `object: string` parameter | 19 |
250+
| object name is some other run-time expression | 59 |
251251

252252
### Subtractions the census could NOT defend — enforced
253253

@@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
297297
their values are not compared. The reasoning, and the measurement behind it,
298298
are in `scripts/check-tenant-audit-census.mjs`.
299299

300-
Measured on 2026-10-06 at `2bea8b684`.
300+
Measured on 2026-10-07 at `e1171ca48`.
301301

302302
| corpus scale (not enforced) | count |
303303
| :--- | ---: |
304304
| tracked non-test sources scanned | 612 |
305305
| engine-shaped types recognised | 70 |
306306
| declared objects in the registry | 116 |
307-
| same-named calls subtracted as non-engine | 159 |
307+
| same-named calls subtracted as non-engine | 160 |
308308

309309
{/* END GENERATED: tenant-audit-census */}

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-10-06 at `2bea8b684`.
93+
Measured on 2026-10-07 at `e1171ca48`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
9797
| tracked non-test sources scanned | 612 |
9898
| engine-shaped types recognised | 70 |
9999
| declared objects in the registry | 116 |
100-
| same-named calls subtracted as non-engine | 159 |
100+
| same-named calls subtracted as non-engine | 160 |
101101

102102
## Every site
103103

‎packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts‎

Lines changed: 213 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
upsertDeclaredEmailTemplate,
1717
deactivateDeclaredEmailTemplate,
1818
mapTemplateToRow,
19+
SWEEP_NAMES_PER_READ,
20+
SWEEP_ROWS_PER_READ,
1921
} from './bootstrap-declared-email-templates.js';
2022
import { bindEmailTemplateProvenanceStamp } from './email-template-provenance.js';
2123

@@ -54,14 +56,20 @@ class FakeEngine {
5456

5557
private matches(row: any, cond?: Record<string, any>): boolean {
5658
if (!cond) return true;
57-
return Object.entries(cond).every(([k, v]) => row[k] === v);
59+
// [#22062] `$in` as the real engine reads it: the boot sweep's bulk read.
60+
return Object.entries(cond).every(([k, v]) =>
61+
v && typeof v === 'object' && Array.isArray(v.$in) ? v.$in.includes(row[k]) : row[k] === v);
5862
}
5963

6064
async find(name: string, q?: any): Promise<any[]> {
6165
const all = this.rows[name] ?? [];
6266
const cond = q?.filter ?? q?.where;
6367
const out = all.filter((r) => this.matches(r, cond));
64-
return typeof q?.limit === 'number' ? out.slice(0, q.limit) : out;
68+
// [#22062] Rows leave as COPIES, as a real driver's do. Handing out the
69+
// stored objects let a later write reach into a row the caller had
70+
// already read, which no real read does — and it hid a stale copy in the
71+
// sweep's bulk read from every pin here.
72+
return (typeof q?.limit === 'number' ? out.slice(0, q.limit) : out).map((r) => ({ ...r }));
6573
}
6674
async insert(name: string, data: any): Promise<any> {
6775
const arr = (this.rows[name] = this.rows[name] ?? []);
@@ -573,3 +581,206 @@ describe('bootstrapDeclaredEmailTemplates — the effective template (#21785)',
573581
expect(warn).toHaveBeenCalledTimes(1);
574582
});
575583
});
584+
585+
// ---------------------------------------------------------------------------
586+
// [#22062] What a boot costs: a bulk read, and no write for an unchanged row
587+
// ---------------------------------------------------------------------------
588+
589+
/**
590+
* Before, the sweep looked every template up on its own and rewrote its row
591+
* unconditionally: on a steady boot, one lookup, one UPDATE and the engine's two
592+
* read-backs per template. These pin the three halves of the fix: the bulk read
593+
* (and what happens to a key it does not answer), the compare before the write
594+
* (and the controls that must still be written), and the provenance rules the
595+
* compare must not touch. The row-choice pin over several organizations runs on
596+
* the real engine and SQL driver, in `packages/qa/dogfood`
597+
* (`email-template-boot-sweep.test.ts`): this package cannot import a driver.
598+
*/
599+
describe('[#22062] the boot sweep reads stored rows in bulk and rewrites only what changed', () => {
600+
const isBulkRead = (q: any) => Array.isArray(q?.where?.name?.$in);
601+
602+
/** One template carrying a string, a boolean, an optional column and `variables_json`. */
603+
const fullTemplate = () => declaredTemplate({ bodyText: 'Plain body', description: 'Reset mail' });
604+
605+
/** Boot once, then change the stored row the way a case says. `before` is the row as the boot left it. */
606+
async function steadyWith(mutate: (row: any) => void) {
607+
const engine = new FakeEngine({ declared: { email_template: [fullTemplate()] } });
608+
await bootstrapDeclaredEmailTemplates(engine as any, undefined);
609+
const before = { ...rowsOf(engine)[0] };
610+
mutate(rowsOf(engine)[0]);
611+
return { engine, before };
612+
}
613+
614+
it('a boot over unchanged templates writes nothing and reads once per 200 names', async () => {
615+
const declared = Array.from({ length: 450 }, (_, i) => declaredTemplate({ name: `tpl.n${i}` }));
616+
const engine = new FakeEngine({ declared: { email_template: declared } });
617+
await bootstrapDeclaredEmailTemplates(engine as any, undefined);
618+
619+
const find = vi.spyOn(engine, 'find');
620+
const update = vi.spyOn(engine, 'update');
621+
const insert = vi.spyOn(engine, 'insert');
622+
const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined);
623+
624+
expect(result).toEqual({ seeded: 0, skipped: 450 });
625+
expect(update).not.toHaveBeenCalled();
626+
expect(insert).not.toHaveBeenCalled();
627+
// ceil(450 / 200): the bulk reads only. A steady boot has no key the bulk
628+
// read leaves unanswered, so no per-template lookup is added.
629+
expect(SWEEP_NAMES_PER_READ).toBe(200);
630+
expect(find).toHaveBeenCalledTimes(3);
631+
expect(find.mock.calls.every(([, q]) => isBulkRead(q))).toBe(true);
632+
expect(rowsOf(engine)).toHaveLength(450);
633+
});
634+
635+
it.each([
636+
['a string column (`subject`)', (r: any) => { r.subject = 'Stale wording'; }, 'subject'],
637+
['a boolean column holding the other boolean (`active`)', (r: any) => { r.active = false; }, 'active'],
638+
['a boolean column holding the other 0/1 (`active`)', (r: any) => { r.active = 0; }, 'active'],
639+
['an optional column that is null (`body_text`)', (r: any) => { r.body_text = null; }, 'body_text'],
640+
['an optional column that is absent (`body_text`)', (r: any) => { delete r.body_text; }, 'body_text'],
641+
['`variables_json` holding other text', (r: any) => { r.variables_json = '[]'; }, 'variables_json'],
642+
[
643+
'`variables_json` handed back parsed, and different',
644+
(r: any) => { r.variables_json = [{ name: 'other', type: 'string', required: false }]; },
645+
'variables_json',
646+
],
647+
] as const)('control: rewrites a package row whose projected column differs: %s', async (_case, mutate, column) => {
648+
const { engine, before } = await steadyWith(mutate);
649+
const update = vi.spyOn(engine, 'update');
650+
651+
const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined);
652+
653+
expect(result).toEqual({ seeded: 1, skipped: 0 });
654+
expect(update).toHaveBeenCalledTimes(1);
655+
expect(rowsOf(engine)[0][column]).toEqual(before[column]);
656+
});
657+
658+
it.each([
659+
['a boolean stored as 1 (`active: true`)', (r: any) => { r.active = 1; }],
660+
['a boolean stored as 0 (`is_system: false`)', (r: any) => { r.is_system = 0; }],
661+
['`variables_json` handed back parsed', (r: any) => { r.variables_json = JSON.parse(r.variables_json); }],
662+
['a column the projection omits, holding a value (`from_address`)', (r: any) => { r.from_address = 'ops@example.com'; }],
663+
] as const)('leaves a package row that already holds the projection unwritten: %s', async (_case, mutate) => {
664+
const { engine } = await steadyWith(mutate);
665+
const update = vi.spyOn(engine, 'update');
666+
667+
const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined);
668+
669+
expect(result).toEqual({ seeded: 0, skipped: 1 });
670+
expect(update).not.toHaveBeenCalled();
671+
});
672+
673+
it('still adopts a legacy row with no `managed_by`, even one that already holds the projection', async () => {
674+
const { engine } = await steadyWith((r) => { delete r.managed_by; });
675+
const update = vi.spyOn(engine, 'update');
676+
677+
const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined);
678+
679+
expect(result).toEqual({ seeded: 1, skipped: 0 });
680+
expect(update).toHaveBeenCalledTimes(1);
681+
expect(rowsOf(engine)[0].managed_by).toBe('package');
682+
});
683+
684+
it('keeps the first row a key meets in the bulk read: the row the per-template lookup returns', async () => {
685+
const stale = (id: string, organization_id: string) => ({
686+
id, organization_id, name: 'auth.password_reset', locale: 'en-US', subject: `stale ${id}`, managed_by: 'package',
687+
});
688+
const engine = new FakeEngine({
689+
rows: { [TABLE]: [stale('etpl_first', 'org_b'), stale('etpl_second', 'org_a')] },
690+
declared: { email_template: [declaredTemplate()] },
691+
});
692+
const [chosen] = await engine.find(TABLE, { where: { name: 'auth.password_reset', locale: 'en-US' }, limit: 1 });
693+
expect(chosen.id).toBe('etpl_first');
694+
695+
await bootstrapDeclaredEmailTemplates(engine as any, undefined);
696+
697+
expect(rowsOf(engine).map((r) => [r.id, r.subject])).toEqual([
698+
['etpl_first', PACKAGE_WORDING],
699+
['etpl_second', 'stale etpl_second'],
700+
]);
701+
});
702+
703+
it('looks a key the bulk read did not answer up on its own BEFORE inserting anything', async () => {
704+
const engine = new FakeEngine({ declared: { email_template: [declaredTemplate({ name: 'ops.new' })] } });
705+
const find = vi.spyOn(engine, 'find');
706+
const insert = vi.spyOn(engine, 'insert');
707+
708+
const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined);
709+
710+
expect(result).toEqual({ seeded: 1, skipped: 0 });
711+
expect(find).toHaveBeenCalledTimes(2);
712+
expect(isBulkRead(find.mock.calls[0][1])).toBe(true);
713+
expect(find.mock.calls[1][1]).toMatchObject({ where: { name: 'ops.new', locale: 'en-US' }, limit: 1 });
714+
expect(insert).toHaveBeenCalledTimes(1);
715+
expect(find.mock.invocationCallOrder[1]).toBeLessThan(insert.mock.invocationCallOrder[0]);
716+
});
717+
718+
it('never inserts a row twice when a bulk read is cut short at its bound', async () => {
719+
// Other locales of the same name fill the bound, stored BEFORE the declared
720+
// slot's own row, so the bulk read stops exactly where that row would come.
721+
const filler = Array.from({ length: SWEEP_ROWS_PER_READ }, (_, i) => ({
722+
id: `etpl_fill_${i}`, name: 'auth.password_reset', locale: `x-${i}`, subject: 'Other locale', managed_by: 'package',
723+
}));
724+
const engine = new FakeEngine({ rows: { [TABLE]: filler }, declared: { email_template: [declaredTemplate()] } });
725+
expect(await bootstrapDeclaredEmailTemplates(engine as any, undefined)).toEqual({ seeded: 1, skipped: 0 });
726+
// ANTI-VACUITY: the bulk read really does not reach the slot's row.
727+
const page = await engine.find(TABLE, {
728+
where: { name: { $in: ['auth.password_reset'] } },
729+
limit: SWEEP_ROWS_PER_READ,
730+
});
731+
expect(page.some((r) => r.locale === 'en-US')).toBe(false);
732+
733+
const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined);
734+
735+
expect(result).toEqual({ seeded: 0, skipped: 1 });
736+
expect(rowsOf(engine).filter((r) => r.locale === 'en-US')).toHaveLength(1);
737+
expect(rowsOf(engine)).toHaveLength(SWEEP_ROWS_PER_READ + 1);
738+
});
739+
740+
it('looks every template up on its own when the bulk read fails, inserting nothing twice', async () => {
741+
class FailingBulkRead extends FakeEngine {
742+
override async find(name: string, q?: any): Promise<any[]> {
743+
if (Array.isArray(q?.where?.name?.$in)) throw new Error('bulk read failed');
744+
return super.find(name, q);
745+
}
746+
}
747+
const declared = [declaredTemplate(), declaredTemplate({ name: 'ops.digest', category: 'notification' })];
748+
const engine = new FailingBulkRead({ declared: { email_template: declared } });
749+
const warn = vi.fn();
750+
expect(await bootstrapDeclaredEmailTemplates(engine as any, undefined, { warn })).toEqual({ seeded: 2, skipped: 0 });
751+
752+
const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined, { warn });
753+
754+
expect(result).toEqual({ seeded: 0, skipped: 2 });
755+
expect(rowsOf(engine)).toHaveLength(2);
756+
// Said once per boot, never once per template.
757+
expect(warn).toHaveBeenCalledTimes(2);
758+
expect(warn.mock.calls[1][1]).toEqual({ error: 'bulk read failed' });
759+
});
760+
761+
it('meets the row as it now is when the list names one slot twice (a package entry, then its env-wide overlay)', async () => {
762+
const overlay = declaredTemplate({ subject: OVERLAY_WORDING });
763+
const engine = new FakeEngine({ declared: { email_template: [overlay] } });
764+
await bootstrapDeclaredEmailTemplates(engine as any, undefined);
765+
// The registry lists an env-wide overlay AFTER the package entry it overrides.
766+
(engine as any).declared = { email_template: [declaredTemplate(), overlay] };
767+
768+
await bootstrapDeclaredEmailTemplates(engine as any, undefined);
769+
770+
expect(rowsOf(engine)).toHaveLength(1);
771+
expect(rowsOf(engine)[0].subject).toBe(OVERLAY_WORDING);
772+
});
773+
774+
it('the live door shares the write step: an unchanged save writes nothing, a changed one writes', async () => {
775+
const engine = new FakeEngine();
776+
expect(await upsertDeclaredEmailTemplate(engine as any, declaredTemplate())).toBe(true);
777+
const update = vi.spyOn(engine, 'update');
778+
779+
expect(await upsertDeclaredEmailTemplate(engine as any, declaredTemplate())).toBe(false);
780+
expect(update).not.toHaveBeenCalled();
781+
782+
expect(await upsertDeclaredEmailTemplate(engine as any, declaredTemplate({ subject: 'Saved again' }))).toBe(true);
783+
expect(update).toHaveBeenCalledTimes(1);
784+
expect(rowsOf(engine)[0].subject).toBe('Saved again');
785+
});
786+
});

0 commit comments

Comments
 (0)