Skip to content

Commit ea4fba4

Browse files
authored
Merge pull request #816 from objectstack-ai/copilot/add-persistence-support
2 parents 849eebd + 3da9227 commit ea4fba4

File tree

10 files changed

+788
-42
lines changed

10 files changed

+788
-42
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ package-lock.json
66
dist/
77
*.tsbuildinfo
88

9+
# ObjectStack data directory (persistence)
10+
**/.objectstack/data/
11+
912
# IDE
1013
.vscode/
1114
.idea/

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@ business/custom objects, aligning with industry best practices (e.g., ServiceNow
395395
- [x] **ObjectQL Engine** — CRUD, hooks (before/after), middleware chain, action registry
396396
- [x] **Schema Registry** — FQN namespacing, multi-package contribution, priority resolution
397397
- [x] **In-Memory Driver** — Full CRUD, bulk ops, transactions, aggregation pipeline (Mingo), streaming
398+
- [x] **In-Memory Driver Persistence** — File-system (Node.js) and localStorage (Browser) persistence adapters with auto-save, custom adapter support
398399
- [x] **Metadata Service** — CRUD, query, bulk ops, overlay system, dependency tracking, import/export, file watching
399400
- [x] **Serializers** — JSON, YAML, TypeScript format support
400401
- [x] **Loaders** — Memory, Filesystem, Remote (HTTP) loaders

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
import { InMemoryDriver } from './memory-driver.js';
44

55
export { InMemoryDriver }; // Export class for direct usage
6-
export type { InMemoryDriverConfig } from './memory-driver.js';
6+
export type { InMemoryDriverConfig, PersistenceAdapterInterface } from './memory-driver.js';
7+
8+
export { FileSystemPersistenceAdapter } from './persistence/file-adapter.js';
9+
export { LocalStoragePersistenceAdapter } from './persistence/local-storage-adapter.js';
710

811
export { MemoryAnalyticsService } from './memory-analytics.js';
912
export type { MemoryAnalyticsConfig } from './memory-analytics.js';

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

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@ import { Logger, createLogger } from '@objectstack/core';
66
import { Query, Aggregator } from 'mingo';
77
import { getValueByPath } from './memory-matcher.js';
88

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+
923
/**
1024
* Configuration options for the InMemory driver.
1125
* Aligned with @objectstack/spec MemoryConfigSchema.
@@ -17,6 +31,21 @@ export interface InMemoryDriverConfig {
1731
strictMode?: boolean;
1832
/** Optional: Logger instance */
1933
logger?: Logger;
34+
/**
35+
* Optional persistence configuration.
36+
* - `'file'` — File-system persistence with defaults (Node.js only)
37+
* - `'local'` — localStorage persistence with defaults (Browser only)
38+
* - `{ type: 'file', path?: string, autoSaveInterval?: number }` — File-system with options
39+
* - `{ type: 'local', key?: string }` — localStorage with options
40+
* - `{ adapter: PersistenceAdapterInterface }` — Custom adapter
41+
*/
42+
persistence?: string | {
43+
type?: 'file' | 'local';
44+
path?: string;
45+
key?: string;
46+
autoSaveInterval?: number;
47+
adapter?: PersistenceAdapterInterface;
48+
};
2049
}
2150

2251
/**
@@ -51,6 +80,7 @@ export class InMemoryDriver implements DriverInterface {
5180
private logger: Logger;
5281
private idCounters: Map<string, number> = new Map();
5382
private transactions: Map<string, MemoryTransaction> = new Map();
83+
private persistenceAdapter: PersistenceAdapterInterface | null = null;
5484

5585
constructor(config?: InMemoryDriverConfig) {
5686
this.config = config || {};
@@ -100,6 +130,37 @@ export class InMemoryDriver implements DriverInterface {
100130
// ===================================
101131

102132
async connect() {
133+
// Initialize persistence adapter if configured
134+
await this.initPersistence();
135+
136+
// Load persisted data if available
137+
if (this.persistenceAdapter) {
138+
const persisted = await this.persistenceAdapter.load();
139+
if (persisted) {
140+
for (const [objectName, records] of Object.entries(persisted)) {
141+
this.db[objectName] = records;
142+
// Update ID counters based on persisted data
143+
for (const record of records) {
144+
if (record.id && typeof record.id === 'string') {
145+
// ID format: {objectName}-{timestamp}-{counter}
146+
const parts = record.id.split('-');
147+
const lastPart = parts[parts.length - 1];
148+
const counter = parseInt(lastPart, 10);
149+
if (!isNaN(counter)) {
150+
const current = this.idCounters.get(objectName) || 0;
151+
if (counter > current) {
152+
this.idCounters.set(objectName, counter);
153+
}
154+
}
155+
}
156+
}
157+
}
158+
this.logger.info('InMemory Database restored from persistence', {
159+
tables: Object.keys(persisted).length,
160+
});
161+
}
162+
}
163+
103164
// Load initial data if provided
104165
if (this.config.initialData) {
105166
for (const [objectName, records] of Object.entries(this.config.initialData)) {
@@ -115,9 +176,22 @@ export class InMemoryDriver implements DriverInterface {
115176
} else {
116177
this.logger.info('InMemory Database Connected (Virtual)');
117178
}
179+
180+
// Start auto-save if using file adapter
181+
if (this.persistenceAdapter?.startAutoSave) {
182+
this.persistenceAdapter.startAutoSave();
183+
}
118184
}
119185

120186
async disconnect() {
187+
// Stop auto-save and flush pending writes
188+
if (this.persistenceAdapter) {
189+
if (this.persistenceAdapter.stopAutoSave) {
190+
await this.persistenceAdapter.stopAutoSave();
191+
}
192+
await this.persistenceAdapter.flush();
193+
}
194+
121195
const tableCount = Object.keys(this.db).length;
122196
const recordCount = Object.values(this.db).reduce((sum, table) => sum + table.length, 0);
123197

@@ -226,6 +300,7 @@ export class InMemoryDriver implements DriverInterface {
226300
};
227301

228302
table.push(newRecord);
303+
this.markDirty();
229304
this.logger.debug('Record created', { object, id: newRecord.id, tableSize: table.length });
230305
return { ...newRecord };
231306
}
@@ -253,6 +328,7 @@ export class InMemoryDriver implements DriverInterface {
253328
};
254329

255330
table[index] = updatedRecord;
331+
this.markDirty();
256332
this.logger.debug('Record updated', { object, id });
257333
return { ...updatedRecord };
258334
}
@@ -293,6 +369,7 @@ export class InMemoryDriver implements DriverInterface {
293369
}
294370

295371
table.splice(index, 1);
372+
this.markDirty();
296373
this.logger.debug('Record deleted', { object, id, tableSize: table.length });
297374
return true;
298375
}
@@ -350,6 +427,7 @@ export class InMemoryDriver implements DriverInterface {
350427
}
351428
}
352429

430+
if (count > 0) this.markDirty();
353431
this.logger.debug('UpdateMany completed', { object, count });
354432
return { count };
355433
}
@@ -377,6 +455,7 @@ export class InMemoryDriver implements DriverInterface {
377455
}
378456

379457
const count = initialLength - this.db[object].length;
458+
if (count > 0) this.markDirty();
380459
this.logger.debug('DeleteMany completed', { object, count });
381460
return { count };
382461
}
@@ -435,6 +514,7 @@ export class InMemoryDriver implements DriverInterface {
435514
// Restore the snapshot
436515
this.db = tx.snapshot;
437516
this.transactions.delete(txId);
517+
this.markDirty();
438518
this.logger.debug('Transaction rolled back', { txId });
439519
}
440520

@@ -448,6 +528,7 @@ export class InMemoryDriver implements DriverInterface {
448528
async clear() {
449529
this.db = {};
450530
this.idCounters.clear();
531+
this.markDirty();
451532
this.logger.debug('All data cleared');
452533
}
453534

@@ -818,4 +899,65 @@ export class InMemoryDriver implements DriverInterface {
818899
const timestamp = Date.now();
819900
return `${key}-${timestamp}-${counter}`;
820901
}
902+
903+
// ===================================
904+
// Persistence
905+
// ===================================
906+
907+
/**
908+
* Mark the database as dirty, triggering persistence save.
909+
*/
910+
private markDirty(): void {
911+
if (this.persistenceAdapter) {
912+
this.persistenceAdapter.save(this.db);
913+
}
914+
}
915+
916+
/**
917+
* Flush pending persistence writes to ensure data is safely stored.
918+
*/
919+
async flush(): Promise<void> {
920+
if (this.persistenceAdapter) {
921+
await this.persistenceAdapter.flush();
922+
}
923+
}
924+
925+
/**
926+
* Initialize the persistence adapter based on configuration.
927+
*/
928+
private async initPersistence(): Promise<void> {
929+
const persistence = this.config.persistence;
930+
if (!persistence) return;
931+
932+
if (typeof persistence === 'string') {
933+
if (persistence === 'file') {
934+
const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
935+
this.persistenceAdapter = new FileSystemPersistenceAdapter();
936+
} else if (persistence === 'local') {
937+
const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js');
938+
this.persistenceAdapter = new LocalStoragePersistenceAdapter();
939+
} else {
940+
throw new Error(`Unknown persistence type: "${persistence}". Use 'file' or 'local'.`);
941+
}
942+
} else if ('adapter' in persistence && persistence.adapter) {
943+
this.persistenceAdapter = persistence.adapter;
944+
} else if ('type' in persistence) {
945+
if (persistence.type === 'file') {
946+
const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
947+
this.persistenceAdapter = new FileSystemPersistenceAdapter({
948+
path: persistence.path,
949+
autoSaveInterval: persistence.autoSaveInterval,
950+
});
951+
} else if (persistence.type === 'local') {
952+
const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js');
953+
this.persistenceAdapter = new LocalStoragePersistenceAdapter({
954+
key: persistence.key,
955+
});
956+
}
957+
}
958+
959+
if (this.persistenceAdapter) {
960+
this.logger.debug('Persistence adapter initialized');
961+
}
962+
}
821963
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 commit comments

Comments
 (0)