Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions packages/objectql/src/plugin.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,86 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
});
});

// A driver that serves one runtime-created object (with inline fields)
// from sys_metadata — models an isolated, proxy-free project kernel.
const makeLocalMetadataDriver = (findCalls: Array<{ object: string }>) => ({
name: 'local-meta-driver',
version: '1.0.0',
connect: async () => {},
disconnect: async () => {},
find: async (object: string) => {
findCalls.push({ object });
if (object === 'sys_metadata') {
return [
{
id: '1',
type: 'object',
name: 'product',
state: 'active',
metadata: JSON.stringify({
name: 'product',
label: 'Product',
fields: { sku: { name: 'sku', label: 'SKU', type: 'text' } },
}),
packageId: 'inventory_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 () => {},
});

it('does NOT hydrate a project kernel (environmentId set) without the opt-in', async () => {
// Default behavior: a project kernel sources metadata from the artifact /
// control-plane proxy, so boot must not read a local sys_metadata.
const findCalls: Array<{ object: string }> = [];
await kernel.use({
name: 'mock-noopt-driver',
type: 'driver',
version: '1.0.0',
init: async (ctx) => ctx.registerService('driver.noopt', makeLocalMetadataDriver(findCalls)),
});

const plugin = new ObjectQLPlugin({ environmentId: 'env_1' });
await kernel.use(plugin);
await kernel.bootstrap();

expect(findCalls.find((c) => c.object === 'sys_metadata')).toBeUndefined();
const registry = (kernel.getService('objectql') as any).registry;
expect(registry.getObject('product')).toBeUndefined();
});

it('hydrates a project kernel from local sys_metadata when hydrateMetadataFromDb is set (runtime objects regain their fields)', async () => {
// The single-env tenant runtime case: an isolated, proxy-free kernel that
// persists its OWN sys_metadata. Objects created at runtime (here:
// `product`, not in any boot manifest) must re-enter the registry WITH
// their fields after a restart — otherwise registry.getObject('product')
// is empty and the engine.find unknown-$select guard can't fire.
const findCalls: Array<{ object: string }> = [];
await kernel.use({
name: 'mock-opt-driver',
type: 'driver',
version: '1.0.0',
init: async (ctx) => ctx.registerService('driver.opt', makeLocalMetadataDriver(findCalls)),
});

const plugin = new ObjectQLPlugin({ environmentId: 'env_1', hydrateMetadataFromDb: true });
await kernel.use(plugin);
await kernel.bootstrap();

expect(findCalls.find((c) => c.object === 'sys_metadata')).toBeDefined();
const registry = (kernel.getService('objectql') as any).registry;
const product = registry.getObject('product') as any;
expect(product).toBeDefined();
expect(product.fields).toBeDefined();
expect(Object.keys(product.fields)).toContain('sku');
});

it('should not throw when protocol.loadMetaFromDb fails (graceful degradation)', async () => {
// Arrange — driver that throws on find('sys_metadata')
const mockDriver = {
Expand Down
42 changes: 37 additions & 5 deletions packages/objectql/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,28 @@ export interface ObjectQLPluginOptions {
* code change.
*/
skipSchemaSync?: boolean;
/**
* Hydrate the SchemaRegistry from this kernel's local `sys_metadata`
* even when `environmentId` is set.
*
* By default Phase-2 hydration in `start()` is gated on
* `environmentId === undefined`, because the original multi-environment
* model assumed project kernels source metadata from a remote artifact /
* control-plane proxy and have NO local `sys_metadata` to read. That is
* NOT true for an isolated, proxy-free project kernel that persists its
* OWN `sys_metadata` locally (e.g. the cloud single-env tenant runtime on
* Turso): objects CREATED AT RUNTIME there — not present in the boot
* artifact manifest — would otherwise never re-enter the registry after a
* restart, so `registry.getObject(name)` returns nothing for them and any
* registry consumer (the unknown-`$select` guard, hooks, relationships)
* silently degrades.
*
* Set this ONLY when the kernel's registry is per-instance isolated AND
* `sys_metadata` lives on the kernel's own local driver (no control-plane
* proxy) — hydrating a proxied kernel would read the wrong database.
* Safe to leave unset: hydration tolerates a missing table.
*/
hydrateMetadataFromDb?: boolean;
}

export class ObjectQLPlugin implements Plugin {
Expand All @@ -92,6 +114,7 @@ export class ObjectQLPlugin implements Plugin {
private hostContext?: Record<string, any>;
private environmentId?: string;
private skipSchemaSync = false;
private hydrateMetadataFromDb = false;
/** Unsubscribe handles for metadata-event subscriptions (ADR-0008 PR-7). */
private metadataUnsubscribes: Array<() => void> = [];

Expand All @@ -116,6 +139,7 @@ export class ObjectQLPlugin implements Plugin {
typeof opts.skipSchemaSync === 'boolean'
? opts.skipSchemaSync
: process.env.OS_SKIP_SCHEMA_SYNC === '1';
this.hydrateMetadataFromDb = opts.hydrateMetadataFromDb === true;
}

init = async (ctx: PluginContext) => {
Expand Down Expand Up @@ -313,11 +337,19 @@ export class ObjectQLPlugin implements Plugin {
}

// Phase 2: Hydrate SchemaRegistry from sys_metadata (loads custom/template objects).
// Project kernels (environmentId set) never persist sys_metadata locally —
// metadata is sourced from the artifact (MetadataPlugin) or routed to the
// control plane via ControlPlaneProxyDriver. Skip to avoid querying a table
// that does not exist on local project DBs.
if (this.environmentId === undefined) {
// Project kernels (environmentId set) USUALLY source metadata from the
// artifact (MetadataPlugin) or a control-plane proxy and have no local
// sys_metadata, so hydration is skipped to avoid querying a table that
// does not exist (or, worse, a proxied remote one). EXCEPTION: an
// isolated, proxy-free project kernel that persists its OWN sys_metadata
// locally (the cloud single-env tenant runtime) opts in via
// `hydrateMetadataFromDb` so objects CREATED AT RUNTIME there re-enter the
// registry after a restart — otherwise registry.getObject() returns
// nothing for them and every registry consumer (the unknown-$select
// guard, hooks, relationships) silently degrades. Safe because each engine
// owns its registry (no cross-kernel leakage) and hydration tolerates a
// missing table.
if (this.environmentId === undefined || this.hydrateMetadataFromDb) {
await this.restoreMetadataFromDb(ctx);
} else {
ctx.logger.info('Project kernel — skipping sys_metadata hydration (metadata sourced from artifact)');
Expand Down