Skip to content

Commit d929735

Browse files
authored
Merge pull request #1019 from objectstack-ai/copilot/refactor-objectui-plugin-architecture
2 parents d1e1dae + f32e9a6 commit d929735

File tree

11 files changed

+403
-48
lines changed

11 files changed

+403
-48
lines changed

ROADMAP.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ObjectUI Development Roadmap
22

3-
> **Last Updated:** March 2, 2026
3+
> **Last Updated:** March 4, 2026
44
> **Current Version:** v0.5.x
55
> **Spec Version:** @objectstack/spec v3.2.0
66
> **Client Version:** @objectstack/client v3.2.0
@@ -1164,6 +1164,38 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
11641164
- [ ] Conflict resolution on reconnection wired into Console flow
11651165
- [ ] Optimistic updates with TransactionManager state application
11661166

1167+
### P2.6 Plugin Modularization & Dynamic Management
1168+
1169+
> **Status:** Phase 1 complete — Plugin class standard, install/uninstall API, example plugin classes.
1170+
1171+
Plugin architecture refactoring to support true modular development, plugin isolation, and dynamic plugin install/uninstall at runtime.
1172+
1173+
**Phase 1 — Plugin Class Standard & Example Plugins ✅**
1174+
- [x] Define `AppMetadataPlugin` interface in `@object-ui/types` (name, version, type, description, init/start/stop/getConfig)
1175+
- [x] Define `PluginContext` interface for lifecycle hook context (logger, kernel)
1176+
- [x] Add `install(plugin)` / `uninstall(pluginName)` convenience methods to `PluginSystem` in `@object-ui/core`
1177+
- [x] Create `CRMPlugin` with full lifecycle (init/start/stop/getConfig) — `examples/crm/plugin.ts`
1178+
- [x] Create `TodoPlugin``examples/todo/plugin.ts`
1179+
- [x] Create `KitchenSinkPlugin``examples/kitchen-sink/plugin.ts`
1180+
- [x] Update `package.json` exports for all example apps (`./plugin` entry point)
1181+
- [x] Refactor root `objectstack.config.ts` to use plugin-based config collection via `getConfig()`
1182+
- [x] Unit tests for `install()` / `uninstall()` (5 new tests, 18 total in PluginSystem)
1183+
1184+
**Phase 2 — Dynamic Plugin Loading (Planned)**
1185+
- [ ] Hot-reload / lazy loading of plugins for development
1186+
- [ ] Runtime plugin discovery and loading from registry
1187+
- [ ] Plugin dependency graph visualization in Console
1188+
1189+
**Phase 3 — Plugin Identity & Isolation (Planned)**
1190+
- [ ] Preserve origin plugin metadata on objects, actions, dashboards for runtime inspection
1191+
- [ ] Per-plugin i18n namespace support
1192+
- [ ] Per-plugin permissions and data isolation
1193+
1194+
**Phase 4 — Cross-Repo Plugin Ecosystem (Planned)**
1195+
- [ ] Plugin marketplace / registry for third-party plugins
1196+
- [ ] Plugin publish/validate tooling (spec v3.0.9 `PluginBuildOptions`, `PluginPublishOptions`)
1197+
- [ ] Cross-repo plugin loading from npm packages
1198+
11671199
---
11681200

11691201
## 🔮 P3 — Future Vision (Deferred)

examples/crm/plugin.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
* import { crmConfig } from '@object-ui/example-crm/plugin';
1919
*/
2020

21+
import type { AppPluginContext } from '@object-ui/types';
2122
import config from './objectstack.config';
2223

2324
/** Raw CRM stack configuration for direct merging */
@@ -39,7 +40,7 @@ export class CRMPlugin {
3940
// No initialization needed
4041
}
4142

42-
async start(ctx: any) {
43+
async start(ctx: AppPluginContext) {
4344
const logger = ctx.logger || console;
4445

4546
try {
@@ -53,6 +54,15 @@ export class CRMPlugin {
5354
logger.info('[CRM] Config is available via crmConfig export for manual merging');
5455
}
5556
}
57+
58+
async stop() {
59+
// Teardown: no persistent resources to release
60+
}
61+
62+
/** Raw stack configuration for legacy/manual merging */
63+
getConfig() {
64+
return config;
65+
}
5666
}
5767

5868
export default CRMPlugin;

examples/kitchen-sink/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"types": "src/index.ts",
88
"exports": {
99
".": "./src/index.ts",
10-
"./objectstack.config": "./objectstack.config.ts"
10+
"./objectstack.config": "./objectstack.config.ts",
11+
"./plugin": "./plugin.ts"
1112
},
1213
"scripts": {
1314
"serve": "objectstack serve objectstack.config.ts",

examples/kitchen-sink/plugin.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* Kitchen Sink Example Plugin
3+
*
4+
* Exports the Kitchen Sink configuration as an ObjectStack plugin that can be
5+
* loaded by any ObjectStack application. Other projects can import
6+
* the Kitchen Sink metadata (objects, apps, dashboards, manifest) without
7+
* needing to know the internal structure.
8+
*
9+
* Usage in another project:
10+
*
11+
* import { KitchenSinkPlugin } from '@object-ui/example-kitchen-sink/plugin';
12+
*
13+
* const kernel = new ObjectKernel();
14+
* kernel.use(new KitchenSinkPlugin());
15+
*
16+
* Or import the raw config for merging:
17+
*
18+
* import { kitchenSinkConfig } from '@object-ui/example-kitchen-sink/plugin';
19+
*/
20+
21+
import type { AppPluginContext } from '@object-ui/types';
22+
import config from './objectstack.config';
23+
24+
/** Raw Kitchen Sink stack configuration for direct merging */
25+
export const kitchenSinkConfig = config;
26+
27+
/**
28+
* Kitchen Sink Plugin — wraps the Kitchen Sink metadata as a kernel-compatible plugin.
29+
*
30+
* When loaded via `kernel.use(new KitchenSinkPlugin())`, ObjectStack's AppPlugin
31+
* will register all Kitchen Sink objects, apps, dashboards, and seed data.
32+
*/
33+
export class KitchenSinkPlugin {
34+
readonly name = '@object-ui/example-kitchen-sink';
35+
readonly version = '1.0.0';
36+
readonly type = 'app-metadata' as const;
37+
readonly description = 'Kitchen Sink showcase metadata (all field types, views, and UI capabilities)';
38+
39+
async init() {
40+
// No initialization needed
41+
}
42+
43+
async start(ctx: AppPluginContext) {
44+
const logger = ctx.logger || console;
45+
46+
try {
47+
// Dynamically import AppPlugin to keep plugin.ts dependency-light
48+
const { AppPlugin } = await import('@objectstack/runtime');
49+
const appPlugin = new AppPlugin(config);
50+
await ctx.kernel?.use?.(appPlugin);
51+
logger.info('[KitchenSink] Metadata loaded: objects, apps, dashboards, seed data');
52+
} catch (e: any) {
53+
logger.warn(`[KitchenSink] Could not auto-register via AppPlugin: ${e.message}`);
54+
logger.info('[KitchenSink] Config is available via kitchenSinkConfig export for manual merging');
55+
}
56+
}
57+
58+
async stop() {
59+
// Teardown: no persistent resources to release
60+
}
61+
62+
/** Raw stack configuration for legacy/manual merging */
63+
getConfig() {
64+
return config;
65+
}
66+
}
67+
68+
export default KitchenSinkPlugin;

examples/todo/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
"private": true,
66
"type": "module",
77
"exports": {
8-
"./objectstack.config": "./objectstack.config.ts"
8+
"./objectstack.config": "./objectstack.config.ts",
9+
"./plugin": "./plugin.ts"
910
},
1011
"scripts": {
1112
"serve": "objectstack serve objectstack.config.ts",

examples/todo/plugin.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* Todo Example Plugin
3+
*
4+
* Exports the Todo configuration as an ObjectStack plugin that can be
5+
* loaded by any ObjectStack application. Other projects can import
6+
* the Todo metadata (objects, apps, dashboards, manifest) without
7+
* needing to know the internal structure.
8+
*
9+
* Usage in another project:
10+
*
11+
* import { TodoPlugin } from '@object-ui/example-todo/plugin';
12+
*
13+
* const kernel = new ObjectKernel();
14+
* kernel.use(new TodoPlugin());
15+
*
16+
* Or import the raw config for merging:
17+
*
18+
* import { todoConfig } from '@object-ui/example-todo/plugin';
19+
*/
20+
21+
import type { AppPluginContext } from '@object-ui/types';
22+
import config from './objectstack.config';
23+
24+
/** Raw Todo stack configuration for direct merging */
25+
export const todoConfig = config;
26+
27+
/**
28+
* Todo Plugin — wraps the Todo metadata as a kernel-compatible plugin.
29+
*
30+
* When loaded via `kernel.use(new TodoPlugin())`, ObjectStack's AppPlugin
31+
* will register all Todo objects, apps, dashboards, and seed data.
32+
*/
33+
export class TodoPlugin {
34+
readonly name = '@object-ui/example-todo';
35+
readonly version = '1.0.0';
36+
readonly type = 'app-metadata' as const;
37+
readonly description = 'Task management metadata (objects, apps, dashboards, seed data)';
38+
39+
async init() {
40+
// No initialization needed
41+
}
42+
43+
async start(ctx: AppPluginContext) {
44+
const logger = ctx.logger || console;
45+
46+
try {
47+
// Dynamically import AppPlugin to keep plugin.ts dependency-light
48+
const { AppPlugin } = await import('@objectstack/runtime');
49+
const appPlugin = new AppPlugin(config);
50+
await ctx.kernel?.use?.(appPlugin);
51+
logger.info('[Todo] Metadata loaded: objects, apps, dashboards, seed data');
52+
} catch (e: any) {
53+
logger.warn(`[Todo] Could not auto-register via AppPlugin: ${e.message}`);
54+
logger.info('[Todo] Config is available via todoConfig export for manual merging');
55+
}
56+
}
57+
58+
async stop() {
59+
// Teardown: no persistent resources to release
60+
}
61+
62+
/** Raw stack configuration for legacy/manual merging */
63+
getConfig() {
64+
return config;
65+
}
66+
}
67+
68+
export default TodoPlugin;

objectstack.config.ts

Lines changed: 24 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -12,30 +12,33 @@
1212
* because CRM and Kitchen Sink both define an `account` object, which would
1313
* trigger an ownership conflict in the ObjectQL Schema Registry.
1414
*
15+
* Plugins: Each example app exports a plugin class (CRMPlugin, TodoPlugin,
16+
* KitchenSinkPlugin) that implements the AppMetadataPlugin interface.
17+
* For standalone use, each plugin can be loaded independently via
18+
* `kernel.use(new CRMPlugin())`. In the dev workspace, we collect their
19+
* configs via `getConfig()` and merge them into a single AppPlugin.
1520
*/
1621
import { defineStack } from '@objectstack/spec';
1722
import { AppPlugin, DriverPlugin } from '@objectstack/runtime';
1823
import { ObjectQLPlugin } from '@objectstack/objectql';
1924
import { InMemoryDriver } from '@objectstack/driver-memory';
2025
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
2126
import { ConsolePlugin } from '@object-ui/console';
22-
import CrmConfig from './examples/crm/objectstack.config';
23-
import TodoConfig from './examples/todo/objectstack.config';
24-
import KitchenSinkConfig from './examples/kitchen-sink/objectstack.config';
27+
import { CRMPlugin } from './examples/crm/plugin';
28+
import { TodoPlugin } from './examples/todo/plugin';
29+
import { KitchenSinkPlugin } from './examples/kitchen-sink/plugin';
2530

26-
const crm = (CrmConfig as any).default || CrmConfig;
27-
const todo = (TodoConfig as any).default || TodoConfig;
28-
const kitchenSink = (KitchenSinkConfig as any).default || KitchenSinkConfig;
31+
// Instantiate example plugins
32+
const plugins = [new CRMPlugin(), new TodoPlugin(), new KitchenSinkPlugin()];
2933

30-
// Base objects from built-in examples
31-
const baseObjects = [
32-
...(crm.objects || []),
33-
...(todo.objects || []),
34-
...(kitchenSink.objects || []),
35-
];
34+
// Collect raw configs from each plugin via getConfig()
35+
const allConfigs = plugins.map((p) => {
36+
const raw = p.getConfig();
37+
return (raw as any).default || raw;
38+
});
3639

37-
// Collect all example configs for view merging
38-
const allConfigs = [crm, todo, kitchenSink];
40+
// Base objects from all plugins
41+
const baseObjects = allConfigs.flatMap((cfg: any) => cfg.objects || []);
3942

4043
// ---------------------------------------------------------------------------
4144
// Merge stack-level views into object definitions.
@@ -110,44 +113,22 @@ function mergeActionsIntoObjects(objects: any[], configs: any[]): any[] {
110113
});
111114
}
112115

113-
// Merge all example configs into a single app bundle for AppPlugin
116+
// Merge all plugin configs into a single app bundle for AppPlugin
114117
const mergedApp = defineStack({
115118
manifest: {
116119
id: 'dev-workspace',
117120
name: 'dev_workspace',
118121
version: '0.0.0',
119122
description: 'ObjectUI monorepo development workspace',
120123
type: 'app',
121-
data: [
122-
...(crm.manifest?.data || []),
123-
...(todo.manifest?.data || []),
124-
...(kitchenSink.manifest?.data || []),
125-
],
124+
data: allConfigs.flatMap((cfg: any) => cfg.manifest?.data || []),
126125
},
127126
objects: baseObjects,
128-
views: [
129-
...(crm.views || []),
130-
...(todo.views || []),
131-
...(kitchenSink.views || []),
132-
],
133-
apps: [
134-
...(crm.apps || []),
135-
...(todo.apps || []),
136-
...(kitchenSink.apps || []),
137-
],
138-
dashboards: [
139-
...(crm.dashboards || []),
140-
...(todo.dashboards || []),
141-
...(kitchenSink.dashboards || []),
142-
],
143-
reports: [
144-
...(crm.reports || []),
145-
],
146-
pages: [
147-
...(crm.pages || []),
148-
...(todo.pages || []),
149-
...(kitchenSink.pages || []),
150-
],
127+
views: allConfigs.flatMap((cfg: any) => cfg.views || []),
128+
apps: allConfigs.flatMap((cfg: any) => cfg.apps || []),
129+
dashboards: allConfigs.flatMap((cfg: any) => cfg.dashboards || []),
130+
reports: allConfigs.flatMap((cfg: any) => cfg.reports || []),
131+
pages: allConfigs.flatMap((cfg: any) => cfg.pages || []),
151132
} as any);
152133

153134
// Re-merge listViews and actions that defineStack stripped from objects

packages/core/src/registry/PluginSystem.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import type { Registry } from './Registry.js';
10-
import type { PluginScope, PluginScopeConfig } from '@object-ui/types';
10+
import type { PluginScope, PluginScopeConfig, AppMetadataPlugin, AppPluginContext } from '@object-ui/types';
1111
import { PluginScopeImpl } from './PluginScopeImpl.js';
1212

1313
export interface PluginDefinition {
@@ -158,4 +158,49 @@ export class PluginSystem {
158158
getAllPlugins(): PluginDefinition[] {
159159
return Array.from(this.plugins.values());
160160
}
161+
162+
/**
163+
* Install an AppMetadataPlugin at runtime.
164+
*
165+
* Wraps the plugin as a PluginDefinition, calls its `init()` and `start()`
166+
* lifecycle hooks, and loads it into the system.
167+
*
168+
* @param plugin - An AppMetadataPlugin instance
169+
* @param registry - The component registry
170+
* @param ctx - Optional context passed to the plugin's start() hook
171+
*/
172+
async install(plugin: AppMetadataPlugin, registry: Registry, ctx?: AppPluginContext): Promise<void> {
173+
if (this.loaded.has(plugin.name)) {
174+
console.warn(`Plugin "${plugin.name}" is already installed. Skipping.`);
175+
return;
176+
}
177+
178+
await plugin.init();
179+
180+
const definition: PluginDefinition = {
181+
name: plugin.name,
182+
version: plugin.version,
183+
register: () => {},
184+
onLoad: async () => {
185+
await plugin.start(ctx ?? { logger: console });
186+
},
187+
onUnload: async () => {
188+
await plugin.stop();
189+
},
190+
};
191+
192+
await this.loadPlugin(definition, registry);
193+
}
194+
195+
/**
196+
* Uninstall an AppMetadataPlugin at runtime.
197+
*
198+
* Calls the plugin's `stop()` lifecycle hook (via onUnload) and
199+
* removes it from the system.
200+
*
201+
* @param pluginName - Name of the plugin to uninstall
202+
*/
203+
async uninstall(pluginName: string): Promise<void> {
204+
await this.unloadPlugin(pluginName);
205+
}
161206
}

0 commit comments

Comments
 (0)