@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.
@@ -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,4 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ export { FileSystemPersistenceAdapter } from './file-adapter.js';
4
+ export { LocalStoragePersistenceAdapter } from './local-storage-adapter.js';
@@ -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
+ }