Skip to content

Commit 68390c2

Browse files
os-zhuangclaude
andauthored
fix(audit,messaging): provision system tables at kernel:ready (#2253)
sys_activity / sys_inbox_message / sys_notification_receipt (and their sibling system objects) are lazy-created on first WRITE, so a freshly provisioned env that READS them first — the home page's recent-activity feed and the Console inbox/notifications bell — hits SQLite "no such table". The engine logs that as a `Find operation failed` ERROR on every load (the UI degrades to empty, but the log is noisy and can mask real errors). AuditPlugin and MessagingServicePlugin now call engine.syncObjectSchema() for their own objects in a kernel:ready hook, creating the tables up front so a new env is consistent from the start. syncObjectSchema is idempotent (creates only when the table is absent) and guarded, so it is a safe no-op when boot-time schema sync already provisioned the tables or when the engine exposes no on-demand DDL. The messaging hook runs independently of reliableDelivery (the inbox tables are needed either way). Adds unit tests in both packages. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 715d667 commit 68390c2

4 files changed

Lines changed: 233 additions & 0 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { AuditPlugin } from './audit-plugin.js';
5+
6+
/**
7+
* Regression coverage: a freshly provisioned env READS sys_activity (the home
8+
* page's recent-activity feed) before anything has WRITTEN to it. The plugin's
9+
* objects are otherwise lazy-created on first write, so the read used to hit
10+
* SQLite "no such table" — logged by the engine as a `Find operation failed`
11+
* ERROR on every load. AuditPlugin now provisions its system tables at
12+
* kernel:ready so a new env is consistent from the start.
13+
*/
14+
15+
/**
16+
* A fake engine that models the lazy-table behavior: `find()` throws
17+
* "no such table" until `syncObjectSchema()` has created it. No `registerHook`,
18+
* so `installAuditWriters()` early-returns and we stay focused on provisioning.
19+
*/
20+
function makeFakeEngine() {
21+
const tables = new Set<string>();
22+
const synced: string[] = [];
23+
const engine = {
24+
async syncObjectSchema(name: string) {
25+
synced.push(name);
26+
tables.add(name);
27+
},
28+
async find(object: string) {
29+
if (!tables.has(object)) throw new Error(`no such table: ${object}`);
30+
return [] as unknown[];
31+
},
32+
};
33+
return { engine, synced };
34+
}
35+
36+
function makeCtx(engine: unknown) {
37+
const services = new Map<string, unknown>([
38+
['objectql', engine],
39+
['manifest', { register() {} }],
40+
]);
41+
const readyHooks: Array<() => Promise<void> | void> = [];
42+
const logger = {
43+
info() {}, warn() {}, error() {}, debug() {},
44+
child() { return logger; },
45+
};
46+
const ctx = {
47+
logger,
48+
getService(name: string) { return services.get(name); },
49+
registerService(name: string, svc: unknown) { services.set(name, svc); },
50+
hook(event: string, fn: () => Promise<void> | void) {
51+
if (event === 'kernel:ready') readyHooks.push(fn);
52+
},
53+
} as any;
54+
return { ctx, fireReady: async () => { for (const fn of readyHooks) await fn(); } };
55+
}
56+
57+
describe('AuditPlugin — system table provisioning', () => {
58+
it('creates sys_audit_log / sys_activity / sys_comment on kernel:ready', async () => {
59+
const { engine, synced } = makeFakeEngine();
60+
const { ctx, fireReady } = makeCtx(engine);
61+
62+
const plugin = new AuditPlugin();
63+
await plugin.init(ctx);
64+
await plugin.start(ctx);
65+
66+
// Before kernel:ready the table is absent — the read that the activity feed
67+
// performs would throw the "no such table" the engine logs as an ERROR.
68+
await expect(engine.find('sys_activity')).rejects.toThrow(/no such table/);
69+
70+
await fireReady();
71+
72+
expect(synced).toEqual(
73+
expect.arrayContaining(['sys_audit_log', 'sys_activity', 'sys_comment']),
74+
);
75+
// The activity-feed read now degrades to empty instead of throwing.
76+
await expect(engine.find('sys_activity')).resolves.toEqual([]);
77+
});
78+
79+
it('skips provisioning gracefully when the engine has no syncObjectSchema', async () => {
80+
// An engine/driver without on-demand DDL (e.g. a federated-only kernel)
81+
// must not blow up start().
82+
const engine = { async find() { return []; } };
83+
const { ctx, fireReady } = makeCtx(engine);
84+
85+
const plugin = new AuditPlugin();
86+
await plugin.init(ctx);
87+
await plugin.start(ctx);
88+
await expect(fireReady()).resolves.toBeUndefined();
89+
});
90+
});

packages/plugins/plugin-audit/src/audit-plugin.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ export class AuditPlugin implements Plugin {
9393
ctx.logger.warn('AuditPlugin: ObjectQL engine not available — audit writers NOT installed');
9494
return;
9595
}
96+
// Create the physical tables for this plugin's system objects up-front so
97+
// a freshly provisioned env is consistent from the start (see
98+
// provisionSystemTables).
99+
await this.provisionSystemTables(engine, ctx);
96100
// Resolve the messaging service lazily at hook time so collaboration
97101
// @mention / assignment notifications go through the ADR-0030 single
98102
// ingress (emit) instead of writing sys_notification directly. Messaging
@@ -109,4 +113,36 @@ export class AuditPlugin implements Plugin {
109113
ctx.logger.info('AuditPlugin: audit + activity writers installed');
110114
});
111115
}
116+
117+
/**
118+
* Provision the physical tables for this plugin's system objects up-front.
119+
*
120+
* sys_audit_log / sys_activity / sys_comment are otherwise lazy-created on
121+
* first WRITE (the SQL driver issues DDL when the first row is inserted). A
122+
* freshly provisioned env that READS one first — the home page's recent-
123+
* activity feed queries sys_activity before any mutation has happened — hits
124+
* SQLite "no such table", which the engine logs as a `Find operation failed`
125+
* ERROR on every load. The UI degrades to an empty feed, but the log is noisy
126+
* and can mask real errors. Creating the tables at kernel:ready (once the
127+
* engine + registry are ready) makes a new env consistent from the start.
128+
*
129+
* `syncObjectSchema` is idempotent — the SQL driver only creates a table when
130+
* it is absent (and alters to add columns) — so this is safe on every boot,
131+
* and a no-op for objects whose table already exists. Per-object failures are
132+
* isolated so one bad object can't block the rest.
133+
*/
134+
private async provisionSystemTables(engine: IDataEngine, ctx: PluginContext): Promise<void> {
135+
// `syncObjectSchema` lives on the concrete ObjectQL engine, not the
136+
// IDataEngine contract; engines/drivers without on-demand DDL (e.g. an
137+
// in-memory test double) simply skip provisioning.
138+
const sync = (engine as unknown as { syncObjectSchema?: (name: string) => Promise<void> }).syncObjectSchema;
139+
if (typeof sync !== 'function') return;
140+
for (const obj of [SysAuditLog, SysActivity, SysComment]) {
141+
try {
142+
await sync.call(engine, obj.name);
143+
} catch (err) {
144+
ctx.logger.warn(`AuditPlugin: could not provision ${obj.name} storage — ${(err as Error)?.message ?? err}`);
145+
}
146+
}
147+
}
112148
}

packages/services/service-messaging/src/messaging-service-plugin.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,59 @@ describe('MessagingServicePlugin', () => {
9393
expect(messaging.getRegisteredChannels()).toEqual([]);
9494
});
9595
});
96+
97+
/**
98+
* A ctx that supports `kernel:ready` hooks plus an engine modelling the
99+
* lazy-table behavior: `find()` throws "no such table" until
100+
* `syncObjectSchema()` has created it. Mirrors the audit-plugin coverage.
101+
*/
102+
function provisionCtx() {
103+
const tables = new Set<string>();
104+
const synced: string[] = [];
105+
const engine = {
106+
async syncObjectSchema(name: string) { synced.push(name); tables.add(name); },
107+
async find(object: string) {
108+
if (!tables.has(object)) throw new Error(`no such table: ${object}`);
109+
return [] as unknown[];
110+
},
111+
async insert() { return {}; },
112+
async findOne() { return null; },
113+
async update() { return {}; },
114+
async delete() { return {}; },
115+
};
116+
const services = new Map<string, unknown>([
117+
['data', engine],
118+
['manifest', { register() {} }],
119+
]);
120+
const readyHooks: Array<() => Promise<void> | void> = [];
121+
const logger = { info() {}, warn() {}, error() {}, debug() {}, child() { return logger; } };
122+
const ctx = {
123+
logger,
124+
getService(name: string) { return services.get(name); },
125+
registerService(name: string, svc: unknown) { services.set(name, svc); },
126+
hook(event: string, fn: () => Promise<void> | void) {
127+
if (event === 'kernel:ready') readyHooks.push(fn);
128+
},
129+
} as any;
130+
return { ctx, engine, synced, fireReady: async () => { for (const fn of readyHooks) await fn(); } };
131+
}
132+
133+
describe('MessagingServicePlugin — system table provisioning', () => {
134+
it('creates the inbox + receipt tables on kernel:ready', async () => {
135+
const { ctx, engine, synced, fireReady } = provisionCtx();
136+
// reliableDelivery/retention off so only the provisioning hook runs.
137+
await new MessagingServicePlugin({ reliableDelivery: false, retentionDays: 0 }).init(ctx);
138+
139+
// Before kernel:ready the inbox read throws the "no such table" the
140+
// engine logs as `Find operation failed`.
141+
await expect(engine.find('sys_inbox_message')).rejects.toThrow(/no such table/);
142+
143+
await fireReady();
144+
145+
expect(synced).toEqual(
146+
expect.arrayContaining(['sys_inbox_message', 'sys_notification_receipt']),
147+
);
148+
await expect(engine.find('sys_inbox_message')).resolves.toEqual([]);
149+
await expect(engine.find('sys_notification_receipt')).resolves.toEqual([]);
150+
});
151+
});

packages/services/service-messaging/src/messaging-service-plugin.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,22 @@ export class MessagingServicePlugin implements Plugin {
175175
],
176176
});
177177

178+
// Provision the physical tables for this service's system objects
179+
// up-front, once the engine is ready. The inbox channel materializes
180+
// sys_inbox_message + sys_notification_receipt rows on first delivery,
181+
// so the tables are otherwise lazy-created on first WRITE — a freshly
182+
// provisioned env that READS the inbox / notifications before any
183+
// message has been delivered hits "no such table", logged as a
184+
// `Find operation failed` ERROR on every page load. Runs independently
185+
// of `reliableDelivery` (the inbox tables are needed either way) and is
186+
// idempotent. See {@link provisionSystemTables}.
187+
if (typeof ctx.hook === 'function') {
188+
ctx.hook('kernel:ready', async () => {
189+
const engine = getData();
190+
if (engine) await this.provisionSystemTables(engine, ctx);
191+
});
192+
}
193+
178194
// Email channel (ADR-0030 P3): register when an `email` service is
179195
// present. Resolved at kernel:ready so init order with the email plugin
180196
// doesn't matter; absent email ⇒ no channel (a notify(channels:['email'])
@@ -278,6 +294,41 @@ export class MessagingServicePlugin implements Plugin {
278294
);
279295
}
280296

297+
/**
298+
* Provision the physical tables for this service's system objects up-front.
299+
*
300+
* These objects are lazy-created on first WRITE (the SQL driver issues DDL
301+
* when the first row is inserted), so an env that READS them first — the
302+
* Console bell / inbox queries sys_inbox_message + sys_notification_receipt
303+
* before any notification has been delivered — hits "no such table", which
304+
* the engine logs as a `Find operation failed` ERROR on every page load.
305+
* Creating the tables at kernel:ready makes a new env consistent from the
306+
* start. Idempotent (the driver only creates a table when absent), so it is
307+
* safe on every boot; per-object failures are isolated.
308+
*/
309+
private async provisionSystemTables(engine: IDataEngine, ctx: PluginContext): Promise<void> {
310+
// `syncObjectSchema` lives on the concrete ObjectQL engine, not the
311+
// IDataEngine contract; engines without on-demand DDL skip provisioning.
312+
const sync = (engine as unknown as { syncObjectSchema?: (name: string) => Promise<void> }).syncObjectSchema;
313+
if (typeof sync !== 'function') return;
314+
const objects = [
315+
InboxMessage,
316+
NotificationReceipt,
317+
NotificationDelivery,
318+
NotificationPreference,
319+
NotificationSubscription,
320+
NotificationTemplate,
321+
HttpDelivery,
322+
];
323+
for (const obj of objects) {
324+
try {
325+
await sync.call(engine, (obj as { name: string }).name);
326+
} catch (err) {
327+
ctx.logger.warn(`[messaging] could not provision ${(obj as { name: string }).name} storage — ${(err as Error)?.message ?? err}`);
328+
}
329+
}
330+
}
331+
281332
/** Stop the dispatcher loop + retention sweep on shutdown. */
282333
async stop(): Promise<void> {
283334
await this.dispatcher?.stop();

0 commit comments

Comments
 (0)