@objectstack/driver-memory 3.0.10 → 3.1.0
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 +16 -0
- package/dist/index.d.mts +151 -1
- package/dist/index.d.ts +151 -1
- package/dist/index.js +337 -7
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +332 -7
- 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 +229 -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 +298 -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,28 @@ 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, serverless → disabled)
|
|
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
|
+
* ⚠️ 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.
|
|
48
|
+
*/
|
|
49
|
+
persistence?: string | false | {
|
|
50
|
+
type?: 'file' | 'local' | 'auto';
|
|
51
|
+
path?: string;
|
|
52
|
+
key?: string;
|
|
53
|
+
autoSaveInterval?: number;
|
|
54
|
+
adapter?: PersistenceAdapterInterface;
|
|
55
|
+
};
|
|
20
56
|
}
|
|
21
57
|
|
|
22
58
|
/**
|
|
@@ -51,6 +87,7 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
51
87
|
private logger: Logger;
|
|
52
88
|
private idCounters: Map<string, number> = new Map();
|
|
53
89
|
private transactions: Map<string, MemoryTransaction> = new Map();
|
|
90
|
+
private persistenceAdapter: PersistenceAdapterInterface | null = null;
|
|
54
91
|
|
|
55
92
|
constructor(config?: InMemoryDriverConfig) {
|
|
56
93
|
this.config = config || {};
|
|
@@ -100,6 +137,37 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
100
137
|
// ===================================
|
|
101
138
|
|
|
102
139
|
async connect() {
|
|
140
|
+
// Initialize persistence adapter if configured
|
|
141
|
+
await this.initPersistence();
|
|
142
|
+
|
|
143
|
+
// Load persisted data if available
|
|
144
|
+
if (this.persistenceAdapter) {
|
|
145
|
+
const persisted = await this.persistenceAdapter.load();
|
|
146
|
+
if (persisted) {
|
|
147
|
+
for (const [objectName, records] of Object.entries(persisted)) {
|
|
148
|
+
this.db[objectName] = records;
|
|
149
|
+
// Update ID counters based on persisted data
|
|
150
|
+
for (const record of records) {
|
|
151
|
+
if (record.id && typeof record.id === 'string') {
|
|
152
|
+
// ID format: {objectName}-{timestamp}-{counter}
|
|
153
|
+
const parts = record.id.split('-');
|
|
154
|
+
const lastPart = parts[parts.length - 1];
|
|
155
|
+
const counter = parseInt(lastPart, 10);
|
|
156
|
+
if (!isNaN(counter)) {
|
|
157
|
+
const current = this.idCounters.get(objectName) || 0;
|
|
158
|
+
if (counter > current) {
|
|
159
|
+
this.idCounters.set(objectName, counter);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
this.logger.info('InMemory Database restored from persistence', {
|
|
166
|
+
tables: Object.keys(persisted).length,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
103
171
|
// Load initial data if provided
|
|
104
172
|
if (this.config.initialData) {
|
|
105
173
|
for (const [objectName, records] of Object.entries(this.config.initialData)) {
|
|
@@ -115,9 +183,22 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
115
183
|
} else {
|
|
116
184
|
this.logger.info('InMemory Database Connected (Virtual)');
|
|
117
185
|
}
|
|
186
|
+
|
|
187
|
+
// Start auto-save if using file adapter
|
|
188
|
+
if (this.persistenceAdapter?.startAutoSave) {
|
|
189
|
+
this.persistenceAdapter.startAutoSave();
|
|
190
|
+
}
|
|
118
191
|
}
|
|
119
192
|
|
|
120
193
|
async disconnect() {
|
|
194
|
+
// Stop auto-save and flush pending writes
|
|
195
|
+
if (this.persistenceAdapter) {
|
|
196
|
+
if (this.persistenceAdapter.stopAutoSave) {
|
|
197
|
+
await this.persistenceAdapter.stopAutoSave();
|
|
198
|
+
}
|
|
199
|
+
await this.persistenceAdapter.flush();
|
|
200
|
+
}
|
|
201
|
+
|
|
121
202
|
const tableCount = Object.keys(this.db).length;
|
|
122
203
|
const recordCount = Object.values(this.db).reduce((sum, table) => sum + table.length, 0);
|
|
123
204
|
|
|
@@ -226,6 +307,7 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
226
307
|
};
|
|
227
308
|
|
|
228
309
|
table.push(newRecord);
|
|
310
|
+
this.markDirty();
|
|
229
311
|
this.logger.debug('Record created', { object, id: newRecord.id, tableSize: table.length });
|
|
230
312
|
return { ...newRecord };
|
|
231
313
|
}
|
|
@@ -253,6 +335,7 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
253
335
|
};
|
|
254
336
|
|
|
255
337
|
table[index] = updatedRecord;
|
|
338
|
+
this.markDirty();
|
|
256
339
|
this.logger.debug('Record updated', { object, id });
|
|
257
340
|
return { ...updatedRecord };
|
|
258
341
|
}
|
|
@@ -293,6 +376,7 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
293
376
|
}
|
|
294
377
|
|
|
295
378
|
table.splice(index, 1);
|
|
379
|
+
this.markDirty();
|
|
296
380
|
this.logger.debug('Record deleted', { object, id, tableSize: table.length });
|
|
297
381
|
return true;
|
|
298
382
|
}
|
|
@@ -350,6 +434,7 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
350
434
|
}
|
|
351
435
|
}
|
|
352
436
|
|
|
437
|
+
if (count > 0) this.markDirty();
|
|
353
438
|
this.logger.debug('UpdateMany completed', { object, count });
|
|
354
439
|
return { count };
|
|
355
440
|
}
|
|
@@ -377,6 +462,7 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
377
462
|
}
|
|
378
463
|
|
|
379
464
|
const count = initialLength - this.db[object].length;
|
|
465
|
+
if (count > 0) this.markDirty();
|
|
380
466
|
this.logger.debug('DeleteMany completed', { object, count });
|
|
381
467
|
return { count };
|
|
382
468
|
}
|
|
@@ -435,6 +521,7 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
435
521
|
// Restore the snapshot
|
|
436
522
|
this.db = tx.snapshot;
|
|
437
523
|
this.transactions.delete(txId);
|
|
524
|
+
this.markDirty();
|
|
438
525
|
this.logger.debug('Transaction rolled back', { txId });
|
|
439
526
|
}
|
|
440
527
|
|
|
@@ -448,6 +535,7 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
448
535
|
async clear() {
|
|
449
536
|
this.db = {};
|
|
450
537
|
this.idCounters.clear();
|
|
538
|
+
this.markDirty();
|
|
451
539
|
this.logger.debug('All data cleared');
|
|
452
540
|
}
|
|
453
541
|
|
|
@@ -818,4 +906,145 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
818
906
|
const timestamp = Date.now();
|
|
819
907
|
return `${key}-${timestamp}-${counter}`;
|
|
820
908
|
}
|
|
909
|
+
|
|
910
|
+
// ===================================
|
|
911
|
+
// Persistence
|
|
912
|
+
// ===================================
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Mark the database as dirty, triggering persistence save.
|
|
916
|
+
*/
|
|
917
|
+
private markDirty(): void {
|
|
918
|
+
if (this.persistenceAdapter) {
|
|
919
|
+
this.persistenceAdapter.save(this.db);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Flush pending persistence writes to ensure data is safely stored.
|
|
925
|
+
*/
|
|
926
|
+
async flush(): Promise<void> {
|
|
927
|
+
if (this.persistenceAdapter) {
|
|
928
|
+
await this.persistenceAdapter.flush();
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Detect whether the current runtime is a browser environment.
|
|
934
|
+
*/
|
|
935
|
+
private isBrowserEnvironment(): boolean {
|
|
936
|
+
return typeof globalThis.localStorage !== 'undefined';
|
|
937
|
+
}
|
|
938
|
+
|
|
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
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Initialize the persistence adapter based on configuration.
|
|
979
|
+
* Defaults to 'auto' when persistence is not specified.
|
|
980
|
+
* 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.
|
|
985
|
+
*/
|
|
986
|
+
private async initPersistence(): Promise<void> {
|
|
987
|
+
const persistence = this.config.persistence === undefined ? 'auto' : this.config.persistence;
|
|
988
|
+
if (persistence === false) return;
|
|
989
|
+
|
|
990
|
+
if (typeof persistence === 'string') {
|
|
991
|
+
if (persistence === 'auto') {
|
|
992
|
+
if (this.isBrowserEnvironment()) {
|
|
993
|
+
const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js');
|
|
994
|
+
this.persistenceAdapter = new LocalStoragePersistenceAdapter();
|
|
995
|
+
this.logger.debug('Auto-detected browser environment, using localStorage persistence');
|
|
996
|
+
} else if (this.isServerlessEnvironment()) {
|
|
997
|
+
this.logger.warn(InMemoryDriver.SERVERLESS_PERSISTENCE_WARNING);
|
|
998
|
+
} else {
|
|
999
|
+
const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
|
|
1000
|
+
this.persistenceAdapter = new FileSystemPersistenceAdapter();
|
|
1001
|
+
this.logger.debug('Auto-detected Node.js environment, using file persistence');
|
|
1002
|
+
}
|
|
1003
|
+
} else if (persistence === 'file') {
|
|
1004
|
+
const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
|
|
1005
|
+
this.persistenceAdapter = new FileSystemPersistenceAdapter();
|
|
1006
|
+
} else if (persistence === 'local') {
|
|
1007
|
+
const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js');
|
|
1008
|
+
this.persistenceAdapter = new LocalStoragePersistenceAdapter();
|
|
1009
|
+
} else {
|
|
1010
|
+
throw new Error(`Unknown persistence type: "${persistence}". Use 'file', 'local', or 'auto'.`);
|
|
1011
|
+
}
|
|
1012
|
+
} else if ('adapter' in persistence && persistence.adapter) {
|
|
1013
|
+
this.persistenceAdapter = persistence.adapter;
|
|
1014
|
+
} else if ('type' in persistence) {
|
|
1015
|
+
if (persistence.type === 'auto') {
|
|
1016
|
+
if (this.isBrowserEnvironment()) {
|
|
1017
|
+
const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js');
|
|
1018
|
+
this.persistenceAdapter = new LocalStoragePersistenceAdapter({
|
|
1019
|
+
key: persistence.key,
|
|
1020
|
+
});
|
|
1021
|
+
this.logger.debug('Auto-detected browser environment, using localStorage persistence');
|
|
1022
|
+
} else if (this.isServerlessEnvironment()) {
|
|
1023
|
+
this.logger.warn(InMemoryDriver.SERVERLESS_PERSISTENCE_WARNING);
|
|
1024
|
+
} else {
|
|
1025
|
+
const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
|
|
1026
|
+
this.persistenceAdapter = new FileSystemPersistenceAdapter({
|
|
1027
|
+
path: persistence.path,
|
|
1028
|
+
autoSaveInterval: persistence.autoSaveInterval,
|
|
1029
|
+
});
|
|
1030
|
+
this.logger.debug('Auto-detected Node.js environment, using file persistence');
|
|
1031
|
+
}
|
|
1032
|
+
} else if (persistence.type === 'file') {
|
|
1033
|
+
const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
|
|
1034
|
+
this.persistenceAdapter = new FileSystemPersistenceAdapter({
|
|
1035
|
+
path: persistence.path,
|
|
1036
|
+
autoSaveInterval: persistence.autoSaveInterval,
|
|
1037
|
+
});
|
|
1038
|
+
} else if (persistence.type === 'local') {
|
|
1039
|
+
const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js');
|
|
1040
|
+
this.persistenceAdapter = new LocalStoragePersistenceAdapter({
|
|
1041
|
+
key: persistence.key,
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
if (this.persistenceAdapter) {
|
|
1047
|
+
this.logger.debug('Persistence adapter initialized');
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
821
1050
|
}
|
|
@@ -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
|
+
}
|