-
Notifications
You must be signed in to change notification settings - Fork 1
fix: restore persisted metadata from sys_metadata on ObjectQLPlugin cold start #1076
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -822,4 +822,174 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => { | |||||
| expect(singleCalls).toContain('nb__item'); | ||||||
| }); | ||||||
| }); | ||||||
|
|
||||||
| describe('Cold-Start Metadata Restoration', () => { | ||||||
| it('should restore metadata from sys_metadata via protocol.loadMetaFromDb on start', async () => { | ||||||
| // Arrange — a driver whose find() returns persisted metadata records | ||||||
| const findCalls: Array<{ object: string; query: any }> = []; | ||||||
| const mockDriver = { | ||||||
| name: 'restore-driver', | ||||||
| version: '1.0.0', | ||||||
| connect: async () => {}, | ||||||
| disconnect: async () => {}, | ||||||
| find: async (object: string, query: any) => { | ||||||
| findCalls.push({ object, query }); | ||||||
| if (object === 'sys_metadata') { | ||||||
| return [ | ||||||
| { | ||||||
| id: '1', | ||||||
| type: 'app', | ||||||
| name: 'custom_crm', | ||||||
| state: 'active', | ||||||
| metadata: JSON.stringify({ name: 'custom_crm', label: 'Custom CRM' }), | ||||||
| }, | ||||||
| { | ||||||
| id: '2', | ||||||
| type: 'object', | ||||||
| name: 'invoice', | ||||||
| state: 'active', | ||||||
| metadata: JSON.stringify({ | ||||||
| name: 'invoice', | ||||||
| label: 'Invoice', | ||||||
| fields: { amount: { name: 'amount', label: 'Amount', type: 'number' } }, | ||||||
| }), | ||||||
| packageId: 'user_pkg', | ||||||
| }, | ||||||
| ]; | ||||||
| } | ||||||
| return []; | ||||||
| }, | ||||||
| findOne: async () => null, | ||||||
| create: async (_o: string, d: any) => d, | ||||||
| update: async (_o: string, _i: any, d: any) => d, | ||||||
| delete: async () => true, | ||||||
| syncSchema: async () => {}, | ||||||
| }; | ||||||
|
|
||||||
| await kernel.use({ | ||||||
| name: 'mock-restore-driver', | ||||||
| type: 'driver', | ||||||
| version: '1.0.0', | ||||||
| init: async (ctx) => { | ||||||
| ctx.registerService('driver.restore', mockDriver); | ||||||
| }, | ||||||
| }); | ||||||
|
|
||||||
| const plugin = new ObjectQLPlugin(); | ||||||
| await kernel.use(plugin); | ||||||
|
|
||||||
| // Act | ||||||
| await kernel.bootstrap(); | ||||||
|
|
||||||
| // Assert — sys_metadata should have been queried | ||||||
| const metaQuery = findCalls.find((c) => c.object === 'sys_metadata'); | ||||||
| expect(metaQuery).toBeDefined(); | ||||||
| expect(metaQuery!.query.where).toEqual({ state: 'active' }); | ||||||
|
|
||||||
| // Assert — items should be restored into the registry | ||||||
| const registry = (kernel.getService('objectql') as any).registry; | ||||||
| expect(registry.getItem('app', 'custom_crm')).toEqual({ | ||||||
|
||||||
| expect(registry.getItem('app', 'custom_crm')).toEqual({ | |
| expect(registry.getAllApps()).toContainEqual({ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -99,6 +99,12 @@ export class ObjectQLPlugin implements Plugin { | |
| // Initialize drivers (calls driver.connect() which sets up persistence) | ||
| await this.ql?.init(); | ||
|
|
||
| // Restore persisted metadata from sys_metadata table. | ||
| // This hydrates SchemaRegistry with objects/views/apps that were saved | ||
| // via protocol.saveMetaItem() in a previous session, ensuring custom | ||
| // schemas survive cold starts and redeployments. | ||
| await this.restoreMetadataFromDb(ctx); | ||
|
|
||
| // Sync all registered object schemas to database | ||
| // This ensures tables/collections are created or updated for every | ||
| // object registered by plugins (e.g., sys_user from plugin-auth). | ||
|
|
@@ -332,6 +338,42 @@ export class ObjectQLPlugin implements Plugin { | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Restore persisted metadata from the database (sys_metadata) on startup. | ||
| * | ||
| * Calls `protocol.loadMetaFromDb()` to bulk-load all active metadata | ||
| * records (objects, views, apps, etc.) into the in-memory SchemaRegistry. | ||
| * This closes the persistence loop so that user-created schemas survive | ||
| * kernel cold starts and redeployments. | ||
| * | ||
| * Gracefully degrades when: | ||
| * - The protocol service is unavailable (e.g., in-memory-only mode). | ||
| * - `loadMetaFromDb` is not implemented by the protocol shim. | ||
| * - The underlying driver/table does not exist yet (first-run scenario). | ||
| */ | ||
| private async restoreMetadataFromDb(ctx: PluginContext): Promise<void> { | ||
| try { | ||
| const protocol = ctx.getService('protocol') as any; | ||
| if (!protocol || typeof protocol.loadMetaFromDb !== 'function') { | ||
| ctx.logger.debug('Protocol service does not support loadMetaFromDb, skipping DB restore'); | ||
| return; | ||
| } | ||
|
|
||
| const { loaded, errors } = await protocol.loadMetaFromDb(); | ||
|
|
||
|
Comment on lines
+373
to
+393
|
||
| if (loaded > 0 || errors > 0) { | ||
| ctx.logger.info('Metadata restored from database', { loaded, errors }); | ||
| } else { | ||
| ctx.logger.debug('No persisted metadata found in database'); | ||
| } | ||
| } catch (e: unknown) { | ||
| // Non-fatal: first-run or in-memory driver may not have sys_metadata yet | ||
| ctx.logger.debug('DB metadata restore skipped (non-fatal)', { | ||
| error: e instanceof Error ? e.message : String(e), | ||
| }); | ||
| } | ||
|
Comment on lines
+373
to
+404
|
||
| } | ||
|
|
||
| /** | ||
| * Load metadata from external metadata service into ObjectQL registry | ||
| * This enables ObjectQL to use file-based or remote metadata | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test persists an app record with
type: 'app', but the registry’s canonical app helpers/validation are keyed under'apps'(e.g.,SchemaRegistry.registerApp()stores type'apps'). Using'apps'here would better reflect how apps are typically registered/persisted and would catch type-normalization issues earlier.