Skip to content
Merged
14 changes: 14 additions & 0 deletions .changeset/27488-artifact-reload-registry-refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/objectql': patch
---

fix(objectql): an artifact hot reload refreshes every registry entry the boot registered, so an edited email template is what the next send renders

Clause-②: no
Metadata-protocol: none

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.

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.

Cold boots and `PUT /api/v1/meta` saves are unchanged.
72 changes: 72 additions & 0 deletions packages/objectql/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1100,6 +1100,13 @@ export class ObjectQLPlugin implements Plugin {
// (`skipSchemaSync` / OS_SKIP_SCHEMA_SYNC) for deployments that
// manage DDL out-of-band, and serializes through
// `reloadSchemaSync` so overlapping reload events can't race DDL.
// 3. Every OTHER item the boot registered through the `manifest`
// service kept its boot copy in the registry, which the list read
// and its execution face lay over the metadata service: an edited
// email template sent its old wording, an edited flow re-bound its
// old body, while the by-name read served the edit. See
// {@link refreshReloadedPackages}.
this.refreshReloadedPackages(ctx, payload);
this.ingestReloadedObjects(ctx, payload);
if (this.skipSchemaSync) {
// DDL is managed out-of-band here, but the objects this reload just
Expand Down Expand Up @@ -1376,6 +1383,71 @@ export class ObjectQLPlugin implements Plugin {
return owner.packageId === packageId && !tenantAuthored ? 'own' : 'overlay';
}

/**
* Re-register each package body carried on a `metadata:reloaded` payload
* through `registerApp`, the body door the boot registered it through (the
* `manifest` service), so every collection that door decomposes into the
* SchemaRegistry (`METADATA_ARRAY_KEYS`: email templates, flows, views,
* dashboards, pages, actions, …) holds the reloaded item, not the boot copy.
*
* A REFRESH, never an install: only a package the registry already holds
* from code or the marketplace is re-registered, under the source it was
* installed with, and `installPackage` keeps its lifecycle (a disabled
* package stays disabled). A body the boot never registered — the artifact
* door alone serves it, so nothing in the registry is stale — is left to
* that door, as before. Ordering is `resolveArtifactPackageEntries`', the
* same call the `manifest` service makes on the same artifact.
*
* Items removed from the artifact stay registered until restart, the
* lifecycle {@link ingestReloadedObjects} keeps for objects. A body that
* fails to register is named and skipped: the reload goes on, and that
* package keeps serving what it served before.
*/
private refreshReloadedPackages(ctx: PluginContext, payload: unknown): void {
if (!this.ql) return;
const metadata = (payload as { metadata?: unknown } | undefined)?.metadata as Record<string, any> | undefined;
if (!metadata || typeof metadata !== 'object') return;
// The shape `AppPlugin` hands the `manifest` service: the manifest's
// fields with the collections spread over them.
const artifact = metadata.manifest && typeof metadata.manifest === 'object'
? { ...metadata.manifest, ...metadata }
: metadata;
let entries: ReturnType<typeof resolveArtifactPackageEntries>;
try {
entries = resolveArtifactPackageEntries(
Array.isArray(artifact.packages) ? this.ql.convertPackageBodyForward(artifact) : artifact,
);
} catch (e: any) {
ctx.logger.warn('[ObjectQLPlugin] reload could not read the artifact\'s packages — registry entries keep their previous definitions', {
error: e?.message,
});
return;
}
const scope = {
packageIds: entries
.map((entry) => artifactPackageId(entry.manifest))
.filter((id): id is string => id !== undefined),
};
const refreshed: string[] = [];
for (const { manifest } of entries as Array<{ manifest: any }>) {
const id = artifactPackageId(manifest);
const installed = id === undefined ? undefined : this.ql.registry.getPackage(id);
if (!installed || (installed.source !== 'code' && installed.source !== 'marketplace')) continue;
try {
this.ql.registerApp(manifest, scope, installed.source);
refreshed.push(id!);
} catch (e: any) {
ctx.logger.warn('[ObjectQLPlugin] reload could not re-register a package — its registry entries keep their previous definitions', {
packageId: id,
error: e?.message,
});
}
}
if (refreshed.length > 0) {
ctx.logger.info('[ObjectQLPlugin] reload refreshed the registry entries of its packages', { packageIds: refreshed });
}
}

private ingestReloadedObjects(ctx: PluginContext, payload: unknown): void {
if (!this.ql) return;
const objects = (payload as any)?.metadata?.objects;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// A declared template edited in source and hot reloaded is what the next send
// renders. The dev loop (`os dev`, watcher on) recompiles the artifact, the
// artifact door re-reads it, and `metadata:reloaded` carries the parsed
// artifact to its subscribers. The send path reads the protocol's layered
// `email_template` list on its execution face (`template-loader.ts`), and that
// list lays the SchemaRegistry over the metadata service — so the registry
// entry the boot registered through the `manifest` service has to follow the
// reload, or every send renders the boot copy until a cold restart while the
// by-name read already serves the edit.
//
// The engine plugin is the real `ObjectQLPlugin` (its `manifest` service, its
// protocol, its `metadata:reloaded` hook); the app registers through the
// `manifest` service at init as `AppPlugin` does; the reload is announced with
// the payload the artifact door sends. The send is the real `EmailService`
// over the loader `EmailServicePlugin` wires.

import { describe, it, expect, afterEach } from 'vitest';
import { ObjectKernel } from '@objectstack/core';
import type { Plugin, PluginContext } from '@objectstack/core';
import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql';
import type { IEmailTransport, TransportSendResult } from '@objectstack/spec/contracts';
import { EmailService } from './email-service.js';
import { createRegistryEmailTemplateLoader } from './template-loader.js';

const PACKAGE_ID = 'com.test.mail-reload';

/** The app's stack body as the `manifest` service receives it, with the template's subject at `v`. */
const stack = (v: string) => ({
manifest: { id: PACKAGE_ID, name: 'mail_reload', type: 'app', version: '1.0.0' },
emailTemplates: [{
name: 'mr_task_done',
label: 'Task done',
locale: 'en-US',
subject: `Task done (${v}): {{title}}`,
bodyHtml: `<p>{{title}} is done (${v}).</p>`,
variables: [{ name: 'title', type: 'string', required: true }],
}],
});

/** A driver that stores nothing: the template is metadata, not a row. */
const emptyDriver = {
name: 'mail-reload-driver',
version: '1.0.0',
supports: {},
connect: async () => {},
disconnect: async () => {},
find: async () => [],
findOne: async () => null,
count: async () => 0,
create: async (_o: string, d: any) => d,
update: async (_o: string, _i: any, d: any) => d,
delete: async () => true,
syncSchema: async () => {},
};

/** Registers the driver, and the app body through the `manifest` service, at init. */
class AppFixture implements Plugin {
name = 'test.mail-reload.app';
type = 'standard' as const;
version = '1.0.0';
ctx?: PluginContext;
async init(ctx: PluginContext) {
this.ctx = ctx;
ctx.registerService('driver.mail-reload', emptyDriver);
const body = stack('v1');
ctx.getService<{ register(m: unknown): void }>('manifest').register({ ...body.manifest, ...body });
}
}

class CaptureTransport implements IEmailTransport {
readonly subjects: string[] = [];
async send(message: { subject: string }): Promise<TransportSendResult> {
this.subjects.push(message.subject);
return { messageId: `<reload-${this.subjects.length}@test>` };
}
}

describe('a hot reloaded declared email template is what the next send renders', () => {
let kernel: ObjectKernel | undefined;

afterEach(async () => {
if (kernel?.getState() === 'running') await kernel.shutdown();
kernel = undefined;
});

async function boot() {
kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
const app = new AppFixture();
await kernel.use(new ObjectQLPlugin());
await kernel.use(app);
await kernel.bootstrap();
const ctx = app.ctx!;
const engine = kernel.getService<ObjectQL>('objectql');
const protocol: any = kernel.getService('protocol');
const transport = new CaptureTransport();
const mail = new EmailService({
transport,
defaultFrom: { address: 'no-reply@example.com' },
// The sources `EmailServicePlugin` resolves at every load.
templateLoader: createRegistryEmailTemplateLoader(() => ({ protocol, registry: engine.registry })),
});
const send = async () => {
const res = await mail.sendTemplate({ template: 'mr_task_done', to: 'ann@example.com', data: { title: 'Ship it' } });
expect(res.status).toBe('sent');
return transport.subjects[transport.subjects.length - 1];
};
const listed = async () => ((await protocol.getMetaItems({ type: 'email_template' })).items ?? [])
.filter((i: any) => i?.name === 'mr_task_done')
.map((i: any) => i.subject);
// The payload the artifact door announces: the freshly parsed artifact.
const reload = (v: string) => ctx.trigger('metadata:reloaded', { changed: ['dist/objectstack.json'], metadata: stack(v) });
return { send, listed, reload };
}

it('renders the reloaded subject, and the list read serves it', async () => {
const { send, listed, reload } = await boot();

// Positive control: the boot copy is what both doors serve.
expect(await send()).toBe('Task done (v1): Ship it');
expect(await listed()).toEqual(['Task done (v1): {{title}}']);

await reload('v2');

expect(await send()).toBe('Task done (v2): Ship it');
expect(await listed()).toEqual(['Task done (v2): {{title}}']);
});
});
Loading
Loading