Skip to content

Commit 28bcae8

Browse files
Merge branch 'main' into copilot/add-field-groups-mvp
2 parents be962ae + c2bbde1 commit 28bcae8

38 files changed

Lines changed: 1483 additions & 606 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818
- Supported migrations at this layer: add / rename / delete / reorder groups (by editing the `fieldGroups` array) and assigning an existing field to a group (by editing `Field.group`). Explicit per-field in-group ordering is deferred to a future iteration.
1919
- New `ObjectFieldGroup` / `ObjectFieldGroupInput` type exports alongside the schema.
2020
- Tests: 12 new round-trip cases in `packages/spec/src/data/object.test.ts` covering minimal/full-group parsing, required fields, snake_case key validation, declaration-order preservation, duplicate-key rejection, `Field.group` referencing, and `ObjectSchema.create()` integration.
21+
### Fixed
22+
- **Doubly-prefixed FQN for `@objectstack/objectos` system objects** — The ObjectOS-layer object definitions (`SysObject`, `SysView`, `SysAgent`, `SysTool`, `SysFlow`, `SysMetadata`) were being registered with fully-qualified names like `sys__sys_object`, `sys__sys_view`, `sys__sys_metadata`, because each object hard-coded a `sys_` prefix into its `name` **and** its manifest was registered under `namespace: 'sys'`, causing `SchemaRegistry.computeFQN(namespace, name)` to apply the prefix twice. The object `name` values are now the unprefixed short form (`object`, `view`, `agent`, `tool`, `flow`, `metadata`), producing the correct FQNs (`sys__object`, `sys__view`, `sys__agent`, `sys__tool`, `sys__flow`). `SysMetadata` (which would collide with the canonical `sys__metadata` owned by `@objectstack/metadata`) is now exported separately and excluded from the auto-registered `SystemObjects` catalog to avoid ownership conflicts; consumers that need it can still import it directly. See `packages/objectos/src/objects/*.ts` and `packages/objectos/src/registry.ts`.
2123

2224
### Added
2325
- **Environment-per-database multi-tenancy (`service-tenant` v4.1)** — Refactored the multi-tenant architecture from "per-organization database" to **per-environment database** high-isolation, with a hard split between Control Plane (environment registry / addressing / credentials / RBAC) and Data Plane (one physical database per environment). See [`docs/adr/0002-environment-database-isolation.md`](docs/adr/0002-environment-database-isolation.md) for the full rationale and trade-offs.

apps/docs/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@
1313
"postinstall": "fumadocs-mdx"
1414
},
1515
"dependencies": {
16-
"fumadocs-core": "16.7.14",
17-
"fumadocs-mdx": "14.2.13",
18-
"fumadocs-ui": "16.7.14",
16+
"fumadocs-core": "16.8.1",
17+
"fumadocs-mdx": "14.3.1",
18+
"fumadocs-ui": "16.8.1",
1919
"lucide-react": "^1.8.0",
20-
"next": "16.2.3",
20+
"next": "16.2.4",
2121
"react": "^19.2.5",
2222
"react-dom": "^19.2.5",
2323
"tailwind-merge": "^3.5.0"

apps/server/.env.example

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
# Turso Database Configuration
1+
# Turso Database Configuration (control-plane DB)
22
# Required for Vercel deployment
33
TURSO_DATABASE_URL=libsql://your-database.turso.io
44
TURSO_AUTH_TOKEN=your-auth-token-here
5+
6+
# Turso Platform API (environment provisioning)
7+
# When set, new environments are provisioned as real Turso cloud databases.
8+
# When unset, each environment gets a local SQLite file under .objectstack/data/environments/.
9+
TURSO_ORG_NAME=your-org-slug
10+
TURSO_API_TOKEN=your-platform-api-token

apps/server/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,5 @@ node_modules/
1919
# OS files
2020
.DS_Store
2121
Thumbs.db
22+
.vercel
23+
.env*.local

apps/server/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@
1212
"typecheck": "tsc --noEmit",
1313
"test": "objectstack test",
1414
"test:e2e": "tsx test/e2e.test.ts",
15+
"test:provisioning": "tsx test/provisioning.test.ts",
1516
"clean": "rm -rf dist node_modules"
1617
},
1718
"dependencies": {
1819
"@example/app-crm": "workspace:*",
1920
"@example/app-todo": "workspace:*",
2021
"@example/plugin-bi": "workspace:*",
2122
"@hono/node-server": "^1.19.14",
22-
"@libsql/client": "^0.14.0",
23+
"@libsql/client": "^0.17.2",
2324
"@objectstack/driver-memory": "workspace:*",
2425
"@objectstack/driver-turso": "workspace:*",
2526
"@objectstack/hono": "workspace:*",
@@ -38,7 +39,7 @@
3839
"@objectstack/service-package": "workspace:*",
3940
"@objectstack/service-tenant": "workspace:*",
4041
"@objectstack/spec": "workspace:*",
41-
"hono": "^4.12.12",
42+
"hono": "^4.12.14",
4243
"pino": "^10.3.1",
4344
"pino-pretty": "^13.1.3"
4445
},
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Integration test for EnvironmentProvisioningService + TursoEnvironmentDatabaseAdapter.
5+
*
6+
* Requires TURSO_ORG_NAME and TURSO_API_TOKEN — loaded from .env.local if present.
7+
*
8+
* Run: pnpm --filter @objectstack/server test:provisioning
9+
*/
10+
11+
import { readFileSync } from 'node:fs';
12+
import { resolve, dirname } from 'node:path';
13+
import { fileURLToPath } from 'node:url';
14+
15+
// ---------------------------------------------------------------------------
16+
// Load .env.local
17+
// ---------------------------------------------------------------------------
18+
19+
const __dirname = dirname(fileURLToPath(import.meta.url));
20+
const envLocalPath = resolve(__dirname, '../.env.local');
21+
try {
22+
const lines = readFileSync(envLocalPath, 'utf-8').split('\n');
23+
for (const line of lines) {
24+
const trimmed = line.trim();
25+
if (!trimmed || trimmed.startsWith('#')) continue;
26+
const eq = trimmed.indexOf('=');
27+
if (eq === -1) continue;
28+
const key = trimmed.slice(0, eq).trim();
29+
const raw = trimmed.slice(eq + 1).trim();
30+
const value = raw.startsWith('"') && raw.endsWith('"') ? raw.slice(1, -1) : raw;
31+
if (!(key in process.env)) process.env[key] = value;
32+
}
33+
} catch {
34+
// .env.local absent — rely on environment variables already being set
35+
}
36+
37+
// ---------------------------------------------------------------------------
38+
// Assertions
39+
// ---------------------------------------------------------------------------
40+
41+
function assert(condition: boolean, message: string): asserts condition {
42+
if (!condition) throw new Error(`FAIL: ${message}`);
43+
}
44+
45+
function ok(label: string) {
46+
console.log(` ✓ ${label}`);
47+
}
48+
49+
// ---------------------------------------------------------------------------
50+
// Test: TursoEnvironmentDatabaseAdapter
51+
// ---------------------------------------------------------------------------
52+
53+
import {
54+
TursoEnvironmentDatabaseAdapter,
55+
createDefaultEnvironmentAdapters,
56+
EnvironmentProvisioningService,
57+
} from '@objectstack/service-tenant';
58+
import { TursoPlatformClient } from '@objectstack/service-tenant';
59+
60+
const orgName = process.env.TURSO_ORG_NAME;
61+
const apiToken = process.env.TURSO_API_TOKEN;
62+
63+
if (!orgName || !apiToken) {
64+
console.error('SKIP: TURSO_ORG_NAME and TURSO_API_TOKEN must be set.');
65+
process.exit(0);
66+
}
67+
68+
console.log('\n── Environment Provisioning Integration Test ──\n');
69+
console.log(` org: ${orgName}`);
70+
71+
const createdDbs: string[] = [];
72+
73+
async function cleanup(client: TursoPlatformClient) {
74+
for (const name of createdDbs) {
75+
try {
76+
await client.deleteDatabase(name);
77+
console.log(` 🗑 cleaned up: ${name}`);
78+
} catch (e) {
79+
console.warn(` ⚠ failed to delete ${name}:`, (e as Error).message);
80+
}
81+
}
82+
}
83+
84+
async function run() {
85+
const client = new TursoPlatformClient({ apiToken: apiToken!, organization: orgName! });
86+
const adapter = new TursoEnvironmentDatabaseAdapter({ apiToken: apiToken!, organization: orgName! });
87+
88+
// ── Test 1: createDefaultEnvironmentAdapters picks Turso when TURSO_ORG_NAME is set
89+
{
90+
const adapters = createDefaultEnvironmentAdapters();
91+
assert(adapters.length === 1, 'should return exactly one adapter');
92+
assert(adapters[0].driver === 'turso', `expected driver=turso, got ${adapters[0].driver}`);
93+
ok('createDefaultEnvironmentAdapters → TursoEnvironmentDatabaseAdapter');
94+
}
95+
96+
// ── Test 2: TursoEnvironmentDatabaseAdapter.createDatabase
97+
let databaseUrl = '';
98+
let plaintextSecret = '';
99+
let databaseName = '';
100+
{
101+
const envId = `test-${Date.now()}`;
102+
databaseName = `env-${envId}`;
103+
createdDbs.push(databaseName);
104+
105+
const result = await adapter.createDatabase({
106+
environmentId: envId,
107+
databaseName,
108+
region: 'us-east-1',
109+
storageLimitMb: 256,
110+
});
111+
112+
assert(result.databaseUrl.startsWith('libsql://'), `expected libsql:// URL, got: ${result.databaseUrl}`);
113+
assert(result.plaintextSecret.length > 0, 'expected non-empty JWT token');
114+
ok(`createDatabase → ${result.databaseUrl}`);
115+
116+
databaseUrl = result.databaseUrl;
117+
plaintextSecret = result.plaintextSecret;
118+
}
119+
120+
// ── Test 3: EnvironmentProvisioningService.provisionEnvironment end-to-end
121+
{
122+
const svc = new EnvironmentProvisioningService({
123+
adapters: [adapter],
124+
defaultDriver: 'turso',
125+
});
126+
127+
const dbName2 = `env-svc-${Date.now()}`;
128+
createdDbs.push(dbName2);
129+
130+
const result = await svc.provisionEnvironment({
131+
organizationId: 'org-integration-test',
132+
slug: 'test-env',
133+
envType: 'test',
134+
createdBy: 'integration-test',
135+
driver: 'turso',
136+
});
137+
138+
assert(result.environment.databaseUrl.startsWith('libsql://'), 'environment URL must be libsql://');
139+
assert(result.environment.databaseDriver === 'turso', 'driver must be turso');
140+
assert(result.credential.secretCiphertext.length > 0, 'credential must have ciphertext');
141+
ok(`provisionEnvironment → ${result.environment.databaseUrl}`);
142+
143+
// Track the actual db name for cleanup
144+
const hostname = result.environment.databaseUrl.replace('libsql://', '');
145+
const actualDbName = hostname.split('.')[0];
146+
if (!createdDbs.includes(actualDbName)) createdDbs.push(actualDbName);
147+
}
148+
149+
await cleanup(client);
150+
console.log('\n✅ All integration tests passed.\n');
151+
}
152+
153+
run().catch(async (err) => {
154+
const client = new TursoPlatformClient({ apiToken: apiToken!, organization: orgName! });
155+
await cleanup(client);
156+
console.error('\n❌', err.message);
157+
process.exit(1);
158+
});

apps/studio/package.json

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@
1919
"preview": "vite preview"
2020
},
2121
"dependencies": {
22-
"@ai-sdk/anthropic": "^3.0.69",
23-
"@ai-sdk/gateway": "^3.0.95",
24-
"@ai-sdk/google": "^3.0.62",
25-
"@ai-sdk/openai": "^3.0.52",
26-
"@ai-sdk/react": "^3.0.160",
22+
"@ai-sdk/anthropic": "^3.0.71",
23+
"@ai-sdk/gateway": "^3.0.104",
24+
"@ai-sdk/google": "^3.0.64",
25+
"@ai-sdk/openai": "^3.0.53",
26+
"@ai-sdk/react": "^3.0.170",
2727
"@hono/node-server": "^1.19.14",
2828
"@objectstack/client": "workspace:*",
2929
"@objectstack/client-react": "workspace:*",
@@ -44,7 +44,7 @@
4444
"@objectstack/service-feed": "workspace:*",
4545
"@objectstack/service-tenant": "workspace:*",
4646
"@objectstack/spec": "workspace:*",
47-
"@tanstack/react-router": "^1.91.6",
47+
"@tanstack/react-router": "^1.168.23",
4848
"@radix-ui/react-avatar": "^1.1.11",
4949
"@radix-ui/react-checkbox": "^1.3.3",
5050
"@radix-ui/react-collapsible": "^1.1.12",
@@ -60,10 +60,10 @@
6060
"@radix-ui/react-tabs": "^1.1.13",
6161
"@radix-ui/react-toast": "^1.2.15",
6262
"@radix-ui/react-tooltip": "^1.2.8",
63-
"ai": "^6.0.158",
63+
"ai": "^6.0.168",
6464
"class-variance-authority": "^0.7.1",
6565
"clsx": "^2.1.1",
66-
"hono": "^4.12.12",
66+
"hono": "^4.12.14",
6767
"lucide-react": "^1.8.0",
6868
"react": "^19.2.5",
6969
"react-dom": "^19.2.5",
@@ -85,7 +85,7 @@
8585
"autoprefixer": "^10.4.27",
8686
"esbuild": "^0.28.0",
8787
"happy-dom": "^20.8.9",
88-
"msw": "^2.13.2",
88+
"msw": "^2.13.4",
8989
"postcss": "^8.5.10",
9090
"tailwindcss": "^4.2.2",
9191
"typescript": "^6.0.2",
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* EnvironmentStatusBadge — color-coded pill for the environment lifecycle
5+
* status (provisioning / active / failed / suspended / archived / migrating).
6+
*
7+
* Rendered alongside {@link EnvironmentBadge} (which encodes envType) on the
8+
* environment list and detail pages so operators can tell at a glance whether
9+
* a given environment is ready, still coming up, or broken.
10+
*
11+
* Keep this component purely presentational — no data fetching or navigation
12+
* side-effects — so it can be rendered in tables, badges, and dialogs
13+
* without pulling in context.
14+
*/
15+
16+
import { Loader2, AlertTriangle, CheckCircle2, PauseCircle, Archive, MoveRight } from 'lucide-react';
17+
import { cn } from '@/lib/utils';
18+
import type { EnvironmentStatus } from '@objectstack/spec/cloud';
19+
20+
const VARIANT: Record<EnvironmentStatus, string> = {
21+
provisioning:
22+
'border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300',
23+
active:
24+
'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
25+
failed:
26+
'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300',
27+
suspended:
28+
'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300',
29+
archived:
30+
'border-muted bg-muted text-muted-foreground',
31+
migrating:
32+
'border-violet-500/40 bg-violet-500/10 text-violet-700 dark:text-violet-300',
33+
};
34+
35+
const LABEL: Record<EnvironmentStatus, string> = {
36+
provisioning: 'Provisioning',
37+
active: 'Active',
38+
failed: 'Provisioning failed',
39+
suspended: 'Suspended',
40+
archived: 'Archived',
41+
migrating: 'Migrating',
42+
};
43+
44+
function StatusIcon({ status, className }: { status: EnvironmentStatus; className?: string }) {
45+
switch (status) {
46+
case 'provisioning':
47+
return <Loader2 className={cn('h-3 w-3 animate-spin', className)} />;
48+
case 'failed':
49+
return <AlertTriangle className={cn('h-3 w-3', className)} />;
50+
case 'active':
51+
return <CheckCircle2 className={cn('h-3 w-3', className)} />;
52+
case 'suspended':
53+
return <PauseCircle className={cn('h-3 w-3', className)} />;
54+
case 'archived':
55+
return <Archive className={cn('h-3 w-3', className)} />;
56+
case 'migrating':
57+
return <MoveRight className={cn('h-3 w-3', className)} />;
58+
default:
59+
return null;
60+
}
61+
}
62+
63+
export interface EnvironmentStatusBadgeProps {
64+
status: EnvironmentStatus;
65+
/** Omit the label and show just the icon chip. Useful in dense lists. */
66+
iconOnly?: boolean;
67+
className?: string;
68+
}
69+
70+
export function EnvironmentStatusBadge({ status, iconOnly, className }: EnvironmentStatusBadgeProps) {
71+
return (
72+
<span
73+
className={cn(
74+
'inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider',
75+
VARIANT[status],
76+
className,
77+
)}
78+
title={LABEL[status]}
79+
>
80+
<StatusIcon status={status} />
81+
{!iconOnly && <span>{LABEL[status]}</span>}
82+
</span>
83+
);
84+
}

0 commit comments

Comments
 (0)