Skip to content

Commit 22c53ce

Browse files
committed
Refactor single-project plugin and server configuration
- Updated the single-project plugin to support project mode with a new identity seeding mechanism. - Removed deprecated API routes and consolidated server logic for clarity. - Introduced a new configuration file for ObjectStack server to manage boot modes and environment variables. - Added a new utility for ensuring local identity in project mode, handling organization and project seeding. - Deleted obsolete server entry points and API routes to streamline the codebase.
1 parent 3e04b13 commit 22c53ce

7 files changed

Lines changed: 613 additions & 666 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Changed
11+
- **`OBJECTSTACK_MODE` redefined into three values** — Boot-mode selection now accepts `project` (default), `cloud`, and `standalone`. The previous semantics — where `standalone` meant "single-project local dev with full Auth + Studio" — moved under `project`. The new `standalone` value is **runtime-only**: ObjectQL + REST + Driver, no Auth, no control plane, no Studio data. Designed for embedding ObjectStack in other frameworks. Aliases `local` / `single-project` continue to map to `project`; `multi-project` continues to map to `cloud`. Default also changed: an unset `OBJECTSTACK_MODE` now resolves to `project` (was: `standalone`).
12+
- **Migration:** users running with `OBJECTSTACK_MODE=standalone` and expecting Auth/Studio should switch to `OBJECTSTACK_MODE=project` (or unset it).
13+
- **Internals:** `apps/server/objectstack.config.ts` now drives `project` mode through `createCloudStack()` with two local SQLite files (`control.db` for the control plane and `proj_local.db` for the single project's data), instead of a separate plugin stack. `apps/server/server/single-project-plugin.ts` is reduced to (a) seeding a real `sys_organization` + `sys_project` row into the control plane via `ensure-local-identity.ts` so `KernelManager` resolves `proj_local` exactly as in cloud mode, and (b) overriding `GET /api/v1/studio/runtime-config` with `{ singleProject: true, … }`. Synthetic `/cloud/projects` rows are gone — both modes now serve real DB-backed records. `apps/studio/server/index.ts` no longer wraps the kernel app in an outer Hono router for single-project mode.
1114
- **`OBJECTSTACK_MODE` replaces `OBJECTSTACK_MULTI_PROJECT`** — Boot-mode selection is now driven by a single `OBJECTSTACK_MODE` variable accepting `standalone` (default) or `cloud`. The legacy `OBJECTSTACK_MULTI_PROJECT=true` flag remains as a deprecated alias (with a one-shot console warning at boot) and will be removed in the next major release. Root `pnpm dev` now starts in standalone mode; use `pnpm dev:cloud` for the multi-project / control-plane shape. Updated `apps/server/objectstack.config.ts`, `apps/studio/server/index.ts`, `.env.example`, the cloud-deployment guide, and the north-star env table.
1215

1316
### Added

apps/server/objectstack.config.ts

Lines changed: 171 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -8,43 +8,47 @@
88
* ## Boot modes
99
*
1010
* Selected via the `OBJECTSTACK_MODE` environment variable:
11-
* - `standalone` (default) — single-project, no control plane
12-
* - `cloud` — multi-project, control plane + per-project DBs
11+
*
12+
* - `project` (default) — local single-project deployment. Reuses the
13+
* cloud (multi-project) plugin stack but backs
14+
* it with two SQLite files (`control.db` for
15+
* 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.
22+
* Alias: `multi-project`.
23+
*
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.
1328
*
1429
* The legacy flag `OBJECTSTACK_MULTI_PROJECT=true` is still honoured as a
1530
* deprecated alias for `OBJECTSTACK_MODE=cloud` and will be removed in a
1631
* future major release.
1732
*
18-
* ### Standalone mode (`OBJECTSTACK_MODE` unset or `standalone`)
19-
*
20-
* Single-project, offline-first. No control-plane DB is required.
21-
* Authentication is fully real — first-run users are walked through `/setup`
22-
* to create the owner account; thereafter every request requires a session.
23-
* Required env vars:
24-
* OBJECTSTACK_PROJECT_ID — project identity (e.g. "proj_local")
25-
* OBJECTSTACK_DATABASE_URL — project business DB (file:./app.db, memory://mydb, libsql://…, https://…)
26-
* OBJECTSTACK_DATABASE_AUTH_TOKEN — optional auth token for libSQL/Turso URLs
27-
* OBJECTSTACK_DATABASE_DRIVER — driver name: sqlite | memory | turso (auto-detected from URL)
28-
* OBJECTSTACK_ARTIFACT_PATH — path to compiled artifact (default: ./dist/objectstack.json)
29-
* AUTH_SECRET — JWT signing secret (≥32 chars)
33+
* ### Common env vars
3034
*
31-
* For Vercel / serverless deployments use a Turso database:
32-
* TURSO_DATABASE_URL — libsql:// or https:// Turso URL (fallback alias for OBJECTSTACK_DATABASE_URL)
33-
* TURSO_AUTH_TOKEN — Turso auth token (fallback alias for OBJECTSTACK_DATABASE_AUTH_TOKEN)
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)
3439
*
35-
* ### Cloud mode (`OBJECTSTACK_MODE=cloud`)
40+
* ### Project / Standalone DB
3641
*
37-
* Multi-project, control-plane connected. See @objectstack/service-cloud for details.
38-
* Required env vars:
39-
* OBJECTSTACK_DATABASE_URL — control-plane DB URL
40-
* OBJECTSTACK_DATABASE_AUTH_TOKEN — optional, for libSQL/Turso URLs
41-
* AUTH_SECRET / NEXT_PUBLIC_BASE_URL — same as standalone
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
4246
*/
4347

4448
import { resolve as resolvePath, dirname } from 'node:path';
4549
import { fileURLToPath } from 'node:url';
50+
import { mkdirSync } from 'node:fs';
4651
import { readFile } from 'node:fs/promises';
47-
import { AppPlugin } from '@objectstack/runtime';
4852
import { createCloudStack } from '@objectstack/service-cloud';
4953
import { createSingleProjectPlugin } from './server/single-project-plugin.js';
5054
import { templateRegistry } from './server/templates/registry.js';
@@ -54,16 +58,24 @@ function envFlag(name: string): boolean {
5458
return ['1', 'true', 'yes', 'on'].includes((process.env[name] ?? '').trim().toLowerCase());
5559
}
5660

61+
type Mode = 'project' | 'cloud' | 'standalone';
62+
5763
/**
5864
* 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).
5970
*/
60-
function resolveMode(): 'standalone' | 'cloud' {
71+
function resolveMode(): Mode {
6172
const raw = process.env.OBJECTSTACK_MODE?.trim().toLowerCase();
62-
if (raw === 'cloud' || raw === 'multi-project' /* legacy alias */) return 'cloud';
63-
if (raw === 'standalone' || raw === 'local' || raw === 'single-project') return 'standalone';
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';
6476
if (raw && raw.length > 0) {
6577
// eslint-disable-next-line no-console
66-
console.warn(`[objectstack] Unknown OBJECTSTACK_MODE=${raw}; falling back to "standalone".`);
78+
console.warn(`[objectstack] Unknown OBJECTSTACK_MODE=${raw}; falling back to "project".`);
6779
}
6880
if (envFlag('OBJECTSTACK_MULTI_PROJECT')) {
6981
// eslint-disable-next-line no-console
@@ -72,12 +84,10 @@ function resolveMode(): 'standalone' | 'cloud' {
7284
);
7385
return 'cloud';
7486
}
75-
return 'standalone';
87+
return 'project';
7688
}
7789

78-
// ── Boot mode ─────────────────────────────────────────────────────────────────
7990
const mode = resolveMode();
80-
const isStandaloneMode = mode === 'standalone';
8191

8292
const authSecret = process.env.AUTH_SECRET
8393
?? 'dev-secret-please-change-in-production-min-32-chars';
@@ -90,33 +100,106 @@ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL
90100
: undefined)
91101
?? `http://localhost:${process.env.PORT ?? 3000}`;
92102

93-
// ── STANDALONE MODE ───────────────────────────────────────────────────────────
94-
103+
const serverDir = dirname(fileURLToPath(import.meta.url));
95104
const localProjectId = process.env.OBJECTSTACK_PROJECT_ID ?? 'proj_local';
96105
const localArtifactPath = process.env.OBJECTSTACK_ARTIFACT_PATH
97-
?? resolvePath(dirname(fileURLToPath(import.meta.url)), 'dist/objectstack.json');
106+
?? resolvePath(serverDir, 'dist/objectstack.json');
107+
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,
126+
appBundles: createFsAppBundleResolver(),
127+
// Project-mode per-project plugins. Identical to the cloud default
128+
// except for the AppPlugin tail — we eagerly load the local
129+
// artifact bundle (if present) so the single project picks up
130+
// schema definitions without needing a control-plane app row.
131+
basePlugins: async ({ projectId }: { projectId: string }) => {
132+
const { ObjectQLPlugin } = await import('@objectstack/objectql');
133+
const { MetadataPlugin } = await import('@objectstack/metadata');
134+
const { createTenantPlugin } = await import('@objectstack/service-tenant');
135+
const { AuthPlugin } = await import('@objectstack/plugin-auth');
136+
const { SecurityPlugin } = await import('@objectstack/plugin-security');
137+
const { AuditPlugin } = await import('@objectstack/plugin-audit');
138+
const { AppPlugin } = await import('@objectstack/runtime');
98139

99-
async function buildStandalonePlugins() {
140+
let artifactBundle: any = null;
141+
try {
142+
const raw = await readFile(localArtifactPath, 'utf8');
143+
const parsed = JSON.parse(raw);
144+
artifactBundle = (parsed?.schemaVersion != null && parsed?.metadata !== undefined)
145+
? parsed.metadata
146+
: parsed;
147+
} catch {
148+
// First boot before `objectstack build` — AppPlugin skipped.
149+
}
150+
151+
const plugins: any[] = [
152+
new ObjectQLPlugin({ environmentId: projectId }),
153+
new MetadataPlugin({
154+
watch: false,
155+
environmentId: projectId,
156+
artifactSource: { mode: 'local-file', path: localArtifactPath },
157+
}),
158+
createTenantPlugin({ registerSystemObjects: true }),
159+
new AuthPlugin({ secret: authSecret, baseUrl }),
160+
new SecurityPlugin(),
161+
new AuditPlugin(),
162+
];
163+
if (artifactBundle) plugins.push(new AppPlugin(artifactBundle));
164+
return plugins;
165+
},
166+
});
167+
168+
// The cloud preset registers a `studio/runtime-config` route returning
169+
// `{ singleProject: false }`. Hono is first-match-wins, so we drop that
170+
// plugin and substitute our own which seeds local identity AND emits
171+
// `{ singleProject: true, … }`.
172+
const filtered = stack.plugins.filter(
173+
(p: any) => p?.name !== 'com.objectstack.studio.runtime-config',
174+
);
175+
filtered.push(
176+
createSingleProjectPlugin({
177+
projectId: localProjectId,
178+
projectDatabaseUrl: projectDbUrl,
179+
projectDatabaseDriver: 'sqlite',
180+
}),
181+
);
182+
183+
return {
184+
plugins: filtered,
185+
api: stack.api,
186+
};
187+
}
188+
189+
// ── STANDALONE MODE ──────────────────────────────────────────────────────────
190+
//
191+
// Runtime-only: ObjectQL + Driver + REST. No Auth, no Studio, no control
192+
// plane. Designed for embedding ObjectStack in other frameworks or
193+
// internal back-end services.
194+
195+
async function buildRuntimeOnlyConfig() {
100196
const { ObjectQLPlugin } = await import('@objectstack/objectql');
101197
const { MetadataPlugin } = await import('@objectstack/metadata');
102-
const { AuthPlugin } = await import('@objectstack/plugin-auth');
103-
const { DriverPlugin } = await import('@objectstack/runtime');
104-
105-
let artifactBundle: any = null;
106-
try {
107-
const raw = await readFile(localArtifactPath, 'utf8');
108-
const parsed = JSON.parse(raw);
109-
artifactBundle = (parsed?.schemaVersion && parsed?.metadata !== undefined)
110-
? parsed.metadata
111-
: parsed;
112-
} catch {
113-
// Artifact not available yet (e.g. first run before compile) — AppPlugin skipped.
114-
}
198+
const { DriverPlugin, AppPlugin } = await import('@objectstack/runtime');
115199

116-
const serverDir = dirname(fileURLToPath(import.meta.url));
117200
const dbUrl = process.env.OBJECTSTACK_DATABASE_URL?.trim()
118201
|| process.env.TURSO_DATABASE_URL?.trim()
119-
|| `file:${resolvePath(serverDir, '.objectstack/data/app.db')}`;
202+
|| `file:${resolvePath(serverDir, '.objectstack/data/standalone.db')}`;
120203
const dbAuthToken = process.env.OBJECTSTACK_DATABASE_AUTH_TOKEN?.trim()
121204
|| process.env.TURSO_AUTH_TOKEN?.trim();
122205
const dbDriver = process.env.OBJECTSTACK_DATABASE_DRIVER?.trim()
@@ -128,17 +211,33 @@ async function buildStandalonePlugins() {
128211
driverPlugin = new DriverPlugin(new MemoryDriver());
129212
} else if (dbDriver === 'turso' || /^(libsql|https?):\/\//i.test(dbUrl)) {
130213
const { TursoDriver } = await import('@objectstack/driver-turso');
131-
driverPlugin = new DriverPlugin(new TursoDriver({ url: dbUrl, authToken: dbAuthToken }) as any);
214+
driverPlugin = new DriverPlugin(
215+
new TursoDriver({ url: dbUrl, authToken: dbAuthToken }) as any,
216+
);
132217
} else {
133218
const { SqlDriver } = await import('@objectstack/driver-sql');
134219
const filename = dbUrl.replace(/^file:(\/\/)?/, '');
135-
const { mkdirSync } = await import('node:fs');
136220
mkdirSync(resolvePath(filename, '..'), { recursive: true });
137221
driverPlugin = new DriverPlugin(
138-
new SqlDriver({ client: 'better-sqlite3', connection: { filename }, useNullAsDefault: true }),
222+
new SqlDriver({
223+
client: 'better-sqlite3',
224+
connection: { filename },
225+
useNullAsDefault: true,
226+
}),
139227
);
140228
}
141229

230+
let artifactBundle: any = null;
231+
try {
232+
const raw = await readFile(localArtifactPath, 'utf8');
233+
const parsed = JSON.parse(raw);
234+
artifactBundle = (parsed?.schemaVersion != null && parsed?.metadata !== undefined)
235+
? parsed.metadata
236+
: parsed;
237+
} catch {
238+
// No artifact yet — AppPlugin skipped.
239+
}
240+
142241
const plugins: any[] = [
143242
driverPlugin,
144243
new MetadataPlugin({
@@ -147,32 +246,30 @@ async function buildStandalonePlugins() {
147246
artifactSource: { mode: 'local-file', path: localArtifactPath },
148247
}),
149248
new ObjectQLPlugin({ environmentId: localProjectId }),
150-
new AuthPlugin({ secret: authSecret, baseUrl, plugins: { organization: true, twoFactor: true, passkeys: false, magicLink: false, oidcProvider: true, deviceAuthorization: true } }),
151-
createSingleProjectPlugin({ projectId: localProjectId }),
152249
];
250+
if (artifactBundle) plugins.push(new AppPlugin(artifactBundle));
153251

154-
if (artifactBundle) {
155-
plugins.push(new AppPlugin(artifactBundle));
156-
}
157-
158-
return plugins;
159-
}
160-
161-
// ── Export ────────────────────────────────────────────────────────────────────
162-
163-
const config = isStandaloneMode
164-
? {
165-
plugins: await buildStandalonePlugins(),
252+
return {
253+
plugins,
166254
api: {
167255
enableProjectScoping: false,
168256
projectResolution: 'none' as const,
169257
},
170-
}
171-
: await createCloudStack({
172-
authSecret,
173-
baseUrl,
174-
templates: templateRegistry,
175-
appBundles: createFsAppBundleResolver(),
176-
});
258+
};
259+
}
260+
261+
// ── Export ──────────────────────────────────────────────────────────────────
262+
263+
const config =
264+
mode === 'cloud'
265+
? await createCloudStack({
266+
authSecret,
267+
baseUrl,
268+
templates: templateRegistry,
269+
appBundles: createFsAppBundleResolver(),
270+
})
271+
: mode === 'standalone'
272+
? await buildRuntimeOnlyConfig()
273+
: await createProjectStack();
177274

178275
export default config;

0 commit comments

Comments
 (0)