Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,9 @@ package-lock.json
dist/
*.tsbuildinfo

# ObjectStack data directory (persistence)
**/.objectstack/data/

# IDE
.vscode/
.idea/
Expand Down
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -395,6 +395,7 @@ business/custom objects, aligning with industry best practices (e.g., ServiceNow
- [x] **ObjectQL Engine** — CRUD, hooks (before/after), middleware chain, action registry
- [x] **Schema Registry** — FQN namespacing, multi-package contribution, priority resolution
- [x] **In-Memory Driver** — Full CRUD, bulk ops, transactions, aggregation pipeline (Mingo), streaming
- [x] **In-Memory Driver Persistence** — File-system (Node.js) and localStorage (Browser) persistence adapters with auto-save, custom adapter support
- [x] **Metadata Service** — CRUD, query, bulk ops, overlay system, dependency tracking, import/export, file watching
- [x] **Serializers** — JSON, YAML, TypeScript format support
- [x] **Loaders** — Memory, Filesystem, Remote (HTTP) loaders
Expand Down
5 changes: 4 additions & 1 deletion packages/plugins/driver-memory/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,10 @@
import { InMemoryDriver } from './memory-driver.js';

export { InMemoryDriver }; // Export class for direct usage
export type { InMemoryDriverConfig } from './memory-driver.js';
export type { InMemoryDriverConfig, PersistenceAdapterInterface } from './memory-driver.js';

export { FileSystemPersistenceAdapter } from './persistence/file-adapter.js';
export { LocalStoragePersistenceAdapter } from './persistence/local-storage-adapter.js';

Comment on lines +8 to 10

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-exporting FileSystemPersistenceAdapter from the package root forces consumers (including browser bundles) to resolve ./persistence/file-adapter.js, which imports node:fs/node:path. This undermines the dynamic-import strategy and will break browser/edge builds even when file persistence isn’t used. Consider removing these root exports and instead exposing adapters via environment-specific/conditional exports (or separate entrypoints) so browser imports don’t pull in Node-only modules.

Suggested change
export{FileSystemPersistenceAdapter}from'./persistence/file-adapter.js';
export{LocalStoragePersistenceAdapter}from'./persistence/local-storage-adapter.js';

Copilot uses AI. Check for mistakes.
export { MemoryAnalyticsService } from './memory-analytics.js';
export type { MemoryAnalyticsConfig } from './memory-analytics.js';
Expand Down
142 changes: 142 additions & 0 deletions packages/plugins/driver-memory/src/memory-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,20 @@ import { Logger, createLogger } from '@objectstack/core';
import { Query, Aggregator } from 'mingo';
import { getValueByPath } from './memory-matcher.js';

/**
* Persistence adapter interface.
* Matches the PersistenceAdapterSchema contract from @objectstack/spec.
*/
export interface PersistenceAdapterInterface {
load(): Promise<Record<string, any[]> | null>;
save(db: Record<string, any[]>): Promise<void>;
flush(): Promise<void>;
/** Optional: Start periodic auto-save (used by FileSystemPersistenceAdapter). */
startAutoSave?(): void;
/** Optional: Stop auto-save timer and flush pending writes. */
stopAutoSave?(): Promise<void>;
}

/**
* Configuration options for the InMemory driver.
* Aligned with @objectstack/spec MemoryConfigSchema.
Expand All@@ -17,6 +31,21 @@ export interface InMemoryDriverConfig {
strictMode?: boolean;
/** Optional: Logger instance */
logger?: Logger;
/**
* Optional persistence configuration.
* - `'file'` — File-system persistence with defaults (Node.js only)
* - `'local'` — localStorage persistence with defaults (Browser only)
* - `{ type: 'file', path?: string, autoSaveInterval?: number }` — File-system with options
* - `{ type: 'local', key?: string }` — localStorage with options
* - `{ adapter: PersistenceAdapterInterface }` — Custom adapter
*/
persistence?: string | {
type?: 'file' | 'local';
path?: string;
key?: string;
autoSaveInterval?: number;
adapter?: PersistenceAdapterInterface;
};
Comment on lines +42 to +48

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The object form of persistence uses type?: 'file' | 'local', but object configs are only meaningful when type is present (per the spec union). With type?, callers can pass an object without type and persistence will be silently ignored. Make type required for the object variants (and/or use a discriminated union) and throw on unsupported shapes.

Suggested change
persistence?: string|{
type?: 'file'|'local';
path?: string;
key?: string;
autoSaveInterval?: number;
adapter?: PersistenceAdapterInterface;
};
persistence?:
|'file'
|'local'
|{
/** File-system persistence adapter with options */
type: 'file';
path?: string;
autoSaveInterval?: number;
/** Not applicable for file persistence */
key?: never;
/** Not applicable when using built-in adapters */
adapter?: never;
}
|{
/** localStorage persistence adapter with options */
type: 'local';
key?: string;
/** Not applicable for local persistence */
path?: never;
autoSaveInterval?: never;
/** Not applicable when using built-in adapters */
adapter?: never;
}
|{
/** Custom persistence adapter implementation */
adapter: PersistenceAdapterInterface;
/** Disallow built-in adapter discriminator on custom adapter configs */
type?: never;
path?: never;
key?: never;
autoSaveInterval?: never;
};

Copilot uses AI. Check for mistakes.
}

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

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

async connect() {
// Initialize persistence adapter if configured
await this.initPersistence();

// Load persisted data if available
if (this.persistenceAdapter) {
const persisted = await this.persistenceAdapter.load();
if (persisted) {
for (const [objectName, records] of Object.entries(persisted)) {
this.db[objectName] = records;
// Update ID counters based on persisted data
for (const record of records) {
if (record.id && typeof record.id === 'string') {
// ID format: {objectName}-{timestamp}-{counter}
const parts = record.id.split('-');
const lastPart = parts[parts.length - 1];
const counter = parseInt(lastPart, 10);
if (!isNaN(counter)) {
const current = this.idCounters.get(objectName) || 0;
if (counter > current) {
this.idCounters.set(objectName, counter);
}
}
}
}
}
this.logger.info('InMemory Database restored from persistence', {
tables: Object.keys(persisted).length,
});
}
}

// Load initial data if provided
if (this.config.initialData) {
for (const [objectName, records] of Object.entries(this.config.initialData)) {
Expand All@@ -115,9 +176,22 @@ export class InMemoryDriver implements DriverInterface {
} else {
this.logger.info('InMemory Database Connected (Virtual)');
}

// Start auto-save if using file adapter
if (this.persistenceAdapter?.startAutoSave) {
this.persistenceAdapter.startAutoSave();
}
}

async disconnect() {
// Stop auto-save and flush pending writes
if (this.persistenceAdapter) {
if (this.persistenceAdapter.stopAutoSave) {
await this.persistenceAdapter.stopAutoSave();
}
await this.persistenceAdapter.flush();
}

const tableCount = Object.keys(this.db).length;
const recordCount = Object.values(this.db).reduce((sum, table) => sum + table.length, 0);

Expand DownExpand Up@@ -226,6 +300,7 @@ export class InMemoryDriver implements DriverInterface {
};

table.push(newRecord);
this.markDirty();
this.logger.debug('Record created', { object, id: newRecord.id, tableSize: table.length });
return { ...newRecord };
}
Expand DownExpand Up@@ -253,6 +328,7 @@ export class InMemoryDriver implements DriverInterface {
};

table[index] = updatedRecord;
this.markDirty();
this.logger.debug('Record updated', { object, id });
return { ...updatedRecord };
}
Expand DownExpand Up@@ -293,6 +369,7 @@ export class InMemoryDriver implements DriverInterface {
}

table.splice(index, 1);
this.markDirty();
this.logger.debug('Record deleted', { object, id, tableSize: table.length });
return true;
}
Expand DownExpand Up@@ -350,6 +427,7 @@ export class InMemoryDriver implements DriverInterface {
}
}

if (count > 0) this.markDirty();
this.logger.debug('UpdateMany completed', { object, count });
return { count };
}
Expand DownExpand Up@@ -377,6 +455,7 @@ export class InMemoryDriver implements DriverInterface {
}

const count = initialLength - this.db[object].length;
if (count > 0) this.markDirty();
this.logger.debug('DeleteMany completed', { object, count });
return { count };
}
Expand DownExpand Up@@ -435,6 +514,7 @@ export class InMemoryDriver implements DriverInterface {
// Restore the snapshot
this.db = tx.snapshot;
this.transactions.delete(txId);
this.markDirty();
this.logger.debug('Transaction rolled back', { txId });
}

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

Expand DownExpand Up@@ -818,4 +899,65 @@ export class InMemoryDriver implements DriverInterface {
const timestamp = Date.now();
return `${key}-${timestamp}-${counter}`;
}

// ===================================
// Persistence
// ===================================

/**
* Mark the database as dirty, triggering persistence save.
*/
private markDirty(): void {
if (this.persistenceAdapter) {
this.persistenceAdapter.save(this.db);

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

markDirty() calls the async persistenceAdapter.save() without awaiting or handling rejections. If a custom adapter rejects (or JSON.stringify fails in the localStorage adapter), this can surface as an unhandled promise rejection. Consider firing-and-forgetting with explicit rejection handling (e.g., void save(...).catch(...)) or queueing/throttling saves with error logging via the driver logger.

Suggested change
this.persistenceAdapter.save(this.db);
voidthis.persistenceAdapter.save(this.db).catch((error)=>{
this.logger.error('MemoryDriver persistence save failed',{ error });
});

Copilot uses AI. Check for mistakes.
}
}

/**
* Flush pending persistence writes to ensure data is safely stored.
*/
async flush(): Promise<void> {
if (this.persistenceAdapter) {
await this.persistenceAdapter.flush();
}
}

/**
* Initialize the persistence adapter based on configuration.
*/
private async initPersistence(): Promise<void> {
const persistence = this.config.persistence;
if (!persistence) return;

if (typeof persistence === 'string') {
if (persistence === 'file') {
const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
this.persistenceAdapter = new FileSystemPersistenceAdapter();
} else if (persistence === 'local') {
Comment on lines +932 to +936

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When persistence is a string, adapters are imported/initialized without checking whether the current runtime supports them. Add explicit environment checks before selecting/importing file vs local so persistence: 'local' in Node and persistence: 'file' in browsers fail with a clear, actionable error message (instead of silent no-ops or import errors).

Copilot uses AI. Check for mistakes.
const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js');
this.persistenceAdapter = new LocalStoragePersistenceAdapter();
} else {
throw new Error(`Unknown persistence type: "${persistence}". Use 'file' or 'local'.`);
}
} else if ('adapter' in persistence && persistence.adapter) {
this.persistenceAdapter = persistence.adapter;
} else if ('type' in persistence) {
if (persistence.type === 'file') {
const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
this.persistenceAdapter = new FileSystemPersistenceAdapter({
path: persistence.path,
Comment on lines +944 to +948

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the object-config branch, an unexpected persistence.type value currently results in no adapter being set (and no error). Add an explicit else that throws for unknown type values so misconfiguration doesn’t silently disable persistence.

Copilot uses AI. Check for mistakes.
autoSaveInterval: persistence.autoSaveInterval,
});
} else if (persistence.type === 'local') {
const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js');
this.persistenceAdapter = new LocalStoragePersistenceAdapter({
key: persistence.key,
});
}
}

if (this.persistenceAdapter) {
this.logger.debug('Persistence adapter initialized');
}
}
}
103 changes: 103 additions & 0 deletions packages/plugins/driver-memory/src/persistence/file-adapter.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import * as fs from 'node:fs';
import * as path from 'node:path';

/**
* FileSystemPersistenceAdapter
*
* Persists the in-memory database to a JSON file on disk.
* Supports atomic writes (write to temp file then rename) and auto-save with dirty tracking.
*
* Node.js only — will throw if used in non-Node.js environments.
*/
export class FileSystemPersistenceAdapter {
private readonly filePath: string;
private readonly autoSaveInterval: number;
private dirty = false;
private timer: ReturnType<typeof setInterval> | null = null;
private currentDb: Record<string, any[]> | null = null;

constructor(options?: { path?: string; autoSaveInterval?: number }) {
this.filePath = options?.path || path.join('.objectstack', 'data', 'memory-driver.json');
this.autoSaveInterval = options?.autoSaveInterval ?? 2000;
}

/**
* Load persisted data from disk.
* Returns null if no file exists.
*/
async load(): Promise<Record<string, any[]> | null> {
try {
if (!fs.existsSync(this.filePath)) {
return null;
}
const raw = fs.readFileSync(this.filePath, 'utf-8');
const data = JSON.parse(raw);
return data as Record<string, any[]>;
} catch {
return null;
}
}

/**
* Save data to disk using atomic write (temp file + rename).
*/
async save(db: Record<string, any[]>): Promise<void> {
this.currentDb = db;
this.dirty = true;
}

/**
* Flush pending writes to disk immediately.
*/
async flush(): Promise<void> {
if (!this.dirty || !this.currentDb) return;
await this.writeToDisk(this.currentDb);
this.dirty = false;
}

/**
* Start the auto-save timer.
*/
startAutoSave(): void {
if (this.timer) return;
this.timer = setInterval(async () => {
if (this.dirty && this.currentDb) {
await this.writeToDisk(this.currentDb);
this.dirty = false;
Comment on lines +66 to +68

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setInterval(async () => ...) auto-save callback doesn’t handle write errors. If writeToDisk() throws (permissions, disk full, invalid path), the rejected promise can surface as an unhandled rejection. Wrap the interval body in try/catch and surface failures (e.g. via logging) so persistence failures are observable.

Suggested change
if(this.dirty&&this.currentDb){
awaitthis.writeToDisk(this.currentDb);
this.dirty=false;
try{
if(this.dirty&&this.currentDb){
awaitthis.writeToDisk(this.currentDb);
this.dirty=false;
}
}catch(error){
// Surface auto-save persistence failures without crashing the process
console.error(
'[FileSystemPersistenceAdapter] Auto-save failed for',
this.filePath,
error,
);

Copilot uses AI. Check for mistakes.
}
}, this.autoSaveInterval);

// Allow process to exit even if timer is running
if (this.timer) {
this.timer.unref();
}
}

/**
* Stop the auto-save timer and flush pending writes.
*/
async stopAutoSave(): Promise<void> {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
await this.flush();
}

/**
* Atomic write: write to temp file, then rename.
*/
private async writeToDisk(db: Record<string, any[]>): Promise<void> {
const dir = path.dirname(this.filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}

const tmpPath = this.filePath + '.tmp';
const json = JSON.stringify(db, null, 2);
fs.writeFileSync(tmpPath, json, 'utf-8');
fs.renameSync(tmpPath, this.filePath);
}
}
4 changes: 4 additions & 0 deletions packages/plugins/driver-memory/src/persistence/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

export { FileSystemPersistenceAdapter } from './file-adapter.js';
export { LocalStoragePersistenceAdapter } from './local-storage-adapter.js';
Loading