Skip to content

Commit 9733df2

Browse files
committed
feat: implement third-party artifact binding and app bundle resolver for multi-project support
1 parent 85b818a commit 9733df2

5 files changed

Lines changed: 261 additions & 80 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **Third-party project binding via `metadata.artifact_path`** — Multi-project `POST /api/v1/cloud/projects` now accepts `metadata.artifact_path` to bind a locally-compiled bundle (e.g. `examples/app-crm/dist/objectstack.json`) into a fresh project. Provisioning loads the JSON, registers schemas in the per-project ObjectQL engine, and seeds the bundle's `data` arrays — same pipeline that built-in templates use. New `TemplateSeeder.seedBundle({ projectId, bundle })` method exposes the seeder for arbitrary bundles. Bind errors (read failure, malformed JSON) are recorded as non-fatal `metadata.artifactBindError` so the project still flips to `active`. Verified end-to-end: query `/api/v1/projects/{id}/data/account` returns the seeded CRM accounts.
12+
- **`AppBundleResolver` for live kernel binding**`apps/server` now ships `createFsAppBundleResolver()` in `apps/server/server/fs-app-bundle-resolver.ts`. Reads `sys_project.metadata.artifact_path` (or `artifact_paths[]`) at per-project kernel boot, with optional `OBJECTSTACK_PROJECT_ARTIFACTS=projId:path,…` env override and `OBJECTSTACK_PROJECT_ARTIFACT_ROOT` for relative path resolution. Used wherever a project kernel is materialized (trigger / function / metadata API requests). Replaces the previous `return []` stub.
13+
1014
### Fixed
1115
- `apps/server`: `GET /api/v1/cloud/templates` now returns the full template registry (`blank`, `crm`, `todo`) on Vercel / play.objectstack.ai. Root cause: the dispatcher resolved templates through a `template-seeder` service registered by `MultiProjectPlugin`, and on Vercel cold starts that service registration could be missed by the request handler — the dispatcher then silently returned `{ templates: [], total: 0 }`. Added a `createTemplatesRoutePlugin` that registers `/cloud/templates` directly on `http.server` from a static, module-scope `listTemplates()` snapshot, registered before `DispatcherPlugin`. Local single-project mode is unchanged.
1216
- `packages/runtime/http-dispatcher.ts`: `/cloud/templates` fallback now logs the resolution error via `console.error` instead of swallowing it silently, so the underlying cause is visible in production logs.

apps/server/objectstack.config.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import { createSingleProjectPlugin } from './server/single-project-plugin.js';
5050
import { createStudioRuntimeConfigPlugin, createTemplatesRoutePlugin } from './server/multi-project-plugins.js';
5151
import { listTemplates } from './server/templates/registry.js';
5252
import { templateRegistry } from './server/templates/registry.js';
53+
import { createFsAppBundleResolver } from './server/fs-app-bundle-resolver.js';
5354

5455
type IDataDriver = Contracts.IDataDriver;
5556

@@ -197,9 +198,7 @@ const basePlugins: BasePluginsFactory = async ({ projectId, project }) => {
197198
];
198199
};
199200

200-
const appBundles: AppBundleResolver = {
201-
async resolve() { return []; },
202-
};
201+
const appBundles: AppBundleResolver = createFsAppBundleResolver();
203202

204203
// Single shared promise — both control-plane plugins and MultiProjectPlugin
205204
// use the same DB connection.
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* File-System AppBundleResolver
5+
*
6+
* Resolves the artifact bundles a project is bound to by reading the
7+
* project's row from the control plane (`sys_project.metadata`) and
8+
* loading any artifact files referenced there.
9+
*
10+
* Binding model — a project's metadata column may carry:
11+
* {
12+
* "artifact_path": "./examples/app-crm/dist/objectstack.json",
13+
* // -- OR --
14+
* "artifact_paths": ["./pkg-a/dist/objectstack.json", "./pkg-b/dist/objectstack.json"]
15+
* }
16+
*
17+
* Paths are resolved relative to `OBJECTSTACK_PROJECT_ARTIFACT_ROOT`
18+
* (defaults to `process.cwd()`). Absolute paths are honored as-is.
19+
*
20+
* For pure dev convenience, an env-var override is also supported:
21+
* OBJECTSTACK_PROJECT_ARTIFACTS=proj_crm:/abs/path/crm.json,proj_todo:/abs/path/todo.json
22+
* Entries listed there override `metadata.artifact_path` for that
23+
* project id, so a developer can rebind without rewriting the DB row.
24+
*
25+
* On read errors (missing file, malformed JSON) the resolver logs a
26+
* warning and returns `[]` for that path — the kernel still boots, just
27+
* without that bundle, which is the same behaviour as the legacy stub.
28+
*/
29+
30+
import { readFile } from 'node:fs/promises';
31+
import { resolve as resolvePath, isAbsolute } from 'node:path';
32+
import type { AppBundleResolver } from '@objectstack/runtime';
33+
34+
const ENV_MAP_VAR = 'OBJECTSTACK_PROJECT_ARTIFACTS';
35+
const ARTIFACT_ROOT_VAR = 'OBJECTSTACK_PROJECT_ARTIFACT_ROOT';
36+
37+
function parseEnvMap(raw: string | undefined): Map<string, string[]> {
38+
const map = new Map<string, string[]>();
39+
if (!raw) return map;
40+
for (const segment of raw.split(',')) {
41+
const trimmed = segment.trim();
42+
if (!trimmed) continue;
43+
const idx = trimmed.indexOf(':');
44+
if (idx <= 0) continue;
45+
const projectId = trimmed.slice(0, idx).trim();
46+
const path = trimmed.slice(idx + 1).trim();
47+
if (!projectId || !path) continue;
48+
const list = map.get(projectId) ?? [];
49+
list.push(path);
50+
map.set(projectId, list);
51+
}
52+
return map;
53+
}
54+
55+
function extractMetadataPaths(metadata: any): string[] {
56+
if (!metadata || typeof metadata !== 'object') return [];
57+
const out: string[] = [];
58+
if (typeof metadata.artifact_path === 'string') out.push(metadata.artifact_path);
59+
if (Array.isArray(metadata.artifact_paths)) {
60+
for (const p of metadata.artifact_paths) {
61+
if (typeof p === 'string') out.push(p);
62+
}
63+
}
64+
return out;
65+
}
66+
67+
export function createFsAppBundleResolver(): AppBundleResolver {
68+
const envMap = parseEnvMap(process.env[ENV_MAP_VAR]);
69+
const root = process.env[ARTIFACT_ROOT_VAR] ?? process.cwd();
70+
const cache = new Map<string, any>();
71+
72+
async function loadOne(path: string): Promise<any | null> {
73+
const abs = isAbsolute(path) ? path : resolvePath(root, path);
74+
if (cache.has(abs)) return cache.get(abs);
75+
try {
76+
const raw = await readFile(abs, 'utf-8');
77+
const parsed = JSON.parse(raw);
78+
cache.set(abs, parsed);
79+
return parsed;
80+
} catch (err: any) {
81+
// eslint-disable-next-line no-console
82+
console.warn(
83+
`[FsAppBundleResolver] Failed to load artifact '${abs}': ${err?.message ?? err}`,
84+
);
85+
cache.set(abs, null);
86+
return null;
87+
}
88+
}
89+
90+
return {
91+
async resolve(project) {
92+
const projectId = (project as any)?.id;
93+
// Env-var override wins so devs can hot-swap without a DB write.
94+
const overridePaths = projectId ? envMap.get(projectId) : undefined;
95+
// sys_project.metadata is stored as JSON text in some drivers; tolerate both.
96+
let meta: any = (project as any)?.metadata;
97+
if (typeof meta === 'string') {
98+
try { meta = JSON.parse(meta); } catch { meta = undefined; }
99+
}
100+
const metadataPaths = extractMetadataPaths(meta);
101+
102+
const paths = overridePaths && overridePaths.length > 0
103+
? overridePaths
104+
: metadataPaths;
105+
if (paths.length === 0) return [];
106+
107+
const bundles: any[] = [];
108+
for (const p of paths) {
109+
const b = await loadOne(p);
110+
if (b) bundles.push(b);
111+
}
112+
return bundles;
113+
},
114+
};
115+
}

packages/runtime/src/http-dispatcher.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1731,6 +1731,54 @@ export class HttpDispatcher {
17311731
}
17321732
}
17331733
}
1734+
1735+
// Bind a third-party developer's locally compiled artifact
1736+
// into this project. The caller passes
1737+
// `metadata.artifact_path` (path to a compiled
1738+
// ObjectStack bundle JSON) and we delegate to the same
1739+
// seeder pipeline that templates use. Resolved relative
1740+
// to OBJECTSTACK_PROJECT_ARTIFACT_ROOT (or process.cwd
1741+
// if unset).
1742+
const artifactPathRaw = (baseMetadata as any).artifact_path;
1743+
if (typeof artifactPathRaw === 'string' && artifactPathRaw.length > 0) {
1744+
try {
1745+
const fs = await import('node:fs/promises');
1746+
const path = await import('node:path');
1747+
const root = process.env.OBJECTSTACK_PROJECT_ARTIFACT_ROOT
1748+
?? process.cwd();
1749+
const resolved = path.isAbsolute(artifactPathRaw)
1750+
? artifactPathRaw
1751+
: path.resolve(root, artifactPathRaw);
1752+
const text = await fs.readFile(resolved, 'utf8');
1753+
const bundle = JSON.parse(text);
1754+
const seeder: any = await this.resolveService('template-seeder');
1755+
if (seeder?.seedBundle) {
1756+
await seeder.seedBundle({ projectId, bundle });
1757+
} else {
1758+
throw new Error('template-seeder.seedBundle is unavailable');
1759+
}
1760+
} catch (bindErr) {
1761+
const bindMessage = bindErr instanceof Error ? bindErr.message : String(bindErr);
1762+
try {
1763+
const existing = await findOne(ENV, { id: projectId });
1764+
const existingMeta = typeof existing?.metadata === 'string'
1765+
? JSON.parse(existing.metadata)
1766+
: (existing?.metadata ?? {});
1767+
await ql.update(
1768+
ENV,
1769+
{
1770+
metadata: JSON.stringify({
1771+
...existingMeta,
1772+
artifactBindError: { message: bindMessage, artifactPath: artifactPathRaw },
1773+
}),
1774+
},
1775+
{ where: { id: projectId } } as any,
1776+
);
1777+
} catch {
1778+
// Best-effort metadata update — ignore secondary failure.
1779+
}
1780+
}
1781+
}
17341782
} catch (err) {
17351783
const message = err instanceof Error ? err.message : String(err);
17361784
const failedAt = new Date().toISOString();

packages/runtime/src/multi-project-plugin.ts

Lines changed: 92 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ export interface ProjectTemplate {
5757
export interface TemplateSeeder {
5858
listTemplates(): Array<Pick<ProjectTemplate, 'id' | 'label' | 'description' | 'category'>>;
5959
seed(params: { projectId: string; templateId: string }): Promise<void>;
60+
/**
61+
* Seed an arbitrary `ObjectStackDefinition`-shaped bundle into the
62+
* given project. Used for binding a third-party developer's locally
63+
* compiled artifact (e.g. `examples/app-crm/dist/objectstack.json`)
64+
* into a multi-project server at provisioning time.
65+
*/
66+
seedBundle(params: { projectId: string; bundle: any }): Promise<void>;
6067
}
6168

6269
export interface MultiProjectPluginConfig {
@@ -131,6 +138,87 @@ function createTemplateSeeder(
131138
templates: Record<string, ProjectTemplate>,
132139
envRegistry: EnvironmentDriverRegistry,
133140
): TemplateSeeder {
141+
const seedBundleForProject = async (projectId: string, bundle: any): Promise<void> => {
142+
const items = bundle ? extractMetadataItems(bundle) : [];
143+
const dataSets = bundle ? namespaceDatasets(bundle) : [];
144+
145+
// Empty bundle (e.g. the "blank" template) → nothing to seed.
146+
if (items.length === 0 && dataSets.length === 0) return;
147+
148+
const kernel = await kernelManager.getOrCreate(projectId);
149+
150+
let metadata: any;
151+
try {
152+
metadata = await kernel.getServiceAsync('metadata');
153+
} catch (err: any) {
154+
throw new Error(
155+
`metadata service unavailable for project ${projectId}: ${err?.message ?? err}`,
156+
);
157+
}
158+
if (!metadata || typeof metadata.bulkRegister !== 'function') {
159+
throw new Error(
160+
`metadata.bulkRegister unavailable for project ${projectId} (got ${metadata ? typeof metadata : 'null'})`,
161+
);
162+
}
163+
164+
const engine: any = await kernel.getServiceAsync('objectql').catch(() => null);
165+
if (!engine) {
166+
throw new Error(
167+
`objectql engine unavailable for project ${projectId} — metadata persistence would be in-memory only`,
168+
);
169+
}
170+
if (typeof metadata.setDataEngine === 'function') {
171+
const cached = envRegistry.peekById(projectId);
172+
const orgId = (cached?.project as any)?.organization_id as string | undefined;
173+
try { metadata.setDataEngine(engine, orgId, projectId); } catch { /* already set */ }
174+
}
175+
176+
if (items.length > 0) {
177+
const result: any = await metadata.bulkRegister(items, { continueOnError: true });
178+
const failed = result?.failed ?? 0;
179+
if (failed > 0) {
180+
const errs = (result?.errors ?? [])
181+
.slice(0, 5)
182+
.map((e: any) => `${e?.type}/${e?.name}: ${e?.error ?? 'unknown'}`)
183+
.join('; ');
184+
throw new Error(
185+
`bulkRegister reported ${failed} failures for project ${projectId}: ${errs}`,
186+
);
187+
}
188+
}
189+
190+
// Register the bundle into the ObjectQL engine's SchemaRegistry so that
191+
// syncSchemas() can create tables for the newly-registered objects.
192+
// bulkRegister() only updates MetadataManager's registry, not the engine's
193+
// internal _registry — without this, syncSchemas() has no objects to create.
194+
if (items.length > 0 && typeof engine?.registerApp === 'function') {
195+
try { (engine as any).registerApp(bundle); } catch { /* best effort */ }
196+
}
197+
198+
// Sync schemas so tables exist before seeding.
199+
if (items.length > 0 && typeof engine?.syncSchemas === 'function') {
200+
try { await engine.syncSchemas(); } catch { /* best effort */ }
201+
}
202+
203+
if (dataSets.length > 0) {
204+
const seedLoader = new SeedLoaderService(engine, metadata, console as any);
205+
const config = SeedLoaderConfigSchema.parse({});
206+
await seedLoader.load({ datasets: dataSets, config });
207+
}
208+
209+
// Force a persistence flush so the per-project JSON file is
210+
// written before the provisioning handler returns; otherwise
211+
// the memory driver's dirty flag only saves on a 2s timer and
212+
// early HTTP responses may see an empty file on disk.
213+
const driverWithFlush = (kernel as any).services?.driver ?? (kernel as any).getService?.('driver');
214+
const flushable = typeof driverWithFlush?.flush === 'function'
215+
? driverWithFlush
216+
: null;
217+
if (flushable) {
218+
try { await flushable.flush(); } catch { /* best effort */ }
219+
}
220+
};
221+
134222
return {
135223
listTemplates() {
136224
return Object.values(templates).map(({ id, label, description, category }) => ({
@@ -150,84 +238,11 @@ function createTemplateSeeder(
150238
}
151239

152240
const bundle = await template.load();
153-
const items = bundle ? extractMetadataItems(bundle) : [];
154-
const dataSets = bundle ? namespaceDatasets(bundle) : [];
155-
156-
// Empty bundle (e.g. the "blank" template) → nothing to seed.
157-
if (items.length === 0 && dataSets.length === 0) return;
158-
159-
const kernel = await kernelManager.getOrCreate(projectId);
160-
161-
let metadata: any;
162-
try {
163-
metadata = await kernel.getServiceAsync('metadata');
164-
} catch (err: any) {
165-
throw new Error(
166-
`metadata service unavailable for project ${projectId}: ${err?.message ?? err}`,
167-
);
168-
}
169-
if (!metadata || typeof metadata.bulkRegister !== 'function') {
170-
throw new Error(
171-
`metadata.bulkRegister unavailable for project ${projectId} (got ${metadata ? typeof metadata : 'null'})`,
172-
);
173-
}
174-
175-
const engine: any = await kernel.getServiceAsync('objectql').catch(() => null);
176-
if (!engine) {
177-
throw new Error(
178-
`objectql engine unavailable for project ${projectId} — metadata persistence would be in-memory only`,
179-
);
180-
}
181-
if (typeof metadata.setDataEngine === 'function') {
182-
const cached = envRegistry.peekById(projectId);
183-
const orgId = (cached?.project as any)?.organization_id as string | undefined;
184-
try { metadata.setDataEngine(engine, orgId, projectId); } catch { /* already set */ }
185-
}
186-
187-
if (items.length > 0) {
188-
const result: any = await metadata.bulkRegister(items, { continueOnError: true });
189-
const failed = result?.failed ?? 0;
190-
if (failed > 0) {
191-
const errs = (result?.errors ?? [])
192-
.slice(0, 5)
193-
.map((e: any) => `${e?.type}/${e?.name}: ${e?.error ?? 'unknown'}`)
194-
.join('; ');
195-
throw new Error(
196-
`bulkRegister reported ${failed} failures for project ${projectId}: ${errs}`,
197-
);
198-
}
199-
}
200-
201-
// Register the bundle into the ObjectQL engine's SchemaRegistry so that
202-
// syncSchemas() can create tables for the newly-registered objects.
203-
// bulkRegister() only updates MetadataManager's registry, not the engine's
204-
// internal _registry — without this, syncSchemas() has no objects to create.
205-
if (items.length > 0 && typeof engine?.registerApp === 'function') {
206-
try { (engine as any).registerApp(bundle); } catch { /* best effort */ }
207-
}
208-
209-
// Sync schemas so tables exist before seeding.
210-
if (items.length > 0 && typeof engine?.syncSchemas === 'function') {
211-
try { await engine.syncSchemas(); } catch { /* best effort */ }
212-
}
213-
214-
if (dataSets.length > 0) {
215-
const seedLoader = new SeedLoaderService(engine, metadata, console as any);
216-
const config = SeedLoaderConfigSchema.parse({});
217-
await seedLoader.load({ datasets: dataSets, config });
218-
}
241+
await seedBundleForProject(projectId, bundle);
242+
},
219243

220-
// Force a persistence flush so the per-project JSON file is
221-
// written before the provisioning handler returns; otherwise
222-
// the memory driver's dirty flag only saves on a 2s timer and
223-
// early HTTP responses may see an empty file on disk.
224-
const driverWithFlush = (kernel as any).services?.driver ?? (kernel as any).getService?.('driver');
225-
const flushable = typeof driverWithFlush?.flush === 'function'
226-
? driverWithFlush
227-
: null;
228-
if (flushable) {
229-
try { await flushable.flush(); } catch { /* best effort */ }
230-
}
244+
async seedBundle({ projectId, bundle }) {
245+
await seedBundleForProject(projectId, bundle);
231246
},
232247
};
233248
}

0 commit comments

Comments
 (0)