Skip to content

Commit e29a1b1

Browse files
fix(objectql): an artifact hot reload refreshes every registry entry the boot registered, so an edited email template is what the next send renders (#27596)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu96YrKSitCWAfPtJGCAS6LD)_ Fixes #27488 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:`os dev` 热重载 artifact 后,引擎把它已装的包重新登记一遍,邮件模板、流程、仪表盘、页面、按钮等所有类型的列表读与发送读都跟上新版本。 - **为什么改**:开发者改了邮件模板源码,按名字读已经是新文案,但列表和实际发出的邮件还是旧文案,要冷重启才生效;流程热重载也绑的是旧定义。 - **风险与回滚**:只走开发热重载路径,冷启动和 `PUT /meta` 保存不受影响(有测试钉住);只刷新已装的包,禁用状态保留;回滚即还原 `packages/objectql/src/plugin.ts` 一处。 - **先看**:`packages/objectql/src/plugin.ts`(`refreshReloadedPackages` 与它在 `metadata:reloaded` 钩子里的调用)、`packages/runtime/src/standalone-stack-artifact-reload-registry-refresh.test.ts`、`packages/plugins/plugin-email/src/template-artifact-reload.integration.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/runtime exec vitest run src/standalone-stack-artifact-reload-registry-refresh.test.ts`,预期 3 条全绿;去掉钩子里那一行调用则第一条转红,列表读停在 v1。 - **需要你做的**:无,席位落地。 ## What changes The boot registers an artifact twice: the artifact door (`MetadataPlugin`) into the metadata service, and `AppPlugin` into the SchemaRegistry through the `manifest` service. A hot reload (artifact-file watcher or HMR POST, both `_reloadAndAnnounce`) re-registered only the first, and ObjectQL's `metadata:reloaded` hook re-ingested objects alone. The by-name read asks the metadata service first, so it served the edit; the list read and its execution face (`getMetaItemsForExecution`) lay the registry over the metadata service, so they kept the boot copy until a cold restart. The email plugin renders a send from that execution face, and the automation engine re-binds flows from it on the same reload. The hook now calls `refreshReloadedPackages` before the object ingest: each package body on the payload that the registry already holds (source `code` or `marketplace`) is re-registered through `registerApp`, the body door the boot used, under its installed source, with `resolveArtifactPackageEntries` for the order (the call the `manifest` service makes). `installPackage` keeps the lifecycle on a re-install, so a disabled package stays disabled. A package the boot never put in the registry is left to the artifact door as before (nothing of it is stale there). A body that fails to re-register is named in one `warn` line and keeps its previous entries; the reload goes on. Scope (the card's first deliverable): measured on the real `createStandaloneStack` composition, every registry-backed type was stale, not only `email_template`. With each type at v1 at boot and v2 in the file, before the fix: | type | list | execution face | by name | |---|---|---|---| | `email_template`, `dashboard`, `flow`, `page`, `action` | v1 | v1 | v2 | | `object` | v2 | v2 | v2 | So the fix refreshes every collection `registerApp` decomposes. That changes no semantics for the other types: it makes their list and execution reads agree with the by-name read, the same defect. The flow re-bind on reload (service-automation's `metadata:reloaded` re-sync) now binds the edited flow, which is what that re-sync was written for. File surface: `packages/objectql/src/plugin.ts` only, plus a test in `packages/runtime` and one in `packages/plugins/plugin-email`. No `packages/spec`, no `objectql/src/engine.ts`. Branch is the card thread's harness-pinned `claude/project-thread-mtq6wj` (the claim is the identity record). ## Tests - `packages/runtime/src/standalone-stack-artifact-reload-registry-refresh.test.ts` (new): the real `os dev` composition and the real reload body. Pins list, execution face and by-name for six types after one and two reloads (v1 positive control at boot = cold boot), the package record (source, enabled, status) across a reload, and that an environment save through `saveMetaItem` still wins on all three reads after a reload re-registers the package beneath it. - `packages/plugins/plugin-email/src/template-artifact-reload.integration.test.ts` (new): real `ObjectQLPlugin`, the app registered through the `manifest` service at init as `AppPlugin` does, the artifact door's reload payload, and a real `EmailService` send over the loader `EmailServicePlugin` wires. The send renders the reloaded subject and the list serves it. Red before the fix: plugin-email `expected 'Task done (v1): Ship it' to be 'Task done (v2): Ship it'`; runtime: the five types above listed v1 on both list faces. ## Acceptance notes - Items removed from the artifact stay registered until restart, the lifecycle objects already have on this path. Not measured as a defect here; noted only. - A reload that adds a brand-new package to the artifact does not install it into the registry (refresh only); the artifact door serves it, as before. ## Verification <details><summary>Ablation (fix commit 41d95a7): drop the refresh call, both pins go red; restore, both green</summary> `scripts/ablation-replace.mjs --hold` replaced `this.refreshReloadedPackages(ctx, payload);` in `packages/objectql/src/plugin.ts` with a marker statement (on-disk counts: marker 1, call 0), then `pnpm --filter @objectstack/objectql build`, and `scripts/ablation-dist-preflight.mjs objectql '__ABLATE_27488'` found the marker in `dist/index.js` and `dist/index.mjs` (the JS reached dist; the DTS step reported the now-unused method, which does not change the JS the suites load). - plugin-email pin: red, `expected 'Task done (v1): Ship it' to be 'Task done (v2): Ship it'`. - runtime pin: red, `1 failed | 1 passed (2)` (the reload case; list and execution face back on v1). Restore: `--restore` gave blob after restore == blob at HEAD (`4aa979799172`), `git diff HEAD` empty; rebuilt, `--absent` preflight: marker absent from all 14 built files, tree clean. Both pins green again (`1 passed (1)`, `2 passed (2)`). The preservation pin for an environment save was added after the ablation; it is a keep-alone pin, green with the fix. </details> <details><summary>Suites (after merging origin/main at 37e2caf, closure rebuilt)</summary> - `pnpm --filter @objectstack/objectql test`: Test Files 486 passed (486), Tests 9128 passed (9128). - `pnpm --filter @objectstack/plugin-email test`: Test Files 45 passed (45), Tests 747 passed (747). - `pnpm --filter @objectstack/runtime exec vitest run src/standalone-stack-artifact-reload-registry-refresh.test.ts`: 3 passed (3). The rest of the runtime suite is left to CI (`Test Core`): the diff adds one test file there and changes no runtime source. - typecheck: `@objectstack/objectql`, `@objectstack/plugin-email` (incl. `check:test-typecheck`), `@objectstack/runtime`: all exit 0. - eslint `--no-inline-config` on the three changed files: exit 0. </details> <details><summary>Derived gates on head ba97ffc: 63 derived, 62 run (all exit 0), 1 NOT MEASURED</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this tree, every command run with its exit code captured before any pipe, then `--ran`: "63 derived famil(ies) accounted for — 62 run, 1 NOT-MEASURED". - NOT MEASURED: `pnpm check:dual-build-cjs-loads`, exit 3 (prerequisite: 35 workspace packages outside this diff's build closure have no `dist/` in this container). The diff changes no export and no package entry; CI's `Lint & Repo Gates` runs it on a full build. - `check-plugin-teardown-shape --self-test` needed its pinned fixture commit fetched into this shallow clone, then 48 cases pass. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9c51314 commit e29a1b1

4 files changed

Lines changed: 449 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): an artifact hot reload refreshes every registry entry the boot registered, so an edited email template is what the next send renders
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
On the dev loop (`os dev`, artifact watcher on), editing a declared item and letting the artifact reload used to refresh only the by-name read. The list read (`GET /api/v1/meta/<type>`) and its execution face (`getMetaItemsForExecution`) kept the boot copy until a cold restart, because the `metadata:reloaded` hook re-ingested objects alone and those reads lay the SchemaRegistry over the metadata service. The execution face is what the email plugin renders a send from and what the automation engine re-binds flows from on the same reload, so recipients kept getting the old wording and an edited flow kept its old body.
11+
12+
Now the hook re-registers each reloaded package body the registry already holds, through `registerApp` (the door the boot registered it through), under the source it was installed with. Every collection that door decomposes (email templates, flows, views, dashboards, pages, actions and the rest) holds the reloaded item, and the package's lifecycle is kept: a disabled package stays disabled. A package the boot never registered into the registry is left to the artifact door, as before. Items removed from the artifact stay registered until restart, as objects already do. A package that fails to re-register is named in a `warn` line and keeps serving what it served before.
13+
14+
Cold boots and `PUT /api/v1/meta` saves are unchanged.

‎packages/objectql/src/plugin.ts‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,6 +1100,13 @@ export class ObjectQLPlugin implements Plugin {
11001100
// (`skipSchemaSync` / OS_SKIP_SCHEMA_SYNC) for deployments that
11011101
// manage DDL out-of-band, and serializes through
11021102
// `reloadSchemaSync` so overlapping reload events can't race DDL.
1103+
// 3. Every OTHER item the boot registered through the `manifest`
1104+
// service kept its boot copy in the registry, which the list read
1105+
// and its execution face lay over the metadata service: an edited
1106+
// email template sent its old wording, an edited flow re-bound its
1107+
// old body, while the by-name read served the edit. See
1108+
// {@link refreshReloadedPackages}.
1109+
this.refreshReloadedPackages(ctx, payload);
11031110
this.ingestReloadedObjects(ctx, payload);
11041111
if (this.skipSchemaSync) {
11051112
// DDL is managed out-of-band here, but the objects this reload just
@@ -1376,6 +1383,71 @@ export class ObjectQLPlugin implements Plugin {
13761383
return owner.packageId === packageId && !tenantAuthored ? 'own' : 'overlay';
13771384
}
13781385

1386+
/**
1387+
* Re-register each package body carried on a `metadata:reloaded` payload
1388+
* through `registerApp`, the body door the boot registered it through (the
1389+
* `manifest` service), so every collection that door decomposes into the
1390+
* SchemaRegistry (`METADATA_ARRAY_KEYS`: email templates, flows, views,
1391+
* dashboards, pages, actions, …) holds the reloaded item, not the boot copy.
1392+
*
1393+
* A REFRESH, never an install: only a package the registry already holds
1394+
* from code or the marketplace is re-registered, under the source it was
1395+
* installed with, and `installPackage` keeps its lifecycle (a disabled
1396+
* package stays disabled). A body the boot never registered — the artifact
1397+
* door alone serves it, so nothing in the registry is stale — is left to
1398+
* that door, as before. Ordering is `resolveArtifactPackageEntries`', the
1399+
* same call the `manifest` service makes on the same artifact.
1400+
*
1401+
* Items removed from the artifact stay registered until restart, the
1402+
* lifecycle {@link ingestReloadedObjects} keeps for objects. A body that
1403+
* fails to register is named and skipped: the reload goes on, and that
1404+
* package keeps serving what it served before.
1405+
*/
1406+
private refreshReloadedPackages(ctx: PluginContext, payload: unknown): void {
1407+
if (!this.ql) return;
1408+
const metadata = (payload as { metadata?: unknown } | undefined)?.metadata as Record<string, any> | undefined;
1409+
if (!metadata || typeof metadata !== 'object') return;
1410+
// The shape `AppPlugin` hands the `manifest` service: the manifest's
1411+
// fields with the collections spread over them.
1412+
const artifact = metadata.manifest && typeof metadata.manifest === 'object'
1413+
? { ...metadata.manifest, ...metadata }
1414+
: metadata;
1415+
let entries: ReturnType<typeof resolveArtifactPackageEntries>;
1416+
try {
1417+
entries = resolveArtifactPackageEntries(
1418+
Array.isArray(artifact.packages) ? this.ql.convertPackageBodyForward(artifact) : artifact,
1419+
);
1420+
} catch (e: any) {
1421+
ctx.logger.warn('[ObjectQLPlugin] reload could not read the artifact\'s packages — registry entries keep their previous definitions', {
1422+
error: e?.message,
1423+
});
1424+
return;
1425+
}
1426+
const scope = {
1427+
packageIds: entries
1428+
.map((entry) => artifactPackageId(entry.manifest))
1429+
.filter((id): id is string => id !== undefined),
1430+
};
1431+
const refreshed: string[] = [];
1432+
for (const { manifest } of entries as Array<{ manifest: any }>) {
1433+
const id = artifactPackageId(manifest);
1434+
const installed = id === undefined ? undefined : this.ql.registry.getPackage(id);
1435+
if (!installed || (installed.source !== 'code' && installed.source !== 'marketplace')) continue;
1436+
try {
1437+
this.ql.registerApp(manifest, scope, installed.source);
1438+
refreshed.push(id!);
1439+
} catch (e: any) {
1440+
ctx.logger.warn('[ObjectQLPlugin] reload could not re-register a package — its registry entries keep their previous definitions', {
1441+
packageId: id,
1442+
error: e?.message,
1443+
});
1444+
}
1445+
}
1446+
if (refreshed.length > 0) {
1447+
ctx.logger.info('[ObjectQLPlugin] reload refreshed the registry entries of its packages', { packageIds: refreshed });
1448+
}
1449+
}
1450+
13791451
private ingestReloadedObjects(ctx: PluginContext, payload: unknown): void {
13801452
if (!this.ql) return;
13811453
const objects = (payload as any)?.metadata?.objects;
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// A declared template edited in source and hot reloaded is what the next send
4+
// renders. The dev loop (`os dev`, watcher on) recompiles the artifact, the
5+
// artifact door re-reads it, and `metadata:reloaded` carries the parsed
6+
// artifact to its subscribers. The send path reads the protocol's layered
7+
// `email_template` list on its execution face (`template-loader.ts`), and that
8+
// list lays the SchemaRegistry over the metadata service — so the registry
9+
// entry the boot registered through the `manifest` service has to follow the
10+
// reload, or every send renders the boot copy until a cold restart while the
11+
// by-name read already serves the edit.
12+
//
13+
// The engine plugin is the real `ObjectQLPlugin` (its `manifest` service, its
14+
// protocol, its `metadata:reloaded` hook); the app registers through the
15+
// `manifest` service at init as `AppPlugin` does; the reload is announced with
16+
// the payload the artifact door sends. The send is the real `EmailService`
17+
// over the loader `EmailServicePlugin` wires.
18+
19+
import { describe, it, expect, afterEach } from 'vitest';
20+
import { ObjectKernel } from '@objectstack/core';
21+
import type { Plugin, PluginContext } from '@objectstack/core';
22+
import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql';
23+
import type { IEmailTransport, TransportSendResult } from '@objectstack/spec/contracts';
24+
import { EmailService } from './email-service.js';
25+
import { createRegistryEmailTemplateLoader } from './template-loader.js';
26+
27+
const PACKAGE_ID = 'com.test.mail-reload';
28+
29+
/** The app's stack body as the `manifest` service receives it, with the template's subject at `v`. */
30+
const stack = (v: string) => ({
31+
manifest: { id: PACKAGE_ID, name: 'mail_reload', type: 'app', version: '1.0.0' },
32+
emailTemplates: [{
33+
name: 'mr_task_done',
34+
label: 'Task done',
35+
locale: 'en-US',
36+
subject: `Task done (${v}): {{title}}`,
37+
bodyHtml: `<p>{{title}} is done (${v}).</p>`,
38+
variables: [{ name: 'title', type: 'string', required: true }],
39+
}],
40+
});
41+
42+
/** A driver that stores nothing: the template is metadata, not a row. */
43+
const emptyDriver = {
44+
name: 'mail-reload-driver',
45+
version: '1.0.0',
46+
supports: {},
47+
connect: async () => {},
48+
disconnect: async () => {},
49+
find: async () => [],
50+
findOne: async () => null,
51+
count: async () => 0,
52+
create: async (_o: string, d: any) => d,
53+
update: async (_o: string, _i: any, d: any) => d,
54+
delete: async () => true,
55+
syncSchema: async () => {},
56+
};
57+
58+
/** Registers the driver, and the app body through the `manifest` service, at init. */
59+
class AppFixture implements Plugin {
60+
name = 'test.mail-reload.app';
61+
type = 'standard' as const;
62+
version = '1.0.0';
63+
ctx?: PluginContext;
64+
async init(ctx: PluginContext) {
65+
this.ctx = ctx;
66+
ctx.registerService('driver.mail-reload', emptyDriver);
67+
const body = stack('v1');
68+
ctx.getService<{ register(m: unknown): void }>('manifest').register({ ...body.manifest, ...body });
69+
}
70+
}
71+
72+
class CaptureTransport implements IEmailTransport {
73+
readonly subjects: string[] = [];
74+
async send(message: { subject: string }): Promise<TransportSendResult> {
75+
this.subjects.push(message.subject);
76+
return { messageId: `<reload-${this.subjects.length}@test>` };
77+
}
78+
}
79+
80+
describe('a hot reloaded declared email template is what the next send renders', () => {
81+
let kernel: ObjectKernel | undefined;
82+
83+
afterEach(async () => {
84+
if (kernel?.getState() === 'running') await kernel.shutdown();
85+
kernel = undefined;
86+
});
87+
88+
async function boot() {
89+
kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
90+
const app = new AppFixture();
91+
await kernel.use(new ObjectQLPlugin());
92+
await kernel.use(app);
93+
await kernel.bootstrap();
94+
const ctx = app.ctx!;
95+
const engine = kernel.getService<ObjectQL>('objectql');
96+
const protocol: any = kernel.getService('protocol');
97+
const transport = new CaptureTransport();
98+
const mail = new EmailService({
99+
transport,
100+
defaultFrom: { address: 'no-reply@example.com' },
101+
// The sources `EmailServicePlugin` resolves at every load.
102+
templateLoader: createRegistryEmailTemplateLoader(() => ({ protocol, registry: engine.registry })),
103+
});
104+
const send = async () => {
105+
const res = await mail.sendTemplate({ template: 'mr_task_done', to: 'ann@example.com', data: { title: 'Ship it' } });
106+
expect(res.status).toBe('sent');
107+
return transport.subjects[transport.subjects.length - 1];
108+
};
109+
const listed = async () => ((await protocol.getMetaItems({ type: 'email_template' })).items ?? [])
110+
.filter((i: any) => i?.name === 'mr_task_done')
111+
.map((i: any) => i.subject);
112+
// The payload the artifact door announces: the freshly parsed artifact.
113+
const reload = (v: string) => ctx.trigger('metadata:reloaded', { changed: ['dist/objectstack.json'], metadata: stack(v) });
114+
return { send, listed, reload };
115+
}
116+
117+
it('renders the reloaded subject, and the list read serves it', async () => {
118+
const { send, listed, reload } = await boot();
119+
120+
// Positive control: the boot copy is what both doors serve.
121+
expect(await send()).toBe('Task done (v1): Ship it');
122+
expect(await listed()).toEqual(['Task done (v1): {{title}}']);
123+
124+
await reload('v2');
125+
126+
expect(await send()).toBe('Task done (v2): Ship it');
127+
expect(await listed()).toEqual(['Task done (v2): {{title}}']);
128+
});
129+
});

0 commit comments

Comments
 (0)