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
2 changes: 2 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ ObjectQL is the **Standard Protocol for AI Software Generation** — a universal
- ✅ `@objectql/driver-turso` — Turso/libSQL driver (Phase A: Core Driver) with 125 tests, 3 connection modes (remote, local, embedded replica)
- ✅ `@objectql/driver-turso` — Phase B: Multi-Tenant Router, Schema Diff Engine, Platform API Client, Driver Plugin (52 new tests, 177 total)
- ✅ 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)
- ✅ 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.

---

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

### Phase B: Dispose Bridge Class ✅
Expand Down
45 changes: 17 additions & 28 deletions objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,44 +31,30 @@ import { ValidatorPlugin } from '@objectql/plugin-validator';
import { FormulaPlugin } from '@objectql/plugin-formula';
import { createApiRegistryPlugin } from '@objectstack/core';
import { MemoryDriver } from '@objectql/driver-memory';
import * as fs from 'fs';
import * as yaml from 'js-yaml';

function loadObjects(dir: string) {
const objects: Record<string, any> = {};
if (!fs.existsSync(dir)) return objects;

const files = fs.readdirSync(dir);
for (const file of files) {
if (file.endsWith('.object.yml') || file.endsWith('.object.yaml')) {
const content = fs.readFileSync(path.join(dir, file), 'utf8');
try {
const doc = yaml.load(content) as any;
if (doc) {
const name = doc.name || file.replace(/\.object\.ya?ml$/, '');
objects[name] = { ...doc, name };
}
} catch (e) {
console.error(`Failed to load ${file}:`, e);
}
}
}
return objects;
}

const projectTrackerDir = path.join(__dirname, 'examples/showcase/project-tracker/src');
import { createAppPlugin } from '@objectql/platform-node';

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

// App plugins: each business module is loaded via createAppPlugin.
// ObjectLoader recursively scans for *.object.yml, *.view.yml, *.permission.yml, etc.
// The assembled manifest is registered as an `app.<id>` service.
// Upstream ObjectQLPlugin auto-discovers all `app.*` services during start().
const projectTrackerPlugin = createAppPlugin({
id: 'project-tracker',
dir: path.join(__dirname, 'examples/showcase/project-tracker/src'),
label: 'Project Tracker',
description: 'A showcase of ObjectQL capabilities including all field types.',
});

export default {
metadata: {
name: 'objectos',
version: '1.0.0'
},
objects: loadObjects(projectTrackerDir),
// Runtime plugins (instances only)
// No manual `objects:` field — metadata is auto-loaded via AppPlugin.
plugins: [
createApiRegistryPlugin(),
new HonoServerPlugin({}),
Expand All @@ -82,9 +68,12 @@ export default {
},
start: async () => {},
},
// App plugins: register app metadata as `app.*` services.
// Must be before ObjectQLPlugin so services are available during start().
projectTrackerPlugin,
// Upstream ObjectQLPlugin from @objectstack/objectql:
// - Registers objectql, metadata, data, protocol services
// - Discovers driver.* and app.* services (fixes auth plugin object registration)
// - Discovers driver.* and app.* services and calls ql.registerApp()
// - Registers audit hooks (created_by/updated_by) and tenant isolation middleware
new ObjectQLPlugin(),
new QueryPlugin({ datasources: { default: defaultDriver } }),
Expand Down
10 changes: 10 additions & 0 deletions packages/foundation/core/test/__mocks__/@objectstack/objectql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,16 @@ export class ObjectQL {

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

/**
* Utility: Convert snake_case to Title Case.
* Mirrors the real implementation from @objectstack/objectql.
*/
export function toTitleCase(str: string): string {
return str
.replace(/_/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase());
}

export const SchemaRegistry = {
register: jest.fn(),
get: jest.fn(),
Expand Down
194 changes: 194 additions & 0 deletions packages/foundation/platform-node/src/app-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
/**
* ObjectQL
* Copyright (c) 2026-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import { MetadataRegistry, ObjectConfig } from '@objectql/types';
import { ObjectLoader } from './loader';
import * as path from 'path';
import * as fs from 'fs';
import * as yaml from 'js-yaml';
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

js-yaml is imported here but not used anywhere in this module. Remove the unused import to avoid unnecessary dependencies/unused-import lint failures.

Suggested change
import * as yaml from 'js-yaml';

Copilot uses AI. Check for mistakes.

/**
* Configuration for createAppPlugin factory.
*/
export interface AppPluginConfig {
/**
* Unique app identifier. Used as the service name suffix: `app.<id>`.
* If not provided, it will be inferred from the app manifest YAML
* in the directory, or from the directory name.
*/
id?: string;

/**
* Directory path containing the app's metadata files
* (*.object.yml, *.view.yml, *.permission.yml, etc.).
* ObjectLoader will recursively scan this directory.
*/
dir: string;

/**
* Human-readable label for the application.
* Falls back to the app manifest's label or the id.
*/
label?: string;

/**
* Description of the application.
*/
description?: string;
}

/**
* Assemble a manifest object from a MetadataRegistry.
*
* The manifest matches the format expected by the upstream
* `ObjectQL.registerApp()` — objects as a Record, plus arrays
* for views, permissions, workflows, etc.
*/
function assembleManifest(
registry: MetadataRegistry,
config: AppPluginConfig,
appManifest: Record<string, unknown> | undefined,
): Record<string, unknown> {
const id = config.id
?? (appManifest?.name as string | undefined)
?? path.basename(config.dir);

// Build objects map (Record<string, ObjectConfig>)
const objectsMap: Record<string, ObjectConfig> = {};

// Merge actions into their parent objects.
// registry.list() already unwraps .content, returning the inner actions map.
// We need to use getEntry() to get the raw entry with its `id` field.
const actionEntries = registry.listEntries('action');
for (const entry of actionEntries) {
const actionId = (entry.id ?? entry.name) as string;
const actionContent = entry.content ?? entry;
const obj = registry.get<ObjectConfig>('object', actionId);
if (obj) {
obj.actions = actionContent as ObjectConfig['actions'];
}
}

for (const obj of registry.list<ObjectConfig>('object')) {
objectsMap[obj.name] = obj;
}

// Start with app manifest as base, then override with explicit config values.
// This ensures config.label/description take precedence over appManifest values.
const manifest: Record<string, unknown> = {
...(appManifest ?? {}),
id,
name: id,
label: config.label ?? (appManifest?.label as string | undefined) ?? id,
description: config.description ?? (appManifest?.description as string | undefined),
objects: objectsMap,
};

// Add collected metadata arrays (non-empty only)
const metadataTypes = [
'view', 'form', 'permission', 'report', 'workflow',
'validation', 'data', 'page', 'menu',
];
for (const type of metadataTypes) {
const items = registry.list(type);
if (items.length > 0) {
// Pluralize key for array form: view → views, etc.
const key = type.endsWith('s') ? type : `${type}s`;
Comment on lines +97 to +101
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pluralization logic (const key = type.endsWith('s') ? type : ${type}s``) will turn the metadata type data into `datas`. Since ObjectLoader registers `*.data.yml` under type `data`, this likely produces a manifest key that downstream app registration does not recognize. Use an explicit mapping for manifest keys (e.g., data -> data, permission -> permissions, etc.) instead of generic pluralization.

Suggested change
for (const type of metadataTypes) {
const items = registry.list(type);
if (items.length > 0) {
// Pluralize key for array form: view → views, etc.
const key = type.endsWith('s') ? type : `${type}s`;
// Explicit mapping from registry type → manifest key to avoid incorrect
// generic pluralization (e.g. "data" must stay "data", not "datas").
const manifestKeyByType: Record<string, string> = {
view: 'views',
form: 'forms',
permission: 'permissions',
report: 'reports',
workflow: 'workflows',
validation: 'validations',
data: 'data',
page: 'pages',
menu: 'menus',
};
for (const type of metadataTypes) {
const items = registry.list(type);
if (items.length > 0) {
const key = manifestKeyByType[type] ?? type;

Copilot uses AI. Check for mistakes.
manifest[key] = items;
}
}

// Always include objects even if empty (signal to registerApp)
if (Object.keys(objectsMap).length === 0) {
manifest.objects = {};
}

return manifest;
}

/**
* Create a plugin that loads metadata from a filesystem directory
* and registers it as an `app.<id>` service.
*
* The upstream `@objectstack/objectql` ObjectQLPlugin will automatically
* discover all `app.*` services during its `start()` phase and call
* `ql.registerApp(manifest)` for each one.
*
* **Usage:**
* ```typescript
* import { createAppPlugin } from '@objectql/platform-node';
* import path from 'path';
*
* export default {
* plugins: [
* new ObjectQLPlugin(),
* createAppPlugin({
* id: 'project-tracker',
* dir: path.join(__dirname, 'examples/showcase/project-tracker/src'),
* }),
* // ... other plugins
* ]
* };
* ```
*
* @param config - App plugin configuration
* @returns A plugin object compatible with @objectstack/core Plugin interface
*/
export function createAppPlugin(config: AppPluginConfig) {
const { dir } = config;

return {
name: `app-loader:${config.id ?? path.basename(dir)}`,
type: 'app' as const,

/**
* init phase: Load metadata and register as `app.<id>` service.
*/
init: async (ctx: {
registerService: (name: string, service: unknown) => void;
logger?: { info: (...args: unknown[]) => void; debug: (...args: unknown[]) => void };
}) => {
const log = ctx.logger ?? console;

// Validate directory exists
if (!fs.existsSync(dir)) {
log.info(`[AppPlugin] Directory not found, skipping: ${dir}`);
return;
}

// 1. Load metadata using ObjectLoader
const registry = new MetadataRegistry();
const loader = new ObjectLoader(registry);
loader.load(dir);

// 2. Extract app manifest from loaded *.app.yml files (if any)
const appEntries = registry.list<{ content?: Record<string, unknown> }>('app');
const appManifest = appEntries.length > 0
? (appEntries[0].content ?? appEntries[0]) as Record<string, unknown>
: undefined;

// 3. Assemble the full manifest
const manifest = assembleManifest(registry, config, appManifest);
const appId = manifest.id as string;

// 4. Register as app.<id> service for ObjectQLPlugin auto-discovery
const serviceName = `app.${appId}`;
ctx.registerService(serviceName, manifest);

log.info(`[AppPlugin] Registered service '${serviceName}'`, {
objects: Object.keys(manifest.objects as Record<string, unknown>).length,
dir,
});
},

/**
* start phase: No-op — ObjectQLPlugin handles registration during its start().
*/
start: async () => {},
};
}
1 change: 1 addition & 0 deletions packages/foundation/platform-node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ export * from './loader';
export * from './plugin';
export * from './driver';
export * from './module';
export * from './app-plugin';
Loading
Loading