@magek/adapter-read-model-store-memory 0.0.10
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/dist/index.d.ts +10 -0
- package/dist/index.js +119 -0
- package/dist/library/filter-evaluator.d.ts +42 -0
- package/dist/library/filter-evaluator.js +238 -0
- package/dist/memory-read-model-registry.d.ts +27 -0
- package/dist/memory-read-model-registry.js +237 -0
- package/package.json +62 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { MagekConfig, ReadModelInterface, ReadModelStoreAdapter, UUID, FilterFor, SortFor, ProjectionFor, ReadModelListResult, SequenceKey, ReadOnlyNonEmptyArray } from '@magek/common';
|
|
2
|
+
import { MemoryReadModelRegistry } from './memory-read-model-registry';
|
|
3
|
+
declare function fetchReadModel(db: MemoryReadModelRegistry, config: MagekConfig, readModelName: string, readModelID: UUID, sequenceKey?: SequenceKey): Promise<ReadOnlyNonEmptyArray<ReadModelInterface> | undefined>;
|
|
4
|
+
declare function storeReadModel(db: MemoryReadModelRegistry, config: MagekConfig, readModelName: string, readModel: ReadModelInterface, expectedCurrentVersion: number): Promise<void>;
|
|
5
|
+
declare function searchReadModel<TReadModel extends ReadModelInterface>(db: MemoryReadModelRegistry, config: MagekConfig, readModelName: string, filters: FilterFor<unknown>, sortBy?: SortFor<unknown>, limit?: number, afterCursor?: Record<string, string> | undefined, paginatedVersion?: boolean, select?: ProjectionFor<TReadModel>): Promise<Array<TReadModel> | ReadModelListResult<TReadModel>>;
|
|
6
|
+
declare function deleteReadModel(db: MemoryReadModelRegistry, config: MagekConfig, readModelName: string, readModel: ReadModelInterface): Promise<void>;
|
|
7
|
+
export declare const readModelStore: ReadModelStoreAdapter;
|
|
8
|
+
export { MemoryReadModelRegistry } from './memory-read-model-registry';
|
|
9
|
+
export { evaluateFilter, convertFilter } from './library/filter-evaluator';
|
|
10
|
+
export { fetchReadModel, storeReadModel, searchReadModel, deleteReadModel };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.convertFilter = exports.evaluateFilter = exports.MemoryReadModelRegistry = exports.readModelStore = void 0;
|
|
4
|
+
exports.fetchReadModel = fetchReadModel;
|
|
5
|
+
exports.storeReadModel = storeReadModel;
|
|
6
|
+
exports.searchReadModel = searchReadModel;
|
|
7
|
+
exports.deleteReadModel = deleteReadModel;
|
|
8
|
+
const common_1 = require("@magek/common");
|
|
9
|
+
const memory_read_model_registry_1 = require("./memory-read-model-registry");
|
|
10
|
+
// Pre-built Memory Read Model Store Adapter instance
|
|
11
|
+
const readModelRegistry = new memory_read_model_registry_1.MemoryReadModelRegistry();
|
|
12
|
+
async function fetchReadModel(db, config, readModelName, readModelID, sequenceKey) {
|
|
13
|
+
const logger = (0, common_1.getLogger)(config, 'memory-read-model-adapter#fetchReadModel');
|
|
14
|
+
const query = { typeName: readModelName, 'value.id': readModelID };
|
|
15
|
+
// If sequenceKey is provided, add it to the query
|
|
16
|
+
if (sequenceKey) {
|
|
17
|
+
query[`value.${sequenceKey.name}`] = sequenceKey.value;
|
|
18
|
+
}
|
|
19
|
+
const response = await db.query(query);
|
|
20
|
+
if (response.length === 0) {
|
|
21
|
+
logger.debug(`Read model ${readModelName} with ID ${readModelID} not found`);
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
logger.debug(`Loaded read model ${readModelName} with ID ${readModelID} with result:`, response.map((item) => item.value));
|
|
25
|
+
return response.map((item) => item.value);
|
|
26
|
+
}
|
|
27
|
+
async function storeReadModel(db, config, readModelName, readModel, expectedCurrentVersion) {
|
|
28
|
+
const logger = (0, common_1.getLogger)(config, 'memory-read-model-adapter#storeReadModel');
|
|
29
|
+
logger.debug('Storing readModel ' + JSON.stringify(readModel));
|
|
30
|
+
try {
|
|
31
|
+
await db.store({ typeName: readModelName, value: readModel }, expectedCurrentVersion);
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
if (e instanceof common_1.OptimisticConcurrencyUnexpectedVersionError) {
|
|
35
|
+
logger.warn(`Unique violated storing ReadModel ${JSON.stringify(readModel)} and expectedCurrentVersion ${expectedCurrentVersion}`);
|
|
36
|
+
throw e;
|
|
37
|
+
}
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
logger.debug('Read model stored');
|
|
41
|
+
}
|
|
42
|
+
async function searchReadModel(db, config, readModelName, filters, sortBy, limit, afterCursor, paginatedVersion = false, select) {
|
|
43
|
+
const logger = (0, common_1.getLogger)(config, 'memory-read-model-adapter#searchReadModel');
|
|
44
|
+
logger.debug('Converting filter to query');
|
|
45
|
+
const query = { typeName: readModelName, filters };
|
|
46
|
+
logger.debug('Got query ', query);
|
|
47
|
+
const skipId = afterCursor?.id ? parseInt(afterCursor?.id) : 0;
|
|
48
|
+
const result = await db.query(query, sortBy, skipId, limit, select);
|
|
49
|
+
logger.debug('Search result: ', result);
|
|
50
|
+
const items = result?.map((envelope) => envelope.value) ?? [];
|
|
51
|
+
if (paginatedVersion) {
|
|
52
|
+
return {
|
|
53
|
+
items: items,
|
|
54
|
+
count: items?.length ?? 0,
|
|
55
|
+
cursor: { id: ((limit ? limit : 1) + skipId).toString() },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return items;
|
|
59
|
+
}
|
|
60
|
+
async function deleteReadModel(db, config, readModelName, readModel) {
|
|
61
|
+
const logger = (0, common_1.getLogger)(config, 'memory-read-model-adapter#deleteReadModel');
|
|
62
|
+
logger.debug(`Entering to Read model deleted. ID=${readModel.id}.Name=${readModelName}`);
|
|
63
|
+
try {
|
|
64
|
+
await db.deleteById(readModel.id, readModelName);
|
|
65
|
+
logger.debug(`Read model deleted. ${readModelName} ID = ${readModel.id}`);
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
logger.warn(`Read model to delete ${readModelName} ID = ${readModel.id} not found`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
exports.readModelStore = {
|
|
72
|
+
fetch: async (config, readModelName, readModelID, sequenceKey) => {
|
|
73
|
+
const result = await fetchReadModel(readModelRegistry, config, readModelName, readModelID, sequenceKey);
|
|
74
|
+
if (!result || result.length === 0) {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
},
|
|
79
|
+
search: async (config, readModelName, filters, sortBy, limit, afterCursor, paginatedVersion, select) => {
|
|
80
|
+
return await searchReadModel(readModelRegistry, config, readModelName, filters, sortBy, limit, afterCursor, paginatedVersion ?? false, select);
|
|
81
|
+
},
|
|
82
|
+
store: async (config, readModelName, readModel) => {
|
|
83
|
+
const expectedCurrentVersion = (readModel.version ?? 1) - 1;
|
|
84
|
+
await storeReadModel(readModelRegistry, config, readModelName, readModel.value, expectedCurrentVersion);
|
|
85
|
+
// Return the stored envelope with updated timestamps
|
|
86
|
+
return {
|
|
87
|
+
...readModel,
|
|
88
|
+
updatedAt: new Date().toISOString(),
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
delete: async (config, readModelName, readModelID) => {
|
|
92
|
+
// Create a minimal ReadModelInterface for the delete operation
|
|
93
|
+
const readModel = {
|
|
94
|
+
id: readModelID,
|
|
95
|
+
};
|
|
96
|
+
await deleteReadModel(readModelRegistry, config, readModelName, readModel);
|
|
97
|
+
},
|
|
98
|
+
rawToEnvelopes: async (config, rawReadModels) => {
|
|
99
|
+
// This would typically convert raw database records to envelopes
|
|
100
|
+
// For now, assume rawReadModels is already in the correct format
|
|
101
|
+
return rawReadModels;
|
|
102
|
+
},
|
|
103
|
+
healthCheck: {
|
|
104
|
+
isUp: async () => true,
|
|
105
|
+
details: async () => {
|
|
106
|
+
return {
|
|
107
|
+
type: 'memory',
|
|
108
|
+
count: readModelRegistry.getCount(),
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
urls: async () => ['memory://in-memory-read-model-store'],
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
// Export individual components for backward compatibility and testing
|
|
115
|
+
var memory_read_model_registry_2 = require("./memory-read-model-registry");
|
|
116
|
+
Object.defineProperty(exports, "MemoryReadModelRegistry", { enumerable: true, get: function () { return memory_read_model_registry_2.MemoryReadModelRegistry; } });
|
|
117
|
+
var filter_evaluator_1 = require("./library/filter-evaluator");
|
|
118
|
+
Object.defineProperty(exports, "evaluateFilter", { enumerable: true, get: function () { return filter_evaluator_1.evaluateFilter; } });
|
|
119
|
+
Object.defineProperty(exports, "convertFilter", { enumerable: true, get: function () { return filter_evaluator_1.convertFilter; } });
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type FilterValue = number | string | boolean | null | undefined;
|
|
2
|
+
export interface EvaluatedFilter {
|
|
3
|
+
[key: string]: FilterOperation;
|
|
4
|
+
}
|
|
5
|
+
export type FilterOperation = FilterValue | {
|
|
6
|
+
$eq?: FilterValue;
|
|
7
|
+
} | {
|
|
8
|
+
$ne?: FilterValue;
|
|
9
|
+
} | {
|
|
10
|
+
$lt?: FilterValue;
|
|
11
|
+
} | {
|
|
12
|
+
$gt?: FilterValue;
|
|
13
|
+
} | {
|
|
14
|
+
$lte?: FilterValue;
|
|
15
|
+
} | {
|
|
16
|
+
$gte?: FilterValue;
|
|
17
|
+
} | {
|
|
18
|
+
$in?: FilterValue[];
|
|
19
|
+
} | {
|
|
20
|
+
$exists?: boolean;
|
|
21
|
+
} | {
|
|
22
|
+
$regex?: RegExp;
|
|
23
|
+
} | {
|
|
24
|
+
$elemMatch?: FilterValue;
|
|
25
|
+
} | {
|
|
26
|
+
$and?: EvaluatedFilter[];
|
|
27
|
+
} | {
|
|
28
|
+
$or?: EvaluatedFilter[];
|
|
29
|
+
} | {
|
|
30
|
+
$not?: EvaluatedFilter;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Evaluates a filter against a value object.
|
|
34
|
+
* Returns true if the value matches the filter conditions.
|
|
35
|
+
*/
|
|
36
|
+
export declare function evaluateFilter(value: Record<string, any>, filters: any): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Converts a GraphQL filter to an evaluated filter structure.
|
|
39
|
+
* This function transforms Magek-style filters into a format that can be
|
|
40
|
+
* efficiently evaluated against data.
|
|
41
|
+
*/
|
|
42
|
+
export declare function convertFilter(filters: any, prefix?: string): EvaluatedFilter;
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.evaluateFilter = evaluateFilter;
|
|
5
|
+
exports.convertFilter = convertFilter;
|
|
6
|
+
/**
|
|
7
|
+
* Evaluates a filter against a value object.
|
|
8
|
+
* Returns true if the value matches the filter conditions.
|
|
9
|
+
*/
|
|
10
|
+
function evaluateFilter(value, filters) {
|
|
11
|
+
if (!filters || Object.keys(filters).length === 0) {
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
for (const key in filters) {
|
|
15
|
+
const filterValue = filters[key];
|
|
16
|
+
// Handle logical operators
|
|
17
|
+
if (key === 'and') {
|
|
18
|
+
const andFilters = filterValue;
|
|
19
|
+
if (!andFilters.every((f) => evaluateFilter(value, f))) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (key === 'or') {
|
|
25
|
+
const orFilters = filterValue;
|
|
26
|
+
if (!orFilters.some((f) => evaluateFilter(value, f))) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (key === 'not') {
|
|
32
|
+
if (evaluateFilter(value, filterValue)) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
// Handle field-level filters
|
|
38
|
+
const fieldValue = getNestedValue(value, key);
|
|
39
|
+
if (!evaluateFieldFilter(fieldValue, filterValue)) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Gets a nested value from an object using dot notation.
|
|
47
|
+
*/
|
|
48
|
+
function getNestedValue(obj, path) {
|
|
49
|
+
const parts = path.split('.');
|
|
50
|
+
let current = obj;
|
|
51
|
+
for (const part of parts) {
|
|
52
|
+
if (current === null || current === undefined) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
current = current[part];
|
|
56
|
+
}
|
|
57
|
+
return current;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Evaluates a filter against a single field value.
|
|
61
|
+
*/
|
|
62
|
+
function evaluateFieldFilter(fieldValue, filter) {
|
|
63
|
+
if (filter === null || filter === undefined) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
// If filter is a primitive, it's an implicit eq operation
|
|
67
|
+
if (typeof filter !== 'object') {
|
|
68
|
+
return fieldValue === filter;
|
|
69
|
+
}
|
|
70
|
+
// Check for filter operations
|
|
71
|
+
const filterKeys = Object.keys(filter);
|
|
72
|
+
for (const op of filterKeys) {
|
|
73
|
+
const opValue = filter[op];
|
|
74
|
+
switch (op) {
|
|
75
|
+
case 'eq':
|
|
76
|
+
if (fieldValue !== opValue)
|
|
77
|
+
return false;
|
|
78
|
+
break;
|
|
79
|
+
case 'ne':
|
|
80
|
+
if (fieldValue === opValue)
|
|
81
|
+
return false;
|
|
82
|
+
break;
|
|
83
|
+
case 'lt':
|
|
84
|
+
if (fieldValue >= opValue)
|
|
85
|
+
return false;
|
|
86
|
+
break;
|
|
87
|
+
case 'gt':
|
|
88
|
+
if (fieldValue <= opValue)
|
|
89
|
+
return false;
|
|
90
|
+
break;
|
|
91
|
+
case 'lte':
|
|
92
|
+
if (fieldValue > opValue)
|
|
93
|
+
return false;
|
|
94
|
+
break;
|
|
95
|
+
case 'gte':
|
|
96
|
+
if (fieldValue < opValue)
|
|
97
|
+
return false;
|
|
98
|
+
break;
|
|
99
|
+
case 'in':
|
|
100
|
+
if (!Array.isArray(opValue) || !opValue.includes(fieldValue))
|
|
101
|
+
return false;
|
|
102
|
+
break;
|
|
103
|
+
case 'isDefined': {
|
|
104
|
+
const exists = fieldValue !== undefined && fieldValue !== null;
|
|
105
|
+
if (opValue !== exists)
|
|
106
|
+
return false;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
case 'contains':
|
|
110
|
+
if (typeof fieldValue !== 'string' || !fieldValue.includes(opValue))
|
|
111
|
+
return false;
|
|
112
|
+
break;
|
|
113
|
+
case 'beginsWith':
|
|
114
|
+
if (typeof fieldValue !== 'string' || !fieldValue.startsWith(opValue))
|
|
115
|
+
return false;
|
|
116
|
+
break;
|
|
117
|
+
case 'regex':
|
|
118
|
+
if (typeof fieldValue !== 'string')
|
|
119
|
+
return false;
|
|
120
|
+
try {
|
|
121
|
+
const regex = new RegExp(opValue);
|
|
122
|
+
if (!regex.test(fieldValue))
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
break;
|
|
129
|
+
case 'iRegex':
|
|
130
|
+
if (typeof fieldValue !== 'string')
|
|
131
|
+
return false;
|
|
132
|
+
try {
|
|
133
|
+
const regex = new RegExp(opValue, 'i');
|
|
134
|
+
if (!regex.test(fieldValue))
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
break;
|
|
141
|
+
case 'includes':
|
|
142
|
+
if (!Array.isArray(fieldValue))
|
|
143
|
+
return false;
|
|
144
|
+
if (typeof opValue === 'string') {
|
|
145
|
+
// Check if any element contains the string
|
|
146
|
+
if (!fieldValue.some((item) => typeof item === 'string' && item.includes(opValue)))
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
// Check if array includes the value (elemMatch-like)
|
|
151
|
+
if (!fieldValue.some((item) => {
|
|
152
|
+
if (typeof opValue === 'object' && opValue !== null) {
|
|
153
|
+
return evaluateFilter(item, opValue);
|
|
154
|
+
}
|
|
155
|
+
return item === opValue;
|
|
156
|
+
}))
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
break;
|
|
160
|
+
default:
|
|
161
|
+
// If the key is not a known operator, it might be a nested filter
|
|
162
|
+
if (!evaluateFieldFilter(getNestedValue(fieldValue, op), filter[op])) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Converts a GraphQL filter to an evaluated filter structure.
|
|
171
|
+
* This function transforms Magek-style filters into a format that can be
|
|
172
|
+
* efficiently evaluated against data.
|
|
173
|
+
*/
|
|
174
|
+
function convertFilter(filters, prefix = '') {
|
|
175
|
+
const result = {};
|
|
176
|
+
for (const key in filters) {
|
|
177
|
+
const filterValue = filters[key];
|
|
178
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
179
|
+
if (key === 'and' || key === 'or') {
|
|
180
|
+
const logicalFilters = filterValue.map((f) => convertFilter(f));
|
|
181
|
+
result[`$${key}`] = logicalFilters;
|
|
182
|
+
}
|
|
183
|
+
else if (key === 'not') {
|
|
184
|
+
result['$not'] = convertFilter(filterValue);
|
|
185
|
+
}
|
|
186
|
+
else if (typeof filterValue === 'object' && filterValue !== null) {
|
|
187
|
+
// Check if this is a filter operation object
|
|
188
|
+
const opKeys = Object.keys(filterValue);
|
|
189
|
+
const isFilterOp = opKeys.some((k) => ['eq', 'ne', 'lt', 'gt', 'lte', 'gte', 'in', 'isDefined', 'contains', 'beginsWith', 'regex', 'iRegex', 'includes'].includes(k));
|
|
190
|
+
if (isFilterOp) {
|
|
191
|
+
result[fullKey] = convertFilterOperation(filterValue);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
// Nested object filter
|
|
195
|
+
Object.assign(result, convertFilter(filterValue, fullKey));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
result[fullKey] = filterValue;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
function convertFilterOperation(filter) {
|
|
205
|
+
const op = Object.keys(filter)[0];
|
|
206
|
+
const value = filter[op];
|
|
207
|
+
switch (op) {
|
|
208
|
+
case 'eq':
|
|
209
|
+
return { $eq: value };
|
|
210
|
+
case 'ne':
|
|
211
|
+
return { $ne: value };
|
|
212
|
+
case 'lt':
|
|
213
|
+
return { $lt: value };
|
|
214
|
+
case 'gt':
|
|
215
|
+
return { $gt: value };
|
|
216
|
+
case 'lte':
|
|
217
|
+
return { $lte: value };
|
|
218
|
+
case 'gte':
|
|
219
|
+
return { $gte: value };
|
|
220
|
+
case 'in':
|
|
221
|
+
return { $in: value };
|
|
222
|
+
case 'isDefined':
|
|
223
|
+
return { $exists: value };
|
|
224
|
+
case 'contains':
|
|
225
|
+
case 'beginsWith':
|
|
226
|
+
case 'regex':
|
|
227
|
+
return { $regex: new RegExp(op === 'beginsWith' ? `^${value}` : value) };
|
|
228
|
+
case 'iRegex':
|
|
229
|
+
return { $regex: new RegExp(value, 'i') };
|
|
230
|
+
case 'includes':
|
|
231
|
+
if (typeof value === 'string') {
|
|
232
|
+
return { $regex: new RegExp(value) };
|
|
233
|
+
}
|
|
234
|
+
return { $elemMatch: value };
|
|
235
|
+
default:
|
|
236
|
+
return value;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ReadModelEnvelope, SortFor, ProjectionFor, UUID } from '@magek/common';
|
|
2
|
+
export interface ReadModelStoreEntry extends ReadModelEnvelope {
|
|
3
|
+
uniqueKey: string;
|
|
4
|
+
}
|
|
5
|
+
export declare class MemoryReadModelRegistry {
|
|
6
|
+
private readModels;
|
|
7
|
+
private byType;
|
|
8
|
+
private getKey;
|
|
9
|
+
query(query: QueryFilter, sortBy?: SortFor<unknown>, skip?: number, limit?: number, select?: ProjectionFor<unknown>): Promise<Array<ReadModelEnvelope>>;
|
|
10
|
+
store(readModel: ReadModelEnvelope, expectedCurrentVersion: number): Promise<void>;
|
|
11
|
+
deleteById(id: UUID, typeName: string): Promise<number>;
|
|
12
|
+
deleteAll(): Promise<number>;
|
|
13
|
+
count(query?: QueryFilter): Promise<number>;
|
|
14
|
+
getCount(): number;
|
|
15
|
+
private matchesFilter;
|
|
16
|
+
private getNestedValue;
|
|
17
|
+
private toLocalSortFor;
|
|
18
|
+
private sortResults;
|
|
19
|
+
private filterFields;
|
|
20
|
+
private setNestedValue;
|
|
21
|
+
}
|
|
22
|
+
export interface QueryFilter {
|
|
23
|
+
typeName?: string;
|
|
24
|
+
'value.id'?: UUID;
|
|
25
|
+
filters?: any;
|
|
26
|
+
[key: string]: any;
|
|
27
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MemoryReadModelRegistry = void 0;
|
|
4
|
+
const common_1 = require("@magek/common");
|
|
5
|
+
const filter_evaluator_1 = require("./library/filter-evaluator");
|
|
6
|
+
class MemoryReadModelRegistry {
|
|
7
|
+
readModels = new Map();
|
|
8
|
+
byType = new Map();
|
|
9
|
+
getKey(typeName, id) {
|
|
10
|
+
return `${typeName}:${id}`;
|
|
11
|
+
}
|
|
12
|
+
async query(query, sortBy, skip, limit, select) {
|
|
13
|
+
let results = [];
|
|
14
|
+
// Filter by typeName first if provided
|
|
15
|
+
if (query.typeName) {
|
|
16
|
+
const typeKeys = this.byType.get(query.typeName);
|
|
17
|
+
if (typeKeys) {
|
|
18
|
+
for (const key of Array.from(typeKeys)) {
|
|
19
|
+
const entry = this.readModels.get(key);
|
|
20
|
+
if (entry && this.matchesFilter(entry, query)) {
|
|
21
|
+
results.push(entry);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
// Query all read models
|
|
28
|
+
for (const entry of Array.from(this.readModels.values())) {
|
|
29
|
+
if (this.matchesFilter(entry, query)) {
|
|
30
|
+
results.push(entry);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Apply sorting
|
|
35
|
+
if (sortBy && Object.keys(sortBy).length > 0) {
|
|
36
|
+
const sortedList = this.toLocalSortFor(sortBy);
|
|
37
|
+
if (sortedList) {
|
|
38
|
+
results = this.sortResults(results, sortedList);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Apply skip
|
|
42
|
+
if (skip && skip > 0) {
|
|
43
|
+
results = results.slice(skip);
|
|
44
|
+
}
|
|
45
|
+
// Apply limit
|
|
46
|
+
if (limit && limit > 0) {
|
|
47
|
+
results = results.slice(0, limit);
|
|
48
|
+
}
|
|
49
|
+
// Apply select (field projection)
|
|
50
|
+
if (select && select.length > 0) {
|
|
51
|
+
results = results.map((result) => ({
|
|
52
|
+
...result,
|
|
53
|
+
value: this.filterFields(result.value, select),
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
return results;
|
|
57
|
+
}
|
|
58
|
+
async store(readModel, expectedCurrentVersion) {
|
|
59
|
+
const key = this.getKey(readModel.typeName, readModel.value.id);
|
|
60
|
+
const version = readModel.value.magekMetadata?.version ?? 1;
|
|
61
|
+
const uniqueKey = `${readModel.typeName}_${readModel.value.id}_${version}`;
|
|
62
|
+
const entry = {
|
|
63
|
+
...readModel,
|
|
64
|
+
uniqueKey,
|
|
65
|
+
};
|
|
66
|
+
if (version === 1) {
|
|
67
|
+
// Insert new read model
|
|
68
|
+
this.readModels.set(key, entry);
|
|
69
|
+
// Index by type
|
|
70
|
+
if (!this.byType.has(readModel.typeName)) {
|
|
71
|
+
this.byType.set(readModel.typeName, new Set());
|
|
72
|
+
}
|
|
73
|
+
this.byType.get(readModel.typeName).add(key);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
// Update existing read model with optimistic concurrency check
|
|
77
|
+
const existing = this.readModels.get(key);
|
|
78
|
+
if (!existing) {
|
|
79
|
+
throw new common_1.OptimisticConcurrencyUnexpectedVersionError(`Can't update readModel ${JSON.stringify(readModel)} with expectedCurrentVersion = ${expectedCurrentVersion}. Read model not found.`);
|
|
80
|
+
}
|
|
81
|
+
const existingVersion = existing.value.magekMetadata?.version ?? 0;
|
|
82
|
+
if (existingVersion !== expectedCurrentVersion) {
|
|
83
|
+
throw new common_1.OptimisticConcurrencyUnexpectedVersionError(`Can't update readModel ${JSON.stringify(readModel)} with expectedCurrentVersion = ${expectedCurrentVersion}. Current version is ${existingVersion}.`);
|
|
84
|
+
}
|
|
85
|
+
this.readModels.set(key, entry);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async deleteById(id, typeName) {
|
|
89
|
+
const key = this.getKey(typeName, id);
|
|
90
|
+
const exists = this.readModels.has(key);
|
|
91
|
+
if (exists) {
|
|
92
|
+
this.readModels.delete(key);
|
|
93
|
+
this.byType.get(typeName)?.delete(key);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
async deleteAll() {
|
|
99
|
+
const count = this.readModels.size;
|
|
100
|
+
this.readModels.clear();
|
|
101
|
+
this.byType.clear();
|
|
102
|
+
return count;
|
|
103
|
+
}
|
|
104
|
+
async count(query) {
|
|
105
|
+
if (!query) {
|
|
106
|
+
return this.readModels.size;
|
|
107
|
+
}
|
|
108
|
+
let count = 0;
|
|
109
|
+
for (const entry of Array.from(this.readModels.values())) {
|
|
110
|
+
if (this.matchesFilter(entry, query)) {
|
|
111
|
+
count++;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return count;
|
|
115
|
+
}
|
|
116
|
+
getCount() {
|
|
117
|
+
return this.readModels.size;
|
|
118
|
+
}
|
|
119
|
+
matchesFilter(entry, query) {
|
|
120
|
+
// Check typeName
|
|
121
|
+
if (query.typeName && entry.typeName !== query.typeName) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
// Check value.id
|
|
125
|
+
if (query['value.id'] && entry.value.id !== query['value.id']) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
// Check sequence key if present
|
|
129
|
+
for (const key of Object.keys(query)) {
|
|
130
|
+
if (key.startsWith('value.') && key !== 'value.id') {
|
|
131
|
+
const fieldPath = key.substring(6); // Remove 'value.' prefix
|
|
132
|
+
const fieldValue = this.getNestedValue(entry.value, fieldPath);
|
|
133
|
+
if (fieldValue !== query[key]) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Check GraphQL-style filters
|
|
139
|
+
if (query.filters) {
|
|
140
|
+
if (!(0, filter_evaluator_1.evaluateFilter)(entry.value, query.filters)) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
getNestedValue(obj, path) {
|
|
147
|
+
const parts = path.split('.');
|
|
148
|
+
let current = obj;
|
|
149
|
+
for (const part of parts) {
|
|
150
|
+
if (current === null || current === undefined) {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
current = current[part];
|
|
154
|
+
}
|
|
155
|
+
return current;
|
|
156
|
+
}
|
|
157
|
+
toLocalSortFor(sortBy, parentKey = '', sortedList = Object.create(null)) {
|
|
158
|
+
if (!sortBy || Object.keys(sortBy).length === 0)
|
|
159
|
+
return undefined;
|
|
160
|
+
Object.entries(sortBy).forEach(([key, value]) => {
|
|
161
|
+
if (typeof value === 'string') {
|
|
162
|
+
sortedList[`value.${parentKey}${key}`] = value === 'ASC' ? 1 : -1;
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
this.toLocalSortFor(value, `${parentKey}${key}.`, sortedList);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
return sortedList;
|
|
169
|
+
}
|
|
170
|
+
sortResults(results, sortedList) {
|
|
171
|
+
return [...results].sort((a, b) => {
|
|
172
|
+
for (const [path, direction] of Object.entries(sortedList)) {
|
|
173
|
+
const aValue = this.getNestedValue(a, path);
|
|
174
|
+
const bValue = this.getNestedValue(b, path);
|
|
175
|
+
if (aValue < bValue)
|
|
176
|
+
return -1 * direction;
|
|
177
|
+
if (aValue > bValue)
|
|
178
|
+
return 1 * direction;
|
|
179
|
+
}
|
|
180
|
+
return 0;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
filterFields(obj, select) {
|
|
184
|
+
const result = Object.create(null);
|
|
185
|
+
select.forEach((field) => {
|
|
186
|
+
const parts = field.split('.');
|
|
187
|
+
this.setNestedValue(result, obj, parts);
|
|
188
|
+
});
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
setNestedValue(result, source, parts) {
|
|
192
|
+
let currentResult = result;
|
|
193
|
+
let currentSource = source;
|
|
194
|
+
for (let i = 0; i < parts.length; i++) {
|
|
195
|
+
const part = parts[i];
|
|
196
|
+
const isLast = i === parts.length - 1;
|
|
197
|
+
if (part.endsWith('[]')) {
|
|
198
|
+
const arrayField = part.slice(0, -2);
|
|
199
|
+
if (!Array.isArray(currentSource[arrayField])) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (!currentResult[arrayField]) {
|
|
203
|
+
currentResult[arrayField] = [];
|
|
204
|
+
}
|
|
205
|
+
if (isLast) {
|
|
206
|
+
currentResult[arrayField] = currentSource[arrayField];
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
currentSource[arrayField].forEach((item, index) => {
|
|
210
|
+
if (!currentResult[arrayField][index]) {
|
|
211
|
+
currentResult[arrayField][index] = Object.create(null);
|
|
212
|
+
}
|
|
213
|
+
this.setNestedValue(currentResult[arrayField][index], item, parts.slice(i + 1));
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
if (isLast) {
|
|
219
|
+
if (currentSource[part] !== undefined) {
|
|
220
|
+
currentResult[part] = currentSource[part];
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
if (!currentSource[part]) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (!currentResult[part]) {
|
|
228
|
+
currentResult[part] = Array.isArray(currentSource[part]) ? [] : Object.create(null);
|
|
229
|
+
}
|
|
230
|
+
currentResult = currentResult[part];
|
|
231
|
+
currentSource = currentSource[part];
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
exports.MemoryReadModelRegistry = MemoryReadModelRegistry;
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@magek/adapter-read-model-store-memory",
|
|
3
|
+
"version": "0.0.10",
|
|
4
|
+
"description": "In-memory read model store adapter for the Magek framework",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"read-model-store",
|
|
7
|
+
"memory",
|
|
8
|
+
"in-memory"
|
|
9
|
+
],
|
|
10
|
+
"author": "Boosterin Labs SLU",
|
|
11
|
+
"homepage": "https://magek.ai",
|
|
12
|
+
"license": "Apache-2.0",
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"main": "dist/index.js",
|
|
17
|
+
"files": [
|
|
18
|
+
"dist"
|
|
19
|
+
],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/theam/magek.git"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22.0.0 <23.0.0"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@magek/common": "workspace:^0.0.10",
|
|
29
|
+
"tslib": "2.8.1"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"format": "prettier --write --ext '.js,.ts' **/*.ts **/*/*.ts",
|
|
33
|
+
"lint:check": "eslint \"**/*.ts\"",
|
|
34
|
+
"lint:fix": "eslint --quiet --fix \"**/*.ts\"",
|
|
35
|
+
"build": "tsc -b tsconfig.json",
|
|
36
|
+
"clean": "rimraf ./dist tsconfig.tsbuildinfo",
|
|
37
|
+
"prepack": "tsc -b tsconfig.json",
|
|
38
|
+
"test": "tsc --noEmit -p tsconfig.test.json && c8 mocha --forbid-only \"test/**/*.test.ts\""
|
|
39
|
+
},
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/theam/magek/issues"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@magek/eslint-config": "workspace:^0.0.10",
|
|
45
|
+
"@types/chai": "5.2.3",
|
|
46
|
+
"@types/chai-as-promised": "8.0.2",
|
|
47
|
+
"@types/mocha": "10.0.10",
|
|
48
|
+
"@types/node": "22.19.8",
|
|
49
|
+
"@types/sinon": "21.0.0",
|
|
50
|
+
"@types/sinon-chai": "4.0.0",
|
|
51
|
+
"chai": "6.2.2",
|
|
52
|
+
"chai-as-promised": "8.0.2",
|
|
53
|
+
"@faker-js/faker": "10.2.0",
|
|
54
|
+
"mocha": "11.7.5",
|
|
55
|
+
"c8": "^10.1.3",
|
|
56
|
+
"rimraf": "6.1.2",
|
|
57
|
+
"sinon": "21.0.1",
|
|
58
|
+
"sinon-chai": "4.0.1",
|
|
59
|
+
"tsx": "^4.19.2",
|
|
60
|
+
"typescript": "5.9.3"
|
|
61
|
+
}
|
|
62
|
+
}
|