From fae09e7cf8bb161a76d0c098d415fd845f860a2a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 23:58:54 +0000 Subject: [PATCH 1/3] fix(studio): export webcrypto from node-polyfills to fix build The studio Vite build aliases 'crypto' to mocks/node-polyfills.ts. @objectstack/runtime imports `webcrypto` from 'crypto', so the polyfill must export it. Map webcrypto to globalThis.crypto (Web Crypto API) which provides the same SubtleCrypto/getRandomValues surface in browsers. Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/49abf5ad-ca45-4ce6-b799-657ad8f2cd69 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- apps/studio/mocks/node-polyfills.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/studio/mocks/node-polyfills.ts b/apps/studio/mocks/node-polyfills.ts index c02500a4a..0fbc8c696 100644 --- a/apps/studio/mocks/node-polyfills.ts +++ b/apps/studio/mocks/node-polyfills.ts @@ -102,3 +102,13 @@ export const rename = async () => {}; export const renameSync = () => {}; export const createHash = () => ({ update: () => ({ digest: () => '' }) }); export const randomUUID = () => '00000000-0000-0000-0000-000000000000'; + +// node:crypto webcrypto polyfill — maps to the browser's native Web Crypto API. +// The runtime package uses `import { webcrypto as crypto } from "crypto"` to access +// SubtleCrypto/getRandomValues; in the browser, globalThis.crypto provides the same surface. +export const webcrypto = + (typeof globalThis !== 'undefined' && (globalThis as any).crypto) || { + subtle: undefined, + getRandomValues: (array: T): T => array, + randomUUID: () => '00000000-0000-0000-0000-000000000000', + }; From 5833df4af3cc577ac9d8d7e7ff99ce217d525fb5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 00:07:42 +0000 Subject: [PATCH 2/3] fix(service-cloud): tmpdir fallback for default data dir on serverless apps/cloud booted on Vercel was failing with "ENOENT: no such file or directory, mkdir '/var/task/.objectstack'" because cloud-stack / runtime-stack / project-kernel-factory / environment-registry / artifact-environment-registry all hard-coded `/.objectstack/data` for the default SQLite control DB and InMemoryDriver per-project JSON paths. /var/task on Vercel (and analogous bundle roots on AWS Lambda / Netlify) is read-only. Centralise the resolution in a new resolveDefaultDataDir() helper that: 1. Honours OS_DATA_DIR. 2. Defaults to /.objectstack/data on writable filesystems. 3. Falls back to /.objectstack/data on Vercel / AWS_LAMBDA_FUNCTION_NAME / NETLIFY (or OS_READONLY_FS=1) with a one-time warning advising users to configure OS_CONTROL_DATABASE_URL for production. 12 new unit tests cover the precedence and serverless detection. CHANGELOG updated. Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/d91ac991-ce21-4bce-a544-d2053fd5f8a5 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- CHANGELOG.md | 2 + .../src/artifact-environment-registry.ts | 3 +- .../services/service-cloud/src/cloud-stack.ts | 5 +- .../services/service-cloud/src/data-dir.ts | 91 +++++++++++++++++++ .../service-cloud/src/environment-registry.ts | 3 +- packages/services/service-cloud/src/index.ts | 3 + .../src/project-kernel-factory.ts | 3 +- .../service-cloud/src/runtime-stack.ts | 3 +- .../service-cloud/test/data-dir.test.ts | 81 +++++++++++++++++ 9 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 packages/services/service-cloud/src/data-dir.ts create mode 100644 packages/services/service-cloud/test/data-dir.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 621093d9b..0a2ee0cec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **Cold-start `ENOENT: mkdir '/var/task/.objectstack'` on Vercel/Lambda** (`@objectstack/service-cloud`) — `createCloudStack()`, `createRuntimeStack()`, `DefaultProjectKernelFactory`, `DefaultEnvironmentDriverRegistry`, and `ArtifactEnvironmentDriverRegistry` previously hard-coded the SQLite/InMemoryDriver default data directory to `/.objectstack/data`. On serverless platforms the bundle root is read-only, which made `apps/cloud` fail to boot whenever no explicit `OS_CONTROL_DATABASE_URL` was configured. Centralised the resolution in a new `resolveDefaultDataDir()` helper that honours `OS_DATA_DIR`, then falls back to `/.objectstack/data` on writable filesystems and `/.objectstack/data` (with a one-time warning) on Vercel / AWS Lambda / Netlify (or any environment where `OS_READONLY_FS=1`). Production deployments must still configure a persistent database; this only restores cold-start safety so the warning surfaces instead of an opaque `ENOENT`. Helper covered by 12 new unit tests in `packages/services/service-cloud/test/data-dir.test.ts`. +- **`@objectstack/studio` Vercel build: `webcrypto` not exported by `mocks/node-polyfills.ts`** — `@objectstack/runtime` imports `webcrypto` from `crypto`, but the studio Vite alias swaps `crypto` for the local node polyfill which did not export it. Added a `webcrypto` shim that proxies to `globalThis.crypto`, restoring the rolldown build. - **`@objectstack/driver-sql` tests failing in CI** — Added `vitest.config.ts` with resolve aliases for `@objectstack/spec/*` subpath exports (`/data`, `/contracts`, `/system`). Without these aliases, vitest could not resolve the source paths at test time, causing all 81 tests to fail with `ERR_MODULE_NOT_FOUND`. - **RLS fail-open across tenants** (`@objectstack/plugin-security`) — A logged-in user with no active organization (e.g. immediately after sign-up, before joining or creating one) was previously seeing every tenant's data on `account`, `sys_member`, `sys_organization`, etc. Multiple compounding bugs were responsible: 1. `RLSCompiler.compileFilter` returned `null` when policies were applicable but none compiled — interpreted by callers as "no RLS configured" → no filter → all rows. **Fix:** introduced exported `RLS_DENY_FILTER` sentinel (`{ id: '__rls_deny__:00000000-0000-0000-0000-000000000000' }`); `compileFilter` now returns it when `policies.length > 0` but every policy expression failed to compile (missing `current_user.*` variable, unsupported expression, etc.). This naturally yields zero rows on every driver without throwing. diff --git a/packages/services/service-cloud/src/artifact-environment-registry.ts b/packages/services/service-cloud/src/artifact-environment-registry.ts index 601f9cbaf..b4c0fcbc1 100644 --- a/packages/services/service-cloud/src/artifact-environment-registry.ts +++ b/packages/services/service-cloud/src/artifact-environment-registry.ts @@ -18,6 +18,7 @@ import type * as Contracts from '@objectstack/spec/contracts'; import type { EnvironmentDriverRegistry } from './environment-registry.js'; import type { ArtifactApiClient, ProjectRuntimeConfig } from './artifact-api-client.js'; +import { resolveDefaultDataDir } from './data-dir.js'; type IDataDriver = Contracts.IDataDriver; @@ -219,7 +220,7 @@ async function createDriver(driverType: string, databaseUrl: string, authToken: const { resolve: resolvePath } = await import('node:path'); const dbName = databaseUrl.replace(/^memory:\/\//, '').trim(); const filePath = dbName - ? resolvePath(process.cwd(), '.objectstack/data/projects', `${dbName}.json`) + ? resolvePath(resolveDefaultDataDir(), 'projects', `${dbName}.json`) : undefined; return new InMemoryDriver({ persistence: filePath ? { type: 'file', path: filePath } : 'file', diff --git a/packages/services/service-cloud/src/cloud-stack.ts b/packages/services/service-cloud/src/cloud-stack.ts index 47cca4cb3..0362c9b37 100644 --- a/packages/services/service-cloud/src/cloud-stack.ts +++ b/packages/services/service-cloud/src/cloud-stack.ts @@ -20,6 +20,7 @@ import { MultiProjectPlugin } from './multi-project-plugin.js'; import { createControlPlanePlugins } from './control-plane-preset.js'; import { createStudioRuntimeConfigPlugin, createTemplatesRoutePlugin } from './multi-project-plugins.js'; import { createCloudArtifactApiPlugin } from './cloud-artifact-api-plugin.js'; +import { resolveDefaultDataDir } from './data-dir.js'; type IDataDriver = Contracts.IDataDriver; @@ -70,7 +71,7 @@ export async function createCloudStack(config: CloudStackConfig): Promise<{ const { authSecret, baseUrl, - controlDriverUrl = `file:${resolvePath(process.cwd(), '.objectstack/data/control.db')}`, + controlDriverUrl = `file:${resolvePath(resolveDefaultDataDir(), 'control.db')}`, controlDriverAuthToken, basePlugins, appBundles, @@ -93,7 +94,7 @@ export async function createCloudStack(config: CloudStackConfig): Promise<{ const explicitControlUrl = process.env.OS_CONTROL_DATABASE_URL?.trim(); const legacyControlUrl = (process.env.OS_DATABASE_URL || process.env.TURSO_DATABASE_URL)?.trim(); const controlDriverPromise = buildControlDriver( - explicitControlUrl || controlDriverUrl || legacyControlUrl || `file:${resolvePath(process.cwd(), '.objectstack/data/control.db')}`, + explicitControlUrl || controlDriverUrl || legacyControlUrl || `file:${resolvePath(resolveDefaultDataDir(), 'control.db')}`, process.env.OS_CONTROL_DATABASE_AUTH_TOKEN || process.env.OS_DATABASE_AUTH_TOKEN || process.env.TURSO_AUTH_TOKEN || controlDriverAuthToken, ); diff --git a/packages/services/service-cloud/src/data-dir.ts b/packages/services/service-cloud/src/data-dir.ts new file mode 100644 index 000000000..1a3188beb --- /dev/null +++ b/packages/services/service-cloud/src/data-dir.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Default data-directory resolution. + * + * Single source of truth for the on-disk location of the control-plane + * SQLite file (`control.db`), per-project SQLite files, and InMemoryDriver + * persistence JSON files. All cloud-stack / runtime-stack code paths + * resolve their default paths through {@link resolveDefaultDataDir} so + * that the same precedence and serverless fallback applies everywhere. + * + * Resolution order: + * + * 1. `OS_DATA_DIR` environment variable (explicit override — wins always). + * 2. `/.objectstack/data` on a writable filesystem (the default for + * `objectstack dev`, `objectstack serve`, Docker, bare metal, …). + * 3. `/.objectstack/data` when running on a serverless + * platform with a read-only application bundle (Vercel, AWS Lambda, + * Netlify Functions, Cloudflare Workers Node compat). A one-time + * warning is emitted because `/tmp` is ephemeral — production + * deployments must configure a real database via + * `OS_CONTROL_DATABASE_URL` (and `OS_DATABASE_URL` for project data). + * + * Centralising this logic prevents the "ENOENT: mkdir '/var/task/.objectstack'" + * class of cold-start failures on serverless without forcing every caller + * to re-implement detection. + */ + +import { resolve as resolvePath } from 'node:path'; +import { tmpdir } from 'node:os'; + +let _warned = false; + +/** + * Returns `true` when the current process is running on a serverless + * platform whose application bundle is a read-only filesystem. The set + * of detected platforms intentionally matches the ones where ObjectStack + * is regularly deployed today; new platforms can be added via the + * `OS_READONLY_FS=1` escape hatch. + */ +export function isServerlessReadOnlyFs(env: NodeJS.ProcessEnv = process.env): boolean { + if (env.OS_READONLY_FS && ['1', 'true', 'yes', 'on'].includes(env.OS_READONLY_FS.trim().toLowerCase())) { + return true; + } + // Vercel sets VERCEL=1 in all build & runtime environments. + if (env.VERCEL === '1') return true; + // AWS Lambda & Lambda@Edge. + if (env.AWS_LAMBDA_FUNCTION_NAME) return true; + // Netlify Functions. + if (env.NETLIFY === 'true' || env.NETLIFY_DEV) return true; + return false; +} + +/** + * Resolve the canonical default data directory for SQLite / file-backed + * driver persistence. See module docstring for precedence rules. + * + * @param env - Optional process-env override, primarily for tests. + * @returns Absolute filesystem path. Never returns a trailing slash. + */ +export function resolveDefaultDataDir(env: NodeJS.ProcessEnv = process.env): string { + const explicit = env.OS_DATA_DIR?.trim(); + if (explicit) return resolvePath(explicit); + + if (isServerlessReadOnlyFs(env)) { + const dir = resolvePath(tmpdir(), '.objectstack/data'); + if (!_warned) { + _warned = true; + // eslint-disable-next-line no-console + console.warn( + `[objectstack] Detected serverless read-only filesystem. ` + + `Falling back to ephemeral data directory: ${dir}. ` + + `Set OS_CONTROL_DATABASE_URL (and OS_DATABASE_URL for project data) ` + + `to a persistent database (libsql://, postgres://, mysql://, …) for production.`, + ); + } + return dir; + } + + return resolvePath(process.cwd(), '.objectstack/data'); +} + +/** + * Test-only helper: reset the one-shot warning latch so test suites can + * assert the warning is emitted exactly once per process. + * + * @internal + */ +export function __resetDataDirWarningForTests(): void { + _warned = false; +} diff --git a/packages/services/service-cloud/src/environment-registry.ts b/packages/services/service-cloud/src/environment-registry.ts index e8380252f..3d1fb3f9d 100644 --- a/packages/services/service-cloud/src/environment-registry.ts +++ b/packages/services/service-cloud/src/environment-registry.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type * as Contracts from '@objectstack/spec/contracts'; +import { resolveDefaultDataDir } from './data-dir.js'; type IDataDriver = Contracts.IDataDriver; /** @@ -284,7 +285,7 @@ export class DefaultEnvironmentDriverRegistry implements EnvironmentDriverRegist const { resolve: resolvePath } = await import('node:path'); const dbName = databaseUrl.replace(/^memory:\/\//, '').trim(); const filePath = dbName - ? resolvePath(process.cwd(), '.objectstack/data/projects', `${dbName}.json`) + ? resolvePath(resolveDefaultDataDir(), 'projects', `${dbName}.json`) : undefined; return new InMemoryDriver({ persistence: filePath ? { type: 'file', path: filePath } : 'file', diff --git a/packages/services/service-cloud/src/index.ts b/packages/services/service-cloud/src/index.ts index 6473ea75f..b9452fe44 100644 --- a/packages/services/service-cloud/src/index.ts +++ b/packages/services/service-cloud/src/index.ts @@ -4,6 +4,9 @@ export { createCloudStack } from './cloud-stack.js'; export type { CloudStackConfig } from './cloud-stack.js'; +// ── Data-directory resolution ───────────────────────────────────────────────── +export { resolveDefaultDataDir, isServerlessReadOnlyFs } from './data-dir.js'; + // ── Multi-project orchestration ─────────────────────────────────────────────── export { MultiProjectPlugin } from './multi-project-plugin.js'; export type { diff --git a/packages/services/service-cloud/src/project-kernel-factory.ts b/packages/services/service-cloud/src/project-kernel-factory.ts index a339b7633..b4fdecbc9 100644 --- a/packages/services/service-cloud/src/project-kernel-factory.ts +++ b/packages/services/service-cloud/src/project-kernel-factory.ts @@ -7,6 +7,7 @@ import { ControlPlaneProxyDriver } from './control-plane-proxy-driver.js'; import type { ProjectKernelFactory } from './kernel-manager.js'; import type { EnvironmentDriverRegistry, SecretEncryptor } from './environment-registry.js'; import { NoopSecretEncryptor } from './environment-registry.js'; +import { resolveDefaultDataDir } from './data-dir.js'; type IDataDriver = Contracts.IDataDriver; @@ -317,7 +318,7 @@ export class DefaultProjectKernelFactory implements ProjectKernelFactory { const { resolve: resolvePath } = await import('node:path'); const dbName = databaseUrl.replace(/^memory:\/\//, '').trim(); const filePath = dbName - ? resolvePath(process.cwd(), '.objectstack/data/projects', `${dbName}.json`) + ? resolvePath(resolveDefaultDataDir(), 'projects', `${dbName}.json`) : undefined; return new InMemoryDriver({ persistence: filePath ? { type: 'file', path: filePath } : 'file', diff --git a/packages/services/service-cloud/src/runtime-stack.ts b/packages/services/service-cloud/src/runtime-stack.ts index f8e950158..a6fe994ac 100644 --- a/packages/services/service-cloud/src/runtime-stack.ts +++ b/packages/services/service-cloud/src/runtime-stack.ts @@ -33,6 +33,7 @@ import { createSingleProjectPlugin } from './single-project-plugin.js'; import { resolveAuthSecret, resolveBaseUrl } from './boot-env.js'; import type { AppBundleResolver } from './project-kernel-factory.js'; import { createObjectOSStack } from './objectos-stack.js'; +import { resolveDefaultDataDir } from './data-dir.js'; /** * Infer the storage driver type from a database connection-URL scheme. @@ -144,7 +145,7 @@ export async function createRuntimeStack(config?: RuntimeStackConfig): Promise { + beforeEach(() => { + vi.restoreAllMocks(); + __resetDataDirWarningForTests(); + }); + + it('honours OS_DATA_DIR when set', () => { + const dir = resolveDefaultDataDir({ OS_DATA_DIR: '/custom/path' }); + expect(dir).toBe(resolvePath('/custom/path')); + }); + + it('OS_DATA_DIR wins over serverless detection', () => { + const dir = resolveDefaultDataDir({ OS_DATA_DIR: '/custom/path', VERCEL: '1' }); + expect(dir).toBe(resolvePath('/custom/path')); + }); + + it('defaults to /.objectstack/data on a writable filesystem', () => { + const dir = resolveDefaultDataDir({}); + expect(dir).toBe(resolvePath(process.cwd(), '.objectstack/data')); + }); + + it('falls back to /tmp when running on Vercel', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const dir = resolveDefaultDataDir({ VERCEL: '1' }); + expect(dir).toBe(resolvePath(tmpdir(), '.objectstack/data')); + expect(warn).toHaveBeenCalledOnce(); + warn.mockRestore(); + }); + + it('falls back to /tmp on AWS Lambda', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const dir = resolveDefaultDataDir({ AWS_LAMBDA_FUNCTION_NAME: 'my-fn' }); + expect(dir).toBe(resolvePath(tmpdir(), '.objectstack/data')); + }); + + it('warns only once per process', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + resolveDefaultDataDir({ VERCEL: '1' }); + resolveDefaultDataDir({ VERCEL: '1' }); + resolveDefaultDataDir({ VERCEL: '1' }); + expect(warn).toHaveBeenCalledOnce(); + warn.mockRestore(); + }); + + it('OS_READONLY_FS escape hatch triggers tmpdir fallback', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const dir = resolveDefaultDataDir({ OS_READONLY_FS: '1' }); + expect(dir).toBe(resolvePath(tmpdir(), '.objectstack/data')); + }); +}); + +describe('isServerlessReadOnlyFs', () => { + it('detects Vercel via VERCEL=1', () => { + expect(isServerlessReadOnlyFs({ VERCEL: '1' })).toBe(true); + }); + it('detects AWS Lambda via AWS_LAMBDA_FUNCTION_NAME', () => { + expect(isServerlessReadOnlyFs({ AWS_LAMBDA_FUNCTION_NAME: 'fn' })).toBe(true); + }); + it('detects Netlify via NETLIFY=true', () => { + expect(isServerlessReadOnlyFs({ NETLIFY: 'true' })).toBe(true); + }); + it('returns false for an empty environment', () => { + expect(isServerlessReadOnlyFs({})).toBe(false); + }); + it('respects the OS_READONLY_FS escape hatch', () => { + expect(isServerlessReadOnlyFs({ OS_READONLY_FS: '1' })).toBe(true); + expect(isServerlessReadOnlyFs({ OS_READONLY_FS: 'true' })).toBe(true); + expect(isServerlessReadOnlyFs({ OS_READONLY_FS: '0' })).toBe(false); + }); +}); From 72c100c94f504d6ece2eadaedbc9b19fd6e70162 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 00:24:33 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(service-cloud):=20require=20Turso=20on?= =?UTF-8?q?=20Vercel=20=E2=80=94=20no=20silent=20/tmp=20SQLite=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the ephemeral /tmp SQLite fallback (introduced in 5833df4) with a fail-fast error on serverless platforms. /tmp on Vercel/Lambda/Netlify is per-instance and ephemeral — SQLite there silently corrupts data across concurrent cold starts. The correct default for Vercel is Turso (libSQL), which ObjectStack already supports as the natural serverless pairing. Behaviour: - resolveDefaultDataDir() throws on serverless (Vercel / AWS Lambda / Netlify / OS_READONLY_FS=1) unless OS_DATA_DIR is set, with an error message naming TURSO_DATABASE_URL, TURSO_AUTH_TOKEN, OS_CONTROL_DATABASE_URL, and OS_DATA_DIR (escape hatch for EFS / mounted volumes). - cloud-stack.ts evaluates the file-backed default lazily so any of OS_CONTROL_DATABASE_URL / OS_DATABASE_URL / TURSO_DATABASE_URL short-circuit it (Vercel deployments configured with Turso boot cleanly). - On writable filesystems (objectstack dev / serve, Docker, bare metal) the existing /.objectstack/data behaviour is preserved. Tests rewritten (15 cases) to assert throw-on-serverless and error message contents. CHANGELOG entry updated. Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/9969986e-eb1d-4d25-b31b-75fb22429b04 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- CHANGELOG.md | 2 +- .../services/service-cloud/src/cloud-stack.ts | 19 +++- .../services/service-cloud/src/data-dir.ts | 104 ++++++++++-------- .../service-cloud/test/data-dir.test.ts | 72 ++++++------ 4 files changed, 114 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a2ee0cec..bd7832d20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed -- **Cold-start `ENOENT: mkdir '/var/task/.objectstack'` on Vercel/Lambda** (`@objectstack/service-cloud`) — `createCloudStack()`, `createRuntimeStack()`, `DefaultProjectKernelFactory`, `DefaultEnvironmentDriverRegistry`, and `ArtifactEnvironmentDriverRegistry` previously hard-coded the SQLite/InMemoryDriver default data directory to `/.objectstack/data`. On serverless platforms the bundle root is read-only, which made `apps/cloud` fail to boot whenever no explicit `OS_CONTROL_DATABASE_URL` was configured. Centralised the resolution in a new `resolveDefaultDataDir()` helper that honours `OS_DATA_DIR`, then falls back to `/.objectstack/data` on writable filesystems and `/.objectstack/data` (with a one-time warning) on Vercel / AWS Lambda / Netlify (or any environment where `OS_READONLY_FS=1`). Production deployments must still configure a persistent database; this only restores cold-start safety so the warning surfaces instead of an opaque `ENOENT`. Helper covered by 12 new unit tests in `packages/services/service-cloud/test/data-dir.test.ts`. +- **Cold-start `ENOENT: mkdir '/var/task/.objectstack'` on Vercel/Lambda** (`@objectstack/service-cloud`) — `createCloudStack()`, `createRuntimeStack()`, `DefaultProjectKernelFactory`, `DefaultEnvironmentDriverRegistry`, and `ArtifactEnvironmentDriverRegistry` previously hard-coded the SQLite/InMemoryDriver default data directory to `/.objectstack/data`. On serverless platforms (Vercel `/var/task`, AWS Lambda, Netlify) the bundle root is read-only, so `apps/cloud` failed to boot whenever no explicit persistent DB URL was configured. Centralised the resolution in a new `resolveDefaultDataDir()` helper (`packages/services/service-cloud/src/data-dir.ts`) that honours `OS_DATA_DIR`, returns `/.objectstack/data` on writable filesystems, and **throws a fail-fast error on serverless** pointing at `TURSO_DATABASE_URL` (recommended on Vercel — Turso is the default ObjectStack pairing for serverless), `OS_CONTROL_DATABASE_URL`, and `OS_DATA_DIR` (escape hatch for EFS / mounted volumes). File-backed SQLite on serverless `/tmp` is rejected by design because `/tmp` is per-instance and ephemeral, which silently corrupts data across concurrent invocations. The `cloud-stack` control-driver default is now lazy so deployments that set `TURSO_DATABASE_URL` never hit the throw. 15 unit tests cover the precedence, error message contents, and platform detection in `test/data-dir.test.ts`. - **`@objectstack/studio` Vercel build: `webcrypto` not exported by `mocks/node-polyfills.ts`** — `@objectstack/runtime` imports `webcrypto` from `crypto`, but the studio Vite alias swaps `crypto` for the local node polyfill which did not export it. Added a `webcrypto` shim that proxies to `globalThis.crypto`, restoring the rolldown build. - **`@objectstack/driver-sql` tests failing in CI** — Added `vitest.config.ts` with resolve aliases for `@objectstack/spec/*` subpath exports (`/data`, `/contracts`, `/system`). Without these aliases, vitest could not resolve the source paths at test time, causing all 81 tests to fail with `ERR_MODULE_NOT_FOUND`. - **RLS fail-open across tenants** (`@objectstack/plugin-security`) — A logged-in user with no active organization (e.g. immediately after sign-up, before joining or creating one) was previously seeing every tenant's data on `account`, `sys_member`, `sys_organization`, etc. Multiple compounding bugs were responsible: diff --git a/packages/services/service-cloud/src/cloud-stack.ts b/packages/services/service-cloud/src/cloud-stack.ts index 0362c9b37..37ffb29b5 100644 --- a/packages/services/service-cloud/src/cloud-stack.ts +++ b/packages/services/service-cloud/src/cloud-stack.ts @@ -71,7 +71,11 @@ export async function createCloudStack(config: CloudStackConfig): Promise<{ const { authSecret, baseUrl, - controlDriverUrl = `file:${resolvePath(resolveDefaultDataDir(), 'control.db')}`, + // NOTE: no eager default here. The file-backed fallback is computed + // lazily below so that serverless deployments which configure + // TURSO_DATABASE_URL / OS_CONTROL_DATABASE_URL never trip the + // resolveDefaultDataDir() throw-on-serverless guard. + controlDriverUrl, controlDriverAuthToken, basePlugins, appBundles, @@ -89,12 +93,19 @@ export async function createCloudStack(config: CloudStackConfig): Promise<{ // 3. OS_DATABASE_URL (legacy alias — only used here when no // higher-priority source is set; reserved // going forward for the project's data DB) - // 4. TURSO_DATABASE_URL (legacy alias) - // 5. file:./.objectstack/data/control.db (default) + // 4. TURSO_DATABASE_URL (legacy alias — recommended on Vercel) + // 5. file:/control.db on writable filesystems. + // On serverless (Vercel / Lambda / Netlify) without any of the above, + // resolveDefaultDataDir() throws with a message pointing at Turso — + // we never silently fall back to ephemeral /tmp SQLite. const explicitControlUrl = process.env.OS_CONTROL_DATABASE_URL?.trim(); const legacyControlUrl = (process.env.OS_DATABASE_URL || process.env.TURSO_DATABASE_URL)?.trim(); + const resolvedControlUrl = explicitControlUrl + || controlDriverUrl + || legacyControlUrl + || `file:${resolvePath(resolveDefaultDataDir(), 'control.db')}`; const controlDriverPromise = buildControlDriver( - explicitControlUrl || controlDriverUrl || legacyControlUrl || `file:${resolvePath(resolveDefaultDataDir(), 'control.db')}`, + resolvedControlUrl, process.env.OS_CONTROL_DATABASE_AUTH_TOKEN || process.env.OS_DATABASE_AUTH_TOKEN || process.env.TURSO_AUTH_TOKEN || controlDriverAuthToken, ); diff --git a/packages/services/service-cloud/src/data-dir.ts b/packages/services/service-cloud/src/data-dir.ts index 1a3188beb..193985ab6 100644 --- a/packages/services/service-cloud/src/data-dir.ts +++ b/packages/services/service-cloud/src/data-dir.ts @@ -1,42 +1,47 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * Default data-directory resolution. + * Default data-directory + serverless-platform detection. * * Single source of truth for the on-disk location of the control-plane * SQLite file (`control.db`), per-project SQLite files, and InMemoryDriver - * persistence JSON files. All cloud-stack / runtime-stack code paths - * resolve their default paths through {@link resolveDefaultDataDir} so - * that the same precedence and serverless fallback applies everywhere. + * persistence JSON files in **non-serverless** deployments. * - * Resolution order: + * On serverless platforms with a read-only application bundle (Vercel, + * AWS Lambda, Netlify Functions, Cloudflare Workers Node compat) the + * file-backed default is unsupported — `/var/task` is read-only and + * `/tmp` is per-instance, ephemeral, and not shared between concurrent + * cold starts. Persisting business data there silently corrupts + * deployments. The recommended (and only sensible) default for these + * platforms is **Turso / libSQL** — set `TURSO_DATABASE_URL` (or + * `OS_CONTROL_DATABASE_URL=libsql://…`) and the cloud-stack driver + * factory will pick it up automatically. * - * 1. `OS_DATA_DIR` environment variable (explicit override — wins always). - * 2. `/.objectstack/data` on a writable filesystem (the default for - * `objectstack dev`, `objectstack serve`, Docker, bare metal, …). - * 3. `/.objectstack/data` when running on a serverless - * platform with a read-only application bundle (Vercel, AWS Lambda, - * Netlify Functions, Cloudflare Workers Node compat). A one-time - * warning is emitted because `/tmp` is ephemeral — production - * deployments must configure a real database via - * `OS_CONTROL_DATABASE_URL` (and `OS_DATABASE_URL` for project data). + * Resolution order for {@link resolveDefaultDataDir}: * - * Centralising this logic prevents the "ENOENT: mkdir '/var/task/.objectstack'" - * class of cold-start failures on serverless without forcing every caller - * to re-implement detection. + * 1. `OS_DATA_DIR` environment variable (explicit override — wins + * always, even on serverless; intended for self-managed mounts + * such as a network volume or EFS share). + * 2. `/.objectstack/data` on a writable filesystem (the default + * for `objectstack dev`, `objectstack serve`, Docker, bare metal, …). + * 3. **THROWS** on a detected serverless read-only filesystem. The + * error message tells the user exactly which env var to set. + * + * Centralising this logic prevents both + * (a) the "ENOENT: mkdir '/var/task/.objectstack'" cold-start crash, and + * (b) the worse failure mode where an ephemeral `/tmp` SQLite "works" + * for a single cold start and silently loses data on the next one. */ import { resolve as resolvePath } from 'node:path'; -import { tmpdir } from 'node:os'; - -let _warned = false; /** * Returns `true` when the current process is running on a serverless - * platform whose application bundle is a read-only filesystem. The set - * of detected platforms intentionally matches the ones where ObjectStack - * is regularly deployed today; new platforms can be added via the - * `OS_READONLY_FS=1` escape hatch. + * platform whose application bundle is a read-only filesystem and whose + * `/tmp` is per-instance / ephemeral. The set of detected platforms + * intentionally matches the ones where ObjectStack is regularly deployed + * today; new platforms can be added via the `OS_READONLY_FS=1` escape + * hatch. */ export function isServerlessReadOnlyFs(env: NodeJS.ProcessEnv = process.env): boolean { if (env.OS_READONLY_FS && ['1', 'true', 'yes', 'on'].includes(env.OS_READONLY_FS.trim().toLowerCase())) { @@ -51,10 +56,38 @@ export function isServerlessReadOnlyFs(env: NodeJS.ProcessEnv = process.env): bo return false; } +/** + * Build the standard "configure a persistent database" error message + * shown when a file-backed default is requested on serverless. + * @internal + */ +export function buildServerlessPersistenceError(role: 'control' | 'project' = 'control'): Error { + const urlVar = role === 'control' ? 'TURSO_DATABASE_URL (or OS_CONTROL_DATABASE_URL)' : 'OS_DATABASE_URL'; + const tokenVar = role === 'control' ? 'TURSO_AUTH_TOKEN (or OS_CONTROL_DATABASE_AUTH_TOKEN)' : 'OS_DATABASE_AUTH_TOKEN'; + return new Error( + `[objectstack/service-cloud] Detected a serverless read-only filesystem ` + + `(Vercel / AWS Lambda / Netlify) but no persistent database is configured ` + + `for the ${role === 'control' ? 'control plane' : 'project data plane'}. ` + + `Set ${urlVar} to a libsql:// URL (recommended on Vercel — Turso is the ` + + `default ObjectStack pairing for serverless) and ${tokenVar} to the ` + + `matching auth token. ` + + `For self-hosted Postgres / MySQL, set the same variable to a ` + + `postgres:// or mysql:// URL instead. ` + + `If you have a writable persistent mount (EFS, network volume, …), ` + + `set OS_DATA_DIR to its path to opt out of this check. ` + + `File-backed SQLite is rejected on these platforms because /tmp is ` + + `per-instance and ephemeral, which silently corrupts data across ` + + `concurrent invocations.`, + ); +} + /** * Resolve the canonical default data directory for SQLite / file-backed * driver persistence. See module docstring for precedence rules. * + * Throws on serverless platforms unless `OS_DATA_DIR` is set — see + * {@link buildServerlessPersistenceError} for the rationale. + * * @param env - Optional process-env override, primarily for tests. * @returns Absolute filesystem path. Never returns a trailing slash. */ @@ -63,29 +96,8 @@ export function resolveDefaultDataDir(env: NodeJS.ProcessEnv = process.env): str if (explicit) return resolvePath(explicit); if (isServerlessReadOnlyFs(env)) { - const dir = resolvePath(tmpdir(), '.objectstack/data'); - if (!_warned) { - _warned = true; - // eslint-disable-next-line no-console - console.warn( - `[objectstack] Detected serverless read-only filesystem. ` + - `Falling back to ephemeral data directory: ${dir}. ` + - `Set OS_CONTROL_DATABASE_URL (and OS_DATABASE_URL for project data) ` + - `to a persistent database (libsql://, postgres://, mysql://, …) for production.`, - ); - } - return dir; + throw buildServerlessPersistenceError('control'); } return resolvePath(process.cwd(), '.objectstack/data'); } - -/** - * Test-only helper: reset the one-shot warning latch so test suites can - * assert the warning is emitted exactly once per process. - * - * @internal - */ -export function __resetDataDirWarningForTests(): void { - _warned = false; -} diff --git a/packages/services/service-cloud/test/data-dir.test.ts b/packages/services/service-cloud/test/data-dir.test.ts index ee778a0c8..cad4635a3 100644 --- a/packages/services/service-cloud/test/data-dir.test.ts +++ b/packages/services/service-cloud/test/data-dir.test.ts @@ -1,28 +1,22 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { resolve as resolvePath } from 'node:path'; -import { tmpdir } from 'node:os'; import { resolveDefaultDataDir, isServerlessReadOnlyFs, - __resetDataDirWarningForTests, + buildServerlessPersistenceError, } from '../src/data-dir.js'; describe('resolveDefaultDataDir', () => { - beforeEach(() => { - vi.restoreAllMocks(); - __resetDataDirWarningForTests(); - }); - it('honours OS_DATA_DIR when set', () => { const dir = resolveDefaultDataDir({ OS_DATA_DIR: '/custom/path' }); expect(dir).toBe(resolvePath('/custom/path')); }); - it('OS_DATA_DIR wins over serverless detection', () => { - const dir = resolveDefaultDataDir({ OS_DATA_DIR: '/custom/path', VERCEL: '1' }); - expect(dir).toBe(resolvePath('/custom/path')); + it('OS_DATA_DIR wins over serverless detection (escape hatch for EFS / mounted volumes)', () => { + const dir = resolveDefaultDataDir({ OS_DATA_DIR: '/mnt/efs', VERCEL: '1' }); + expect(dir).toBe(resolvePath('/mnt/efs')); }); it('defaults to /.objectstack/data on a writable filesystem', () => { @@ -30,33 +24,37 @@ describe('resolveDefaultDataDir', () => { expect(dir).toBe(resolvePath(process.cwd(), '.objectstack/data')); }); - it('falls back to /tmp when running on Vercel', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const dir = resolveDefaultDataDir({ VERCEL: '1' }); - expect(dir).toBe(resolvePath(tmpdir(), '.objectstack/data')); - expect(warn).toHaveBeenCalledOnce(); - warn.mockRestore(); + it('throws on Vercel without OS_DATA_DIR — points at TURSO_DATABASE_URL', () => { + expect(() => resolveDefaultDataDir({ VERCEL: '1' })).toThrowError(/TURSO_DATABASE_URL/); + }); + + it('throws on AWS Lambda without OS_DATA_DIR', () => { + expect(() => resolveDefaultDataDir({ AWS_LAMBDA_FUNCTION_NAME: 'fn' })).toThrowError( + /serverless read-only filesystem/, + ); }); - it('falls back to /tmp on AWS Lambda', () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}); - const dir = resolveDefaultDataDir({ AWS_LAMBDA_FUNCTION_NAME: 'my-fn' }); - expect(dir).toBe(resolvePath(tmpdir(), '.objectstack/data')); + it('throws on Netlify without OS_DATA_DIR', () => { + expect(() => resolveDefaultDataDir({ NETLIFY: 'true' })).toThrowError(/Netlify/); }); - it('warns only once per process', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - resolveDefaultDataDir({ VERCEL: '1' }); - resolveDefaultDataDir({ VERCEL: '1' }); - resolveDefaultDataDir({ VERCEL: '1' }); - expect(warn).toHaveBeenCalledOnce(); - warn.mockRestore(); + it('throws when OS_READONLY_FS=1 escape hatch is set without OS_DATA_DIR', () => { + expect(() => resolveDefaultDataDir({ OS_READONLY_FS: '1' })).toThrowError( + /TURSO_DATABASE_URL/, + ); }); - it('OS_READONLY_FS escape hatch triggers tmpdir fallback', () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}); - const dir = resolveDefaultDataDir({ OS_READONLY_FS: '1' }); - expect(dir).toBe(resolvePath(tmpdir(), '.objectstack/data')); + it('error message mentions both URL and auth-token env vars and explains why /tmp is rejected', () => { + try { + resolveDefaultDataDir({ VERCEL: '1' }); + expect.fail('should have thrown'); + } catch (e: any) { + expect(e.message).toMatch(/TURSO_DATABASE_URL/); + expect(e.message).toMatch(/TURSO_AUTH_TOKEN/); + expect(e.message).toMatch(/OS_CONTROL_DATABASE_URL/); + expect(e.message).toMatch(/OS_DATA_DIR/); + expect(e.message).toMatch(/per-instance|ephemeral/); + } }); }); @@ -79,3 +77,13 @@ describe('isServerlessReadOnlyFs', () => { expect(isServerlessReadOnlyFs({ OS_READONLY_FS: '0' })).toBe(false); }); }); + +describe('buildServerlessPersistenceError', () => { + it('control-plane variant mentions TURSO_DATABASE_URL', () => { + expect(buildServerlessPersistenceError('control').message).toMatch(/TURSO_DATABASE_URL/); + }); + it('project variant mentions OS_DATABASE_URL', () => { + expect(buildServerlessPersistenceError('project').message).toMatch(/OS_DATABASE_URL/); + }); +}); +