Skip to content

Commit 593a37a

Browse files
authored
Merge pull request #424 from objectstack-ai/copilot/fix-object-metadata-plugin-loading
2 parents a56c78c + 4cddde1 commit 593a37a

File tree

7 files changed

+534
-28
lines changed

7 files changed

+534
-28
lines changed

ROADMAP.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ ObjectQL is the **Standard Protocol for AI Software Generation** — a universal
8989
-`@objectql/driver-turso` — Turso/libSQL driver (Phase A: Core Driver) with 125 tests, 3 connection modes (remote, local, embedded replica)
9090
-`@objectql/driver-turso` — Phase B: Multi-Tenant Router, Schema Diff Engine, Platform API Client, Driver Plugin (52 new tests, 177 total)
9191
- ✅ Fix test quality: replaced all `expect(true).toBe(true)` placeholder assertions with meaningful state checks across `plugin-optimizations`, `protocol-odata-v4`, `protocol-json-rpc`, and `protocol-graphql` (7 files, 10 assertions fixed)
92+
- ✅ Plugin-based metadata auto-loading: `createAppPlugin()` factory in `@objectql/platform-node` replaces manual `loadObjects()`. Metadata registered as `app.*` services for upstream ObjectQLPlugin auto-discovery. Added `MetadataRegistry.listEntries()` and 8 new tests.
9293

9394
---
9495

@@ -843,6 +844,7 @@ const kernel = new ObjectStackKernel([
843844
| Extract formula wiring | Already in `@objectql/plugin-formula` — remove re-export from aggregator | ✅ |
844845
| Deprecate `ObjectQLPlugin` aggregator class | Mark as deprecated with `console.warn`, point to explicit imports | ✅ |
845846
| Migrate `objectstack.config.ts` to upstream | Import `ObjectQLPlugin` from `@objectstack/objectql`, compose sub-plugins directly, register MemoryDriver as `driver.default` service — fixes `app.*` discovery chain for AuthPlugin | ✅ |
847+
| Plugin-based metadata auto-loading (`createAppPlugin`) | Replace manual `loadObjects()` with `createAppPlugin()` factory in `@objectql/platform-node`. Each app registers as `app.<id>` service; upstream ObjectQLPlugin auto-discovers via `app.*` pattern. Config no longer needs `objects:` field. | ✅ |
846848
| Add `init`/`start` adapter to `QueryPlugin` | Consistent with ValidatorPlugin / FormulaPlugin adapter pattern for `@objectstack/core` kernel compatibility | ✅ |
847849

848850
### Phase B: Dispose Bridge Class ✅

objectstack.config.ts

Lines changed: 17 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -31,44 +31,30 @@ import { ValidatorPlugin } from '@objectql/plugin-validator';
3131
import { FormulaPlugin } from '@objectql/plugin-formula';
3232
import { createApiRegistryPlugin } from '@objectstack/core';
3333
import { MemoryDriver } from '@objectql/driver-memory';
34-
import * as fs from 'fs';
35-
import * as yaml from 'js-yaml';
36-
37-
function loadObjects(dir: string) {
38-
const objects: Record<string, any> = {};
39-
if (!fs.existsSync(dir)) return objects;
40-
41-
const files = fs.readdirSync(dir);
42-
for (const file of files) {
43-
if (file.endsWith('.object.yml') || file.endsWith('.object.yaml')) {
44-
const content = fs.readFileSync(path.join(dir, file), 'utf8');
45-
try {
46-
const doc = yaml.load(content) as any;
47-
if (doc) {
48-
const name = doc.name || file.replace(/\.object\.ya?ml$/, '');
49-
objects[name] = { ...doc, name };
50-
}
51-
} catch (e) {
52-
console.error(`Failed to load ${file}:`, e);
53-
}
54-
}
55-
}
56-
return objects;
57-
}
58-
59-
const projectTrackerDir = path.join(__dirname, 'examples/showcase/project-tracker/src');
34+
import { createAppPlugin } from '@objectql/platform-node';
6035

6136
// Shared driver instance — registered as 'driver.default' service for
6237
// upstream ObjectQLPlugin discovery and passed to QueryPlugin for query execution.
6338
const defaultDriver = new MemoryDriver();
6439

40+
// App plugins: each business module is loaded via createAppPlugin.
41+
// ObjectLoader recursively scans for *.object.yml, *.view.yml, *.permission.yml, etc.
42+
// The assembled manifest is registered as an `app.<id>` service.
43+
// Upstream ObjectQLPlugin auto-discovers all `app.*` services during start().
44+
const projectTrackerPlugin = createAppPlugin({
45+
id: 'project-tracker',
46+
dir: path.join(__dirname, 'examples/showcase/project-tracker/src'),
47+
label: 'Project Tracker',
48+
description: 'A showcase of ObjectQL capabilities including all field types.',
49+
});
50+
6551
export default {
6652
metadata: {
6753
name: 'objectos',
6854
version: '1.0.0'
6955
},
70-
objects: loadObjects(projectTrackerDir),
7156
// Runtime plugins (instances only)
57+
// No manual `objects:` field — metadata is auto-loaded via AppPlugin.
7258
plugins: [
7359
createApiRegistryPlugin(),
7460
new HonoServerPlugin({}),
@@ -82,9 +68,12 @@ export default {
8268
},
8369
start: async () => {},
8470
},
71+
// App plugins: register app metadata as `app.*` services.
72+
// Must be before ObjectQLPlugin so services are available during start().
73+
projectTrackerPlugin,
8574
// Upstream ObjectQLPlugin from @objectstack/objectql:
8675
// - Registers objectql, metadata, data, protocol services
87-
// - Discovers driver.* and app.* services (fixes auth plugin object registration)
76+
// - Discovers driver.* and app.* services and calls ql.registerApp()
8877
// - Registers audit hooks (created_by/updated_by) and tenant isolation middleware
8978
new ObjectQLPlugin(),
9079
new QueryPlugin({ datasources: { default: defaultDriver } }),

packages/foundation/core/test/__mocks__/@objectstack/objectql.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,16 @@ export class ObjectQL {
258258

259259
const mockStore = new Map<string, Map<string, any>>();
260260

261+
/**
262+
* Utility: Convert snake_case to Title Case.
263+
* Mirrors the real implementation from @objectstack/objectql.
264+
*/
265+
export function toTitleCase(str: string): string {
266+
return str
267+
.replace(/_/g, ' ')
268+
.replace(/\b\w/g, (char) => char.toUpperCase());
269+
}
270+
261271
export const SchemaRegistry = {
262272
register: jest.fn(),
263273
get: jest.fn(),
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/**
2+
* ObjectQL
3+
* Copyright (c) 2026-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { MetadataRegistry, ObjectConfig } from '@objectql/types';
10+
import { ObjectLoader } from './loader';
11+
import * as path from 'path';
12+
import * as fs from 'fs';
13+
import * as yaml from 'js-yaml';
14+
15+
/**
16+
* Configuration for createAppPlugin factory.
17+
*/
18+
export interface AppPluginConfig {
19+
/**
20+
* Unique app identifier. Used as the service name suffix: `app.<id>`.
21+
* If not provided, it will be inferred from the app manifest YAML
22+
* in the directory, or from the directory name.
23+
*/
24+
id?: string;
25+
26+
/**
27+
* Directory path containing the app's metadata files
28+
* (*.object.yml, *.view.yml, *.permission.yml, etc.).
29+
* ObjectLoader will recursively scan this directory.
30+
*/
31+
dir: string;
32+
33+
/**
34+
* Human-readable label for the application.
35+
* Falls back to the app manifest's label or the id.
36+
*/
37+
label?: string;
38+
39+
/**
40+
* Description of the application.
41+
*/
42+
description?: string;
43+
}
44+
45+
/**
46+
* Assemble a manifest object from a MetadataRegistry.
47+
*
48+
* The manifest matches the format expected by the upstream
49+
* `ObjectQL.registerApp()` — objects as a Record, plus arrays
50+
* for views, permissions, workflows, etc.
51+
*/
52+
function assembleManifest(
53+
registry: MetadataRegistry,
54+
config: AppPluginConfig,
55+
appManifest: Record<string, unknown> | undefined,
56+
): Record<string, unknown> {
57+
const id = config.id
58+
?? (appManifest?.name as string | undefined)
59+
?? path.basename(config.dir);
60+
61+
// Build objects map (Record<string, ObjectConfig>)
62+
const objectsMap: Record<string, ObjectConfig> = {};
63+
64+
// Merge actions into their parent objects.
65+
// registry.list() already unwraps .content, returning the inner actions map.
66+
// We need to use getEntry() to get the raw entry with its `id` field.
67+
const actionEntries = registry.listEntries('action');
68+
for (const entry of actionEntries) {
69+
const actionId = (entry.id ?? entry.name) as string;
70+
const actionContent = entry.content ?? entry;
71+
const obj = registry.get<ObjectConfig>('object', actionId);
72+
if (obj) {
73+
obj.actions = actionContent as ObjectConfig['actions'];
74+
}
75+
}
76+
77+
for (const obj of registry.list<ObjectConfig>('object')) {
78+
objectsMap[obj.name] = obj;
79+
}
80+
81+
// Start with app manifest as base, then override with explicit config values.
82+
// This ensures config.label/description take precedence over appManifest values.
83+
const manifest: Record<string, unknown> = {
84+
...(appManifest ?? {}),
85+
id,
86+
name: id,
87+
label: config.label ?? (appManifest?.label as string | undefined) ?? id,
88+
description: config.description ?? (appManifest?.description as string | undefined),
89+
objects: objectsMap,
90+
};
91+
92+
// Add collected metadata arrays (non-empty only)
93+
const metadataTypes = [
94+
'view', 'form', 'permission', 'report', 'workflow',
95+
'validation', 'data', 'page', 'menu',
96+
];
97+
for (const type of metadataTypes) {
98+
const items = registry.list(type);
99+
if (items.length > 0) {
100+
// Pluralize key for array form: view → views, etc.
101+
const key = type.endsWith('s') ? type : `${type}s`;
102+
manifest[key] = items;
103+
}
104+
}
105+
106+
// Always include objects even if empty (signal to registerApp)
107+
if (Object.keys(objectsMap).length === 0) {
108+
manifest.objects = {};
109+
}
110+
111+
return manifest;
112+
}
113+
114+
/**
115+
* Create a plugin that loads metadata from a filesystem directory
116+
* and registers it as an `app.<id>` service.
117+
*
118+
* The upstream `@objectstack/objectql` ObjectQLPlugin will automatically
119+
* discover all `app.*` services during its `start()` phase and call
120+
* `ql.registerApp(manifest)` for each one.
121+
*
122+
* **Usage:**
123+
* ```typescript
124+
* import { createAppPlugin } from '@objectql/platform-node';
125+
* import path from 'path';
126+
*
127+
* export default {
128+
* plugins: [
129+
* new ObjectQLPlugin(),
130+
* createAppPlugin({
131+
* id: 'project-tracker',
132+
* dir: path.join(__dirname, 'examples/showcase/project-tracker/src'),
133+
* }),
134+
* // ... other plugins
135+
* ]
136+
* };
137+
* ```
138+
*
139+
* @param config - App plugin configuration
140+
* @returns A plugin object compatible with @objectstack/core Plugin interface
141+
*/
142+
export function createAppPlugin(config: AppPluginConfig) {
143+
const { dir } = config;
144+
145+
return {
146+
name: `app-loader:${config.id ?? path.basename(dir)}`,
147+
type: 'app' as const,
148+
149+
/**
150+
* init phase: Load metadata and register as `app.<id>` service.
151+
*/
152+
init: async (ctx: {
153+
registerService: (name: string, service: unknown) => void;
154+
logger?: { info: (...args: unknown[]) => void; debug: (...args: unknown[]) => void };
155+
}) => {
156+
const log = ctx.logger ?? console;
157+
158+
// Validate directory exists
159+
if (!fs.existsSync(dir)) {
160+
log.info(`[AppPlugin] Directory not found, skipping: ${dir}`);
161+
return;
162+
}
163+
164+
// 1. Load metadata using ObjectLoader
165+
const registry = new MetadataRegistry();
166+
const loader = new ObjectLoader(registry);
167+
loader.load(dir);
168+
169+
// 2. Extract app manifest from loaded *.app.yml files (if any)
170+
const appEntries = registry.list<{ content?: Record<string, unknown> }>('app');
171+
const appManifest = appEntries.length > 0
172+
? (appEntries[0].content ?? appEntries[0]) as Record<string, unknown>
173+
: undefined;
174+
175+
// 3. Assemble the full manifest
176+
const manifest = assembleManifest(registry, config, appManifest);
177+
const appId = manifest.id as string;
178+
179+
// 4. Register as app.<id> service for ObjectQLPlugin auto-discovery
180+
const serviceName = `app.${appId}`;
181+
ctx.registerService(serviceName, manifest);
182+
183+
log.info(`[AppPlugin] Registered service '${serviceName}'`, {
184+
objects: Object.keys(manifest.objects as Record<string, unknown>).length,
185+
dir,
186+
});
187+
},
188+
189+
/**
190+
* start phase: No-op — ObjectQLPlugin handles registration during its start().
191+
*/
192+
start: async () => {},
193+
};
194+
}

packages/foundation/platform-node/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export * from './loader';
1010
export * from './plugin';
1111
export * from './driver';
1212
export * from './module';
13+
export * from './app-plugin';

0 commit comments

Comments
 (0)