Skip to content

Commit 563c087

Browse files
authored
Merge pull request #1076 from objectstack-ai/copilot/fix-object-metadata-recovery
2 parents 28004e5 + e8330d4 commit 563c087

File tree

3 files changed

+248
-0
lines changed

3 files changed

+248
-0
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7171
match the current monorepo layout.
7272

7373
### Fixed
74+
- **ObjectQLPlugin: cold-start metadata restoration**`ObjectQLPlugin.start()` now calls
75+
`protocol.loadMetaFromDb()` after driver initialization and before schema sync, restoring
76+
all persisted metadata (objects, views, apps, etc.) from the `sys_metadata` table into the
77+
in-memory `SchemaRegistry`. Previously, user-created custom objects were lost after kernel
78+
cold starts or redeployments because the hydration step was missing. The fix gracefully
79+
degrades in in-memory-only or first-run scenarios where `sys_metadata` does not yet exist.
7480
- **Studio Vercel API routes returning HTML instead of JSON** — Adopted the
7581
same Vercel deployment pattern used by `hotcrm`: committed
7682
`api/[[...route]].js` catch-all route so Vercel detects it pre-build,

packages/objectql/src/plugin.integration.test.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,4 +822,174 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
822822
expect(singleCalls).toContain('nb__item');
823823
});
824824
});
825+
826+
describe('Cold-Start Metadata Restoration', () => {
827+
it('should restore metadata from sys_metadata via protocol.loadMetaFromDb on start', async () => {
828+
// Arrange — a driver whose find() returns persisted metadata records
829+
const findCalls: Array<{ object: string; query: any }> = [];
830+
const mockDriver = {
831+
name: 'restore-driver',
832+
version: '1.0.0',
833+
connect: async () => {},
834+
disconnect: async () => {},
835+
find: async (object: string, query: any) => {
836+
findCalls.push({ object, query });
837+
if (object === 'sys_metadata') {
838+
return [
839+
{
840+
id: '1',
841+
type: 'apps',
842+
name: 'custom_crm',
843+
state: 'active',
844+
metadata: JSON.stringify({ name: 'custom_crm', label: 'Custom CRM' }),
845+
},
846+
{
847+
id: '2',
848+
type: 'object',
849+
name: 'invoice',
850+
state: 'active',
851+
metadata: JSON.stringify({
852+
name: 'invoice',
853+
label: 'Invoice',
854+
fields: { amount: { name: 'amount', label: 'Amount', type: 'number' } },
855+
}),
856+
packageId: 'user_pkg',
857+
},
858+
];
859+
}
860+
return [];
861+
},
862+
findOne: async () => null,
863+
create: async (_o: string, d: any) => d,
864+
update: async (_o: string, _i: any, d: any) => d,
865+
delete: async () => true,
866+
syncSchema: async () => {},
867+
};
868+
869+
await kernel.use({
870+
name: 'mock-restore-driver',
871+
type: 'driver',
872+
version: '1.0.0',
873+
init: async (ctx) => {
874+
ctx.registerService('driver.restore', mockDriver);
875+
},
876+
});
877+
878+
const plugin = new ObjectQLPlugin();
879+
await kernel.use(plugin);
880+
881+
// Act
882+
await kernel.bootstrap();
883+
884+
// Assert — sys_metadata should have been queried
885+
const metaQuery = findCalls.find((c) => c.object === 'sys_metadata');
886+
expect(metaQuery).toBeDefined();
887+
expect(metaQuery!.query.where).toEqual({ state: 'active' });
888+
889+
// Assert — items should be restored into the registry
890+
const registry = (kernel.getService('objectql') as any).registry;
891+
expect(registry.getAllApps()).toContainEqual({
892+
name: 'custom_crm',
893+
label: 'Custom CRM',
894+
});
895+
});
896+
897+
it('should not throw when protocol.loadMetaFromDb fails (graceful degradation)', async () => {
898+
// Arrange — driver that throws on find('sys_metadata')
899+
const mockDriver = {
900+
name: 'failing-db-driver',
901+
version: '1.0.0',
902+
connect: async () => {},
903+
disconnect: async () => {},
904+
find: async (object: string) => {
905+
if (object === 'sys_metadata') {
906+
throw new Error('SQLITE_ERROR: no such table: sys_metadata');
907+
}
908+
return [];
909+
},
910+
findOne: async () => null,
911+
create: async (_o: string, d: any) => d,
912+
update: async (_o: string, _i: any, d: any) => d,
913+
delete: async () => true,
914+
syncSchema: async () => {},
915+
};
916+
917+
await kernel.use({
918+
name: 'mock-fail-driver',
919+
type: 'driver',
920+
version: '1.0.0',
921+
init: async (ctx) => {
922+
ctx.registerService('driver.faildb', mockDriver);
923+
},
924+
});
925+
926+
const plugin = new ObjectQLPlugin();
927+
await kernel.use(plugin);
928+
929+
// Act & Assert — should not throw
930+
await expect(kernel.bootstrap()).resolves.not.toThrow();
931+
});
932+
933+
it('should restore metadata before syncRegisteredSchemas so restored objects get table sync', async () => {
934+
// Arrange — track the order of operations
935+
const operations: string[] = [];
936+
const mockDriver = {
937+
name: 'order-driver',
938+
version: '1.0.0',
939+
connect: async () => {},
940+
disconnect: async () => {},
941+
find: async (object: string) => {
942+
if (object === 'sys_metadata') {
943+
operations.push('loadMetaFromDb');
944+
return [
945+
{
946+
id: '1',
947+
type: 'object',
948+
name: 'restored_obj',
949+
state: 'active',
950+
metadata: JSON.stringify({
951+
name: 'restored_obj',
952+
label: 'Restored Object',
953+
fields: { title: { name: 'title', label: 'Title', type: 'text' } },
954+
}),
955+
packageId: 'user_pkg',
956+
},
957+
];
958+
}
959+
return [];
960+
},
961+
findOne: async () => null,
962+
create: async (_o: string, d: any) => d,
963+
update: async (_o: string, _i: any, d: any) => d,
964+
delete: async () => true,
965+
syncSchema: async (object: string) => {
966+
operations.push(`syncSchema:${object}`);
967+
},
968+
};
969+
970+
await kernel.use({
971+
name: 'mock-order-driver',
972+
type: 'driver',
973+
version: '1.0.0',
974+
init: async (ctx) => {
975+
ctx.registerService('driver.order', mockDriver);
976+
},
977+
});
978+
979+
const plugin = new ObjectQLPlugin();
980+
await kernel.use(plugin);
981+
982+
// Act
983+
await kernel.bootstrap();
984+
985+
// Assert — loadMetaFromDb must appear before any syncSchema call
986+
const loadIdx = operations.indexOf('loadMetaFromDb');
987+
expect(loadIdx).toBeGreaterThanOrEqual(0);
988+
989+
const firstSync = operations.findIndex((op) => op.startsWith('syncSchema:'));
990+
if (firstSync >= 0) {
991+
expect(loadIdx).toBeLessThan(firstSync);
992+
}
993+
});
994+
});
825995
});

packages/objectql/src/plugin.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,25 @@ import { Plugin, PluginContext } from '@objectstack/core';
66

77
export type { Plugin, PluginContext };
88

9+
/**
10+
* Protocol extension for DB-based metadata hydration.
11+
* `loadMetaFromDb` is implemented by ObjectStackProtocolImplementation but
12+
* is NOT (yet) part of the canonical ObjectStackProtocol wire-contract in
13+
* `@objectstack/spec`, since it is a server-side bootstrap concern only.
14+
*/
15+
interface ProtocolWithDbRestore {
16+
loadMetaFromDb(): Promise<{ loaded: number; errors: number }>;
17+
}
18+
19+
/** Type guard — checks whether the service exposes `loadMetaFromDb`. */
20+
function hasLoadMetaFromDb(service: unknown): service is ProtocolWithDbRestore {
21+
return (
22+
typeof service === 'object' &&
23+
service !== null &&
24+
typeof (service as Record<string, unknown>)['loadMetaFromDb'] === 'function'
25+
);
26+
}
27+
928
export class ObjectQLPlugin implements Plugin {
1029
name = 'com.objectstack.engine.objectql';
1130
type = 'objectql';
@@ -99,6 +118,12 @@ export class ObjectQLPlugin implements Plugin {
99118
// Initialize drivers (calls driver.connect() which sets up persistence)
100119
await this.ql?.init();
101120

121+
// Restore persisted metadata from sys_metadata table.
122+
// This hydrates SchemaRegistry with objects/views/apps that were saved
123+
// via protocol.saveMetaItem() in a previous session, ensuring custom
124+
// schemas survive cold starts and redeployments.
125+
await this.restoreMetadataFromDb(ctx);
126+
102127
// Sync all registered object schemas to database
103128
// This ensures tables/collections are created or updated for every
104129
// object registered by plugins (e.g., sys_user from plugin-auth).
@@ -332,6 +357,53 @@ export class ObjectQLPlugin implements Plugin {
332357
}
333358
}
334359

360+
/**
361+
* Restore persisted metadata from the database (sys_metadata) on startup.
362+
*
363+
* Calls `protocol.loadMetaFromDb()` to bulk-load all active metadata
364+
* records (objects, views, apps, etc.) into the in-memory SchemaRegistry.
365+
* This closes the persistence loop so that user-created schemas survive
366+
* kernel cold starts and redeployments.
367+
*
368+
* Gracefully degrades when:
369+
* - The protocol service is unavailable (e.g., in-memory-only mode).
370+
* - `loadMetaFromDb` is not implemented by the protocol shim.
371+
* - The underlying driver/table does not exist yet (first-run scenario).
372+
*/
373+
private async restoreMetadataFromDb(ctx: PluginContext): Promise<void> {
374+
// Phase 1: Resolve protocol service (separate from DB I/O for clearer diagnostics)
375+
let protocol: ProtocolWithDbRestore;
376+
try {
377+
const service = ctx.getService('protocol');
378+
if (!service || !hasLoadMetaFromDb(service)) {
379+
ctx.logger.debug('Protocol service does not support loadMetaFromDb, skipping DB restore');
380+
return;
381+
}
382+
protocol = service;
383+
} catch (e: unknown) {
384+
ctx.logger.debug('Protocol service unavailable, skipping DB restore', {
385+
error: e instanceof Error ? e.message : String(e),
386+
});
387+
return;
388+
}
389+
390+
// Phase 2: DB hydration
391+
try {
392+
const { loaded, errors } = await protocol.loadMetaFromDb();
393+
394+
if (loaded > 0 || errors > 0) {
395+
ctx.logger.info('Metadata restored from database', { loaded, errors });
396+
} else {
397+
ctx.logger.debug('No persisted metadata found in database');
398+
}
399+
} catch (e: unknown) {
400+
// Non-fatal: first-run or in-memory driver may not have sys_metadata yet
401+
ctx.logger.debug('DB metadata restore failed (non-fatal)', {
402+
error: e instanceof Error ? e.message : String(e),
403+
});
404+
}
405+
}
406+
335407
/**
336408
* Load metadata from external metadata service into ObjectQL registry
337409
* This enables ObjectQL to use file-based or remote metadata

0 commit comments

Comments
 (0)