Skip to content

Commit df8031e

Browse files
committed
Merge remote-tracking branch 'origin/main' into copilot/add-turso-driver-support
# Conflicts: # ROADMAP.md # objectstack.config.ts
2 parents 473cb73 + 593a37a commit df8031e

7 files changed

Lines changed: 534 additions & 28 deletions

File tree

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
-`pnpm dev` supports Turso/libSQL driver via `TURSO_DATABASE_URL` env var (local embedded, remote cloud, or embedded replica modes)
9394

9495
---
@@ -844,6 +845,7 @@ const kernel = new ObjectStackKernel([
844845
| Extract formula wiring | Already in `@objectql/plugin-formula` — remove re-export from aggregator | ✅ |
845846
| Deprecate `ObjectQLPlugin` aggregator class | Mark as deprecated with `console.warn`, point to explicit imports | ✅ |
846847
| 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 | ✅ |
848+
| 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. | ✅ |
847849
| Add `init`/`start` adapter to `QueryPlugin` | Consistent with ValidatorPlugin / FormulaPlugin adapter pattern for `@objectstack/core` kernel compatibility | ✅ |
848850

849851
### Phase B: Dispose Bridge Class ✅

objectstack.config.ts

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

6237
// Choose driver based on environment — Turso when TURSO_DATABASE_URL is set,
6338
// MemoryDriver otherwise (zero-config fallback for quick starts).
@@ -86,13 +61,24 @@ function createDefaultDriver() {
8661
// upstream ObjectQLPlugin discovery and passed to QueryPlugin for query execution.
8762
const defaultDriver = createDefaultDriver();
8863

64+
// App plugins: each business module is loaded via createAppPlugin.
65+
// ObjectLoader recursively scans for *.object.yml, *.view.yml, *.permission.yml, etc.
66+
// The assembled manifest is registered as an `app.<id>` service.
67+
// Upstream ObjectQLPlugin auto-discovers all `app.*` services during start().
68+
const projectTrackerPlugin = createAppPlugin({
69+
id: 'project-tracker',
70+
dir: path.join(__dirname, 'examples/showcase/project-tracker/src'),
71+
label: 'Project Tracker',
72+
description: 'A showcase of ObjectQL capabilities including all field types.',
73+
});
74+
8975
export default {
9076
metadata: {
9177
name: 'objectos',
9278
version: '1.0.0'
9379
},
94-
objects: loadObjects(projectTrackerDir),
9580
// Runtime plugins (instances only)
81+
// No manual `objects:` field — metadata is auto-loaded via AppPlugin.
9682
plugins: [
9783
createApiRegistryPlugin(),
9884
new HonoServerPlugin({}),
@@ -111,9 +97,12 @@ export default {
11197
}
11298
},
11399
},
100+
// App plugins: register app metadata as `app.*` services.
101+
// Must be before ObjectQLPlugin so services are available during start().
102+
projectTrackerPlugin,
114103
// Upstream ObjectQLPlugin from @objectstack/objectql:
115104
// - Registers objectql, metadata, data, protocol services
116-
// - Discovers driver.* and app.* services (fixes auth plugin object registration)
105+
// - Discovers driver.* and app.* services and calls ql.registerApp()
117106
// - Registers audit hooks (created_by/updated_by) and tenant isolation middleware
118107
new ObjectQLPlugin(),
119108
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)