@objectstack/driver-memory 3.0.10 → 3.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +8 -0
- package/dist/index.d.mts +126 -1
- package/dist/index.d.ts +126 -1
- package/dist/index.js +304 -6
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +299 -6
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +4 -1
- package/src/memory-driver.test.ts +1 -1
- package/src/memory-driver.ts +179 -0
- package/src/persistence/file-adapter.ts +103 -0
- package/src/persistence/index.ts +4 -0
- package/src/persistence/local-storage-adapter.ts +60 -0
- package/src/persistence/persistence.test.ts +215 -0
package/src/memory-driver.ts
CHANGED
|
@@ -6,6 +6,20 @@ import { Logger, createLogger } from '@objectstack/core';
|
|
|
6
6
|
import { Query, Aggregator } from 'mingo';
|
|
7
7
|
import { getValueByPath } from './memory-matcher.js';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Persistence adapter interface.
|
|
11
|
+
* Matches the PersistenceAdapterSchema contract from @objectstack/spec.
|
|
12
|
+
*/
|
|
13
|
+
export interface PersistenceAdapterInterface {
|
|
14
|
+
load(): Promise<Record<string, any[]> | null>;
|
|
15
|
+
save(db: Record<string, any[]>): Promise<void>;
|
|
16
|
+
flush(): Promise<void>;
|
|
17
|
+
/** Optional: Start periodic auto-save (used by FileSystemPersistenceAdapter). */
|
|
18
|
+
startAutoSave?(): void;
|
|
19
|
+
/** Optional: Stop auto-save timer and flush pending writes. */
|
|
20
|
+
stopAutoSave?(): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
/**
|
|
10
24
|
* Configuration options for the InMemory driver.
|
|
11
25
|
* Aligned with @objectstack/spec MemoryConfigSchema.
|
|
@@ -17,6 +31,24 @@ export interface InMemoryDriverConfig {
|
|
|
17
31
|
strictMode?: boolean;
|
|
18
32
|
/** Optional: Logger instance */
|
|
19
33
|
logger?: Logger;
|
|
34
|
+
/**
|
|
35
|
+
* Persistence configuration. Defaults to `'auto'`.
|
|
36
|
+
* - `'auto'` (default) — Auto-detect environment (browser → localStorage, Node.js → file)
|
|
37
|
+
* - `'file'` — File-system persistence with defaults (Node.js only)
|
|
38
|
+
* - `'local'` — localStorage persistence with defaults (Browser only)
|
|
39
|
+
* - `{ type: 'file', path?: string, autoSaveInterval?: number }` — File-system with options
|
|
40
|
+
* - `{ type: 'local', key?: string }` — localStorage with options
|
|
41
|
+
* - `{ type: 'auto', path?: string, key?: string, autoSaveInterval?: number }` — Auto-detect with options
|
|
42
|
+
* - `{ adapter: PersistenceAdapterInterface }` — Custom adapter
|
|
43
|
+
* - `false` — Disable persistence (pure in-memory)
|
|
44
|
+
*/
|
|
45
|
+
persistence?: string | false | {
|
|
46
|
+
type?: 'file' | 'local' | 'auto';
|
|
47
|
+
path?: string;
|
|
48
|
+
key?: string;
|
|
49
|
+
autoSaveInterval?: number;
|
|
50
|
+
adapter?: PersistenceAdapterInterface;
|
|
51
|
+
};
|
|
20
52
|
}
|
|
21
53
|
|
|
22
54
|
/**
|
|
@@ -51,6 +83,7 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
51
83
|
private logger: Logger;
|
|
52
84
|
private idCounters: Map<string, number> = new Map();
|
|
53
85
|
private transactions: Map<string, MemoryTransaction> = new Map();
|
|
86
|
+
private persistenceAdapter: PersistenceAdapterInterface | null = null;
|
|
54
87
|
|
|
55
88
|
constructor(config?: InMemoryDriverConfig) {
|
|
56
89
|
this.config = config || {};
|
|
@@ -100,6 +133,37 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
100
133
|
// ===================================
|
|
101
134
|
|
|
102
135
|
async connect() {
|
|
136
|
+
// Initialize persistence adapter if configured
|
|
137
|
+
await this.initPersistence();
|
|
138
|
+
|
|
139
|
+
// Load persisted data if available
|
|
140
|
+
if (this.persistenceAdapter) {
|
|
141
|
+
const persisted = await this.persistenceAdapter.load();
|
|
142
|
+
if (persisted) {
|
|
143
|
+
for (const [objectName, records] of Object.entries(persisted)) {
|
|
144
|
+
this.db[objectName] = records;
|
|
145
|
+
// Update ID counters based on persisted data
|
|
146
|
+
for (const record of records) {
|
|
147
|
+
if (record.id && typeof record.id === 'string') {
|
|
148
|
+
// ID format: {objectName}-{timestamp}-{counter}
|
|
149
|
+
const parts = record.id.split('-');
|
|
150
|
+
const lastPart = parts[parts.length - 1];
|
|
151
|
+
const counter = parseInt(lastPart, 10);
|
|
152
|
+
if (!isNaN(counter)) {
|
|
153
|
+
const current = this.idCounters.get(objectName) || 0;
|
|
154
|
+
if (counter > current) {
|
|
155
|
+
this.idCounters.set(objectName, counter);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
this.logger.info('InMemory Database restored from persistence', {
|
|
162
|
+
tables: Object.keys(persisted).length,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
103
167
|
// Load initial data if provided
|
|
104
168
|
if (this.config.initialData) {
|
|
105
169
|
for (const [objectName, records] of Object.entries(this.config.initialData)) {
|
|
@@ -115,9 +179,22 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
115
179
|
} else {
|
|
116
180
|
this.logger.info('InMemory Database Connected (Virtual)');
|
|
117
181
|
}
|
|
182
|
+
|
|
183
|
+
// Start auto-save if using file adapter
|
|
184
|
+
if (this.persistenceAdapter?.startAutoSave) {
|
|
185
|
+
this.persistenceAdapter.startAutoSave();
|
|
186
|
+
}
|
|
118
187
|
}
|
|
119
188
|
|
|
120
189
|
async disconnect() {
|
|
190
|
+
// Stop auto-save and flush pending writes
|
|
191
|
+
if (this.persistenceAdapter) {
|
|
192
|
+
if (this.persistenceAdapter.stopAutoSave) {
|
|
193
|
+
await this.persistenceAdapter.stopAutoSave();
|
|
194
|
+
}
|
|
195
|
+
await this.persistenceAdapter.flush();
|
|
196
|
+
}
|
|
197
|
+
|
|
121
198
|
const tableCount = Object.keys(this.db).length;
|
|
122
199
|
const recordCount = Object.values(this.db).reduce((sum, table) => sum + table.length, 0);
|
|
123
200
|
|
|
@@ -226,6 +303,7 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
226
303
|
};
|
|
227
304
|
|
|
228
305
|
table.push(newRecord);
|
|
306
|
+
this.markDirty();
|
|
229
307
|
this.logger.debug('Record created', { object, id: newRecord.id, tableSize: table.length });
|
|
230
308
|
return { ...newRecord };
|
|
231
309
|
}
|
|
@@ -253,6 +331,7 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
253
331
|
};
|
|
254
332
|
|
|
255
333
|
table[index] = updatedRecord;
|
|
334
|
+
this.markDirty();
|
|
256
335
|
this.logger.debug('Record updated', { object, id });
|
|
257
336
|
return { ...updatedRecord };
|
|
258
337
|
}
|
|
@@ -293,6 +372,7 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
293
372
|
}
|
|
294
373
|
|
|
295
374
|
table.splice(index, 1);
|
|
375
|
+
this.markDirty();
|
|
296
376
|
this.logger.debug('Record deleted', { object, id, tableSize: table.length });
|
|
297
377
|
return true;
|
|
298
378
|
}
|
|
@@ -350,6 +430,7 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
350
430
|
}
|
|
351
431
|
}
|
|
352
432
|
|
|
433
|
+
if (count > 0) this.markDirty();
|
|
353
434
|
this.logger.debug('UpdateMany completed', { object, count });
|
|
354
435
|
return { count };
|
|
355
436
|
}
|
|
@@ -377,6 +458,7 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
377
458
|
}
|
|
378
459
|
|
|
379
460
|
const count = initialLength - this.db[object].length;
|
|
461
|
+
if (count > 0) this.markDirty();
|
|
380
462
|
this.logger.debug('DeleteMany completed', { object, count });
|
|
381
463
|
return { count };
|
|
382
464
|
}
|
|
@@ -435,6 +517,7 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
435
517
|
// Restore the snapshot
|
|
436
518
|
this.db = tx.snapshot;
|
|
437
519
|
this.transactions.delete(txId);
|
|
520
|
+
this.markDirty();
|
|
438
521
|
this.logger.debug('Transaction rolled back', { txId });
|
|
439
522
|
}
|
|
440
523
|
|
|
@@ -448,6 +531,7 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
448
531
|
async clear() {
|
|
449
532
|
this.db = {};
|
|
450
533
|
this.idCounters.clear();
|
|
534
|
+
this.markDirty();
|
|
451
535
|
this.logger.debug('All data cleared');
|
|
452
536
|
}
|
|
453
537
|
|
|
@@ -818,4 +902,99 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
818
902
|
const timestamp = Date.now();
|
|
819
903
|
return `${key}-${timestamp}-${counter}`;
|
|
820
904
|
}
|
|
905
|
+
|
|
906
|
+
// ===================================
|
|
907
|
+
// Persistence
|
|
908
|
+
// ===================================
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Mark the database as dirty, triggering persistence save.
|
|
912
|
+
*/
|
|
913
|
+
private markDirty(): void {
|
|
914
|
+
if (this.persistenceAdapter) {
|
|
915
|
+
this.persistenceAdapter.save(this.db);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Flush pending persistence writes to ensure data is safely stored.
|
|
921
|
+
*/
|
|
922
|
+
async flush(): Promise<void> {
|
|
923
|
+
if (this.persistenceAdapter) {
|
|
924
|
+
await this.persistenceAdapter.flush();
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Detect whether the current runtime is a browser environment.
|
|
930
|
+
*/
|
|
931
|
+
private isBrowserEnvironment(): boolean {
|
|
932
|
+
return typeof globalThis.localStorage !== 'undefined';
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Initialize the persistence adapter based on configuration.
|
|
937
|
+
* Defaults to 'auto' when persistence is not specified.
|
|
938
|
+
* Use `persistence: false` to explicitly disable persistence.
|
|
939
|
+
*/
|
|
940
|
+
private async initPersistence(): Promise<void> {
|
|
941
|
+
const persistence = this.config.persistence === undefined ? 'auto' : this.config.persistence;
|
|
942
|
+
if (persistence === false) return;
|
|
943
|
+
|
|
944
|
+
if (typeof persistence === 'string') {
|
|
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') {
|
|
956
|
+
const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
|
|
957
|
+
this.persistenceAdapter = new FileSystemPersistenceAdapter();
|
|
958
|
+
} else if (persistence === 'local') {
|
|
959
|
+
const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js');
|
|
960
|
+
this.persistenceAdapter = new LocalStoragePersistenceAdapter();
|
|
961
|
+
} else {
|
|
962
|
+
throw new Error(`Unknown persistence type: "${persistence}". Use 'file', 'local', or 'auto'.`);
|
|
963
|
+
}
|
|
964
|
+
} else if ('adapter' in persistence && persistence.adapter) {
|
|
965
|
+
this.persistenceAdapter = persistence.adapter;
|
|
966
|
+
} else if ('type' in persistence) {
|
|
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') {
|
|
983
|
+
const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
|
|
984
|
+
this.persistenceAdapter = new FileSystemPersistenceAdapter({
|
|
985
|
+
path: persistence.path,
|
|
986
|
+
autoSaveInterval: persistence.autoSaveInterval,
|
|
987
|
+
});
|
|
988
|
+
} else if (persistence.type === 'local') {
|
|
989
|
+
const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js');
|
|
990
|
+
this.persistenceAdapter = new LocalStoragePersistenceAdapter({
|
|
991
|
+
key: persistence.key,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (this.persistenceAdapter) {
|
|
997
|
+
this.logger.debug('Persistence adapter initialized');
|
|
998
|
+
}
|
|
999
|
+
}
|
|
821
1000
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* FileSystemPersistenceAdapter
|
|
8
|
+
*
|
|
9
|
+
* Persists the in-memory database to a JSON file on disk.
|
|
10
|
+
* Supports atomic writes (write to temp file then rename) and auto-save with dirty tracking.
|
|
11
|
+
*
|
|
12
|
+
* Node.js only — will throw if used in non-Node.js environments.
|
|
13
|
+
*/
|
|
14
|
+
export class FileSystemPersistenceAdapter {
|
|
15
|
+
private readonly filePath: string;
|
|
16
|
+
private readonly autoSaveInterval: number;
|
|
17
|
+
private dirty = false;
|
|
18
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
19
|
+
private currentDb: Record<string, any[]> | null = null;
|
|
20
|
+
|
|
21
|
+
constructor(options?: { path?: string; autoSaveInterval?: number }) {
|
|
22
|
+
this.filePath = options?.path || path.join('.objectstack', 'data', 'memory-driver.json');
|
|
23
|
+
this.autoSaveInterval = options?.autoSaveInterval ?? 2000;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Load persisted data from disk.
|
|
28
|
+
* Returns null if no file exists.
|
|
29
|
+
*/
|
|
30
|
+
async load(): Promise<Record<string, any[]> | null> {
|
|
31
|
+
try {
|
|
32
|
+
if (!fs.existsSync(this.filePath)) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const raw = fs.readFileSync(this.filePath, 'utf-8');
|
|
36
|
+
const data = JSON.parse(raw);
|
|
37
|
+
return data as Record<string, any[]>;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Save data to disk using atomic write (temp file + rename).
|
|
45
|
+
*/
|
|
46
|
+
async save(db: Record<string, any[]>): Promise<void> {
|
|
47
|
+
this.currentDb = db;
|
|
48
|
+
this.dirty = true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Flush pending writes to disk immediately.
|
|
53
|
+
*/
|
|
54
|
+
async flush(): Promise<void> {
|
|
55
|
+
if (!this.dirty || !this.currentDb) return;
|
|
56
|
+
await this.writeToDisk(this.currentDb);
|
|
57
|
+
this.dirty = false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Start the auto-save timer.
|
|
62
|
+
*/
|
|
63
|
+
startAutoSave(): void {
|
|
64
|
+
if (this.timer) return;
|
|
65
|
+
this.timer = setInterval(async () => {
|
|
66
|
+
if (this.dirty && this.currentDb) {
|
|
67
|
+
await this.writeToDisk(this.currentDb);
|
|
68
|
+
this.dirty = false;
|
|
69
|
+
}
|
|
70
|
+
}, this.autoSaveInterval);
|
|
71
|
+
|
|
72
|
+
// Allow process to exit even if timer is running
|
|
73
|
+
if (this.timer) {
|
|
74
|
+
this.timer.unref();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Stop the auto-save timer and flush pending writes.
|
|
80
|
+
*/
|
|
81
|
+
async stopAutoSave(): Promise<void> {
|
|
82
|
+
if (this.timer) {
|
|
83
|
+
clearInterval(this.timer);
|
|
84
|
+
this.timer = null;
|
|
85
|
+
}
|
|
86
|
+
await this.flush();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Atomic write: write to temp file, then rename.
|
|
91
|
+
*/
|
|
92
|
+
private async writeToDisk(db: Record<string, any[]>): Promise<void> {
|
|
93
|
+
const dir = path.dirname(this.filePath);
|
|
94
|
+
if (!fs.existsSync(dir)) {
|
|
95
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const tmpPath = this.filePath + '.tmp';
|
|
99
|
+
const json = JSON.stringify(db, null, 2);
|
|
100
|
+
fs.writeFileSync(tmpPath, json, 'utf-8');
|
|
101
|
+
fs.renameSync(tmpPath, this.filePath);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LocalStoragePersistenceAdapter
|
|
5
|
+
*
|
|
6
|
+
* Persists the in-memory database to browser localStorage.
|
|
7
|
+
* Synchronous storage with a ~5MB size limit warning.
|
|
8
|
+
*
|
|
9
|
+
* Browser only — will throw if used in non-browser environments.
|
|
10
|
+
*/
|
|
11
|
+
export class LocalStoragePersistenceAdapter {
|
|
12
|
+
private readonly storageKey: string;
|
|
13
|
+
private static readonly SIZE_WARNING_BYTES = 4.5 * 1024 * 1024; // 4.5MB warning threshold
|
|
14
|
+
|
|
15
|
+
constructor(options?: { key?: string }) {
|
|
16
|
+
this.storageKey = options?.key || 'objectstack:memory-db';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Load persisted data from localStorage.
|
|
21
|
+
* Returns null if no data exists.
|
|
22
|
+
*/
|
|
23
|
+
async load(): Promise<Record<string, any[]> | null> {
|
|
24
|
+
try {
|
|
25
|
+
const raw = localStorage.getItem(this.storageKey);
|
|
26
|
+
if (!raw) return null;
|
|
27
|
+
return JSON.parse(raw) as Record<string, any[]>;
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Save data to localStorage.
|
|
35
|
+
* Warns if data size approaches the ~5MB localStorage limit.
|
|
36
|
+
*/
|
|
37
|
+
async save(db: Record<string, any[]>): Promise<void> {
|
|
38
|
+
const json = JSON.stringify(db);
|
|
39
|
+
|
|
40
|
+
if (json.length > LocalStoragePersistenceAdapter.SIZE_WARNING_BYTES) {
|
|
41
|
+
console.warn(
|
|
42
|
+
`[ObjectStack] localStorage persistence data size (${(json.length / 1024 / 1024).toFixed(2)}MB) ` +
|
|
43
|
+
`is approaching the ~5MB limit. Consider using a different persistence strategy.`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
localStorage.setItem(this.storageKey, json);
|
|
49
|
+
} catch (e: any) {
|
|
50
|
+
console.error('[ObjectStack] Failed to persist data to localStorage:', e?.message || e);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Flush is a no-op for localStorage (writes are synchronous).
|
|
56
|
+
*/
|
|
57
|
+
async flush(): Promise<void> {
|
|
58
|
+
// localStorage writes are synchronous, no flushing needed
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { InMemoryDriver } from '../memory-driver.js';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const TEST_DATA_DIR = path.join('/tmp', 'objectstack-test-persistence');
|
|
7
|
+
const TEST_FILE_PATH = path.join(TEST_DATA_DIR, 'test-db.json');
|
|
8
|
+
|
|
9
|
+
describe('InMemoryDriver Persistence', () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
// Clean up test directory
|
|
12
|
+
if (fs.existsSync(TEST_DATA_DIR)) {
|
|
13
|
+
fs.rmSync(TEST_DATA_DIR, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
if (fs.existsSync(TEST_DATA_DIR)) {
|
|
19
|
+
fs.rmSync(TEST_DATA_DIR, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('File Persistence', () => {
|
|
24
|
+
it('should persist and restore data via file adapter', async () => {
|
|
25
|
+
// Create and populate driver with file persistence
|
|
26
|
+
const driver1 = new InMemoryDriver({
|
|
27
|
+
persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 },
|
|
28
|
+
});
|
|
29
|
+
await driver1.connect();
|
|
30
|
+
await driver1.create('users', { id: '1', name: 'Alice' });
|
|
31
|
+
await driver1.create('users', { id: '2', name: 'Bob' });
|
|
32
|
+
|
|
33
|
+
// Flush and disconnect
|
|
34
|
+
await driver1.flush();
|
|
35
|
+
await driver1.disconnect();
|
|
36
|
+
|
|
37
|
+
// Verify file was created
|
|
38
|
+
expect(fs.existsSync(TEST_FILE_PATH)).toBe(true);
|
|
39
|
+
|
|
40
|
+
// Create a new driver and verify data is restored
|
|
41
|
+
const driver2 = new InMemoryDriver({
|
|
42
|
+
persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 },
|
|
43
|
+
});
|
|
44
|
+
await driver2.connect();
|
|
45
|
+
|
|
46
|
+
const users = await driver2.find('users', { object: 'users' });
|
|
47
|
+
expect(users).toHaveLength(2);
|
|
48
|
+
expect(users[0].name).toBe('Alice');
|
|
49
|
+
expect(users[1].name).toBe('Bob');
|
|
50
|
+
|
|
51
|
+
await driver2.disconnect();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should support shorthand "file" persistence string', async () => {
|
|
55
|
+
// Use shorthand — just verifies no error is thrown with 'file'
|
|
56
|
+
const driver = new InMemoryDriver({ persistence: 'file' });
|
|
57
|
+
await driver.connect();
|
|
58
|
+
await driver.create('items', { id: '1', name: 'Widget' });
|
|
59
|
+
await driver.disconnect();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should persist updates and deletes', async () => {
|
|
63
|
+
const driver1 = new InMemoryDriver({
|
|
64
|
+
persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 },
|
|
65
|
+
});
|
|
66
|
+
await driver1.connect();
|
|
67
|
+
|
|
68
|
+
// Create, update, and delete
|
|
69
|
+
await driver1.create('tasks', { id: '1', title: 'Task A', done: false });
|
|
70
|
+
await driver1.create('tasks', { id: '2', title: 'Task B', done: false });
|
|
71
|
+
await driver1.update('tasks', '1', { done: true });
|
|
72
|
+
await driver1.delete('tasks', '2');
|
|
73
|
+
|
|
74
|
+
await driver1.flush();
|
|
75
|
+
await driver1.disconnect();
|
|
76
|
+
|
|
77
|
+
// Restore
|
|
78
|
+
const driver2 = new InMemoryDriver({
|
|
79
|
+
persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 },
|
|
80
|
+
});
|
|
81
|
+
await driver2.connect();
|
|
82
|
+
|
|
83
|
+
const tasks = await driver2.find('tasks', { object: 'tasks' });
|
|
84
|
+
expect(tasks).toHaveLength(1);
|
|
85
|
+
expect(tasks[0].id).toBe('1');
|
|
86
|
+
expect(tasks[0].done).toBe(true);
|
|
87
|
+
|
|
88
|
+
await driver2.disconnect();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should handle missing persistence file gracefully', async () => {
|
|
92
|
+
const driver = new InMemoryDriver({
|
|
93
|
+
persistence: { type: 'file', path: '/tmp/nonexistent/path/db.json' },
|
|
94
|
+
});
|
|
95
|
+
await driver.connect();
|
|
96
|
+
|
|
97
|
+
const users = await driver.find('users', { object: 'users' });
|
|
98
|
+
expect(users).toHaveLength(0);
|
|
99
|
+
|
|
100
|
+
await driver.disconnect();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('Custom Adapter Persistence', () => {
|
|
105
|
+
it('should use a custom adapter for persistence', async () => {
|
|
106
|
+
const stored: Record<string, any[]> = {};
|
|
107
|
+
const customAdapter = {
|
|
108
|
+
load: async () => Object.keys(stored).length > 0 ? { ...stored } : null,
|
|
109
|
+
save: async (db: Record<string, any[]>) => {
|
|
110
|
+
for (const [k, v] of Object.entries(db)) {
|
|
111
|
+
stored[k] = [...v];
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
flush: async () => {},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const driver1 = new InMemoryDriver({
|
|
118
|
+
persistence: { adapter: customAdapter },
|
|
119
|
+
});
|
|
120
|
+
await driver1.connect();
|
|
121
|
+
await driver1.create('projects', { id: '1', name: 'Alpha' });
|
|
122
|
+
await driver1.disconnect();
|
|
123
|
+
|
|
124
|
+
// Verify data was saved via custom adapter
|
|
125
|
+
expect(stored.projects).toBeDefined();
|
|
126
|
+
expect(stored.projects).toHaveLength(1);
|
|
127
|
+
|
|
128
|
+
// Restore from custom adapter
|
|
129
|
+
const driver2 = new InMemoryDriver({
|
|
130
|
+
persistence: { adapter: customAdapter },
|
|
131
|
+
});
|
|
132
|
+
await driver2.connect();
|
|
133
|
+
const projects = await driver2.find('projects', { object: 'projects' });
|
|
134
|
+
expect(projects).toHaveLength(1);
|
|
135
|
+
expect(projects[0].name).toBe('Alpha');
|
|
136
|
+
|
|
137
|
+
await driver2.disconnect();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('Pure Memory (No Persistence)', () => {
|
|
142
|
+
it('should work without persistence when explicitly disabled', async () => {
|
|
143
|
+
const driver = new InMemoryDriver({ persistence: false });
|
|
144
|
+
await driver.connect();
|
|
145
|
+
|
|
146
|
+
await driver.create('items', { id: '1', name: 'Widget' });
|
|
147
|
+
const items = await driver.find('items', { object: 'items' });
|
|
148
|
+
expect(items).toHaveLength(1);
|
|
149
|
+
|
|
150
|
+
await driver.disconnect();
|
|
151
|
+
|
|
152
|
+
// After disconnect, data is gone
|
|
153
|
+
const itemsAfter = await driver.find('items', { object: 'items' });
|
|
154
|
+
expect(itemsAfter).toHaveLength(0);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
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
|
+
|
|
192
|
+
describe('Bulk Operations with Persistence', () => {
|
|
193
|
+
it('should persist bulk creates', async () => {
|
|
194
|
+
const driver1 = new InMemoryDriver({
|
|
195
|
+
persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 },
|
|
196
|
+
});
|
|
197
|
+
await driver1.connect();
|
|
198
|
+
await driver1.bulkCreate('items', [
|
|
199
|
+
{ id: '1', name: 'A' },
|
|
200
|
+
{ id: '2', name: 'B' },
|
|
201
|
+
{ id: '3', name: 'C' },
|
|
202
|
+
]);
|
|
203
|
+
await driver1.flush();
|
|
204
|
+
await driver1.disconnect();
|
|
205
|
+
|
|
206
|
+
const driver2 = new InMemoryDriver({
|
|
207
|
+
persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 },
|
|
208
|
+
});
|
|
209
|
+
await driver2.connect();
|
|
210
|
+
const items = await driver2.find('items', { object: 'items' });
|
|
211
|
+
expect(items).toHaveLength(3);
|
|
212
|
+
await driver2.disconnect();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
});
|