Skip to content

Commit 5833df4

Browse files
Copilothotlong
andauthored
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 `<cwd>/.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 <cwd>/.objectstack/data on writable filesystems. 3. Falls back to <os.tmpdir()>/.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>
1 parent fae09e7 commit 5833df4

9 files changed

Lines changed: 188 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Fixed
11+
- **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 `<process.cwd()>/.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 `<cwd>/.objectstack/data` on writable filesystems and `<os.tmpdir()>/.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`.
12+
- **`@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.
1113
- **`@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`.
1214
- **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:
1315
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.

packages/services/service-cloud/src/artifact-environment-registry.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import type * as Contracts from '@objectstack/spec/contracts';
1919
import type { EnvironmentDriverRegistry } from './environment-registry.js';
2020
import type { ArtifactApiClient, ProjectRuntimeConfig } from './artifact-api-client.js';
21+
import { resolveDefaultDataDir } from './data-dir.js';
2122

2223
type IDataDriver = Contracts.IDataDriver;
2324

@@ -219,7 +220,7 @@ async function createDriver(driverType: string, databaseUrl: string, authToken:
219220
const { resolve: resolvePath } = await import('node:path');
220221
const dbName = databaseUrl.replace(/^memory:\/\//, '').trim();
221222
const filePath = dbName
222-
? resolvePath(process.cwd(), '.objectstack/data/projects', `${dbName}.json`)
223+
? resolvePath(resolveDefaultDataDir(), 'projects', `${dbName}.json`)
223224
: undefined;
224225
return new InMemoryDriver({
225226
persistence: filePath ? { type: 'file', path: filePath } : 'file',

packages/services/service-cloud/src/cloud-stack.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { MultiProjectPlugin } from './multi-project-plugin.js';
2020
import { createControlPlanePlugins } from './control-plane-preset.js';
2121
import { createStudioRuntimeConfigPlugin, createTemplatesRoutePlugin } from './multi-project-plugins.js';
2222
import { createCloudArtifactApiPlugin } from './cloud-artifact-api-plugin.js';
23+
import { resolveDefaultDataDir } from './data-dir.js';
2324

2425
type IDataDriver = Contracts.IDataDriver;
2526

@@ -70,7 +71,7 @@ export async function createCloudStack(config: CloudStackConfig): Promise<{
7071
const {
7172
authSecret,
7273
baseUrl,
73-
controlDriverUrl = `file:${resolvePath(process.cwd(), '.objectstack/data/control.db')}`,
74+
controlDriverUrl = `file:${resolvePath(resolveDefaultDataDir(), 'control.db')}`,
7475
controlDriverAuthToken,
7576
basePlugins,
7677
appBundles,
@@ -93,7 +94,7 @@ export async function createCloudStack(config: CloudStackConfig): Promise<{
9394
const explicitControlUrl = process.env.OS_CONTROL_DATABASE_URL?.trim();
9495
const legacyControlUrl = (process.env.OS_DATABASE_URL || process.env.TURSO_DATABASE_URL)?.trim();
9596
const controlDriverPromise = buildControlDriver(
96-
explicitControlUrl || controlDriverUrl || legacyControlUrl || `file:${resolvePath(process.cwd(), '.objectstack/data/control.db')}`,
97+
explicitControlUrl || controlDriverUrl || legacyControlUrl || `file:${resolvePath(resolveDefaultDataDir(), 'control.db')}`,
9798
process.env.OS_CONTROL_DATABASE_AUTH_TOKEN || process.env.OS_DATABASE_AUTH_TOKEN || process.env.TURSO_AUTH_TOKEN || controlDriverAuthToken,
9899
);
99100

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Default data-directory resolution.
5+
*
6+
* Single source of truth for the on-disk location of the control-plane
7+
* SQLite file (`control.db`), per-project SQLite files, and InMemoryDriver
8+
* persistence JSON files. All cloud-stack / runtime-stack code paths
9+
* resolve their default paths through {@link resolveDefaultDataDir} so
10+
* that the same precedence and serverless fallback applies everywhere.
11+
*
12+
* Resolution order:
13+
*
14+
* 1. `OS_DATA_DIR` environment variable (explicit override — wins always).
15+
* 2. `<cwd>/.objectstack/data` on a writable filesystem (the default for
16+
* `objectstack dev`, `objectstack serve`, Docker, bare metal, …).
17+
* 3. `<os.tmpdir()>/.objectstack/data` when running on a serverless
18+
* platform with a read-only application bundle (Vercel, AWS Lambda,
19+
* Netlify Functions, Cloudflare Workers Node compat). A one-time
20+
* warning is emitted because `/tmp` is ephemeral — production
21+
* deployments must configure a real database via
22+
* `OS_CONTROL_DATABASE_URL` (and `OS_DATABASE_URL` for project data).
23+
*
24+
* Centralising this logic prevents the "ENOENT: mkdir '/var/task/.objectstack'"
25+
* class of cold-start failures on serverless without forcing every caller
26+
* to re-implement detection.
27+
*/
28+
29+
import { resolve as resolvePath } from 'node:path';
30+
import { tmpdir } from 'node:os';
31+
32+
let _warned = false;
33+
34+
/**
35+
* Returns `true` when the current process is running on a serverless
36+
* platform whose application bundle is a read-only filesystem. The set
37+
* of detected platforms intentionally matches the ones where ObjectStack
38+
* is regularly deployed today; new platforms can be added via the
39+
* `OS_READONLY_FS=1` escape hatch.
40+
*/
41+
export function isServerlessReadOnlyFs(env: NodeJS.ProcessEnv = process.env): boolean {
42+
if (env.OS_READONLY_FS && ['1', 'true', 'yes', 'on'].includes(env.OS_READONLY_FS.trim().toLowerCase())) {
43+
return true;
44+
}
45+
// Vercel sets VERCEL=1 in all build & runtime environments.
46+
if (env.VERCEL === '1') return true;
47+
// AWS Lambda & Lambda@Edge.
48+
if (env.AWS_LAMBDA_FUNCTION_NAME) return true;
49+
// Netlify Functions.
50+
if (env.NETLIFY === 'true' || env.NETLIFY_DEV) return true;
51+
return false;
52+
}
53+
54+
/**
55+
* Resolve the canonical default data directory for SQLite / file-backed
56+
* driver persistence. See module docstring for precedence rules.
57+
*
58+
* @param env - Optional process-env override, primarily for tests.
59+
* @returns Absolute filesystem path. Never returns a trailing slash.
60+
*/
61+
export function resolveDefaultDataDir(env: NodeJS.ProcessEnv = process.env): string {
62+
const explicit = env.OS_DATA_DIR?.trim();
63+
if (explicit) return resolvePath(explicit);
64+
65+
if (isServerlessReadOnlyFs(env)) {
66+
const dir = resolvePath(tmpdir(), '.objectstack/data');
67+
if (!_warned) {
68+
_warned = true;
69+
// eslint-disable-next-line no-console
70+
console.warn(
71+
`[objectstack] Detected serverless read-only filesystem. ` +
72+
`Falling back to ephemeral data directory: ${dir}. ` +
73+
`Set OS_CONTROL_DATABASE_URL (and OS_DATABASE_URL for project data) ` +
74+
`to a persistent database (libsql://, postgres://, mysql://, …) for production.`,
75+
);
76+
}
77+
return dir;
78+
}
79+
80+
return resolvePath(process.cwd(), '.objectstack/data');
81+
}
82+
83+
/**
84+
* Test-only helper: reset the one-shot warning latch so test suites can
85+
* assert the warning is emitted exactly once per process.
86+
*
87+
* @internal
88+
*/
89+
export function __resetDataDirWarningForTests(): void {
90+
_warned = false;
91+
}

packages/services/service-cloud/src/environment-registry.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import type * as Contracts from '@objectstack/spec/contracts';
4+
import { resolveDefaultDataDir } from './data-dir.js';
45
type IDataDriver = Contracts.IDataDriver;
56

67
/**
@@ -284,7 +285,7 @@ export class DefaultEnvironmentDriverRegistry implements EnvironmentDriverRegist
284285
const { resolve: resolvePath } = await import('node:path');
285286
const dbName = databaseUrl.replace(/^memory:\/\//, '').trim();
286287
const filePath = dbName
287-
? resolvePath(process.cwd(), '.objectstack/data/projects', `${dbName}.json`)
288+
? resolvePath(resolveDefaultDataDir(), 'projects', `${dbName}.json`)
288289
: undefined;
289290
return new InMemoryDriver({
290291
persistence: filePath ? { type: 'file', path: filePath } : 'file',

packages/services/service-cloud/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
export { createCloudStack } from './cloud-stack.js';
55
export type { CloudStackConfig } from './cloud-stack.js';
66

7+
// ── Data-directory resolution ─────────────────────────────────────────────────
8+
export { resolveDefaultDataDir, isServerlessReadOnlyFs } from './data-dir.js';
9+
710
// ── Multi-project orchestration ───────────────────────────────────────────────
811
export { MultiProjectPlugin } from './multi-project-plugin.js';
912
export type {

packages/services/service-cloud/src/project-kernel-factory.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ControlPlaneProxyDriver } from './control-plane-proxy-driver.js';
77
import type { ProjectKernelFactory } from './kernel-manager.js';
88
import type { EnvironmentDriverRegistry, SecretEncryptor } from './environment-registry.js';
99
import { NoopSecretEncryptor } from './environment-registry.js';
10+
import { resolveDefaultDataDir } from './data-dir.js';
1011

1112
type IDataDriver = Contracts.IDataDriver;
1213

@@ -317,7 +318,7 @@ export class DefaultProjectKernelFactory implements ProjectKernelFactory {
317318
const { resolve: resolvePath } = await import('node:path');
318319
const dbName = databaseUrl.replace(/^memory:\/\//, '').trim();
319320
const filePath = dbName
320-
? resolvePath(process.cwd(), '.objectstack/data/projects', `${dbName}.json`)
321+
? resolvePath(resolveDefaultDataDir(), 'projects', `${dbName}.json`)
321322
: undefined;
322323
return new InMemoryDriver({
323324
persistence: filePath ? { type: 'file', path: filePath } : 'file',

packages/services/service-cloud/src/runtime-stack.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { createSingleProjectPlugin } from './single-project-plugin.js';
3333
import { resolveAuthSecret, resolveBaseUrl } from './boot-env.js';
3434
import type { AppBundleResolver } from './project-kernel-factory.js';
3535
import { createObjectOSStack } from './objectos-stack.js';
36+
import { resolveDefaultDataDir } from './data-dir.js';
3637

3738
/**
3839
* Infer the storage driver type from a database connection-URL scheme.
@@ -144,7 +145,7 @@ export async function createRuntimeStack(config?: RuntimeStackConfig): Promise<R
144145
const artifactPath = cfg.artifactPath
145146
?? process.env.OS_ARTIFACT_PATH
146147
?? resolvePath(cwd, 'dist/objectstack.json');
147-
const dataDir = cfg.dataDir ?? resolvePath(cwd, '.objectstack/data');
148+
const dataDir = cfg.dataDir ?? resolveDefaultDataDir();
148149
mkdirSync(dataDir, { recursive: true });
149150

150151
// Control-plane DB. In single-project local mode this is the framework's
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeEach, vi } from 'vitest';
4+
import { resolve as resolvePath } from 'node:path';
5+
import { tmpdir } from 'node:os';
6+
import {
7+
resolveDefaultDataDir,
8+
isServerlessReadOnlyFs,
9+
__resetDataDirWarningForTests,
10+
} from '../src/data-dir.js';
11+
12+
describe('resolveDefaultDataDir', () => {
13+
beforeEach(() => {
14+
vi.restoreAllMocks();
15+
__resetDataDirWarningForTests();
16+
});
17+
18+
it('honours OS_DATA_DIR when set', () => {
19+
const dir = resolveDefaultDataDir({ OS_DATA_DIR: '/custom/path' });
20+
expect(dir).toBe(resolvePath('/custom/path'));
21+
});
22+
23+
it('OS_DATA_DIR wins over serverless detection', () => {
24+
const dir = resolveDefaultDataDir({ OS_DATA_DIR: '/custom/path', VERCEL: '1' });
25+
expect(dir).toBe(resolvePath('/custom/path'));
26+
});
27+
28+
it('defaults to <cwd>/.objectstack/data on a writable filesystem', () => {
29+
const dir = resolveDefaultDataDir({});
30+
expect(dir).toBe(resolvePath(process.cwd(), '.objectstack/data'));
31+
});
32+
33+
it('falls back to /tmp when running on Vercel', () => {
34+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
35+
const dir = resolveDefaultDataDir({ VERCEL: '1' });
36+
expect(dir).toBe(resolvePath(tmpdir(), '.objectstack/data'));
37+
expect(warn).toHaveBeenCalledOnce();
38+
warn.mockRestore();
39+
});
40+
41+
it('falls back to /tmp on AWS Lambda', () => {
42+
vi.spyOn(console, 'warn').mockImplementation(() => {});
43+
const dir = resolveDefaultDataDir({ AWS_LAMBDA_FUNCTION_NAME: 'my-fn' });
44+
expect(dir).toBe(resolvePath(tmpdir(), '.objectstack/data'));
45+
});
46+
47+
it('warns only once per process', () => {
48+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
49+
resolveDefaultDataDir({ VERCEL: '1' });
50+
resolveDefaultDataDir({ VERCEL: '1' });
51+
resolveDefaultDataDir({ VERCEL: '1' });
52+
expect(warn).toHaveBeenCalledOnce();
53+
warn.mockRestore();
54+
});
55+
56+
it('OS_READONLY_FS escape hatch triggers tmpdir fallback', () => {
57+
vi.spyOn(console, 'warn').mockImplementation(() => {});
58+
const dir = resolveDefaultDataDir({ OS_READONLY_FS: '1' });
59+
expect(dir).toBe(resolvePath(tmpdir(), '.objectstack/data'));
60+
});
61+
});
62+
63+
describe('isServerlessReadOnlyFs', () => {
64+
it('detects Vercel via VERCEL=1', () => {
65+
expect(isServerlessReadOnlyFs({ VERCEL: '1' })).toBe(true);
66+
});
67+
it('detects AWS Lambda via AWS_LAMBDA_FUNCTION_NAME', () => {
68+
expect(isServerlessReadOnlyFs({ AWS_LAMBDA_FUNCTION_NAME: 'fn' })).toBe(true);
69+
});
70+
it('detects Netlify via NETLIFY=true', () => {
71+
expect(isServerlessReadOnlyFs({ NETLIFY: 'true' })).toBe(true);
72+
});
73+
it('returns false for an empty environment', () => {
74+
expect(isServerlessReadOnlyFs({})).toBe(false);
75+
});
76+
it('respects the OS_READONLY_FS escape hatch', () => {
77+
expect(isServerlessReadOnlyFs({ OS_READONLY_FS: '1' })).toBe(true);
78+
expect(isServerlessReadOnlyFs({ OS_READONLY_FS: 'true' })).toBe(true);
79+
expect(isServerlessReadOnlyFs({ OS_READONLY_FS: '0' })).toBe(false);
80+
});
81+
});

0 commit comments

Comments
 (0)