Skip to content

Commit ee9312f

Browse files
committed
feat(template-seeding): implement project template seeding and management
1 parent d523c0d commit ee9312f

21 files changed

Lines changed: 554 additions & 88 deletions

File tree

apps/server/objectstack.config.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
*/
1515

1616
import { defineStack } from '@objectstack/spec';
17-
import { AppPlugin, DriverPlugin } from '@objectstack/runtime';
17+
import { DriverPlugin } from '@objectstack/runtime';
1818
import { ObjectQLPlugin } from '@objectstack/objectql';
1919
import { InMemoryDriver } from '@objectstack/driver-memory';
2020
import { TursoDriver } from '@objectstack/driver-turso';
@@ -27,9 +27,6 @@ import { MetadataPlugin } from '@objectstack/metadata';
2727
import { AIServicePlugin } from '@objectstack/service-ai';
2828
import { AutomationServicePlugin } from '@objectstack/service-automation';
2929
import { AnalyticsServicePlugin } from '@objectstack/service-analytics';
30-
import CrmApp from '../../examples/app-crm/objectstack.config';
31-
import TodoApp from '../../examples/app-todo/objectstack.config';
32-
import BiPluginManifest from '../../examples/plugin-bi/objectstack.config';
3330
import { fileURLToPath } from 'node:url';
3431
import { dirname, resolve } from 'node:path';
3532

@@ -70,7 +67,7 @@ export default defineStack({
7067
namespace: 'server',
7168
name: 'ObjectStack Server',
7269
version: '1.0.0',
73-
description: 'Production server aggregating CRM, Todo and BI plugins',
70+
description: 'Production server — multi-project control plane',
7471
type: 'app',
7572
},
7673
// Phase 3: enable project-scoped URLs (/api/v1/projects/:projectId/...)
@@ -91,9 +88,6 @@ export default defineStack({
9188
},
9289
new DriverPlugin(new InMemoryDriver(), 'memory'),
9390
new DriverPlugin(tursoDriver, 'turso'),
94-
new AppPlugin(CrmApp),
95-
new AppPlugin(TodoApp),
96-
new AppPlugin(BiPluginManifest),
9791
new SetupPlugin(),
9892
new AuthPlugin({
9993
secret: process.env.AUTH_SECRET ?? 'dev-secret-please-change-in-production-min-32-chars',

apps/server/server/bootstrap.ts

Lines changed: 9 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { TursoDriver } from '@objectstack/driver-turso';
4141
import { SqlDriver } from '@objectstack/driver-sql';
4242
import type { Contracts } from '@objectstack/spec';
4343
import { createControlPlanePlugins } from './control-plane-preset.js';
44+
import { createTemplateSeeder } from './template-seeder.js';
4445

4546
type IDataDriver = Contracts.IDataDriver;
4647

@@ -194,40 +195,10 @@ async function bootstrapMultiProject(
194195
),
195196
});
196197

197-
// MVP app-bundle resolver.
198-
//
199-
// The example CRM / Todo / BI bundles are loaded lazily *and* gated on
200-
// an env flag so that:
201-
// 1. Test environments (E2E, unit tests) can skip them entirely —
202-
// the example `defineStack(...)` configs perform their own Zod
203-
// validation on import, so a single unrelated schema drift in
204-
// an example would otherwise crash bootstrap for everyone.
205-
// 2. Production multi-project deployments that do not ship the
206-
// reference apps (the typical case) avoid paying the cost.
207-
//
208-
// Set `OBJECTSTACK_BUNDLE_EXAMPLES=true` to get the legacy behaviour —
209-
// all three example bundles are attached to every project kernel.
210-
// Swap this resolver for a registry-backed one once
211-
// `sys_project_package` is consulted.
198+
// Example bundles are no longer pre-installed into every project kernel.
199+
// They are seeded once at provisioning time via `createTemplateSeeder`.
212200
const appBundles: AppBundleResolver = {
213-
async resolve() {
214-
if (process.env.OBJECTSTACK_BUNDLE_EXAMPLES !== 'true') {
215-
return [];
216-
}
217-
// Dynamic `new Function('return import(...)')(…)` sidesteps
218-
// TypeScript's static rootDir analysis — the example configs
219-
// live outside apps/server's tsconfig rootDir but are still
220-
// resolvable at runtime. Kept here intentionally so the tsc
221-
// typecheck doesn't need a dedicated include for examples.
222-
const dyn = (spec: string) =>
223-
(new Function('s', 'return import(s)') as (s: string) => Promise<any>)(spec);
224-
const [crm, todo, bi] = await Promise.all([
225-
dyn('../../../examples/app-crm/objectstack.config.ts'),
226-
dyn('../../../examples/app-todo/objectstack.config.ts'),
227-
dyn('../../../examples/plugin-bi/objectstack.config.ts'),
228-
]);
229-
return [crm.default, todo.default, bi.default];
230-
},
201+
async resolve() { return []; },
231202
};
232203

233204
// Per-project kernels only need the minimal base — driver is injected
@@ -259,6 +230,11 @@ async function bootstrapMultiProject(
259230

260231
const controlKernel = await bootstrapControlKernel(controlDriver, driverName);
261232

233+
// Register the template seeder so http-dispatcher can resolve it during
234+
// provisioning via `kernel.getServiceAsync('template-seeder')`.
235+
const seeder = createTemplateSeeder(kernelManager);
236+
controlKernel.registerService('template-seeder', seeder);
237+
262238
return {
263239
kernel: controlKernel,
264240
kernelManager,
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { SeedLoaderService } from '@objectstack/runtime';
4+
import type { KernelManager } from '@objectstack/runtime';
5+
import { templateRegistry, listTemplates } from './templates/registry.js';
6+
import { extractMetadataItems } from './templates/extract.js';
7+
8+
export interface TemplateSeeder {
9+
seed(params: { projectId: string; templateId: string }): Promise<void>;
10+
listTemplates(): Array<{ id: string; label: string; description: string; category?: string }>;
11+
}
12+
13+
export function createTemplateSeeder(kernelManager: KernelManager): TemplateSeeder {
14+
return {
15+
listTemplates,
16+
17+
async seed({ projectId, templateId }) {
18+
const template = templateRegistry[templateId];
19+
if (!template) {
20+
throw new Error(
21+
`Unknown template: '${templateId}'. Available: [${Object.keys(templateRegistry).join(', ')}]`,
22+
);
23+
}
24+
25+
// blank has no metadata to seed — skip early
26+
if (templateId === 'blank') return;
27+
28+
const bundle = await template.load();
29+
const items = extractMetadataItems(bundle);
30+
31+
if (items.length === 0) {
32+
throw new Error(
33+
`template '${templateId}' produced 0 metadata items — bundle shape unexpected (keys=[${bundle ? Object.keys(bundle).join(',') : 'null'}])`,
34+
);
35+
}
36+
37+
const kernel = await kernelManager.getOrCreate(projectId);
38+
39+
let metadata: any;
40+
try {
41+
metadata = await kernel.getServiceAsync('metadata');
42+
} catch (err: any) {
43+
throw new Error(
44+
`metadata service unavailable for project ${projectId}: ${err?.message ?? err}`,
45+
);
46+
}
47+
if (!metadata || typeof metadata.bulkRegister !== 'function') {
48+
throw new Error(
49+
`metadata.bulkRegister unavailable for project ${projectId} (got ${metadata ? typeof metadata : 'null'})`,
50+
);
51+
}
52+
53+
const engine: any = await kernel
54+
.getServiceAsync('objectql')
55+
.catch(() => null);
56+
if (!engine) {
57+
throw new Error(
58+
`objectql engine unavailable for project ${projectId} — metadata persistence would be in-memory only`,
59+
);
60+
}
61+
if (typeof metadata.setDataEngine === 'function') {
62+
// Defensive: ensure DatabaseLoader is wired even if
63+
// MetadataPlugin.start() missed it (e.g. plugin order race).
64+
try { metadata.setDataEngine(engine, undefined, projectId); } catch { /* already set */ }
65+
}
66+
67+
const result: any = await metadata.bulkRegister(items, { continueOnError: true });
68+
const failed = result?.failed ?? 0;
69+
if (failed > 0) {
70+
const errs = (result?.errors ?? [])
71+
.slice(0, 5)
72+
.map((e: any) => `${e?.type}/${e?.name}: ${e?.error ?? 'unknown'}`)
73+
.join('; ');
74+
throw new Error(
75+
`bulkRegister reported ${failed} failures for project ${projectId}: ${errs}`,
76+
);
77+
}
78+
79+
// Seed row data if the bundle ships datasets
80+
if (Array.isArray(bundle.data) && bundle.data.length > 0) {
81+
const seedLoader = new SeedLoaderService(
82+
engine,
83+
metadata,
84+
console as any,
85+
);
86+
await seedLoader.load({ datasets: bundle.data, config: {} });
87+
}
88+
},
89+
};
90+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { ProjectTemplate } from './types.js';
4+
5+
export const blankTemplate: ProjectTemplate = {
6+
id: 'blank',
7+
label: 'Blank',
8+
description: 'Empty project — start from scratch.',
9+
category: 'starter',
10+
async load() {
11+
return { manifest: { id: 'blank', namespace: 'blank' }, objects: [] } as any;
12+
},
13+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { fileURLToPath, pathToFileURL } from 'node:url';
4+
import * as path from 'node:path';
5+
import type { ProjectTemplate } from './types.js';
6+
7+
// Resolve the CRM bundle relative to THIS file so the path survives any
8+
// compilation/build output layout. Using a file:// URL means we can hand
9+
// the path to a dynamic import() without TypeScript trying to type-check
10+
// the target (which lives outside apps/server's rootDir).
11+
const HERE = path.dirname(fileURLToPath(import.meta.url));
12+
const BUNDLE_PATH = path.resolve(HERE, '../../../../examples/app-crm/objectstack.config.ts');
13+
const BUNDLE_URL = pathToFileURL(BUNDLE_PATH).href;
14+
15+
// Lazy dynamic import — the bundle's Zod evaluation is deferred until the
16+
// template is actually selected, so a schema drift in the example cannot
17+
// crash control-plane bootstrap.
18+
const dyn = (spec: string): Promise<any> =>
19+
(new Function('s', 'return import(s)') as (s: string) => Promise<any>)(spec);
20+
21+
export const crmTemplate: ProjectTemplate = {
22+
id: 'crm',
23+
label: 'CRM Starter',
24+
description: 'Accounts, Contacts, Opportunities — full CRM example.',
25+
category: 'business',
26+
async load() {
27+
const mod = await dyn(BUNDLE_URL);
28+
// ESM default can be nested under `.default.default` when the
29+
// loader double-wraps (tsx + file:// URL). Unwrap defensively.
30+
return mod?.default?.manifest ? mod.default : (mod?.default?.default ?? mod?.default ?? mod);
31+
},
32+
};
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Flatten an `ObjectStackDefinition` bundle into the `{type, name, data}`
5+
* shape consumed by `MetadataPlugin.bulkRegister`.
6+
*
7+
* Object names are namespaced (`${ns}__${name}`) when the bundle declares
8+
* a non-reserved namespace and the name is not already prefixed. Other
9+
* metadata types (view/dashboard/flow/...) are preserved as-is to match
10+
* the existing AppPlugin install path.
11+
*
12+
* Skipped on purpose:
13+
* - apis / actions — handler refs require kernel code, not metadata only
14+
* - translations — needs i18n plugin
15+
* - sharingRules / roles — needs security plugin
16+
* - onEnable hooks — code, not metadata
17+
*/
18+
export interface ExtractedItem {
19+
type: string;
20+
name: string;
21+
data: unknown;
22+
}
23+
24+
const RESERVED_NS = new Set(['base', 'system']);
25+
26+
export function extractMetadataItems(bundle: any): ExtractedItem[] {
27+
const items: ExtractedItem[] = [];
28+
const ns = bundle?.manifest?.namespace as string | undefined;
29+
30+
const toFQN = (name: string): string =>
31+
name.includes('__') || !ns || RESERVED_NS.has(ns) ? name : `${ns}__${name}`;
32+
33+
const pushAll = (type: string, arr?: any[], rewriteName = false) => {
34+
for (const item of arr ?? []) {
35+
if (!item?.name) continue;
36+
const name = rewriteName ? toFQN(item.name) : item.name;
37+
const data = rewriteName ? { ...item, name } : item;
38+
items.push({ type, name, data });
39+
}
40+
};
41+
42+
pushAll('object', bundle?.objects, true);
43+
pushAll('view', bundle?.views);
44+
pushAll('dashboard', bundle?.dashboards);
45+
pushAll('report', bundle?.reports);
46+
pushAll('flow', bundle?.flows);
47+
pushAll('agent', bundle?.agents);
48+
pushAll('app', bundle?.apps);
49+
50+
return items;
51+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { blankTemplate } from './blank.js';
4+
import { crmTemplate } from './crm.js';
5+
import { todoTemplate } from './todo.js';
6+
import type { ProjectTemplate } from './types.js';
7+
8+
export const templateRegistry: Record<string, ProjectTemplate> = {
9+
[blankTemplate.id]: blankTemplate,
10+
[crmTemplate.id]: crmTemplate,
11+
[todoTemplate.id]: todoTemplate,
12+
};
13+
14+
export const DEFAULT_TEMPLATE_ID = 'blank';
15+
16+
export function listTemplates(): Array<Pick<ProjectTemplate, 'id' | 'label' | 'description' | 'category'>> {
17+
return Object.values(templateRegistry).map(({ id, label, description, category }) => ({
18+
id,
19+
label,
20+
description,
21+
category,
22+
}));
23+
}
24+
25+
export type { ProjectTemplate } from './types.js';
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { ProjectTemplate } from './types.js';
4+
5+
const dyn = (spec: string) =>
6+
(new Function('s', 'return import(s)') as (s: string) => Promise<any>)(spec);
7+
8+
export const todoTemplate: ProjectTemplate = {
9+
id: 'todo',
10+
label: 'Todo List',
11+
description: 'Lightweight task tracker — single-object example.',
12+
category: 'starter',
13+
async load() {
14+
const mod = await dyn('../../../../examples/app-todo/objectstack.config.ts');
15+
return mod.default;
16+
},
17+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Project Template — descriptor used by the provisioning seeder.
5+
*
6+
* A template's `load()` returns an `ObjectStackDefinition`-shaped bundle
7+
* (objects/views/dashboards/flows/agents/apps/data) which is then fanned
8+
* out into `bulkRegister` calls against the freshly-provisioned project
9+
* kernel. Loading is async + lazy so example bundles are evaluated only
10+
* when the template is actually selected — a Zod drift in one example
11+
* cannot crash control-plane bootstrap.
12+
*/
13+
export interface ProjectTemplate {
14+
/** Stable id used by the API / Studio selector. */
15+
id: string;
16+
/** Human-readable label shown in Studio. */
17+
label: string;
18+
/** Short description for the picker. */
19+
description: string;
20+
/** Optional category tag. */
21+
category?: string;
22+
/** Lazy bundle loader. Must be cheap to call repeatedly. */
23+
load(): Promise<any>;
24+
}

0 commit comments

Comments
 (0)