@objectstack/driver-memory 4.0.3 → 4.0.5

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.
@@ -1,177 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
-
4
- /**
5
- * Simple In-Memory Query Matcher
6
- *
7
- * Implements a subset of the ObjectStack Filter Protocol (MongoDB-compatible)
8
- * for evaluating conditions against in-memory JavaScript objects.
9
- */
10
-
11
- type RecordType = Record<string, any>;
12
-
13
- /**
14
- * matches - Check if a record matches a filter criteria
15
- * @param record The data record to check
16
- * @param filter The filter condition (where clause)
17
- */
18
- export function match(record: RecordType, filter: any): boolean {
19
- if (!filter || Object.keys(filter).length === 0) return true;
20
-
21
- // 1. Handle Top-Level Logical Operators ($and, $or, $not)
22
- // These usually appear at the root or nested.
23
-
24
- // $and: [ { ... }, { ... } ]
25
- if (Array.isArray(filter.$and)) {
26
- if (!filter.$and.every((f: any) => match(record, f))) {
27
- return false;
28
- }
29
- }
30
-
31
- // $or: [ { ... }, { ... } ]
32
- if (Array.isArray(filter.$or)) {
33
- if (!filter.$or.some((f: any) => match(record, f))) {
34
- return false;
35
- }
36
- }
37
-
38
- // $not: { ... }
39
- if (filter.$not) {
40
- if (match(record, filter.$not)) {
41
- return false;
42
- }
43
- }
44
-
45
- // 2. Iterate over field constraints
46
- for (const key of Object.keys(filter)) {
47
- // Skip logical operators we already handled (or future ones)
48
- if (key.startsWith('$')) continue;
49
-
50
- const condition = filter[key];
51
- const value = getValueByPath(record, key);
52
-
53
- if (!checkCondition(value, condition)) {
54
- return false;
55
- }
56
- }
57
-
58
- return true;
59
- }
60
-
61
- /**
62
- * Access nested properties via dot-notation (e.g. "user.name")
63
- */
64
- export function getValueByPath(obj: any, path: string): any {
65
- if (!path.includes('.')) return obj[path];
66
- return path.split('.').reduce((o, i) => (o ? o[i] : undefined), obj);
67
- }
68
-
69
- /**
70
- * Evaluate a specific condition against a value
71
- */
72
- function checkCondition(value: any, condition: any): boolean {
73
- // Case A: Implicit Equality (e.g. status: 'active')
74
- // If condition is a primitive or Date/Array (exact match), treat as equality.
75
- if (
76
- typeof condition !== 'object' ||
77
- condition === null ||
78
- condition instanceof Date ||
79
- Array.isArray(condition)
80
- ) {
81
- // Loose equality to handle undefined/null mismatch or string/number coercion if desired.
82
- // But stick to == for JS loose equality which is often convenient in weakly typed queries.
83
- return value == condition;
84
- }
85
-
86
- // Case B: Operator Object (e.g. { $gt: 10, $lt: 20 })
87
- const keys = Object.keys(condition);
88
- const isOperatorObject = keys.some(k => k.startsWith('$'));
89
-
90
- if (!isOperatorObject) {
91
- // It's just a nested object comparison or implicit equality against an object
92
- // Simplistic check:
93
- return JSON.stringify(value) === JSON.stringify(condition);
94
- }
95
-
96
- // Iterate operators
97
- for (const op of keys) {
98
- const target = condition[op];
99
-
100
- // Handle undefined values
101
- if (value === undefined && op !== '$exists' && op !== '$ne' && op !== '$null') {
102
- return false;
103
- }
104
-
105
- switch (op) {
106
- case '$eq':
107
- if (value != target) return false;
108
- break;
109
- case '$ne':
110
- if (value == target) return false;
111
- break;
112
-
113
- // Numeric / Date
114
- case '$gt':
115
- if (!(value > target)) return false;
116
- break;
117
- case '$gte':
118
- if (!(value >= target)) return false;
119
- break;
120
- case '$lt':
121
- if (!(value < target)) return false;
122
- break;
123
- case '$lte':
124
- if (!(value <= target)) return false;
125
- break;
126
- case '$between':
127
- // target should be [min, max]
128
- if (Array.isArray(target) && (value < target[0] || value > target[1])) return false;
129
- break;
130
-
131
- // Sets
132
- case '$in':
133
- if (!Array.isArray(target) || !target.includes(value)) return false;
134
- break;
135
- case '$nin':
136
- if (Array.isArray(target) && target.includes(value)) return false;
137
- break;
138
-
139
- // Existence
140
- case '$exists':
141
- const exists = value !== undefined && value !== null;
142
- if (exists !== !!target) return false;
143
- break;
144
-
145
- // Strings
146
- case '$contains':
147
- if (typeof value !== 'string' || !value.includes(target)) return false;
148
- break;
149
- case '$notContains':
150
- if (typeof value !== 'string' || value.includes(target)) return false;
151
- break;
152
- case '$startsWith':
153
- if (typeof value !== 'string' || !value.startsWith(target)) return false;
154
- break;
155
- case '$endsWith':
156
- if (typeof value !== 'string' || !value.endsWith(target)) return false;
157
- break;
158
- case '$null':
159
- // $null: true → value must be null/undefined; $null: false → value must not be null/undefined
160
- if (target === true && value != null) return false;
161
- if (target === false && value == null) return false;
162
- break;
163
- case '$regex':
164
- try {
165
- const re = new RegExp(target, condition.$options || '');
166
- if (!re.test(String(value))) return false;
167
- } catch (e) { return false; }
168
- break;
169
-
170
- default:
171
- // Unknown operator, ignore or fail. Ignoring safe for optional features.
172
- break;
173
- }
174
- }
175
-
176
- return true;
177
- }
@@ -1,103 +0,0 @@
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
- }
@@ -1,4 +0,0 @@
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';
@@ -1,60 +0,0 @@
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
- }
@@ -1,298 +0,0 @@
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
-
216
- describe('Serverless Environment Detection', () => {
217
- const serverlessEnvVars = [
218
- 'VERCEL',
219
- 'VERCEL_ENV',
220
- 'AWS_LAMBDA_FUNCTION_NAME',
221
- 'NETLIFY',
222
- 'FUNCTIONS_WORKER_RUNTIME',
223
- 'K_SERVICE',
224
- 'FUNCTION_TARGET',
225
- 'DENO_DEPLOYMENT_ID',
226
- ];
227
-
228
- afterEach(() => {
229
- // Clean up all serverless env vars after each test
230
- for (const key of serverlessEnvVars) {
231
- delete process.env[key];
232
- }
233
- });
234
-
235
- it('should disable file persistence in auto mode when VERCEL env is set', async () => {
236
- process.env.VERCEL = '1';
237
- const filePath = path.join(TEST_DATA_DIR, 'serverless-test.json');
238
- const driver = new InMemoryDriver({
239
- persistence: { type: 'auto', path: filePath },
240
- });
241
- await driver.connect();
242
- await driver.create('items', { id: '1', name: 'Widget' });
243
- await driver.flush();
244
- await driver.disconnect();
245
-
246
- // File should NOT have been created because auto mode skips file persistence in serverless
247
- expect(fs.existsSync(filePath)).toBe(false);
248
- });
249
-
250
- it('should disable file persistence in auto shorthand mode when AWS_LAMBDA_FUNCTION_NAME is set', async () => {
251
- process.env.AWS_LAMBDA_FUNCTION_NAME = 'my-function';
252
- const driver = new InMemoryDriver({ persistence: 'auto' });
253
- await driver.connect();
254
- await driver.create('items', { id: '1', name: 'Widget' });
255
-
256
- // Should work as pure in-memory without errors
257
- const items = await driver.find('items', { object: 'items' });
258
- expect(items).toHaveLength(1);
259
-
260
- await driver.disconnect();
261
- });
262
-
263
- it('should still allow explicit file persistence in serverless if user requests it', async () => {
264
- process.env.VERCEL = '1';
265
- const filePath = path.join(TEST_DATA_DIR, 'explicit-file-serverless.json');
266
- const driver = new InMemoryDriver({
267
- persistence: { type: 'file', path: filePath, autoSaveInterval: 100 },
268
- });
269
- await driver.connect();
270
- await driver.create('items', { id: '1', name: 'Widget' });
271
- await driver.flush();
272
- await driver.disconnect();
273
-
274
- // Explicit 'file' type should still create the file even in serverless
275
- expect(fs.existsSync(filePath)).toBe(true);
276
- });
277
-
278
- it('should still allow custom adapter in serverless', async () => {
279
- process.env.NETLIFY = 'true';
280
- const stored: Record<string, any[]> = {};
281
- const customAdapter = {
282
- load: async () => Object.keys(stored).length > 0 ? { ...stored } : null,
283
- save: async (db: Record<string, any[]>) => {
284
- for (const [k, v] of Object.entries(db)) { stored[k] = [...v]; }
285
- },
286
- flush: async () => {},
287
- };
288
-
289
- const driver = new InMemoryDriver({ persistence: { adapter: customAdapter } });
290
- await driver.connect();
291
- await driver.create('items', { id: '1', name: 'Widget' });
292
- await driver.disconnect();
293
-
294
- expect(stored.items).toBeDefined();
295
- expect(stored.items).toHaveLength(1);
296
- });
297
- });
298
- });
package/tsconfig.json DELETED
@@ -1,27 +0,0 @@
1
- {
2
- "extends": "../../../tsconfig.json",
3
- "compilerOptions": {
4
- "target": "ES2020",
5
- "module": "ES2020",
6
- "moduleResolution": "bundler",
7
- "declaration": true,
8
- "outDir": "./dist",
9
- "strict": true,
10
- "esModuleInterop": true,
11
- "skipLibCheck": true,
12
- "noUnusedLocals": false,
13
- "noUnusedParameters": false,
14
- "forceConsistentCasingInFileNames": true,
15
- "types": [
16
- "node"
17
- ],
18
- "rootDir": "./src"
19
- },
20
- "include": [
21
- "src/**/*"
22
- ],
23
- "exclude": [
24
- "node_modules",
25
- "dist"
26
- ]
27
- }