Skip to content

Commit 932f4ce

Browse files
authored
Merge pull request #827 from objectstack-ai/copilot/fix-memory-driver-persistence
2 parents 33899db + ebb2672 commit 932f4ce

3 files changed

Lines changed: 152 additions & 4 deletions

File tree

packages/plugins/driver-memory/src/memory-driver.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,18 @@ export interface InMemoryDriverConfig {
3333
logger?: Logger;
3434
/**
3535
* Persistence configuration. Defaults to `'auto'`.
36-
* - `'auto'` (default) — Auto-detect environment (browser → localStorage, Node.js → file)
36+
* - `'auto'` (default) — Auto-detect environment (browser → localStorage, Node.js → file, serverless → disabled)
3737
* - `'file'` — File-system persistence with defaults (Node.js only)
3838
* - `'local'` — localStorage persistence with defaults (Browser only)
3939
* - `{ type: 'file', path?: string, autoSaveInterval?: number }` — File-system with options
4040
* - `{ type: 'local', key?: string }` — localStorage with options
4141
* - `{ type: 'auto', path?: string, key?: string, autoSaveInterval?: number }` — Auto-detect with options
4242
* - `{ adapter: PersistenceAdapterInterface }` — Custom adapter
4343
* - `false` — Disable persistence (pure in-memory)
44+
*
45+
* ⚠️ In serverless environments (Vercel, AWS Lambda, Netlify, etc.),
46+
* auto mode disables file persistence to prevent silent data loss.
47+
* Use `persistence: false` or supply a custom adapter for serverless deployments.
4448
*/
4549
persistence?: string | false | {
4650
type?: 'file' | 'local' | 'auto';
@@ -932,10 +936,52 @@ export class InMemoryDriver implements DriverInterface {
932936
return typeof globalThis.localStorage !== 'undefined';
933937
}
934938

939+
/**
940+
* Detect whether the current runtime is a serverless/edge environment.
941+
*
942+
* Checks well-known environment variables set by serverless platforms:
943+
* - `VERCEL` / `VERCEL_ENV` — Vercel Functions / Edge
944+
* - `AWS_LAMBDA_FUNCTION_NAME` — AWS Lambda
945+
* - `NETLIFY` — Netlify Functions
946+
* - `FUNCTIONS_WORKER_RUNTIME` — Azure Functions
947+
* - `K_SERVICE` — Google Cloud Run / Cloud Functions
948+
* - `FUNCTION_TARGET` — Google Cloud Functions (Node.js)
949+
* - `DENO_DEPLOYMENT_ID` — Deno Deploy
950+
*
951+
* Returns `false` when `process` or `process.env` is unavailable
952+
* (e.g. browser or edge runtimes without a Node.js process object).
953+
*/
954+
private isServerlessEnvironment(): boolean {
955+
if (typeof globalThis.process === 'undefined' || !globalThis.process.env) {
956+
return false;
957+
}
958+
const env = globalThis.process.env;
959+
return !!(
960+
env.VERCEL ||
961+
env.VERCEL_ENV ||
962+
env.AWS_LAMBDA_FUNCTION_NAME ||
963+
env.NETLIFY ||
964+
env.FUNCTIONS_WORKER_RUNTIME ||
965+
env.K_SERVICE ||
966+
env.FUNCTION_TARGET ||
967+
env.DENO_DEPLOYMENT_ID
968+
);
969+
}
970+
971+
private static readonly SERVERLESS_PERSISTENCE_WARNING =
972+
'Serverless environment detected — file-system persistence is disabled in auto mode. ' +
973+
'Data will NOT be persisted across function invocations. ' +
974+
'Set persistence: false to silence this warning, or provide a custom adapter ' +
975+
'(e.g. Upstash Redis, Vercel KV) via persistence: { adapter: yourAdapter }.';
976+
935977
/**
936978
* Initialize the persistence adapter based on configuration.
937979
* Defaults to 'auto' when persistence is not specified.
938980
* Use `persistence: false` to explicitly disable persistence.
981+
*
982+
* In serverless environments (Vercel, AWS Lambda, etc.), auto mode disables
983+
* file-system persistence and emits a warning. Use `persistence: false` or
984+
* supply a custom adapter for serverless-safe operation.
939985
*/
940986
private async initPersistence(): Promise<void> {
941987
const persistence = this.config.persistence === undefined ? 'auto' : this.config.persistence;
@@ -947,6 +993,8 @@ export class InMemoryDriver implements DriverInterface {
947993
const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js');
948994
this.persistenceAdapter = new LocalStoragePersistenceAdapter();
949995
this.logger.debug('Auto-detected browser environment, using localStorage persistence');
996+
} else if (this.isServerlessEnvironment()) {
997+
this.logger.warn(InMemoryDriver.SERVERLESS_PERSISTENCE_WARNING);
950998
} else {
951999
const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
9521000
this.persistenceAdapter = new FileSystemPersistenceAdapter();
@@ -971,6 +1019,8 @@ export class InMemoryDriver implements DriverInterface {
9711019
key: persistence.key,
9721020
});
9731021
this.logger.debug('Auto-detected browser environment, using localStorage persistence');
1022+
} else if (this.isServerlessEnvironment()) {
1023+
this.logger.warn(InMemoryDriver.SERVERLESS_PERSISTENCE_WARNING);
9741024
} else {
9751025
const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
9761026
this.persistenceAdapter = new FileSystemPersistenceAdapter({

packages/plugins/driver-memory/src/persistence/persistence.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,4 +212,87 @@ describe('InMemoryDriver Persistence', () => {
212212
await driver2.disconnect();
213213
});
214214
});
215+
216+
describe('Serverless Environment Detection', () => {
217+
const serverlessEnvVars = [
218+
'VERCEL',
219+
'VERCEL_ENV',
220+
'AWS_LAMBDA_FUNCTION_NAME',
221+
'NETLIFY',
222+
'FUNCTIONS_WORKER_RUNTIME',
223+
'K_SERVICE',
224+
'FUNCTION_TARGET',
225+
'DENO_DEPLOYMENT_ID',
226+
];
227+
228+
afterEach(() => {
229+
// Clean up all serverless env vars after each test
230+
for (const key of serverlessEnvVars) {
231+
delete process.env[key];
232+
}
233+
});
234+
235+
it('should disable file persistence in auto mode when VERCEL env is set', async () => {
236+
process.env.VERCEL = '1';
237+
const filePath = path.join(TEST_DATA_DIR, 'serverless-test.json');
238+
const driver = new InMemoryDriver({
239+
persistence: { type: 'auto', path: filePath },
240+
});
241+
await driver.connect();
242+
await driver.create('items', { id: '1', name: 'Widget' });
243+
await driver.flush();
244+
await driver.disconnect();
245+
246+
// File should NOT have been created because auto mode skips file persistence in serverless
247+
expect(fs.existsSync(filePath)).toBe(false);
248+
});
249+
250+
it('should disable file persistence in auto shorthand mode when AWS_LAMBDA_FUNCTION_NAME is set', async () => {
251+
process.env.AWS_LAMBDA_FUNCTION_NAME = 'my-function';
252+
const driver = new InMemoryDriver({ persistence: 'auto' });
253+
await driver.connect();
254+
await driver.create('items', { id: '1', name: 'Widget' });
255+
256+
// Should work as pure in-memory without errors
257+
const items = await driver.find('items', { object: 'items' });
258+
expect(items).toHaveLength(1);
259+
260+
await driver.disconnect();
261+
});
262+
263+
it('should still allow explicit file persistence in serverless if user requests it', async () => {
264+
process.env.VERCEL = '1';
265+
const filePath = path.join(TEST_DATA_DIR, 'explicit-file-serverless.json');
266+
const driver = new InMemoryDriver({
267+
persistence: { type: 'file', path: filePath, autoSaveInterval: 100 },
268+
});
269+
await driver.connect();
270+
await driver.create('items', { id: '1', name: 'Widget' });
271+
await driver.flush();
272+
await driver.disconnect();
273+
274+
// Explicit 'file' type should still create the file even in serverless
275+
expect(fs.existsSync(filePath)).toBe(true);
276+
});
277+
278+
it('should still allow custom adapter in serverless', async () => {
279+
process.env.NETLIFY = 'true';
280+
const stored: Record<string, any[]> = {};
281+
const customAdapter = {
282+
load: async () => Object.keys(stored).length > 0 ? { ...stored } : null,
283+
save: async (db: Record<string, any[]>) => {
284+
for (const [k, v] of Object.entries(db)) { stored[k] = [...v]; }
285+
},
286+
flush: async () => {},
287+
};
288+
289+
const driver = new InMemoryDriver({ persistence: { adapter: customAdapter } });
290+
await driver.connect();
291+
await driver.create('items', { id: '1', name: 'Widget' });
292+
await driver.disconnect();
293+
294+
expect(stored.items).toBeDefined();
295+
expect(stored.items).toHaveLength(1);
296+
});
297+
});
215298
});

packages/spec/src/data/driver/memory.zod.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ export type PersistenceAdapter = z.infer<typeof PersistenceAdapterSchema>;
4343
* - `file`: Persist to disk file (Node.js only).
4444
* - `local`: Persist to localStorage (Browser only).
4545
* - `auto`: Auto-detect environment and choose the best strategy.
46-
* Uses `localStorage` in browser environments and `file` in Node.js.
46+
* Uses `localStorage` in browser environments, `file` in standard Node.js,
47+
* and disables persistence with a warning in serverless/edge runtimes.
4748
*/
4849
export const PersistenceTypeSchema = z.enum(['file', 'local', 'auto']).describe('Persistence backend type');
4950

@@ -89,7 +90,14 @@ export type CustomPersistenceConfig = z.infer<typeof CustomPersistenceConfigSche
8990
* Auto-detect persistence configuration.
9091
* Automatically selects the best persistence strategy based on the runtime environment:
9192
* - Browser → localStorage persistence
92-
* - Node.js → File-system persistence
93+
* - Serverless (Vercel, AWS Lambda, etc.) → Persistence disabled with warning
94+
* - Node.js (standard) → File-system persistence
95+
*
96+
* **⚠️ Serverless Warning:** In serverless/edge environments the file system is
97+
* ephemeral or read-only. Auto mode detects this and disables file persistence to
98+
* prevent silent data loss. Use `persistence: false` to silence the warning, or
99+
* supply a custom adapter (e.g. Upstash Redis, Vercel KV) via
100+
* `persistence: { adapter: yourAdapter }`.
93101
*
94102
* Optional overrides allow customizing the file path or localStorage key
95103
* used by the auto-detected adapter.
@@ -112,7 +120,7 @@ export type AutoPersistenceConfig = z.infer<typeof AutoPersistenceConfigSchema>;
112120
* Supports shorthand strings and detailed object configs:
113121
* - `'file'` — File-system persistence with defaults (Node.js)
114122
* - `'local'` — localStorage persistence with defaults (Browser)
115-
* - `'auto'` — Auto-detect environment (browser → localStorage, Node.js → file)
123+
* - `'auto'` — Auto-detect environment (browser → localStorage, Node.js → file, serverless → disabled)
116124
* - `{ type: 'file', path?: string }` — File-system with custom path
117125
* - `{ type: 'local', key?: string }` — localStorage with custom key
118126
* - `{ type: 'auto', path?: string, key?: string }` — Auto-detect with overrides
@@ -191,6 +199,11 @@ export const MemoryConfigSchema = z.object({
191199
* - `{ adapter: PersistenceAdapter }`: Custom persistence adapter
192200
* - `false`: Disable persistence (pure in-memory, data lost on disconnect)
193201
*
202+
* **⚠️ Serverless / Edge environments (Vercel, AWS Lambda, Netlify, etc.):**
203+
* Auto mode detects serverless runtimes and disables file persistence to prevent
204+
* silent data loss. Set `persistence: false` to opt-in to pure in-memory mode,
205+
* or supply a custom adapter (e.g. Upstash Redis, Vercel KV) for durable storage.
206+
*
194207
* @example
195208
* // Auto-detect environment (default)
196209
* new InMemoryDriver()
@@ -200,6 +213,8 @@ export const MemoryConfigSchema = z.object({
200213
* new InMemoryDriver({ persistence: 'local' })
201214
* // Pure memory (no persistence)
202215
* new InMemoryDriver({ persistence: false })
216+
* // Custom adapter for serverless
217+
* new InMemoryDriver({ persistence: { adapter: upstashAdapter } })
203218
*/
204219
persistence: MemoryPersistenceConfigSchema.or(z.literal(false)).default('auto').describe('Persistence configuration (defaults to auto-detect)'),
205220

0 commit comments

Comments
 (0)