diff --git a/.gitignore b/.gitignore index c768f352..cb100ced 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ apps/site/.next apps/site/out apps/site/.source +# Apps — Demo +apps/demo/.vercel + # Database *.sqlite3 *.db diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..9e84ac62 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +All notable changes to the ObjectQL monorepo are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- **`apps/demo`** — standalone Vercel-deployable demo application ([#issue](https://github.com/objectstack-ai/objectql/issues)): + - `vercel.json` — Vercel deployment configuration (custom serverless, 1 GiB memory, 60 s timeout). + - `api/[[...route]].ts` — catch-all serverless entry point bootstrapping the ObjectStack kernel with ObjectQL plugins, InMemoryDriver, Auth, Console, and Studio UIs. + - `scripts/build-vercel.sh` — ordered build script for all workspace dependencies. + - `scripts/patch-symlinks.cjs` — pnpm symlink dereference for Vercel bundling. + - `objectstack.config.ts` — local development configuration reusing the project-tracker showcase. + - `README.md` — deployment documentation for both local and Vercel workflows. + - Root `demo:dev` script for quick local start. diff --git a/ROADMAP.md b/ROADMAP.md index af35f39f..fca31703 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -793,6 +793,7 @@ Priority tasks following the `@objectstack` v3.2.6 upgrade: | 7 | Structured logging framework | Low | 🔴 Open | Migrate `sdk` retry `console.log` and `types/logger.ts` fallback `console.error` to hook-based structured logging. | | 8 | Add tests for `plugin-optimizations` and `plugin-query` | High | ✅ Done | Both packages now have comprehensive test suites — 202 tests across 4 test files. | | 9 | Reduce `any` in protocol handlers | Medium | 🔴 Open | `protocol-json-rpc` (102), `protocol-graphql` (101), `protocol-odata-v4` (83) — highest `any` density in the monorepo. | +| 10 | Deploy `apps/demo` to Vercel | Medium | ✅ Done | Standalone `apps/demo` sub-project with serverless entry point (`api/[[...route]].ts`), Vercel config, build script, and pnpm symlink patching. Independent from `apps/site`. | --- diff --git a/apps/demo/README.md b/apps/demo/README.md new file mode 100644 index 00000000..c7afec0b --- /dev/null +++ b/apps/demo/README.md @@ -0,0 +1,114 @@ +# ObjectQL Demo + +A standalone, deployable demo application for the ObjectQL platform. +Runs locally with `@objectstack/cli` and deploys to **Vercel** as a serverless function. + +## Features + +- **In-memory driver** — zero external database required; data persists across warm Vercel invocations. +- **Console UI** — full ObjectStack Console available at `/console/`. +- **Studio UI** — ObjectStack Studio available at `/_studio/`. +- **Project-Tracker showcase** — ships with the `examples/showcase/project-tracker` metadata (objects, views, permissions) so the demo has real data structures out of the box. +- **Auth** — Better-Auth based authentication via `@objectstack/plugin-auth`. + +## Local Development + +```bash +# From the monorepo root: +pnpm install + +# Start the demo in dev mode: +pnpm --filter @objectql/demo dev + +# Or from the apps/demo directory: +cd apps/demo +pnpm dev +``` + +The development server starts on `http://localhost:3000`. + +## Vercel Deployment + +### Prerequisites + +1. A [Vercel account](https://vercel.com). +2. The [Vercel CLI](https://vercel.com/docs/cli) installed (`npm i -g vercel`). + +### Setup + +1. **Create a new Vercel project** pointing to this repository. +2. In **Project Settings → General**, set the **Root Directory** to `apps/demo`. +3. Configure the following **Environment Variables**: + +| Variable | Required | Description | +|---|---|---| +| `AUTH_SECRET` | **Yes** (production) | Secret key for signing auth tokens. Generate with `openssl rand -base64 32`. | +| `AUTH_TRUSTED_ORIGINS` | No | Comma-separated list of additional trusted origins (e.g. `https://myapp.example.com`). | + +4. Deploy: + +```bash +# From the monorepo root: +vercel --cwd apps/demo + +# Or for production: +vercel --cwd apps/demo --prod +``` + +### How It Works + +- **`vercel.json`** — Configures Vercel to use a custom build command, allocate 1 GiB memory to the serverless function, and rewrite all requests to the catch-all `api/[[...route]].ts` handler. +- **`api/[[...route]].ts`** — Bootstraps the full ObjectStack kernel with ObjectQL plugins, the in-memory driver, auth, Console, and Studio. Uses `@hono/node-server`'s `getRequestListener()` to bridge the Vercel serverless runtime with the Hono HTTP framework. +- **`scripts/build-vercel.sh`** — Builds all required workspace packages (foundation, drivers, plugins, protocols, examples) in the correct dependency order. +- **`scripts/patch-symlinks.cjs`** — Replaces pnpm workspace symlinks with real copies so Vercel can bundle the function without symlink errors. + +### Monorepo Multi-Project + +This repository contains two independent Vercel projects: + +| Project | Root Directory | Framework | +|---|---|---| +| **`apps/site`** | `apps/site` | Next.js (fumadocs) | +| **`apps/demo`** | `apps/demo` | `null` (custom serverless) | + +Each project is configured independently and deployed separately. Changes to one do not affect the other. + +## Project Structure + +``` +apps/demo/ +├── api/ +│ └── [[...route]].ts # Vercel serverless entry point +├── scripts/ +│ ├── build-vercel.sh # Vercel build script +│ └── patch-symlinks.cjs # pnpm symlink dereference for Vercel +├── objectstack.config.ts # Local dev configuration +├── package.json +├── tsconfig.json +├── vercel.json +└── README.md +``` + +## Architecture + +``` + Vercel Edge Network + │ + ▼ + ┌──────────────────┐ + │ api/[[...route]] │ ← catch-all serverless function + └────────┬─────────┘ + │ + ┌───────────┼───────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌────────┐ ┌──────────┐ + │ Console │ │ Studio │ │ REST/RPC │ + │ SPA (/) │ │/_studio│ │ /api/* │ + └──────────┘ └────────┘ └──────────┘ + │ │ │ + └───────────┴───────────┘ + │ + ObjectStack Kernel + (ObjectQL + Auth + + InMemoryDriver) +``` diff --git a/apps/demo/api/[[...route]].ts b/apps/demo/api/[[...route]].ts new file mode 100644 index 00000000..5fd72ee3 --- /dev/null +++ b/apps/demo/api/[[...route]].ts @@ -0,0 +1,449 @@ +/** + * Vercel Serverless Function — ObjectQL Demo Handler + * + * Bootstraps the ObjectStack kernel with ObjectQL plugins and the + * project-tracker demo metadata, using @objectstack/driver-memory + * for zero-config in-memory data. + * + * Uses `getRequestListener()` from `@hono/node-server` together with + * an `extractBody()` helper to handle Vercel's pre-buffered request + * body. Vercel's Node.js runtime attaches the full body to + * `req.rawBody` / `req.body` before the handler is called, so the + * original stream is already drained when the handler receives the + * request. Reading from `rawBody` / `body` directly and constructing + * a fresh `Request` object prevents POST/PUT/PATCH requests (e.g. + * login) from hanging indefinitely. + * + * Data lives in the function instance's memory and persists across + * warm invocations (Vercel Fluid Compute) but resets on cold start. + * + * Both Console (/) and Studio (/_studio/) UIs are served as static SPAs. + * + * Timeout Protection: + * - Each plugin registration (kernel.use) has a 10 s timeout. + * - kernel.bootstrap() (init + start all plugins) has a 30 s timeout. + * - The entire bootstrap() function has a 50 s budget (10 s margin + * for Vercel's 60 s function limit). + * - On failure the handler returns 503 instead of hanging. + */ +import { ObjectKernel, DriverPlugin, AppPlugin, createDispatcherPlugin, createRestApiPlugin } from '@objectstack/runtime'; +import { HonoHttpServer } from '@objectstack/plugin-hono-server'; +import { AuthPlugin } from '@objectstack/plugin-auth'; +import { InMemoryDriver } from '@objectstack/driver-memory'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { getRequestListener } from '@hono/node-server'; +import type { Hono } from 'hono'; +import { resolve, dirname, join, extname } from 'path'; +import { fileURLToPath } from 'url'; +import { existsSync, readFileSync, statSync } from 'fs'; +import { createRequire } from 'module'; + +// --------------------------------------------------------------------------- +// Timeout constants — protect against permanently-pending promises that would +// cause Vercel's 60 s function timeout. +// --------------------------------------------------------------------------- + +/** Per-plugin kernel.use() timeout (ms). */ +const PLUGIN_TIMEOUT_MS = 10_000; + +/** kernel.bootstrap() (init + start all plugins) timeout (ms). */ +const KERNEL_BOOTSTRAP_TIMEOUT_MS = 30_000; + +/** Overall bootstrap() budget (ms). Leaves ~10 s margin for Vercel's 60 s limit. */ +const BOOTSTRAP_TIMEOUT_MS = 50_000; + +/** + * Race a promise against a timer. Rejects with a descriptive error if the + * promise does not settle within `ms` milliseconds. + */ +function withTimeout(promise: Promise, ms: number, label: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`[ObjectQL Demo] Timeout after ${ms}ms: ${label}`)); + }, ms); + promise.then( + (v) => { clearTimeout(timer); resolve(v); }, + (e) => { clearTimeout(timer); reject(e); }, + ); + }); +} + +// --------------------------------------------------------------------------- +// Static SPA plugins — serve Console at / and Studio at /_studio/ +// --------------------------------------------------------------------------- + +const STUDIO_PATH = '/_studio'; + +const MIME_TYPES: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.mjs': 'application/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.map': 'application/json', +}; + +function mimeType(filePath: string): string { + return MIME_TYPES[extname(filePath).toLowerCase()] || 'application/octet-stream'; +} + +function resolvePackageDistPath(packageName: string): string | null { + try { + const req = createRequire(import.meta.url); + const pkgPath = req.resolve(`${packageName}/package.json`); + const distPath = join(dirname(pkgPath), 'dist'); + if (existsSync(join(distPath, 'index.html'))) return distPath; + } catch { /* ignore */ } + + const __filename = fileURLToPath(import.meta.url); + const projectRoot = resolve(dirname(__filename), '..'); + const directPath = join(projectRoot, 'node_modules', ...packageName.split('/'), 'dist'); + if (existsSync(join(directPath, 'index.html'))) return directPath; + + return null; +} + +function createStaticSpaPlugin(name: string, basePath: string, distPath: string, rewriteAssetPaths = true) { + const absoluteDist = resolve(distPath); + const indexPath = join(absoluteDist, 'index.html'); + const rawHtml = readFileSync(indexPath, 'utf-8'); + // Rewrite relative asset paths (e.g. href="./assets/..." → href="/_studio/assets/...") + // Skip absolute URLs (http://, https://, //) and paths already using the correct base + const rewrittenHtml = rewriteAssetPaths + ? rawHtml.replace( + /(\s(?:href|src))="(?!https?:\/\/|\/\/)\.?\/?(?!\/)/g, + `$1="${basePath}/`, + ) + : rawHtml; + + return { + name, + version: '1.0.0', + init: async () => {}, + start: async (ctx: any) => { + const httpServer = ctx.getService?.('http.server'); + if (!httpServer?.getRawApp) return; + const app = httpServer.getRawApp(); + + app.get(basePath, (c: any) => c.redirect(`${basePath}/`)); + app.get(`${basePath}/*`, async (c: any) => { + const reqPath = c.req.path.substring(basePath.length) || '/'; + const filePath = resolve(absoluteDist, reqPath.replace(/^\//, '')); + // Prevent path traversal: resolved path must stay within distPath + if (!filePath.startsWith(absoluteDist)) { + return c.text('Forbidden', 403); + } + if (existsSync(filePath) && statSync(filePath).isFile()) { + const content = readFileSync(filePath); + return new Response(content, { + headers: { 'content-type': mimeType(filePath) }, + }); + } + return new Response(rewrittenHtml, { + headers: { 'content-type': 'text/html; charset=utf-8' }, + }); + }); + }, + }; +} + +// --------------------------------------------------------------------------- +// Body extraction helper — reads Vercel's pre-buffered request body. +// --------------------------------------------------------------------------- + +/** Shape of the Vercel-augmented IncomingMessage passed via `env.incoming`. */ +interface VercelIncomingMessage { + rawBody?: Buffer | string; + body?: unknown; + headers?: Record; +} + +/** Shape of the env object provided by `getRequestListener` on Vercel. */ +interface VercelEnv { + incoming?: VercelIncomingMessage; +} + +function extractBody(incoming: VercelIncomingMessage, method: string, contentType: string | undefined): BodyInit | null { + if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return null; + if (incoming.rawBody != null) { + if (typeof incoming.rawBody === 'string') return incoming.rawBody; + return incoming.rawBody; + } + if (incoming.body != null) { + if (typeof incoming.body === 'string') return incoming.body; + if (contentType?.includes('application/json')) return JSON.stringify(incoming.body); + return String(incoming.body); + } + return null; +} + +/** + * Derive the correct public URL for the request, fixing the protocol when + * running behind a reverse proxy such as Vercel's edge network. + */ +function resolvePublicUrl(requestUrl: string, incoming: VercelIncomingMessage | undefined): string { + if (!incoming) return requestUrl; + const fwdProto = incoming.headers?.['x-forwarded-proto']; + const rawProto = Array.isArray(fwdProto) ? fwdProto[0] : fwdProto; + // Accept only well-known protocol values to prevent header-injection attacks. + const proto = rawProto === 'https' || rawProto === 'http' ? rawProto : undefined; + if (proto === 'https' && requestUrl.startsWith('http:')) { + return requestUrl.replace(/^http:/, 'https:'); + } + return requestUrl; +} + +// --------------------------------------------------------------------------- +// Singleton bootstrap — runs eagerly at module load, reused across warm +// invocations (Vercel Fluid Compute). +// --------------------------------------------------------------------------- + +const bootstrapPromise: Promise = withTimeout( + bootstrap(), + BOOTSTRAP_TIMEOUT_MS, + 'Overall bootstrap', +).catch((err) => { + console.error('[ObjectQL Demo] Bootstrap failed:', err); + throw err; +}); + +// --------------------------------------------------------------------------- +// Vercel Node.js serverless handler via @hono/node-server getRequestListener. +// --------------------------------------------------------------------------- + +export default getRequestListener(async (request, env) => { + let app: Hono; + try { + app = await bootstrapPromise; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.error('[ObjectQL Demo] Handler error — bootstrap did not complete:', message); + return new Response( + JSON.stringify({ error: 'Service Unavailable', message: 'Kernel bootstrap failed. Check function logs for details.' }), + { status: 503, headers: { 'content-type': 'application/json' } }, + ); + } + + const method = request.method.toUpperCase(); + const incoming = (env as VercelEnv)?.incoming; + + // Fix URL protocol using x-forwarded-proto (Vercel sets this to 'https'). + const url = resolvePublicUrl(request.url, incoming); + + if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS' && incoming) { + const contentType = incoming.headers?.['content-type']; + const contentTypeStr = Array.isArray(contentType) ? contentType[0] : contentType; + const body = extractBody(incoming, method, contentTypeStr); + if (body != null) { + return await app.fetch(new Request(url, { method, headers: request.headers, body })); + } + } + + // For GET/HEAD/OPTIONS (or body-less requests): pass through with corrected URL. + return await app.fetch( + url !== request.url + ? new Request(url, { method, headers: request.headers }) + : request, + ); +}); + +// --------------------------------------------------------------------------- +// Bootstrap — creates the full ObjectStack kernel with all demo plugins +// --------------------------------------------------------------------------- + +async function bootstrap(): Promise { + const t0 = Date.now(); + const elapsed = () => `${Date.now() - t0}ms`; + const log = (msg: string) => console.log(`[ObjectQL Demo] [${elapsed()}] ${msg}`); + + log('Bootstrap starting…'); + + const kernel = new ObjectKernel(); + + // 1. ObjectQL engine (provides metadata, data, and protocol services) + log('Registering ObjectQLPlugin…'); + await withTimeout(kernel.use(new ObjectQLPlugin()), PLUGIN_TIMEOUT_MS, 'ObjectQLPlugin'); + log('ObjectQLPlugin registered.'); + + // 2. In-memory data driver (no external DB required) + log('Registering DriverPlugin (InMemoryDriver)…'); + await withTimeout(kernel.use(new DriverPlugin(new InMemoryDriver(), 'memory')), PLUGIN_TIMEOUT_MS, 'DriverPlugin'); + log('DriverPlugin registered.'); + + // 3. HTTP server adapter — register the Hono app without TCP listener + const httpServer = new HonoHttpServer(); + log('Registering vercel-http…'); + await withTimeout(kernel.use({ + name: 'vercel-http', + version: '1.0.0', + init: async (ctx: any) => { + ctx.registerService('http.server', httpServer); + ctx.registerService('http-server', httpServer); + }, + start: async () => {}, + }), PLUGIN_TIMEOUT_MS, 'vercel-http'); + log('vercel-http registered.'); + + // 4. In-memory cache service (satisfies the 'cache' core service requirement) + log('Registering cache service…'); + await withTimeout(kernel.use({ + name: 'com.objectql.cache.memory', + version: '1.0.0', + init: async (ctx: any) => { + const store = new Map(); + const isExpired = (entry: { expiresAt: number | null }) => + entry.expiresAt !== null && Date.now() > entry.expiresAt; + ctx.registerService('cache', { + async get(key: string) { + const entry = store.get(key); + if (!entry) return undefined; + if (isExpired(entry)) { store.delete(key); return undefined; } + return entry.value; + }, + async set(key: string, value: unknown, ttl?: number) { + store.set(key, { + value, + expiresAt: ttl ? Date.now() + ttl * 1000 : null, + }); + }, + async del(key: string) { store.delete(key); }, + async clear() { store.clear(); }, + async has(key: string) { + const entry = store.get(key); + if (!entry) return false; + if (isExpired(entry)) { store.delete(key); return false; } + return true; + }, + }); + }, + start: async () => {}, + }), PLUGIN_TIMEOUT_MS, 'cache-memory'); + log('Cache service registered.'); + + // 5. Authentication & Identity (better-auth based) + const authSecret = process.env.AUTH_SECRET; + if (!authSecret && process.env.VERCEL) { + throw new Error( + '[ObjectQL Demo] AUTH_SECRET environment variable is required on Vercel. ' + + 'Set it in the Vercel Dashboard → Project Settings → Environment Variables.', + ); + } + + const baseUrl = process.env.VERCEL_PROJECT_PRODUCTION_URL + ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` + : process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : 'http://localhost:3000'; + + log('Registering AuthPlugin…'); + await withTimeout(kernel.use(new AuthPlugin({ + secret: authSecret || 'objectql-demo-dev-secret-change-me-in-production', + baseUrl, + trustedOrigins: [ + 'http://localhost:*', + ...(process.env.VERCEL_URL ? [`https://${process.env.VERCEL_URL}`] : []), + ...(process.env.VERCEL_BRANCH_URL ? [`https://${process.env.VERCEL_BRANCH_URL}`] : []), + ...(process.env.VERCEL_PROJECT_PRODUCTION_URL ? [`https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`] : []), + ...(process.env.AUTH_TRUSTED_ORIGINS ? process.env.AUTH_TRUSTED_ORIGINS.split(',').map(s => s.trim()) : []), + ], + })), PLUGIN_TIMEOUT_MS, 'AuthPlugin'); + log('AuthPlugin registered.'); + + // 6. Application config — empty manifest; demo metadata is loaded via + // the project-tracker dist path below. + log('Registering AppPlugin (manifest)…'); + await withTimeout(kernel.use(new AppPlugin({ + manifest: { + id: 'com.objectql.demo', + namespace: 'demo', + version: '1.0.0', + type: 'app', + name: 'ObjectQL Demo', + }, + objects: [], + plugins: [], + })), PLUGIN_TIMEOUT_MS, 'AppPlugin-manifest'); + log('AppPlugin (manifest) registered.'); + + // 7. Load project-tracker showcase metadata (objects, views, permissions) + // The example-project-tracker package compiles its YAML metadata into dist/. + try { + const req = createRequire(import.meta.url); + const trackerPkg = req.resolve('@objectql/example-project-tracker/package.json'); + const trackerDist = join(dirname(trackerPkg), 'dist'); + if (existsSync(trackerDist)) { + const trackerModule = await import(join(trackerDist, 'index.js')); + if (trackerModule?.default?.objects) { + log('Registering project-tracker objects…'); + await withTimeout(kernel.use(new AppPlugin({ + manifest: { + id: 'com.objectql.demo.project-tracker', + namespace: 'project_tracker', + version: '1.0.0', + type: 'module', + name: 'Project Tracker', + }, + objects: trackerModule.default.objects, + plugins: [], + })), PLUGIN_TIMEOUT_MS, 'AppPlugin-project-tracker'); + log('Project-tracker objects registered.'); + } + } + } catch (err) { + log(`Project-tracker metadata not available: ${err instanceof Error ? err.message : String(err)}`); + } + + // 8. REST API endpoints (auto-generated CRUD for all objects) + log('Registering RestApiPlugin…'); + await withTimeout(kernel.use(createRestApiPlugin()), PLUGIN_TIMEOUT_MS, 'RestApiPlugin'); + log('RestApiPlugin registered.'); + + // 9. Dispatcher (auth, graphql, analytics routes) + log('Registering DispatcherPlugin…'); + await withTimeout(kernel.use(createDispatcherPlugin()), PLUGIN_TIMEOUT_MS, 'DispatcherPlugin'); + log('DispatcherPlugin registered.'); + + // 10. Console UI (serves the ObjectStack Console SPA at /console/) + const consoleDistPath = resolvePackageDistPath('@object-ui/console'); + if (consoleDistPath) { + log('Registering Console SPA static plugin…'); + // Console SPA already has absolute /console/ asset paths — skip rewriting + await withTimeout( + kernel.use(createStaticSpaPlugin('com.objectui.console-static', '/console', consoleDistPath, false)), + PLUGIN_TIMEOUT_MS, + 'Console-SPA', + ); + // Default redirect: / -> /console/ + const app = httpServer.getRawApp(); + app.get('/', (c: any) => c.redirect('/console/')); + log('Console SPA registered.'); + } + + // 11. Studio UI (serves the ObjectStack Studio SPA at /_studio/) + const studioDistPath = resolvePackageDistPath('@objectstack/studio'); + if (studioDistPath) { + log('Registering Studio SPA static plugin…'); + await withTimeout( + kernel.use(createStaticSpaPlugin('com.objectstack.studio-static', STUDIO_PATH, studioDistPath)), + PLUGIN_TIMEOUT_MS, + 'Studio-SPA', + ); + log('Studio SPA registered.'); + } + + // 12. Bootstrap kernel (init + start all plugins, fire kernel:ready) + log('Running kernel.bootstrap()…'); + await withTimeout(kernel.bootstrap(), KERNEL_BOOTSTRAP_TIMEOUT_MS, 'kernel.bootstrap()'); + log(`Bootstrap complete in ${elapsed()}.`); + + return httpServer.getRawApp(); +} diff --git a/apps/demo/objectstack.config.ts b/apps/demo/objectstack.config.ts new file mode 100644 index 00000000..608411ad --- /dev/null +++ b/apps/demo/objectstack.config.ts @@ -0,0 +1,73 @@ +/** + * ObjectQL Demo — Application Configuration + * + * Minimal ObjectStack configuration for the demo application. + * Uses in-memory driver with the project-tracker showcase example. + * + * For local development: `pnpm dev` (uses @objectstack/cli) + * For Vercel deployment: configured via api/[[...route]].ts + */ +import { createRequire } from 'module'; +import * as path from 'path'; + +// Polyfill require and __dirname for ESM +if (typeof globalThis.require === 'undefined') { + const require = createRequire(import.meta.url); + (globalThis as any).require = require; +} +if (typeof globalThis.__dirname === 'undefined') { + (globalThis as any).__dirname = path.dirname(new URL(import.meta.url).pathname); +} + +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import { AuthPlugin } from '@objectstack/plugin-auth'; +import { ConsolePlugin } from '@object-ui/console'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { QueryPlugin } from '@objectql/plugin-query'; +import { ValidatorPlugin } from '@objectql/plugin-validator'; +import { FormulaPlugin } from '@objectql/plugin-formula'; +import { ObjectQLSecurityPlugin } from '@objectql/plugin-security'; +import { createApiRegistryPlugin } from '@objectstack/core'; +import { MemoryDriver } from '@objectql/driver-memory'; +import { createAppPlugin } from '@objectql/platform-node'; + +// In-memory driver — zero-config, no external DB required. +const defaultDriver = new MemoryDriver(); + +// Load the project-tracker showcase metadata. +const projectTrackerPlugin = createAppPlugin({ + id: 'project-tracker', + dir: path.join(__dirname, '../../examples/showcase/project-tracker/src'), + label: 'Project Tracker', + description: 'A showcase of ObjectQL capabilities including all field types.', +}); + +export default { + metadata: { + name: 'objectql-demo', + version: '1.0.0', + }, + plugins: [ + createApiRegistryPlugin(), + new HonoServerPlugin({}), + new ConsolePlugin(), + // Register the driver as 'driver.default' service. + { + name: 'driver-default', + init: async (ctx: any) => { + ctx.registerService('driver.default', defaultDriver); + }, + start: async () => {}, + }, + projectTrackerPlugin, + new ObjectQLPlugin(), + new QueryPlugin({ datasources: { default: defaultDriver } }), + new ValidatorPlugin(), + new FormulaPlugin(), + new ObjectQLSecurityPlugin({ enableAudit: false }), + new AuthPlugin({ + secret: process.env.AUTH_SECRET || 'objectql-demo-dev-secret-change-me-in-production', + trustedOrigins: ['http://localhost:*'], + }), + ], +}; diff --git a/apps/demo/package.json b/apps/demo/package.json new file mode 100644 index 00000000..76bab379 --- /dev/null +++ b/apps/demo/package.json @@ -0,0 +1,40 @@ +{ + "name": "@objectql/demo", + "version": "4.2.2", + "private": true, + "description": "ObjectQL Demo — standalone Vercel-deployable demo application", + "type": "module", + "scripts": { + "dev": "objectstack serve --dev", + "build": "tsc --noEmit", + "start": "objectstack serve" + }, + "devDependencies": { + "@hono/node-server": "^1.19.11", + "@object-ui/console": "^3.1.3", + "@objectql/core": "workspace:*", + "@objectql/driver-memory": "workspace:*", + "@objectql/example-project-tracker": "workspace:*", + "@objectql/platform-node": "workspace:*", + "@objectql/plugin-formula": "workspace:*", + "@objectql/plugin-query": "workspace:*", + "@objectql/plugin-security": "workspace:*", + "@objectql/plugin-validator": "workspace:*", + "@objectql/protocol-graphql": "workspace:*", + "@objectql/protocol-json-rpc": "workspace:*", + "@objectql/protocol-odata-v4": "workspace:*", + "@objectql/types": "workspace:*", + "@objectstack/cli": "^3.2.8", + "@objectstack/core": "^3.2.8", + "@objectstack/objectql": "^3.2.8", + "@objectstack/plugin-auth": "^3.2.8", + "@objectstack/plugin-hono-server": "^3.2.8", + "@objectstack/studio": "^3.2.8", + "@types/node": "^20.19.37", + "hono": "^4.12.8", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=20" + } +} diff --git a/apps/demo/scripts/build-vercel.sh b/apps/demo/scripts/build-vercel.sh new file mode 100755 index 00000000..68238925 --- /dev/null +++ b/apps/demo/scripts/build-vercel.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# build-vercel.sh — Build all ObjectQL packages for Vercel serverless deployment +# +# This script is referenced by apps/demo/vercel.json and runs during the +# Vercel build step. +# +# Steps: +# 1. Build foundation packages (types → core → platform-node) +# 2. Build drivers, plugins, and protocols +# 3. Build the project-tracker showcase example +# 4. Patch pnpm symlinks so Vercel can bundle the serverless function +# +# Usage (called automatically by Vercel via vercel.json): +# bash scripts/build-vercel.sh + +set -euo pipefail + +echo "▸ Building @objectql/types…" +pnpm --filter @objectql/types build + +echo "▸ Building @objectql/core…" +pnpm --filter @objectql/core build + +echo "▸ Building @objectql/platform-node…" +pnpm --filter @objectql/platform-node build + +echo "▸ Building drivers…" +pnpm --filter @objectql/driver-memory build + +echo "▸ Building plugins…" +pnpm --filter @objectql/plugin-query \ + --filter @objectql/plugin-validator \ + --filter @objectql/plugin-formula \ + --filter @objectql/plugin-security \ + build + +echo "▸ Building protocols…" +pnpm --filter @objectql/protocol-graphql \ + --filter @objectql/protocol-json-rpc \ + --filter @objectql/protocol-odata-v4 \ + build + +echo "▸ Building project-tracker example…" +pnpm --filter @objectql/example-project-tracker build + +echo "▸ Patching pnpm symlinks for Vercel…" +node scripts/patch-symlinks.cjs + +echo "✓ Vercel build complete." diff --git a/apps/demo/scripts/patch-symlinks.cjs b/apps/demo/scripts/patch-symlinks.cjs new file mode 100644 index 00000000..dfc88903 --- /dev/null +++ b/apps/demo/scripts/patch-symlinks.cjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +/** + * patch-symlinks.cjs + * + * Prepares node_modules for Vercel deployment. + * + * pnpm uses symlinks in node_modules which Vercel rejects as + * "invalid deployment package … symlinked directories". This script + * replaces ALL top-level symlinks with real copies of the target + * directories so that Vercel can bundle the serverless function. + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); + +/** + * Replace a pnpm symlink with a real copy of the target directory. + */ +function derefSymlink(pkgPath) { + const abs = path.resolve(ROOT, pkgPath); + if (!fs.existsSync(abs)) { + console.warn(` ⚠ ${pkgPath} not found — skipping`); + return false; + } + + const stat = fs.lstatSync(abs); + if (!stat.isSymbolicLink()) { + return true; + } + + const realPath = fs.realpathSync(abs); + console.log(` → Dereferencing ${pkgPath}`); + + // Copy to a temp location first, then swap — avoids data loss if cpSync fails + const tmpPath = abs + '.tmp'; + fs.cpSync(realPath, tmpPath, { recursive: true }); + fs.unlinkSync(abs); + fs.renameSync(tmpPath, abs); + return true; +} + +/** + * Walk a directory and dereference all symlinks found at the top level. + * Handles scoped packages (@scope/pkg) by walking one level deeper. + */ +function derefAllSymlinks(nmDir) { + const abs = path.resolve(ROOT, nmDir); + if (!fs.existsSync(abs)) return 0; + + let count = 0; + for (const entry of fs.readdirSync(abs)) { + // Skip the .pnpm virtual store and hidden files + if (entry === '.pnpm' || entry.startsWith('.')) continue; + + const entryPath = path.join(abs, entry); + + // Scoped package — walk one level deeper + if (entry.startsWith('@')) { + if (!fs.existsSync(entryPath)) continue; + for (const sub of fs.readdirSync(entryPath)) { + const rel = path.join(nmDir, entry, sub); + if (derefSymlink(rel)) count++; + } + continue; + } + + const rel = path.join(nmDir, entry); + if (derefSymlink(rel)) count++; + } + return count; +} + +console.log('\n🔧 Patching pnpm symlinks for Vercel deployment…\n'); + +const count = derefAllSymlinks('node_modules'); +console.log(`\n✅ Patch complete — processed ${count} packages\n`); diff --git a/apps/demo/tsconfig.json b/apps/demo/tsconfig.json new file mode 100644 index 00000000..22506dfc --- /dev/null +++ b/apps/demo/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "composite": true, + "noEmit": true + }, + "include": [ + "api/**/*.ts", + "objectstack.config.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/apps/demo/vercel.json b/apps/demo/vercel.json new file mode 100644 index 00000000..59e8788e --- /dev/null +++ b/apps/demo/vercel.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "installCommand": "pnpm install --ignore-scripts", + "buildCommand": "bash scripts/build-vercel.sh", + "framework": null, + "functions": { + "api/**/*.ts": { + "memory": 1024, + "maxDuration": 60, + "includeFiles": "{packages/*/dist,node_modules/@object-ui/console/dist,node_modules/@objectstack/plugin-auth/dist,node_modules/@objectstack/studio/dist}/**" + } + }, + "rewrites": [ + { "source": "/api/:path*", "destination": "/api/[[...route]]" }, + { "source": "/(.*)", "destination": "/api/[[...route]]" } + ] +} diff --git a/package.json b/package.json index 08857252..5bb8a597 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev": "objectstack serve --dev", "cli:dev": "ts-node packages/tools/cli/src/index.ts", "site:dev": "pnpm --filter @objectql/site run dev", + "demo:dev": "pnpm --filter @objectql/demo run dev", "build": "turbo run build", "test": "pnpm run check-versions && turbo run test --concurrency=1", "lint": "turbo run lint", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2337fcda..7d1071b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,6 +133,78 @@ importers: specifier: ^1.6.1 version: 1.6.1(@types/node@20.19.37)(@vitest/ui@1.6.1)(lightningcss@1.32.0) + apps/demo: + devDependencies: + '@hono/node-server': + specifier: ^1.19.11 + version: 1.19.11(hono@4.12.8) + '@object-ui/console': + specifier: ^3.1.3 + version: 3.1.3 + '@objectql/core': + specifier: workspace:* + version: link:../../packages/foundation/core + '@objectql/driver-memory': + specifier: workspace:* + version: link:../../packages/drivers/memory + '@objectql/example-project-tracker': + specifier: workspace:* + version: link:../../examples/showcase/project-tracker + '@objectql/platform-node': + specifier: workspace:* + version: link:../../packages/foundation/platform-node + '@objectql/plugin-formula': + specifier: workspace:* + version: link:../../packages/foundation/plugin-formula + '@objectql/plugin-query': + specifier: workspace:* + version: link:../../packages/foundation/plugin-query + '@objectql/plugin-security': + specifier: workspace:* + version: link:../../packages/foundation/plugin-security + '@objectql/plugin-validator': + specifier: workspace:* + version: link:../../packages/foundation/plugin-validator + '@objectql/protocol-graphql': + specifier: workspace:* + version: link:../../packages/protocols/graphql + '@objectql/protocol-json-rpc': + specifier: workspace:* + version: link:../../packages/protocols/json-rpc + '@objectql/protocol-odata-v4': + specifier: workspace:* + version: link:../../packages/protocols/odata-v4 + '@objectql/types': + specifier: workspace:* + version: link:../../packages/foundation/types + '@objectstack/cli': + specifier: ^3.2.8 + version: 3.2.8(@objectstack/core@3.2.8)(esbuild@0.27.4) + '@objectstack/core': + specifier: ^3.2.8 + version: 3.2.8 + '@objectstack/objectql': + specifier: ^3.2.8 + version: 3.2.8 + '@objectstack/plugin-auth': + specifier: ^3.2.8 + version: 3.2.8(mongodb@7.1.0(socks@2.8.7))(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@1.6.1)(vue@3.5.30(typescript@5.9.3)) + '@objectstack/plugin-hono-server': + specifier: ^3.2.8 + version: 3.2.8 + '@objectstack/studio': + specifier: ^3.2.8 + version: 3.2.8(@types/node@20.19.37)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(mongodb@7.1.0(socks@2.8.7))(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.20.0)(typescript@5.9.3)(vitest@1.6.1)(vue@3.5.30(typescript@5.9.3)) + '@types/node': + specifier: ^20.19.37 + version: 20.19.37 + hono: + specifier: ^4.12.8 + version: 4.12.8 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + apps/site: dependencies: fumadocs-core: @@ -11008,7 +11080,7 @@ snapshots: '@types/fs-extra@11.0.4': dependencies: '@types/jsonfile': 6.1.4 - '@types/node': 20.19.37 + '@types/node': 25.5.0 '@types/hast@3.0.4': dependencies: @@ -11022,7 +11094,7 @@ snapshots: '@types/jsonfile@6.1.4': dependencies: - '@types/node': 20.19.37 + '@types/node': 25.5.0 '@types/linkify-it@5.0.0': {} diff --git a/tsconfig.json b/tsconfig.json index 7bfd0ba5..b9626484 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -42,6 +42,9 @@ // Examples { "path": "./examples/integrations/express-server" }, { "path": "./examples/showcase/project-tracker" }, - { "path": "./examples/showcase/enterprise-erp" } + { "path": "./examples/showcase/enterprise-erp" }, + + // Apps + { "path": "./apps/demo" } ] }