Skip to content

Commit 772ac25

Browse files
committed
feat: refactor project mode handling and introduce boot orchestration
- Moved single-project plugin implementation to @objectstack/service-cloud. - Enhanced CLI serve command to support booting with library based on OBJECTSTACK_MODE. - Added utility functions for boot environment resolution and mode handling. - Introduced boot stack orchestration to manage project, cloud, and standalone modes. - Implemented local identity seeding for project mode with SQLite databases. - Created file-system app bundle resolver for project artifacts. - Established project and standalone stack factories for better modularity. - Updated package dependencies to include zod for schema validation.
1 parent 7d5b9fb commit 772ac25

16 files changed

Lines changed: 905 additions & 567 deletions

apps/server/objectstack.config.ts

Lines changed: 25 additions & 239 deletions
Original file line numberDiff line numberDiff line change
@@ -13,257 +13,43 @@
1313
* cloud (multi-project) plugin stack but backs
1414
* it with two SQLite files (`control.db` for
1515
* the control plane, `proj_local.db` for the
16-
* single project's business data). Studio +
17-
* Auth fully operational. Aliases: `local`,
18-
* `single-project`.
19-
*
20-
* - `cloud` — multi-project, control-plane connected. See
21-
* @objectstack/service-cloud for details.
16+
* single project's business data).
17+
* Aliases: `local`, `single-project`.
18+
* - `cloud` — multi-project, control-plane connected.
2219
* Alias: `multi-project`.
20+
* - `standalone` — runtime-only (ObjectQL + REST + Driver).
2321
*
24-
* - `standalone` — runtime-only (ObjectQL + REST + Driver). No
25-
* authentication, no Studio, no control plane.
26-
* For embedding in other frameworks or running
27-
* an internal-only data API.
28-
*
29-
* The legacy flag `OBJECTSTACK_MULTI_PROJECT=true` is still honoured as a
30-
* deprecated alias for `OBJECTSTACK_MODE=cloud` and will be removed in a
31-
* future major release.
32-
*
33-
* ### Common env vars
34-
*
35-
* AUTH_SECRET — JWT signing secret (≥32 chars)
36-
* NEXT_PUBLIC_BASE_URL — public origin used by better-auth
37-
* OBJECTSTACK_PROJECT_ID — local project id (default: `proj_local`)
38-
* OBJECTSTACK_ARTIFACT_PATH — compiled artifact (default: ./dist/objectstack.json)
39-
*
40-
* ### Project / Standalone DB
41-
*
42-
* OBJECTSTACK_DATABASE_URL — overrides default file SQLite path
43-
* OBJECTSTACK_DATABASE_AUTH_TOKEN — auth token for libSQL/Turso URLs
44-
* OBJECTSTACK_DATABASE_DRIVER — driver name: sqlite | memory | turso
45-
* TURSO_DATABASE_URL / TURSO_AUTH_TOKEN — fallback aliases
22+
* All boot orchestration now lives in @objectstack/service-cloud.
23+
* This file only supplies the apps/server-specific knobs (templates,
24+
* app bundle resolution).
4625
*/
4726

4827
import { resolve as resolvePath, dirname } from 'node:path';
4928
import { fileURLToPath } from 'node:url';
50-
import { mkdirSync } from 'node:fs';
51-
import { readFile } from 'node:fs/promises';
52-
import { createCloudStack } from '@objectstack/service-cloud';
53-
import { createSingleProjectPlugin } from './server/single-project-plugin.js';
54-
import { templateRegistry } from './server/templates/registry.js';
29+
import { createBootStack } from '@objectstack/service-cloud';
5530
import { createFsAppBundleResolver } from './server/fs-app-bundle-resolver.js';
56-
57-
function envFlag(name: string): boolean {
58-
return ['1', 'true', 'yes', 'on'].includes((process.env[name] ?? '').trim().toLowerCase());
59-
}
60-
61-
type Mode = 'project' | 'cloud' | 'standalone';
62-
63-
/**
64-
* Resolve the deployment mode from environment.
65-
*
66-
* Default changed from `standalone` (legacy) to `project` (current). The
67-
* legacy `standalone` semantics — single-project local dev with full
68-
* Auth + Studio — moved under `project`. The new `standalone` value
69-
* means runtime-only (no Auth, no Studio).
70-
*/
71-
function resolveMode(): Mode {
72-
const raw = process.env.OBJECTSTACK_MODE?.trim().toLowerCase();
73-
if (raw === 'cloud' || raw === 'multi-project') return 'cloud';
74-
if (raw === 'standalone') return 'standalone';
75-
if (raw === 'project' || raw === 'local' || raw === 'single-project') return 'project';
76-
if (raw && raw.length > 0) {
77-
// eslint-disable-next-line no-console
78-
console.warn(`[objectstack] Unknown OBJECTSTACK_MODE=${raw}; falling back to "project".`);
79-
}
80-
if (envFlag('OBJECTSTACK_MULTI_PROJECT')) {
81-
// eslint-disable-next-line no-console
82-
console.warn(
83-
'[objectstack] OBJECTSTACK_MULTI_PROJECT is deprecated. Use `OBJECTSTACK_MODE=cloud` instead.',
84-
);
85-
return 'cloud';
86-
}
87-
return 'project';
88-
}
89-
90-
const mode = resolveMode();
91-
92-
const authSecret = process.env.AUTH_SECRET
93-
?? 'dev-secret-please-change-in-production-min-32-chars';
94-
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL
95-
?? (process.env.VERCEL_PROJECT_PRODUCTION_URL
96-
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
97-
: undefined)
98-
?? (process.env.VERCEL_URL
99-
? `https://${process.env.VERCEL_URL}`
100-
: undefined)
101-
?? `http://localhost:${process.env.PORT ?? 3000}`;
31+
import { templateRegistry } from './server/templates/registry.js';
10232

10333
const serverDir = dirname(fileURLToPath(import.meta.url));
104-
const localProjectId = process.env.OBJECTSTACK_PROJECT_ID ?? 'proj_local';
34+
const dataDir = resolvePath(serverDir, '.objectstack/data');
10535
const localArtifactPath = process.env.OBJECTSTACK_ARTIFACT_PATH
10636
?? resolvePath(serverDir, 'dist/objectstack.json');
10737

108-
// ── PROJECT MODE ─────────────────────────────────────────────────────────────
109-
//
110-
// Reuses `createCloudStack()` with two local SQLite files. The frontend
111-
// detects single-project mode via the `singleProject: true` payload from
112-
// `single-project-plugin`; everything else (auth, control plane,
113-
// kernel resolution, REST routing) follows the cloud code path.
114-
115-
async function createProjectStack() {
116-
const dataDir = resolvePath(serverDir, '.objectstack/data');
117-
mkdirSync(dataDir, { recursive: true });
118-
119-
const controlDbUrl = `file:${resolvePath(dataDir, 'control.db')}`;
120-
const projectDbUrl = `file:${resolvePath(dataDir, 'proj_local.db')}`;
121-
122-
const stack = await createCloudStack({
123-
authSecret,
124-
baseUrl,
125-
controlDriverUrl: controlDbUrl,
38+
const config = await createBootStack({
39+
project: {
40+
dataDir,
41+
artifactPath: localArtifactPath,
12642
appBundles: createFsAppBundleResolver(),
127-
// Project-mode per-project plugins. The control plane (created by
128-
// `createCloudStack`'s preset) is the sole owner of identity,
129-
// authentication, security, audit, tenant catalogs, and packages —
130-
// their tables live in `control.db`. Each per-project kernel only
131-
// registers the engines needed to materialize that project's
132-
// **business data** schemas + records.
133-
basePlugins: async ({ projectId }: { projectId: string }) => {
134-
const { ObjectQLPlugin } = await import('@objectstack/objectql');
135-
const { MetadataPlugin } = await import('@objectstack/metadata');
136-
const { AppPlugin } = await import('@objectstack/runtime');
137-
138-
let artifactBundle: any = null;
139-
try {
140-
const raw = await readFile(localArtifactPath, 'utf8');
141-
const parsed = JSON.parse(raw);
142-
artifactBundle = (parsed?.schemaVersion != null && parsed?.metadata !== undefined)
143-
? parsed.metadata
144-
: parsed;
145-
} catch {
146-
// First boot before `objectstack build` — AppPlugin skipped.
147-
}
148-
149-
const plugins: any[] = [
150-
new ObjectQLPlugin({ environmentId: projectId }),
151-
new MetadataPlugin({
152-
watch: false,
153-
environmentId: projectId,
154-
artifactSource: { mode: 'local-file', path: localArtifactPath },
155-
}),
156-
];
157-
if (artifactBundle) plugins.push(new AppPlugin(artifactBundle));
158-
return plugins;
159-
},
160-
});
161-
162-
// The cloud preset registers a `studio/runtime-config` route returning
163-
// `{ singleProject: false }`. Hono is first-match-wins, so we drop that
164-
// plugin and substitute our own which seeds local identity AND emits
165-
// `{ singleProject: true, … }`.
166-
const filtered = stack.plugins.filter(
167-
(p: any) => p?.name !== 'com.objectstack.studio.runtime-config',
168-
);
169-
filtered.push(
170-
createSingleProjectPlugin({
171-
projectId: localProjectId,
172-
projectDatabaseUrl: projectDbUrl,
173-
projectDatabaseDriver: 'sqlite',
174-
}),
175-
);
176-
177-
return {
178-
plugins: filtered,
179-
api: stack.api,
180-
};
181-
}
182-
183-
// ── STANDALONE MODE ──────────────────────────────────────────────────────────
184-
//
185-
// Runtime-only: ObjectQL + Driver + REST. No Auth, no Studio, no control
186-
// plane. Designed for embedding ObjectStack in other frameworks or
187-
// internal back-end services.
188-
189-
async function buildRuntimeOnlyConfig() {
190-
const { ObjectQLPlugin } = await import('@objectstack/objectql');
191-
const { MetadataPlugin } = await import('@objectstack/metadata');
192-
const { DriverPlugin, AppPlugin } = await import('@objectstack/runtime');
193-
194-
const dbUrl = process.env.OBJECTSTACK_DATABASE_URL?.trim()
195-
|| process.env.TURSO_DATABASE_URL?.trim()
196-
|| `file:${resolvePath(serverDir, '.objectstack/data/standalone.db')}`;
197-
const dbAuthToken = process.env.OBJECTSTACK_DATABASE_AUTH_TOKEN?.trim()
198-
|| process.env.TURSO_AUTH_TOKEN?.trim();
199-
const dbDriver = process.env.OBJECTSTACK_DATABASE_DRIVER?.trim()
200-
|| (/^(libsql|https?):\/\//i.test(dbUrl) ? 'turso' : 'sqlite');
201-
202-
let driverPlugin: any;
203-
if (dbDriver === 'memory' || dbUrl.startsWith('memory://')) {
204-
const { InMemoryDriver: MemoryDriver } = await import('@objectstack/driver-memory');
205-
driverPlugin = new DriverPlugin(new MemoryDriver());
206-
} else if (dbDriver === 'turso' || /^(libsql|https?):\/\//i.test(dbUrl)) {
207-
const { TursoDriver } = await import('@objectstack/driver-turso');
208-
driverPlugin = new DriverPlugin(
209-
new TursoDriver({ url: dbUrl, authToken: dbAuthToken }) as any,
210-
);
211-
} else {
212-
const { SqlDriver } = await import('@objectstack/driver-sql');
213-
const filename = dbUrl.replace(/^file:(\/\/)?/, '');
214-
mkdirSync(resolvePath(filename, '..'), { recursive: true });
215-
driverPlugin = new DriverPlugin(
216-
new SqlDriver({
217-
client: 'better-sqlite3',
218-
connection: { filename },
219-
useNullAsDefault: true,
220-
}),
221-
);
222-
}
223-
224-
let artifactBundle: any = null;
225-
try {
226-
const raw = await readFile(localArtifactPath, 'utf8');
227-
const parsed = JSON.parse(raw);
228-
artifactBundle = (parsed?.schemaVersion != null && parsed?.metadata !== undefined)
229-
? parsed.metadata
230-
: parsed;
231-
} catch {
232-
// No artifact yet — AppPlugin skipped.
233-
}
234-
235-
const plugins: any[] = [
236-
driverPlugin,
237-
new MetadataPlugin({
238-
watch: false,
239-
environmentId: localProjectId,
240-
artifactSource: { mode: 'local-file', path: localArtifactPath },
241-
}),
242-
new ObjectQLPlugin({ environmentId: localProjectId }),
243-
];
244-
if (artifactBundle) plugins.push(new AppPlugin(artifactBundle));
245-
246-
return {
247-
plugins,
248-
api: {
249-
enableProjectScoping: false,
250-
projectResolution: 'none' as const,
251-
},
252-
};
253-
}
254-
255-
// ── Export ──────────────────────────────────────────────────────────────────
256-
257-
const config =
258-
mode === 'cloud'
259-
? await createCloudStack({
260-
authSecret,
261-
baseUrl,
262-
templates: templateRegistry,
263-
appBundles: createFsAppBundleResolver(),
264-
})
265-
: mode === 'standalone'
266-
? await buildRuntimeOnlyConfig()
267-
: await createProjectStack();
43+
},
44+
cloud: {
45+
templates: templateRegistry,
46+
appBundles: createFsAppBundleResolver(),
47+
},
48+
standalone: {
49+
artifactPath: localArtifactPath,
50+
databaseUrl: process.env.OBJECTSTACK_DATABASE_URL
51+
?? `file:${resolvePath(dataDir, 'standalone.db')}`,
52+
},
53+
});
26854

26955
export default config;
Lines changed: 7 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,107 +1,11 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* Idempotent control-plane seed for **project** mode.
5-
*
6-
* In project mode we reuse the cloud (multi-project) plugin stack but back it
7-
* with two local SQLite files:
8-
*
9-
* - `control.db` — control plane (sys_organization, sys_project, …)
10-
* - `proj_local.db` — the single project's business database
11-
*
12-
* For `KernelManager` (cloud factory) to resolve `proj_local`, the control
13-
* plane must contain a real `sys_project` row whose `database_url` points at
14-
* `proj_local.db`. This helper performs that seed idempotently on every
15-
* boot, so the project-mode code path is identical to cloud after the first
16-
* request — no synthetic project rows, no special branches in the dispatcher.
4+
* Re-export shim. Implementation lives in `@objectstack/service-cloud`.
175
*/
18-
19-
export const LOCAL_ORG_ID = 'org_local';
20-
export const LOCAL_PROJECT_ID = 'proj_local';
21-
22-
export interface LocalIdentityOptions {
23-
/** ObjectQL service handle bound to the control-plane DB. */
24-
objectql: any;
25-
/** Override the default `org_local` identifier. */
26-
orgId?: string;
27-
/** Override the default `proj_local` identifier. */
28-
projectId?: string;
29-
/** Display name for the seeded organization. */
30-
orgName?: string;
31-
/** Project DB URL written to `sys_project.database_url`. */
32-
projectDatabaseUrl: string;
33-
/** Project DB driver name (e.g. `sqlite`, `turso`). */
34-
projectDatabaseDriver: string;
35-
}
36-
37-
/**
38-
* Insert the local org + project rows if they don't yet exist. Safe to call
39-
* on every boot — uses `find` with an exact-id filter for the existence
40-
* check.
41-
*/
42-
export async function ensureLocalIdentity(opts: LocalIdentityOptions): Promise<void> {
43-
const {
44-
objectql,
45-
orgId = LOCAL_ORG_ID,
46-
projectId = LOCAL_PROJECT_ID,
47-
orgName = 'Local',
48-
projectDatabaseUrl,
49-
projectDatabaseDriver,
50-
} = opts;
51-
52-
if (!objectql) return;
53-
const now = new Date().toISOString();
54-
55-
// ── Organization ─────────────────────────────────────────────────────
56-
let existingOrg = await safeFind(objectql, 'sys_organization', orgId);
57-
if (!existingOrg?.length) {
58-
await safeInsert(objectql, 'sys_organization', {
59-
id: orgId,
60-
name: orgName,
61-
slug: orgId,
62-
created_at: now,
63-
updated_at: now,
64-
});
65-
}
66-
67-
// ── Project ──────────────────────────────────────────────────────────
68-
let existingProject = await safeFind(objectql, 'sys_project', projectId);
69-
if (!existingProject?.length) {
70-
await safeInsert(objectql, 'sys_project', {
71-
id: projectId,
72-
organization_id: orgId,
73-
display_name: orgName,
74-
is_default: true,
75-
is_system: false,
76-
plan: 'free',
77-
status: 'active',
78-
created_by: 'system',
79-
database_url: projectDatabaseUrl,
80-
database_driver: projectDatabaseDriver,
81-
created_at: now,
82-
updated_at: now,
83-
});
84-
}
85-
}
86-
87-
async function safeFind(objectql: any, name: string, id: string): Promise<any[] | null> {
88-
try {
89-
const result = await objectql.find(name, { filters: [['id', '=', id]], top: 1 });
90-
// Some ObjectQL implementations wrap results in `{ value }`.
91-
const rows = (result && (result as any).value) ?? result;
92-
return Array.isArray(rows) ? rows : [];
93-
} catch (err: any) {
94-
// eslint-disable-next-line no-console
95-
console.warn(`[ensureLocalIdentity] find ${name} failed:`, err?.message ?? err);
96-
return null;
97-
}
98-
}
99-
100-
async function safeInsert(objectql: any, name: string, doc: Record<string, unknown>): Promise<void> {
101-
try {
102-
await objectql.insert(name, doc);
103-
} catch (err: any) {
104-
// eslint-disable-next-line no-console
105-
console.warn(`[ensureLocalIdentity] insert ${name} failed:`, err?.message ?? err);
106-
}
107-
}
6+
export {
7+
ensureLocalIdentity,
8+
LOCAL_ORG_ID,
9+
LOCAL_PROJECT_ID,
10+
} from '@objectstack/service-cloud';
11+
export type { LocalIdentityOptions } from '@objectstack/service-cloud';

0 commit comments

Comments
 (0)