Skip to content

Commit e4ae9d5

Browse files
authored
Merge pull request #818 from objectstack-ai/copilot/add-browser-detection-memory-driver
2 parents ea4fba4 + e992952 commit e4ae9d5

5 files changed

Lines changed: 225 additions & 19 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ describe('InMemoryDriver', () => {
66
const testTable = 'test_table';
77

88
beforeEach(async () => {
9-
driver = new InMemoryDriver();
9+
driver = new InMemoryDriver({ persistence: false });
1010
await driver.connect();
1111
});
1212

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

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,18 @@ export interface InMemoryDriverConfig {
3232
/** Optional: Logger instance */
3333
logger?: Logger;
3434
/**
35-
* Optional persistence configuration.
35+
* Persistence configuration. Defaults to `'auto'`.
36+
* - `'auto'` (default) — Auto-detect environment (browser → localStorage, Node.js → file)
3637
* - `'file'` — File-system persistence with defaults (Node.js only)
3738
* - `'local'` — localStorage persistence with defaults (Browser only)
3839
* - `{ type: 'file', path?: string, autoSaveInterval?: number }` — File-system with options
3940
* - `{ type: 'local', key?: string }` — localStorage with options
41+
* - `{ type: 'auto', path?: string, key?: string, autoSaveInterval?: number }` — Auto-detect with options
4042
* - `{ adapter: PersistenceAdapterInterface }` — Custom adapter
43+
* - `false` — Disable persistence (pure in-memory)
4144
*/
42-
persistence?: string | {
43-
type?: 'file' | 'local';
45+
persistence?: string | false | {
46+
type?: 'file' | 'local' | 'auto';
4447
path?: string;
4548
key?: string;
4649
autoSaveInterval?: number;
@@ -922,27 +925,61 @@ export class InMemoryDriver implements DriverInterface {
922925
}
923926
}
924927

928+
/**
929+
* Detect whether the current runtime is a browser environment.
930+
*/
931+
private isBrowserEnvironment(): boolean {
932+
return typeof globalThis.localStorage !== 'undefined';
933+
}
934+
925935
/**
926936
* Initialize the persistence adapter based on configuration.
937+
* Defaults to 'auto' when persistence is not specified.
938+
* Use `persistence: false` to explicitly disable persistence.
927939
*/
928940
private async initPersistence(): Promise<void> {
929-
const persistence = this.config.persistence;
930-
if (!persistence) return;
941+
const persistence = this.config.persistence === undefined ? 'auto' : this.config.persistence;
942+
if (persistence === false) return;
931943

932944
if (typeof persistence === 'string') {
933-
if (persistence === 'file') {
945+
if (persistence === 'auto') {
946+
if (this.isBrowserEnvironment()) {
947+
const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js');
948+
this.persistenceAdapter = new LocalStoragePersistenceAdapter();
949+
this.logger.debug('Auto-detected browser environment, using localStorage persistence');
950+
} else {
951+
const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
952+
this.persistenceAdapter = new FileSystemPersistenceAdapter();
953+
this.logger.debug('Auto-detected Node.js environment, using file persistence');
954+
}
955+
} else if (persistence === 'file') {
934956
const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
935957
this.persistenceAdapter = new FileSystemPersistenceAdapter();
936958
} else if (persistence === 'local') {
937959
const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js');
938960
this.persistenceAdapter = new LocalStoragePersistenceAdapter();
939961
} else {
940-
throw new Error(`Unknown persistence type: "${persistence}". Use 'file' or 'local'.`);
962+
throw new Error(`Unknown persistence type: "${persistence}". Use 'file', 'local', or 'auto'.`);
941963
}
942964
} else if ('adapter' in persistence && persistence.adapter) {
943965
this.persistenceAdapter = persistence.adapter;
944966
} else if ('type' in persistence) {
945-
if (persistence.type === 'file') {
967+
if (persistence.type === 'auto') {
968+
if (this.isBrowserEnvironment()) {
969+
const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js');
970+
this.persistenceAdapter = new LocalStoragePersistenceAdapter({
971+
key: persistence.key,
972+
});
973+
this.logger.debug('Auto-detected browser environment, using localStorage persistence');
974+
} else {
975+
const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
976+
this.persistenceAdapter = new FileSystemPersistenceAdapter({
977+
path: persistence.path,
978+
autoSaveInterval: persistence.autoSaveInterval,
979+
});
980+
this.logger.debug('Auto-detected Node.js environment, using file persistence');
981+
}
982+
} else if (persistence.type === 'file') {
946983
const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
947984
this.persistenceAdapter = new FileSystemPersistenceAdapter({
948985
path: persistence.path,

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

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,8 @@ describe('InMemoryDriver Persistence', () => {
139139
});
140140

141141
describe('Pure Memory (No Persistence)', () => {
142-
it('should work without persistence (default behavior)', async () => {
143-
const driver = new InMemoryDriver();
142+
it('should work without persistence when explicitly disabled', async () => {
143+
const driver = new InMemoryDriver({ persistence: false });
144144
await driver.connect();
145145

146146
await driver.create('items', { id: '1', name: 'Widget' });
@@ -155,6 +155,40 @@ describe('InMemoryDriver Persistence', () => {
155155
});
156156
});
157157

158+
describe('Auto Persistence', () => {
159+
it('should auto-detect Node.js environment and use file persistence with shorthand', async () => {
160+
// In Node.js, 'auto' should select file persistence
161+
const driver = new InMemoryDriver({ persistence: 'auto' });
162+
await driver.connect();
163+
await driver.create('items', { id: '1', name: 'Widget' });
164+
await driver.disconnect();
165+
});
166+
167+
it('should auto-detect Node.js environment and use file persistence with object config', async () => {
168+
const filePath = path.join(TEST_DATA_DIR, 'auto-test-db.json');
169+
const driver1 = new InMemoryDriver({
170+
persistence: { type: 'auto', path: filePath, autoSaveInterval: 100 },
171+
});
172+
await driver1.connect();
173+
await driver1.create('users', { id: '1', name: 'Alice' });
174+
await driver1.flush();
175+
await driver1.disconnect();
176+
177+
// Verify file was created (Node.js env selects file adapter)
178+
expect(fs.existsSync(filePath)).toBe(true);
179+
180+
// Restore from file
181+
const driver2 = new InMemoryDriver({
182+
persistence: { type: 'auto', path: filePath, autoSaveInterval: 100 },
183+
});
184+
await driver2.connect();
185+
const users = await driver2.find('users', { object: 'users' });
186+
expect(users).toHaveLength(1);
187+
expect(users[0].name).toBe('Alice');
188+
await driver2.disconnect();
189+
});
190+
});
191+
158192
describe('Bulk Operations with Persistence', () => {
159193
it('should persist bulk creates', async () => {
160194
const driver1 = new InMemoryDriver({

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

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,28 @@ import {
88
LocalStoragePersistenceConfigSchema,
99
CustomPersistenceConfigSchema,
1010
PersistenceAdapterSchema,
11+
AutoPersistenceConfigSchema,
1112
} from './memory.zod';
1213

1314
describe('MemoryConfigSchema', () => {
14-
it('should accept empty config (all optional)', () => {
15+
it('should default persistence to "auto" when empty config', () => {
1516
const config = MemoryConfigSchema.parse({});
1617

1718
expect(config.strictMode).toBe(false);
1819
expect(config.initialData).toBeUndefined();
19-
expect(config.persistence).toBeUndefined();
20+
expect(config.persistence).toBe('auto');
2021
expect(config.indexes).toBeUndefined();
2122
expect(config.maxRecordsPerObject).toBeUndefined();
2223
});
2324

25+
it('should accept persistence: false to disable persistence', () => {
26+
const config = MemoryConfigSchema.parse({
27+
persistence: false,
28+
});
29+
30+
expect(config.persistence).toBe(false);
31+
});
32+
2433
it('should accept config with initial data', () => {
2534
const config = MemoryConfigSchema.parse({
2635
initialData: {
@@ -63,6 +72,14 @@ describe('MemoryConfigSchema', () => {
6372
expect(config.persistence).toBe('local');
6473
});
6574

75+
it('should accept persistence shorthand "auto"', () => {
76+
const config = MemoryConfigSchema.parse({
77+
persistence: 'auto',
78+
});
79+
80+
expect(config.persistence).toBe('auto');
81+
});
82+
6683
it('should accept persistence with file object config', () => {
6784
const config = MemoryConfigSchema.parse({
6885
persistence: {
@@ -104,6 +121,34 @@ describe('MemoryConfigSchema', () => {
104121
expect(p.key).toBe('myapp:db');
105122
});
106123

124+
it('should accept persistence with auto object config', () => {
125+
const config = MemoryConfigSchema.parse({
126+
persistence: {
127+
type: 'auto',
128+
path: '/var/data/memory.json',
129+
key: 'myapp:db',
130+
autoSaveInterval: 5000,
131+
},
132+
});
133+
134+
const p = config.persistence as { type: 'auto'; path?: string; key?: string; autoSaveInterval?: number };
135+
expect(p.type).toBe('auto');
136+
expect(p.path).toBe('/var/data/memory.json');
137+
expect(p.key).toBe('myapp:db');
138+
expect(p.autoSaveInterval).toBe(5000);
139+
});
140+
141+
it('should accept auto persistence without overrides', () => {
142+
const config = MemoryConfigSchema.parse({
143+
persistence: {
144+
type: 'auto',
145+
},
146+
});
147+
148+
const p = config.persistence as { type: 'auto' };
149+
expect(p.type).toBe('auto');
150+
});
151+
107152
it('should accept persistence with custom adapter', () => {
108153
const mockAdapter = {
109154
load: async () => null,
@@ -205,6 +250,10 @@ describe('PersistenceTypeSchema', () => {
205250
expect(PersistenceTypeSchema.parse('local')).toBe('local');
206251
});
207252

253+
it('should accept auto type', () => {
254+
expect(PersistenceTypeSchema.parse('auto')).toBe('auto');
255+
});
256+
208257
it('should reject invalid type', () => {
209258
expect(() => PersistenceTypeSchema.parse('indexeddb')).toThrow();
210259
});
@@ -279,6 +328,40 @@ describe('CustomPersistenceConfigSchema', () => {
279328
});
280329
});
281330

331+
describe('AutoPersistenceConfigSchema', () => {
332+
it('should accept minimal auto config', () => {
333+
const config = AutoPersistenceConfigSchema.parse({
334+
type: 'auto',
335+
});
336+
337+
expect(config.type).toBe('auto');
338+
expect(config.path).toBeUndefined();
339+
expect(config.key).toBeUndefined();
340+
expect(config.autoSaveInterval).toBeUndefined();
341+
});
342+
343+
it('should accept auto config with all overrides', () => {
344+
const config = AutoPersistenceConfigSchema.parse({
345+
type: 'auto',
346+
path: '/data/store.json',
347+
key: 'myapp:db',
348+
autoSaveInterval: 5000,
349+
});
350+
351+
expect(config.type).toBe('auto');
352+
expect(config.path).toBe('/data/store.json');
353+
expect(config.key).toBe('myapp:db');
354+
expect(config.autoSaveInterval).toBe(5000);
355+
});
356+
357+
it('should reject auto config with invalid autoSaveInterval', () => {
358+
expect(() => AutoPersistenceConfigSchema.parse({
359+
type: 'auto',
360+
autoSaveInterval: 50, // Below minimum of 100
361+
})).toThrow();
362+
});
363+
});
364+
282365
describe('MemoryPersistenceConfigSchema', () => {
283366
it('should accept shorthand "file"', () => {
284367
const config = MemoryPersistenceConfigSchema.parse('file');
@@ -290,6 +373,11 @@ describe('MemoryPersistenceConfigSchema', () => {
290373
expect(config).toBe('local');
291374
});
292375

376+
it('should accept shorthand "auto"', () => {
377+
const config = MemoryPersistenceConfigSchema.parse('auto');
378+
expect(config).toBe('auto');
379+
});
380+
293381
it('should accept file object config', () => {
294382
const config = MemoryPersistenceConfigSchema.parse({
295383
type: 'file',
@@ -306,6 +394,15 @@ describe('MemoryPersistenceConfigSchema', () => {
306394
expect(config).toEqual({ type: 'local', key: 'myapp:db' });
307395
});
308396

397+
it('should accept auto object config', () => {
398+
const config = MemoryPersistenceConfigSchema.parse({
399+
type: 'auto',
400+
path: '/data/store.json',
401+
key: 'myapp:db',
402+
});
403+
expect(config).toEqual({ type: 'auto', path: '/data/store.json', key: 'myapp:db' });
404+
});
405+
309406
it('should accept custom adapter', () => {
310407
const adapter = {
311408
load: async () => null,

0 commit comments

Comments
 (0)