Skip to content

Commit 72c100c

Browse files
Copilothotlong
andauthored
fix(service-cloud): require Turso on Vercel — no silent /tmp SQLite fallback
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 <cwd>/.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>
1 parent 5833df4 commit 72c100c

4 files changed

Lines changed: 114 additions & 83 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ 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`.
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 (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 `<cwd>/.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`.
1212
- **`@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.
1313
- **`@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`.
1414
- **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:

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,11 @@ export async function createCloudStack(config: CloudStackConfig): Promise<{
7171
const {
7272
authSecret,
7373
baseUrl,
74-
controlDriverUrl = `file:${resolvePath(resolveDefaultDataDir(), 'control.db')}`,
74+
// NOTE: no eager default here. The file-backed fallback is computed
75+
// lazily below so that serverless deployments which configure
76+
// TURSO_DATABASE_URL / OS_CONTROL_DATABASE_URL never trip the
77+
// resolveDefaultDataDir() throw-on-serverless guard.
78+
controlDriverUrl,
7579
controlDriverAuthToken,
7680
basePlugins,
7781
appBundles,
@@ -89,12 +93,19 @@ export async function createCloudStack(config: CloudStackConfig): Promise<{
8993
// 3. OS_DATABASE_URL (legacy alias — only used here when no
9094
// higher-priority source is set; reserved
9195
// going forward for the project's data DB)
92-
// 4. TURSO_DATABASE_URL (legacy alias)
93-
// 5. file:./.objectstack/data/control.db (default)
96+
// 4. TURSO_DATABASE_URL (legacy alias — recommended on Vercel)
97+
// 5. file:<resolveDefaultDataDir()>/control.db on writable filesystems.
98+
// On serverless (Vercel / Lambda / Netlify) without any of the above,
99+
// resolveDefaultDataDir() throws with a message pointing at Turso —
100+
// we never silently fall back to ephemeral /tmp SQLite.
94101
const explicitControlUrl = process.env.OS_CONTROL_DATABASE_URL?.trim();
95102
const legacyControlUrl = (process.env.OS_DATABASE_URL || process.env.TURSO_DATABASE_URL)?.trim();
103+
const resolvedControlUrl = explicitControlUrl
104+
|| controlDriverUrl
105+
|| legacyControlUrl
106+
|| `file:${resolvePath(resolveDefaultDataDir(), 'control.db')}`;
96107
const controlDriverPromise = buildControlDriver(
97-
explicitControlUrl || controlDriverUrl || legacyControlUrl || `file:${resolvePath(resolveDefaultDataDir(), 'control.db')}`,
108+
resolvedControlUrl,
98109
process.env.OS_CONTROL_DATABASE_AUTH_TOKEN || process.env.OS_DATABASE_AUTH_TOKEN || process.env.TURSO_AUTH_TOKEN || controlDriverAuthToken,
99110
);
100111

packages/services/service-cloud/src/data-dir.ts

Lines changed: 58 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,47 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* Default data-directory resolution.
4+
* Default data-directory + serverless-platform detection.
55
*
66
* Single source of truth for the on-disk location of the control-plane
77
* 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.
8+
* persistence JSON files in **non-serverless** deployments.
119
*
12-
* Resolution order:
10+
* On serverless platforms with a read-only application bundle (Vercel,
11+
* AWS Lambda, Netlify Functions, Cloudflare Workers Node compat) the
12+
* file-backed default is unsupported — `/var/task` is read-only and
13+
* `/tmp` is per-instance, ephemeral, and not shared between concurrent
14+
* cold starts. Persisting business data there silently corrupts
15+
* deployments. The recommended (and only sensible) default for these
16+
* platforms is **Turso / libSQL** — set `TURSO_DATABASE_URL` (or
17+
* `OS_CONTROL_DATABASE_URL=libsql://…`) and the cloud-stack driver
18+
* factory will pick it up automatically.
1319
*
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).
20+
* Resolution order for {@link resolveDefaultDataDir}:
2321
*
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.
22+
* 1. `OS_DATA_DIR` environment variable (explicit override — wins
23+
* always, even on serverless; intended for self-managed mounts
24+
* such as a network volume or EFS share).
25+
* 2. `<cwd>/.objectstack/data` on a writable filesystem (the default
26+
* for `objectstack dev`, `objectstack serve`, Docker, bare metal, …).
27+
* 3. **THROWS** on a detected serverless read-only filesystem. The
28+
* error message tells the user exactly which env var to set.
29+
*
30+
* Centralising this logic prevents both
31+
* (a) the "ENOENT: mkdir '/var/task/.objectstack'" cold-start crash, and
32+
* (b) the worse failure mode where an ephemeral `/tmp` SQLite "works"
33+
* for a single cold start and silently loses data on the next one.
2734
*/
2835

2936
import { resolve as resolvePath } from 'node:path';
30-
import { tmpdir } from 'node:os';
31-
32-
let _warned = false;
3337

3438
/**
3539
* 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+
* platform whose application bundle is a read-only filesystem and whose
41+
* `/tmp` is per-instance / ephemeral. The set of detected platforms
42+
* intentionally matches the ones where ObjectStack is regularly deployed
43+
* today; new platforms can be added via the `OS_READONLY_FS=1` escape
44+
* hatch.
4045
*/
4146
export function isServerlessReadOnlyFs(env: NodeJS.ProcessEnv = process.env): boolean {
4247
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
5156
return false;
5257
}
5358

59+
/**
60+
* Build the standard "configure a persistent database" error message
61+
* shown when a file-backed default is requested on serverless.
62+
* @internal
63+
*/
64+
export function buildServerlessPersistenceError(role: 'control' | 'project' = 'control'): Error {
65+
const urlVar = role === 'control' ? 'TURSO_DATABASE_URL (or OS_CONTROL_DATABASE_URL)' : 'OS_DATABASE_URL';
66+
const tokenVar = role === 'control' ? 'TURSO_AUTH_TOKEN (or OS_CONTROL_DATABASE_AUTH_TOKEN)' : 'OS_DATABASE_AUTH_TOKEN';
67+
return new Error(
68+
`[objectstack/service-cloud] Detected a serverless read-only filesystem ` +
69+
`(Vercel / AWS Lambda / Netlify) but no persistent database is configured ` +
70+
`for the ${role === 'control' ? 'control plane' : 'project data plane'}. ` +
71+
`Set ${urlVar} to a libsql:// URL (recommended on Vercel — Turso is the ` +
72+
`default ObjectStack pairing for serverless) and ${tokenVar} to the ` +
73+
`matching auth token. ` +
74+
`For self-hosted Postgres / MySQL, set the same variable to a ` +
75+
`postgres:// or mysql:// URL instead. ` +
76+
`If you have a writable persistent mount (EFS, network volume, …), ` +
77+
`set OS_DATA_DIR to its path to opt out of this check. ` +
78+
`File-backed SQLite is rejected on these platforms because /tmp is ` +
79+
`per-instance and ephemeral, which silently corrupts data across ` +
80+
`concurrent invocations.`,
81+
);
82+
}
83+
5484
/**
5585
* Resolve the canonical default data directory for SQLite / file-backed
5686
* driver persistence. See module docstring for precedence rules.
5787
*
88+
* Throws on serverless platforms unless `OS_DATA_DIR` is set — see
89+
* {@link buildServerlessPersistenceError} for the rationale.
90+
*
5891
* @param env - Optional process-env override, primarily for tests.
5992
* @returns Absolute filesystem path. Never returns a trailing slash.
6093
*/
@@ -63,29 +96,8 @@ export function resolveDefaultDataDir(env: NodeJS.ProcessEnv = process.env): str
6396
if (explicit) return resolvePath(explicit);
6497

6598
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;
99+
throw buildServerlessPersistenceError('control');
78100
}
79101

80102
return resolvePath(process.cwd(), '.objectstack/data');
81103
}
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-
}
Lines changed: 40 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,60 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { describe, it, expect, beforeEach, vi } from 'vitest';
3+
import { describe, it, expect } from 'vitest';
44
import { resolve as resolvePath } from 'node:path';
5-
import { tmpdir } from 'node:os';
65
import {
76
resolveDefaultDataDir,
87
isServerlessReadOnlyFs,
9-
__resetDataDirWarningForTests,
8+
buildServerlessPersistenceError,
109
} from '../src/data-dir.js';
1110

1211
describe('resolveDefaultDataDir', () => {
13-
beforeEach(() => {
14-
vi.restoreAllMocks();
15-
__resetDataDirWarningForTests();
16-
});
17-
1812
it('honours OS_DATA_DIR when set', () => {
1913
const dir = resolveDefaultDataDir({ OS_DATA_DIR: '/custom/path' });
2014
expect(dir).toBe(resolvePath('/custom/path'));
2115
});
2216

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'));
17+
it('OS_DATA_DIR wins over serverless detection (escape hatch for EFS / mounted volumes)', () => {
18+
const dir = resolveDefaultDataDir({ OS_DATA_DIR: '/mnt/efs', VERCEL: '1' });
19+
expect(dir).toBe(resolvePath('/mnt/efs'));
2620
});
2721

2822
it('defaults to <cwd>/.objectstack/data on a writable filesystem', () => {
2923
const dir = resolveDefaultDataDir({});
3024
expect(dir).toBe(resolvePath(process.cwd(), '.objectstack/data'));
3125
});
3226

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();
27+
it('throws on Vercel without OS_DATA_DIR — points at TURSO_DATABASE_URL', () => {
28+
expect(() => resolveDefaultDataDir({ VERCEL: '1' })).toThrowError(/TURSO_DATABASE_URL/);
29+
});
30+
31+
it('throws on AWS Lambda without OS_DATA_DIR', () => {
32+
expect(() => resolveDefaultDataDir({ AWS_LAMBDA_FUNCTION_NAME: 'fn' })).toThrowError(
33+
/serverless read-only filesystem/,
34+
);
3935
});
4036

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'));
37+
it('throws on Netlify without OS_DATA_DIR', () => {
38+
expect(() => resolveDefaultDataDir({ NETLIFY: 'true' })).toThrowError(/Netlify/);
4539
});
4640

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();
41+
it('throws when OS_READONLY_FS=1 escape hatch is set without OS_DATA_DIR', () => {
42+
expect(() => resolveDefaultDataDir({ OS_READONLY_FS: '1' })).toThrowError(
43+
/TURSO_DATABASE_URL/,
44+
);
5445
});
5546

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'));
47+
it('error message mentions both URL and auth-token env vars and explains why /tmp is rejected', () => {
48+
try {
49+
resolveDefaultDataDir({ VERCEL: '1' });
50+
expect.fail('should have thrown');
51+
} catch (e: any) {
52+
expect(e.message).toMatch(/TURSO_DATABASE_URL/);
53+
expect(e.message).toMatch(/TURSO_AUTH_TOKEN/);
54+
expect(e.message).toMatch(/OS_CONTROL_DATABASE_URL/);
55+
expect(e.message).toMatch(/OS_DATA_DIR/);
56+
expect(e.message).toMatch(/per-instance|ephemeral/);
57+
}
6058
});
6159
});
6260

@@ -79,3 +77,13 @@ describe('isServerlessReadOnlyFs', () => {
7977
expect(isServerlessReadOnlyFs({ OS_READONLY_FS: '0' })).toBe(false);
8078
});
8179
});
80+
81+
describe('buildServerlessPersistenceError', () => {
82+
it('control-plane variant mentions TURSO_DATABASE_URL', () => {
83+
expect(buildServerlessPersistenceError('control').message).toMatch(/TURSO_DATABASE_URL/);
84+
});
85+
it('project variant mentions OS_DATABASE_URL', () => {
86+
expect(buildServerlessPersistenceError('project').message).toMatch(/OS_DATABASE_URL/);
87+
});
88+
});
89+

0 commit comments

Comments
 (0)