Skip to content

Commit 405fd47

Browse files
committed
2 parents b8058a5 + 36cb3ea commit 405fd47

82 files changed

Lines changed: 7384 additions & 317 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/config.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
"@objectstack/plugin-msw",
2525
"@objectstack/plugin-dev",
2626
"@objectstack/plugin-security",
27-
"@objectstack/plugin-setup",
27+
"@objectstack/plugin-mcp-server",
2828
"@objectstack/express",
2929
"@objectstack/fastify",
3030
"@objectstack/hono",
@@ -42,6 +42,9 @@
4242
"@objectstack/service-realtime",
4343
"@objectstack/service-ai",
4444
"@objectstack/service-storage",
45+
"@objectstack/service-cloud",
46+
"@objectstack/service-package",
47+
"@objectstack/service-tenant",
4548
"@objectstack/docs",
4649
"create-objectstack",
4750
"objectstack-vscode"

apps/account/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"@radix-ui/react-tabs": "^1.1.13",
3636
"@radix-ui/react-toast": "^1.2.15",
3737
"@radix-ui/react-tooltip": "^1.2.8",
38-
"@tanstack/react-router": "^1.169.1",
38+
"@tanstack/react-router": "^1.169.2",
3939
"class-variance-authority": "^0.7.1",
4040
"clsx": "^2.1.1",
4141
"lucide-react": "^1.14.0",

apps/cloud/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@
1111
"doctor": "objectstack doctor",
1212
"typecheck": "tsc --noEmit",
1313
"test": "objectstack test",
14+
"test:production-flow": "tsx test/production-flow.test.ts",
1415
"clean": "rm -rf dist node_modules"
1516
},
1617
"dependencies": {
17-
"@hono/node-server": "^1.19.14",
18+
"@hono/node-server": "^2.0.1",
1819
"@libsql/client": "^0.17.3",
1920
"@objectstack/driver-memory": "workspace:*",
2021
"@objectstack/driver-sql": "workspace:*",
@@ -35,7 +36,7 @@
3536
"@objectstack/service-package": "workspace:*",
3637
"@objectstack/service-tenant": "workspace:*",
3738
"@objectstack/spec": "workspace:*",
38-
"hono": "^4.12.17"
39+
"hono": "^4.12.18"
3940
},
4041
"devDependencies": {
4142
"@objectstack/cli": "workspace:*",
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* production-flow.test.ts
5+
*
6+
* End-to-end verification of the full production deployment shape:
7+
*
8+
* Browser → DNS (project.hostname)
9+
* → apps/cloud (or apps/objectos as runtime node)
10+
* 1. EnvironmentRegistry.resolveByHostname(host)
11+
* → control-plane lookup of sys_project by hostname
12+
* 2. Per-project kernel created (or fetched from cache)
13+
* with the project's database driver + the bundle
14+
* loaded by the chosen template ('crm' here)
15+
* 3. Request dispatched to the project kernel; hooks
16+
* (e.g. account_protection.beforeInsert) execute
17+
*
18+
* In production, apps/cloud and apps/objectos can run as a single
19+
* unified binary (this test) or as two separate processes connected by
20+
* `OS_CLOUD_URL`. Both topologies share the *exact same code paths*
21+
* exercised here — the only difference is the transport between the
22+
* EnvironmentRegistry and the control-plane SQL driver (in-process
23+
* driver vs HTTP). This test validates the in-process flavour because
24+
* it covers all the framework-side code; cross-process transport is a
25+
* deployment concern.
26+
*/
27+
28+
import { mkdtempSync } from 'node:fs';
29+
import { tmpdir } from 'node:os';
30+
import { join } from 'node:path';
31+
32+
const workdir = mkdtempSync(join(tmpdir(), 'objectstack-prod-flow-'));
33+
const controlDb = join(workdir, 'control.db');
34+
35+
process.env.OS_MODE = 'cloud';
36+
process.env.OS_DATABASE_URL = `file:${controlDb}`;
37+
process.env.AUTH_SECRET = 'production-flow-test-secret-must-be-at-least-32-chars-long';
38+
process.env.PORT = '0';
39+
process.env.OS_KERNEL_CACHE_SIZE = '8';
40+
delete process.env.OS_PROJECT_ARTIFACTS;
41+
delete process.env.OS_ARTIFACT_PATH;
42+
43+
const { ensureApp, ensureBoot } = await import('../server/index.js');
44+
45+
type Init = {
46+
method?: string;
47+
body?: unknown;
48+
headers?: Record<string, string>;
49+
/** Sets the `Host` header — drives EnvironmentRegistry.resolveByHostname. */
50+
host?: string;
51+
};
52+
53+
async function call(path: string, init: Init = {}): Promise<{ status: number; body: any }> {
54+
const app = await ensureApp();
55+
const headers: Record<string, string> = {
56+
'content-type': 'application/json',
57+
...(init.headers ?? {}),
58+
};
59+
const reqInit: RequestInit = { method: init.method ?? 'GET', headers };
60+
if (init.body !== undefined) reqInit.body = JSON.stringify(init.body);
61+
// The `Host` header is forbidden for fetch() Requests — it's always
62+
// derived from the URL. To exercise hostname-based routing we have
63+
// to put the project's hostname into the URL itself; that's what
64+
// production does too (DNS resolves the vanity domain to the
65+
// runtime node, which then receives `Host: vanity.example.com`).
66+
const url = `http://${init.host ?? 'localhost'}${path}`;
67+
const res = await app.fetch(new Request(url, reqInit));
68+
const text = await res.text();
69+
let body: any = text;
70+
try { body = text ? JSON.parse(text) : null; } catch { /* leave as text */ }
71+
return { status: res.status, body };
72+
}
73+
74+
async function waitForActive(projectId: string, timeoutMs = 30_000): Promise<any> {
75+
const deadline = Date.now() + timeoutMs;
76+
while (Date.now() < deadline) {
77+
const { status, body } = await call(`/api/v1/cloud/projects/${projectId}`);
78+
if (status === 200 && body?.data?.project?.status === 'active') return body.data.project;
79+
if (status === 200 && body?.data?.project?.status === 'failed') {
80+
throw new Error(
81+
`Project ${projectId} failed to provision: ${JSON.stringify(body?.data?.project?.metadata)}`,
82+
);
83+
}
84+
await new Promise((r) => setTimeout(r, 100));
85+
}
86+
throw new Error(`Project ${projectId} did not become active within ${timeoutMs}ms`);
87+
}
88+
89+
function assert(cond: any, msg: string) {
90+
if (!cond) throw new Error(`Assertion failed: ${msg}`);
91+
}
92+
93+
const tests: Array<{ name: string; run: () => Promise<void> }> = [];
94+
function test(name: string, run: () => Promise<void>) { tests.push({ name, run }); }
95+
96+
const state = { orgId: '', projectId: '', hostname: '' };
97+
98+
test('boot apps/cloud and seed organization', async () => {
99+
const boot = await ensureBoot();
100+
const ql = (boot.kernel as any).getService('objectql');
101+
if (!ql || typeof ql.insert !== 'function') {
102+
throw new Error('control-plane objectql unavailable on cloud kernel');
103+
}
104+
state.orgId = (globalThis as any).crypto.randomUUID();
105+
await ql.insert('sys_organization', {
106+
id: state.orgId,
107+
name: 'Production Flow Test Org',
108+
slug: `prod-${state.orgId.slice(0, 8)}`,
109+
created_at: new Date().toISOString(),
110+
updated_at: new Date().toISOString(),
111+
});
112+
});
113+
114+
test('GET /cloud/templates exposes the CRM template', async () => {
115+
const { status, body } = await call('/api/v1/cloud/templates');
116+
assert(status === 200, `templates GET expected 200, got ${status}`);
117+
const ids = (body?.data?.templates ?? []).map((t: any) => t.id);
118+
assert(ids.includes('crm'), `expected 'crm' in templates, got ${JSON.stringify(ids)}`);
119+
});
120+
121+
test('POST /cloud/projects with template_id=crm + hostname provisions an active project', async () => {
122+
state.hostname = `crm-${Date.now().toString(36)}.test.localhost`;
123+
const { status, body } = await call('/api/v1/cloud/projects', {
124+
method: 'POST',
125+
body: {
126+
organization_id: state.orgId,
127+
display_name: 'CRM Production Flow',
128+
driver: 'sqlite',
129+
hostname: state.hostname,
130+
template_id: 'crm',
131+
metadata: { __simulateDelayMs: 0 },
132+
},
133+
});
134+
if (status < 200 || status >= 300) {
135+
throw new Error(`project create failed: ${status} ${JSON.stringify(body)}`);
136+
}
137+
state.projectId = body?.data?.project?.id ?? body?.data?.id;
138+
assert(state.projectId, `no project id returned: ${JSON.stringify(body)}`);
139+
const project = await waitForActive(state.projectId);
140+
assert(
141+
project?.hostname === state.hostname,
142+
`persisted hostname mismatch: expected ${state.hostname}, got ${project?.hostname}`,
143+
);
144+
});
145+
146+
test('POST /api/v1/data/account with bad website (Host: <project hostname>) → 400 from CRM hook', async () => {
147+
const { status, body } = await call('/api/v1/data/account', {
148+
method: 'POST',
149+
host: state.hostname,
150+
body: {
151+
name: 'Production Flow Co.',
152+
website: 'bogus',
153+
account_number: 'pf-001',
154+
},
155+
});
156+
assert(status === 400, `expected 400 (hook), got ${status} body=${JSON.stringify(body)}`);
157+
assert(
158+
typeof body?.error === 'string' && /website must start with/i.test(body.error),
159+
`expected CRM hook error, got ${JSON.stringify(body)}`,
160+
);
161+
});
162+
163+
test('POST /api/v1/data/account with valid payload → 201 + uppercased account_number', async () => {
164+
const { status, body } = await call('/api/v1/data/account', {
165+
method: 'POST',
166+
host: state.hostname,
167+
body: {
168+
name: 'Acme Hostname Inc.',
169+
website: 'https://acme.example.com',
170+
account_number: 'pf-002',
171+
},
172+
});
173+
assert(status === 200 || status === 201, `expected 2xx, got ${status} body=${JSON.stringify(body)}`);
174+
const record = body?.record ?? body?.data?.record ?? body?.data;
175+
assert(record, `no record returned: ${JSON.stringify(body)}`);
176+
assert(
177+
record.account_number === 'PF-002',
178+
`account_number not uppercased by hook (got ${JSON.stringify(record.account_number)})`,
179+
);
180+
});
181+
182+
test('seeded CRM data is queryable through the hostname-routed kernel', async () => {
183+
const { status, body } = await call('/api/v1/data/account?limit=200', { host: state.hostname });
184+
assert(status === 200, `query expected 200, got ${status}`);
185+
const rows: any[] = body?.data ?? body?.records ?? [];
186+
assert(Array.isArray(rows) && rows.length >= 1, `expected ≥1 account, got ${rows.length}`);
187+
const acme = rows.find((r) => /Acme/i.test(r.name));
188+
assert(acme, `inserted account not found in list response`);
189+
});
190+
191+
let exitCode = 0;
192+
console.log(`[production-flow] workdir: ${workdir}`);
193+
for (const t of tests) {
194+
process.stdout.write(` • ${t.name} ... `);
195+
try {
196+
await t.run();
197+
console.log('OK');
198+
} catch (err) {
199+
exitCode = 1;
200+
console.log('FAIL');
201+
console.error((err as Error).message);
202+
}
203+
}
204+
process.exit(exitCode);

apps/dashboard/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
"clsx": "^2.1.1",
4040
"react": "^19.2.5",
4141
"react-dom": "^19.2.5",
42-
"react-router-dom": "^7.14.2",
42+
"react-router-dom": "^7.15.0",
4343
"sonner": "^2.0.7"
4444
},
4545
"devDependencies": {

apps/docs/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
"postinstall": "fumadocs-mdx"
1414
},
1515
"dependencies": {
16-
"fumadocs-core": "16.8.5",
16+
"fumadocs-core": "16.8.7",
1717
"fumadocs-mdx": "14.3.2",
18-
"fumadocs-ui": "16.8.5",
18+
"fumadocs-ui": "16.8.7",
1919
"lucide-react": "^1.14.0",
2020
"next": "16.2.4",
2121
"react": "^19.2.5",
@@ -34,7 +34,7 @@
3434
"postcss": "^8.5.14",
3535
"tailwindcss": "^4.2.4",
3636
"typescript": "^6.0.3",
37-
"zod": "4.4.1"
37+
"zod": "4.4.3"
3838
},
3939
"engines": {
4040
"node": ">=18.0.0"

0 commit comments

Comments
 (0)