Skip to content

Commit 404fb51

Browse files
test(app-crm): multi-driver acceptance suite (sqlite, mongodb, postgres)
Refactor examples/app-crm/e2e/mongodb-driver.spec.ts into a shared runDriverAcceptance() helper (e2e/_acceptance.ts) that any driver-specific spec re-exports. Add sqlite-driver.spec.ts and postgres-driver.spec.ts. A new wrapper script scripts/run-driver-acceptance.sh boots 'pnpm dev' once per driver with the matching OS_DATABASE_URL, waits for /api/v1/meta, runs the matching spec, then tears the dev server down (forcefully kills the port-3001 pnpm wrappers don't propagate SIGTERM cleanly).listener Verified locally: 3/3 (file:/tmp/objectstack-crm-acceptance.db)sqlite 3/3 (mongodb://127.0.0.1:27017/objectstack_crm_test)mongodb spec ready; requires user-provided pg instancepostgres Also document apps/objectos env vars (OS_DATABASE_URL + OS_CONTROL_DATABASE_URL with URL-scheme inference) in apps/objectos/README.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d550069 commit 404fb51

9 files changed

Lines changed: 256 additions & 69 deletions

File tree

apps/objectos/README.md

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -172,19 +172,53 @@ Behavior:
172172

173173
### Database (Local / Standalone mode)
174174

175+
ObjectOS in single-project local mode uses **two** databases:
176+
177+
| DB | Purpose | Env var | Default |
178+
|:---|:---|:---|:---|
179+
| **Project DB** | Your business data (records served at `/api/v1/data/*`) | `OS_DATABASE_URL` / `OS_DATABASE_DRIVER` | local SQLite at `.objectstack/data/proj_local.db` |
180+
| **Control DB** | Framework bookkeeping (`sys_organization`, `sys_project`, `sys_user`, …) | `OS_CONTROL_DATABASE_URL` | local SQLite at `.objectstack/data/control.db` |
181+
182+
Other variables:
183+
175184
| Variable | Default | Description |
176185
|:---|:---|:---|
177-
| `OS_DATABASE_URL` | `.objectstack/data/app.db` | SQLite file path, `libsql://`, or `http(s)://` |
178186
| `OS_DATABASE_AUTH_TOKEN` || Auth token for libSQL / Turso |
187+
| `OS_DATABASE_DRIVER` | inferred from URL scheme | Explicit override: `mongodb`, `postgres`, `mysql`, `sqlite`, `turso`, `memory` |
179188

180-
Supported `OS_DATABASE_URL` formats:
189+
The driver is **inferred from the URL scheme** of `OS_DATABASE_URL`
190+
(and `OS_CONTROL_DATABASE_URL`). You almost never need to set
191+
`OS_DATABASE_DRIVER` explicitly.
181192

182-
| Value | Driver |
193+
| `OS_DATABASE_URL` value | Driver |
183194
|:---|:---|
184-
| unset | SQLite — `.objectstack/data/app.db` |
185-
| `file:<path>` | SQLite at that path |
186-
| `libsql://host` | libSQL / Turso |
187-
| `http(s)://host` | libSQL over HTTP (sqld) |
195+
| unset | SQLite — `.objectstack/data/proj_local.db` |
196+
| `file:<path>` / `<path>.db` / `<path>.sqlite` | SQLite at that path |
197+
| `:memory:` | SQLite in-memory |
198+
| `mongodb://…` / `mongodb+srv://…` | **MongoDB** (`@objectstack/driver-mongodb`) |
199+
| `postgres://…` / `postgresql://…` | PostgreSQL (`@objectstack/driver-sql`) |
200+
| `mysql://…` / `mysql2://…` | MySQL (`@objectstack/driver-sql`) |
201+
| `libsql://…` / `https://*.turso.…` | libSQL / Turso (`@objectstack/driver-turso`) |
202+
203+
Examples:
204+
205+
```bash
206+
# Project DB on MongoDB (control DB stays on local SQLite)
207+
OS_DATABASE_URL=mongodb://localhost:27017/objectos pnpm dev
208+
209+
# Project DB on Postgres
210+
OS_DATABASE_URL=postgres://user:pass@host:5432/myapp pnpm dev
211+
212+
# Pin both DBs explicitly (Postgres for project, Turso for control plane)
213+
OS_DATABASE_URL=postgres://user:pass@host/myapp \
214+
OS_CONTROL_DATABASE_URL=libsql://control.turso.io \
215+
OS_DATABASE_AUTH_TOKEN=$TURSO_TOKEN \
216+
pnpm dev
217+
```
218+
219+
For backward compatibility, `OS_DATABASE_URL` is also accepted as a
220+
fallback for the control DB when neither `OS_CONTROL_DATABASE_URL` nor
221+
an explicit programmatic value is set.
188222

189223
### Kernel Tuning
190224

content/docs/references/security/rls.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ permissions (CRUD), RLS provides record-level filtering.
2525

2626
- Users only see records from their organization
2727

28-
- `using: "tenant_id = current_user.tenant_id"`
28+
- `using: "organization_id = current_user.organization_id"`
2929

3030
2. **Ownership-Based Access**
3131

@@ -83,7 +83,7 @@ object: 'account',
8383

8484
operation: 'select',
8585

86-
using: 'tenant_id = current_user.tenant_id'
86+
using: 'organization_id = current_user.organization_id'
8787

8888
\}
8989

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { test, expect, request } from '@playwright/test';
2+
3+
/**
4+
* Shared driver-acceptance suite for the CRM example.
5+
* Each driver-specific spec calls `runDriverAcceptance({ label, baseURL })`.
6+
* The CRM dev server is expected to be reachable at `baseURL`,
7+
* already booted with the appropriate `OS_DATABASE_URL` for the driver
8+
* under test (this is wired by the Playwright `webServer` config or by
9+
* a wrapper script that boots the server out-of-band).
10+
*/
11+
export function runDriverAcceptance(opts: { label: string; baseURL?: string }) {
12+
const BASE_URL = opts.baseURL ?? process.env.CRM_BASE_URL ?? 'http://localhost:3001';
13+
14+
test.describe(`CRM running on ${opts.label} driver`, () => {
15+
test('Studio responds', async () => {
16+
const ctx = await request.newContext({ baseURL: BASE_URL });
17+
const studio = await ctx.get('/_studio/');
18+
expect(studio.status()).toBeGreaterThanOrEqual(200);
19+
expect(studio.status()).toBeLessThan(500);
20+
await ctx.dispose();
21+
});
22+
23+
test('seed data is queryable', async () => {
24+
const ctx = await request.newContext({ baseURL: BASE_URL });
25+
const res = await ctx.get('/api/v1/data/account?limit=10');
26+
expect(res.ok()).toBe(true);
27+
const body = await res.json();
28+
expect(body.object).toBe('account');
29+
expect(Array.isArray(body.records)).toBe(true);
30+
expect(body.records.length).toBeGreaterThan(0);
31+
await ctx.dispose();
32+
});
33+
34+
test('CRUD round-trip on account', async () => {
35+
const ctx = await request.newContext({ baseURL: BASE_URL });
36+
const unique = `Playwright ${opts.label} ${Date.now()}`;
37+
38+
const created = await ctx.post('/api/v1/data/account', {
39+
data: { name: unique, industry: 'technology', type: 'prospect' },
40+
});
41+
expect(created.ok()).toBe(true);
42+
const createdBody = await created.json();
43+
const id = createdBody.id ?? createdBody.record?.id;
44+
expect(id).toBeTruthy();
45+
expect(createdBody.record?.name).toBe(unique);
46+
47+
const fetched = await ctx.get(`/api/v1/data/account/${id}`);
48+
expect(fetched.ok()).toBe(true);
49+
const fetchedBody = await fetched.json();
50+
expect(fetchedBody.record?.name).toBe(unique);
51+
52+
const updated = await ctx.patch(`/api/v1/data/account/${id}`, {
53+
data: { industry: 'finance' },
54+
});
55+
expect(updated.ok()).toBe(true);
56+
57+
const reread = await ctx.get(`/api/v1/data/account/${id}`);
58+
const rereadBody = await reread.json();
59+
expect(rereadBody.record?.industry).toBe('finance');
60+
61+
const deleted = await ctx.delete(`/api/v1/data/account/${id}`);
62+
expect(deleted.ok()).toBe(true);
63+
64+
await ctx.dispose();
65+
});
66+
});
67+
}
Lines changed: 5 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,8 @@
1-
import { test, expect, request } from '@playwright/test';
1+
import { runDriverAcceptance } from './_acceptance';
22

33
/**
4-
* E2E test: verifies the CRM dev server boots with the MongoDB driver
5-
* (selected via `OS_DATABASE_DRIVER=mongodb`) and that CRUD round-trips
6-
* land in the configured MongoDB database.
4+
* Boots `pnpm dev` with `OS_DATABASE_URL=mongodb://...`
5+
* (driver auto-inferred from the URL no OS_DATABASE_DRIVER needed).scheme
6+
* Requires a local mongod listening on the URL.
77
*/
8-
const BASE_URL = process.env.CRM_BASE_URL ?? 'http://localhost:3001';
9-
10-
test.describe('CRM running on MongoDB driver', () => {
11-
test('Studio responds', async () => {
12-
const ctx = await request.newContext({ baseURL: BASE_URL });
13-
const studio = await ctx.get('/_studio/');
14-
expect(studio.status()).toBeGreaterThanOrEqual(200);
15-
expect(studio.status()).toBeLessThan(500);
16-
await ctx.dispose();
17-
});
18-
19-
test('seed data is queryable from MongoDB', async () => {
20-
const ctx = await request.newContext({ baseURL: BASE_URL });
21-
const res = await ctx.get('/api/v1/data/account?limit=10');
22-
expect(res.ok()).toBe(true);
23-
const body = await res.json();
24-
expect(body.object).toBe('account');
25-
expect(Array.isArray(body.records)).toBe(true);
26-
expect(body.records.length).toBeGreaterThan(0);
27-
await ctx.dispose();
28-
});
29-
30-
test('CRUD round-trip on account collection', async () => {
31-
const ctx = await request.newContext({ baseURL: BASE_URL });
32-
const unique = `Playwright Mongo Account ${Date.now()}`;
33-
34-
const created = await ctx.post('/api/v1/data/account', {
35-
data: { name: unique, industry: 'technology', type: 'prospect' },
36-
});
37-
expect(created.ok()).toBe(true);
38-
const createdBody = await created.json();
39-
const id = createdBody.id ?? createdBody.record?.id;
40-
expect(id).toBeTruthy();
41-
expect(createdBody.record?.name).toBe(unique);
42-
43-
const fetched = await ctx.get(`/api/v1/data/account/${id}`);
44-
expect(fetched.ok()).toBe(true);
45-
const fetchedBody = await fetched.json();
46-
expect(fetchedBody.record?.name).toBe(unique);
47-
48-
const updated = await ctx.patch(`/api/v1/data/account/${id}`, {
49-
data: { industry: 'finance' },
50-
});
51-
expect(updated.ok()).toBe(true);
52-
53-
const reread = await ctx.get(`/api/v1/data/account/${id}`);
54-
const rereadBody = await reread.json();
55-
expect(rereadBody.record?.industry).toBe('finance');
56-
57-
const deleted = await ctx.delete(`/api/v1/data/account/${id}`);
58-
expect(deleted.ok()).toBe(true);
59-
60-
await ctx.dispose();
61-
});
62-
});
8+
runDriverAcceptance({ label: 'MongoDB' });
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { runDriverAcceptance } from './_acceptance';
2+
3+
/**
4+
* Boots `pnpm dev` with `OS_DATABASE_URL=postgres://...`
5+
* (driver auto-inferred → SqlDriver(client:'pg')).
6+
* Requires a running PostgreSQL instance reachable at the URL.
7+
*/
8+
runDriverAcceptance({ label: 'PostgreSQL' });
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { runDriverAcceptance } from './_acceptance';
2+
3+
/**
4+
* Boots `pnpm dev` with `OS_DATABASE_URL=file:./<dir>/proj_local.db`
5+
* (driver auto-inferred → SqlDriver(client:'better-sqlite3')).
6+
* Requires no external services — runs anywhere.
7+
*/
8+
runDriverAcceptance({ label: 'SQLite' });

examples/app-crm/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
"build": "objectstack build",
1717
"typecheck": "tsc --noEmit",
1818
"test": "objectstack test",
19-
"test:e2e": "playwright test"
19+
"test:e2e": "playwright test",
20+
"test:e2e:drivers": "./scripts/run-driver-acceptance.sh"
2021
},
2122
"dependencies": {
2223
"@objectstack/spec": "workspace:*",

examples/app-crm/playwright.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ export default defineConfig({
2222
reuseExistingServer: true,
2323
timeout: 180_000,
2424
env: {
25+
// Default to MongoDB so `pnpm test:e2e` (without the wrapper script)
26+
// still works against a local mongod. The driver-acceptance wrapper
27+
// script (`scripts/run-driver-acceptance.sh`) overrides this by
28+
// booting `pnpm dev` itself with each driver's URL — Playwright
29+
// then reuses that server (`reuseExistingServer: true`).
2530
OS_DATABASE_URL:
2631
process.env.OS_DATABASE_URL ??
2732
'mongodb://localhost:27017/objectstack_crm_test',
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/usr/bin/env bash
2+
# Runs the CRM Playwright acceptance suite once for each storage driver.
3+
# Each driver gets its own dev-server boot with the matching OS_DATABASE_URL.
4+
#
5+
# Usage:
6+
# ./scripts/run-driver-acceptance.sh # all drivers
7+
# ./scripts/run-driver-acceptance.sh sqlite # one driver
8+
# ./scripts/run-driver-acceptance.sh sqlite mongodb # subset
9+
#
10+
# Requirements:
11+
# sqlite — none (always available)
12+
# mongodb — local mongod listening on $MONGO_URL (default: 127.0.0.1:27017)
13+
# postgres — running pg accessible at $PG_URL (default: 127.0.0.1:5432)
14+
# On macOS the simplest setup is:
15+
# docker run -d --name pg -p 5432:5432 \
16+
# -e POSTGRES_PASSWORD=postgres \
17+
# -e POSTGRES_DB=objectstack_crm_test postgres:16-alpine
18+
# (Postgres is **not** booted by this script — start it yourself.)
19+
20+
set -euo pipefail
21+
22+
cd "$(dirname "$0")/.."
23+
24+
PORT="${CRM_PORT:-3001}"
25+
MONGO_URL="${MONGO_URL:-mongodb://127.0.0.1:27017/objectstack_crm_test}"
26+
PG_URL="${PG_URL:-postgres://postgres:postgres@127.0.0.1:5432/objectstack_crm_test}"
27+
SQLITE_FILE="${SQLITE_FILE:-/tmp/objectstack-crm-acceptance.db}"
28+
29+
DRIVERS=("$@")
30+
if [ ${#DRIVERS[@]} -eq 0 ]; then
31+
DRIVERS=("sqlite" "mongodb" "postgres")
32+
fi
33+
34+
run_one() {
35+
local driver="$1"
36+
local url="$2"
37+
local spec="$3"
38+
39+
echo
40+
echo "=========================================="
41+
echo "▶ Driver: $driver"
42+
echo " URL: $url"
43+
echo " Spec: $spec"
44+
echo "=========================================="
45+
46+
if lsof -ti:"$PORT" >/dev/null 2>&1; then
47+
lsof -ti:"$PORT" | xargs kill 2>/dev/null || true
48+
sleep 2
49+
fi
50+
51+
rm -f /tmp/crm-dev-"$driver".log
52+
OS_DATABASE_URL="$url" pnpm dev > /tmp/crm-dev-"$driver".log 2>&1 &
53+
local dev_pid=$!
54+
55+
local i=0
56+
until curl -sf "http://127.0.0.1:$PORT/api/v1/meta" >/dev/null 2>&1; do
57+
i=$((i + 1))
58+
if [ $i -gt 90 ]; then
59+
echo "✗ Dev server did not become ready (see /tmp/crm-dev-$driver.log)"
60+
kill "$dev_pid" 2>/dev/null || true
61+
return 1
62+
fi
63+
sleep 1
64+
done
65+
66+
set +e
67+
pnpm exec playwright test "$spec"
68+
local rc=$?
69+
set -e
70+
71+
# `pnpm dev` wraps several layers — kill the whole port-3001 listener
72+
# to ensure the underlying CLI server actually exits, not just the
73+
# pnpm wrapper.
74+
kill "$dev_pid" 2>/dev/null || true
75+
if lsof -ti:"$PORT" >/dev/null 2>&1; then
76+
lsof -ti:"$PORT" | xargs kill 2>/dev/null || true
77+
sleep 1
78+
lsof -ti:"$PORT" | xargs kill -9 2>/dev/null || true
79+
fi
80+
wait "$dev_pid" 2>/dev/null || true
81+
sleep 1
82+
83+
return $rc
84+
}
85+
86+
failed=()
87+
for d in "${DRIVERS[@]}"; do
88+
case "$d" in
89+
sqlite)
90+
rm -f "$SQLITE_FILE"
91+
if ! run_one sqlite "file:$SQLITE_FILE" "e2e/sqlite-driver.spec.ts"; then
92+
failed+=("sqlite")
93+
fi
94+
;;
95+
mongodb)
96+
if ! run_one mongodb "$MONGO_URL" "e2e/mongodb-driver.spec.ts"; then
97+
failed+=("mongodb")
98+
fi
99+
;;
100+
postgres)
101+
if ! run_one postgres "$PG_URL" "e2e/postgres-driver.spec.ts"; then
102+
failed+=("postgres")
103+
fi
104+
;;
105+
*)
106+
echo "Unknown driver: $d"
107+
exit 2
108+
;;
109+
esac
110+
done
111+
112+
echo
113+
if [ ${#failed[@]} -eq 0 ]; then
114+
echo "✓ All driver acceptance suites passed."
115+
else
116+
echo "✗ Failed drivers: ${failed[*]}"
117+
exit 1
118+
fi

0 commit comments

Comments
 (0)