redis-graph-cache 1.0.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.
- package/LICENSE +21 -0
- package/README.md +857 -0
- package/dist/core/compression.d.ts +58 -0
- package/dist/core/compression.js +115 -0
- package/dist/core/hydration-engine.d.ts +68 -0
- package/dist/core/hydration-engine.js +288 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.js +29 -0
- package/dist/core/lua-scripts.d.ts +148 -0
- package/dist/core/lua-scripts.js +259 -0
- package/dist/core/normalization-engine.d.ts +76 -0
- package/dist/core/normalization-engine.js +286 -0
- package/dist/core/redis-connection-manager.d.ts +255 -0
- package/dist/core/redis-connection-manager.js +745 -0
- package/dist/core/schema-manager.d.ts +133 -0
- package/dist/core/schema-manager.js +432 -0
- package/dist/core/serializer.d.ts +69 -0
- package/dist/core/serializer.js +187 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +32 -0
- package/dist/redis-graph-cache.d.ts +302 -0
- package/dist/redis-graph-cache.js +839 -0
- package/dist/types/config.types.d.ts +138 -0
- package/dist/types/config.types.js +5 -0
- package/dist/types/error.types.d.ts +54 -0
- package/dist/types/error.types.js +89 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/index.js +29 -0
- package/dist/types/internal.types.d.ts +67 -0
- package/dist/types/internal.types.js +73 -0
- package/dist/types/operation.types.d.ts +101 -0
- package/dist/types/operation.types.js +5 -0
- package/dist/types/schema.types.d.ts +104 -0
- package/dist/types/schema.types.js +5 -0
- package/package.json +44 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema Manager - Validates and manages entity and list schemas
|
|
3
|
+
*/
|
|
4
|
+
import { Schema, EntitySchema, ListSchema, IndexedListSchema, RelationDefinition } from '../types';
|
|
5
|
+
export declare class SchemaManager {
|
|
6
|
+
private schemas;
|
|
7
|
+
private entitySchemas;
|
|
8
|
+
private listSchemas;
|
|
9
|
+
private indexedListSchemas;
|
|
10
|
+
private validationCache;
|
|
11
|
+
private defaultTTL;
|
|
12
|
+
constructor(schema: Schema, defaultTTL?: number);
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the TTL (seconds) to apply when writing an entity. Schema-level
|
|
15
|
+
* `ttl` wins; otherwise falls back to the engine-wide defaultTTL. Returns
|
|
16
|
+
* 0 when no TTL should be applied (key persists indefinitely).
|
|
17
|
+
*/
|
|
18
|
+
resolveEntityTTL(entityType: string): number;
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the TTL (seconds) for list keys. Schema-level `ttl` on the list
|
|
21
|
+
* wins; otherwise falls back to engine defaultTTL.
|
|
22
|
+
*/
|
|
23
|
+
resolveListTTL(listType: string): number;
|
|
24
|
+
/**
|
|
25
|
+
* Resolve TTL for an indexed-list ZSET key.
|
|
26
|
+
*/
|
|
27
|
+
resolveIndexedListTTL(listType: string): number;
|
|
28
|
+
/**
|
|
29
|
+
* Internal namespace prefix used for membership back-index keys. Kept
|
|
30
|
+
* isolated so user schemas cannot accidentally collide with it.
|
|
31
|
+
*/
|
|
32
|
+
static readonly MEMBERSHIP_PREFIX = "__rse_membership";
|
|
33
|
+
/**
|
|
34
|
+
* Build the back-index key tracking which list keys currently contain a
|
|
35
|
+
* given entity. Used by indexed lists with `trackMembership: true` so
|
|
36
|
+
* cascade invalidation can find and remove the entity from every list.
|
|
37
|
+
*/
|
|
38
|
+
generateMembershipKey(entityType: string, id: string | number): string;
|
|
39
|
+
/**
|
|
40
|
+
* Register and validate a complete schema
|
|
41
|
+
*/
|
|
42
|
+
registerSchema(schema: Schema): void;
|
|
43
|
+
/**
|
|
44
|
+
* Get entity schema by name
|
|
45
|
+
*/
|
|
46
|
+
getEntitySchema(entityType: string): EntitySchema;
|
|
47
|
+
/**
|
|
48
|
+
* Get list schema by name
|
|
49
|
+
*/
|
|
50
|
+
getListSchema(listType: string): ListSchema;
|
|
51
|
+
/**
|
|
52
|
+
* Get indexed-list schema by name. Throws if not registered or not the
|
|
53
|
+
* indexed kind, so callers can rely on the returned schema's structure.
|
|
54
|
+
*/
|
|
55
|
+
getIndexedListSchema(listType: string): IndexedListSchema;
|
|
56
|
+
hasIndexedListSchema(listType: string): boolean;
|
|
57
|
+
getIndexedListSchemaNames(): string[];
|
|
58
|
+
/**
|
|
59
|
+
* Generate Redis key for an indexed list, using its schema-supplied key
|
|
60
|
+
* function. Same pattern as `generateListKey` for plain lists.
|
|
61
|
+
*/
|
|
62
|
+
generateIndexedListKey(listType: string, ...params: any[]): string;
|
|
63
|
+
/**
|
|
64
|
+
* Check if entity schema exists
|
|
65
|
+
*/
|
|
66
|
+
hasEntitySchema(entityType: string): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Check if list schema exists
|
|
69
|
+
*/
|
|
70
|
+
hasListSchema(listType: string): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Get all entity schema names
|
|
73
|
+
*/
|
|
74
|
+
getEntitySchemaNames(): string[];
|
|
75
|
+
/**
|
|
76
|
+
* Get all list schema names
|
|
77
|
+
*/
|
|
78
|
+
getListSchemaNames(): string[];
|
|
79
|
+
/**
|
|
80
|
+
* Validate complete schema for consistency and relationships
|
|
81
|
+
*/
|
|
82
|
+
private validateCompleteSchema;
|
|
83
|
+
/**
|
|
84
|
+
* Validate individual entity schema
|
|
85
|
+
*/
|
|
86
|
+
private validateEntitySchema;
|
|
87
|
+
/**
|
|
88
|
+
* Validate individual list schema
|
|
89
|
+
*/
|
|
90
|
+
private validateListSchema;
|
|
91
|
+
/**
|
|
92
|
+
* Validate an indexed list schema. Mirrors validateListSchema and adds
|
|
93
|
+
* the ZSET-specific checks. The score field, when given, isn't bound to
|
|
94
|
+
* any field type because we only call `Number(...)` on it at insert time.
|
|
95
|
+
*/
|
|
96
|
+
private validateIndexedListSchema;
|
|
97
|
+
/**
|
|
98
|
+
* Validate relationship definition
|
|
99
|
+
*/
|
|
100
|
+
private validateRelationDefinition;
|
|
101
|
+
/**
|
|
102
|
+
* Detect circular dependencies in relationships
|
|
103
|
+
*/
|
|
104
|
+
private detectCircularDependencies;
|
|
105
|
+
/**
|
|
106
|
+
* Generate Redis key for entity
|
|
107
|
+
*/
|
|
108
|
+
generateEntityKey(entityType: string, id: string | number): string;
|
|
109
|
+
/**
|
|
110
|
+
* Generate Redis key for list
|
|
111
|
+
*/
|
|
112
|
+
generateListKey(listType: string, ...params: any[]): string;
|
|
113
|
+
/**
|
|
114
|
+
* Get entity ID field name
|
|
115
|
+
*/
|
|
116
|
+
getEntityIdField(entityType: string): string;
|
|
117
|
+
/**
|
|
118
|
+
* Get list ID field name
|
|
119
|
+
*/
|
|
120
|
+
getListIdField(listType: string): string;
|
|
121
|
+
/**
|
|
122
|
+
* Check if field is defined in entity schema
|
|
123
|
+
*/
|
|
124
|
+
hasEntityField(entityType: string, fieldName: string): boolean;
|
|
125
|
+
/**
|
|
126
|
+
* Check if relation is defined in entity schema
|
|
127
|
+
*/
|
|
128
|
+
hasEntityRelation(entityType: string, relationName: string): boolean;
|
|
129
|
+
/**
|
|
130
|
+
* Get entity relation definition
|
|
131
|
+
*/
|
|
132
|
+
getEntityRelation(entityType: string, relationName: string): RelationDefinition;
|
|
133
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Schema Manager - Validates and manages entity and list schemas
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.SchemaManager = void 0;
|
|
7
|
+
const types_1 = require("../types");
|
|
8
|
+
class SchemaManager {
|
|
9
|
+
constructor(schema, defaultTTL = 0) {
|
|
10
|
+
this.schemas = new Map();
|
|
11
|
+
this.entitySchemas = new Map();
|
|
12
|
+
this.listSchemas = new Map();
|
|
13
|
+
this.indexedListSchemas = new Map();
|
|
14
|
+
this.validationCache = new Map();
|
|
15
|
+
this.defaultTTL = defaultTTL;
|
|
16
|
+
this.registerSchema(schema);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the TTL (seconds) to apply when writing an entity. Schema-level
|
|
20
|
+
* `ttl` wins; otherwise falls back to the engine-wide defaultTTL. Returns
|
|
21
|
+
* 0 when no TTL should be applied (key persists indefinitely).
|
|
22
|
+
*/
|
|
23
|
+
resolveEntityTTL(entityType) {
|
|
24
|
+
const schema = this.entitySchemas.get(entityType);
|
|
25
|
+
if (schema?.ttl !== undefined)
|
|
26
|
+
return schema.ttl;
|
|
27
|
+
return this.defaultTTL;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the TTL (seconds) for list keys. Schema-level `ttl` on the list
|
|
31
|
+
* wins; otherwise falls back to engine defaultTTL.
|
|
32
|
+
*/
|
|
33
|
+
resolveListTTL(listType) {
|
|
34
|
+
const schema = this.listSchemas.get(listType);
|
|
35
|
+
if (schema?.ttl !== undefined)
|
|
36
|
+
return schema.ttl;
|
|
37
|
+
return this.defaultTTL;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Resolve TTL for an indexed-list ZSET key.
|
|
41
|
+
*/
|
|
42
|
+
resolveIndexedListTTL(listType) {
|
|
43
|
+
const schema = this.indexedListSchemas.get(listType);
|
|
44
|
+
if (schema?.ttl !== undefined)
|
|
45
|
+
return schema.ttl;
|
|
46
|
+
return this.defaultTTL;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Build the back-index key tracking which list keys currently contain a
|
|
50
|
+
* given entity. Used by indexed lists with `trackMembership: true` so
|
|
51
|
+
* cascade invalidation can find and remove the entity from every list.
|
|
52
|
+
*/
|
|
53
|
+
generateMembershipKey(entityType, id) {
|
|
54
|
+
return `${SchemaManager.MEMBERSHIP_PREFIX}:${entityType}:${id}`;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Register and validate a complete schema
|
|
58
|
+
*/
|
|
59
|
+
registerSchema(schema) {
|
|
60
|
+
// Clear existing schemas
|
|
61
|
+
this.schemas.clear();
|
|
62
|
+
this.entitySchemas.clear();
|
|
63
|
+
this.listSchemas.clear();
|
|
64
|
+
this.indexedListSchemas.clear();
|
|
65
|
+
this.validationCache.clear();
|
|
66
|
+
// First pass: register all schemas keyed by name and bucket by kind so
|
|
67
|
+
// later validation passes can reference siblings by name.
|
|
68
|
+
for (const [name, definition] of Object.entries(schema)) {
|
|
69
|
+
this.schemas.set(name, definition);
|
|
70
|
+
if (definition.type === 'entity') {
|
|
71
|
+
this.entitySchemas.set(name, definition);
|
|
72
|
+
}
|
|
73
|
+
else if (definition.type === 'list') {
|
|
74
|
+
this.listSchemas.set(name, definition);
|
|
75
|
+
}
|
|
76
|
+
else if (definition.type === 'indexedList') {
|
|
77
|
+
this.indexedListSchemas.set(name, definition);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Second pass: Validate all schemas
|
|
81
|
+
const validationResult = this.validateCompleteSchema();
|
|
82
|
+
if (!validationResult.isValid) {
|
|
83
|
+
throw new types_1.SchemaValidationError(`Schema validation failed: ${validationResult.errors.join(', ')}`, {
|
|
84
|
+
errors: validationResult.errors,
|
|
85
|
+
warnings: validationResult.warnings,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Get entity schema by name
|
|
91
|
+
*/
|
|
92
|
+
getEntitySchema(entityType) {
|
|
93
|
+
const schema = this.entitySchemas.get(entityType);
|
|
94
|
+
if (!schema) {
|
|
95
|
+
throw new types_1.InvalidOperationError('getEntitySchema', `Entity schema '${entityType}' not found`, { entityType, availableSchemas: Array.from(this.entitySchemas.keys()) });
|
|
96
|
+
}
|
|
97
|
+
return schema;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Get list schema by name
|
|
101
|
+
*/
|
|
102
|
+
getListSchema(listType) {
|
|
103
|
+
const schema = this.listSchemas.get(listType);
|
|
104
|
+
if (!schema) {
|
|
105
|
+
throw new types_1.InvalidOperationError('getListSchema', `List schema '${listType}' not found`, { listType, availableSchemas: Array.from(this.listSchemas.keys()) });
|
|
106
|
+
}
|
|
107
|
+
return schema;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Get indexed-list schema by name. Throws if not registered or not the
|
|
111
|
+
* indexed kind, so callers can rely on the returned schema's structure.
|
|
112
|
+
*/
|
|
113
|
+
getIndexedListSchema(listType) {
|
|
114
|
+
const schema = this.indexedListSchemas.get(listType);
|
|
115
|
+
if (!schema) {
|
|
116
|
+
throw new types_1.InvalidOperationError('getIndexedListSchema', `Indexed list schema '${listType}' not found`, {
|
|
117
|
+
listType,
|
|
118
|
+
availableSchemas: Array.from(this.indexedListSchemas.keys()),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return schema;
|
|
122
|
+
}
|
|
123
|
+
hasIndexedListSchema(listType) {
|
|
124
|
+
return this.indexedListSchemas.has(listType);
|
|
125
|
+
}
|
|
126
|
+
getIndexedListSchemaNames() {
|
|
127
|
+
return Array.from(this.indexedListSchemas.keys());
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Generate Redis key for an indexed list, using its schema-supplied key
|
|
131
|
+
* function. Same pattern as `generateListKey` for plain lists.
|
|
132
|
+
*/
|
|
133
|
+
generateIndexedListKey(listType, ...params) {
|
|
134
|
+
const schema = this.getIndexedListSchema(listType);
|
|
135
|
+
return schema.key(...params);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Check if entity schema exists
|
|
139
|
+
*/
|
|
140
|
+
hasEntitySchema(entityType) {
|
|
141
|
+
return this.entitySchemas.has(entityType);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Check if list schema exists
|
|
145
|
+
*/
|
|
146
|
+
hasListSchema(listType) {
|
|
147
|
+
return this.listSchemas.has(listType);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Get all entity schema names
|
|
151
|
+
*/
|
|
152
|
+
getEntitySchemaNames() {
|
|
153
|
+
return Array.from(this.entitySchemas.keys());
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Get all list schema names
|
|
157
|
+
*/
|
|
158
|
+
getListSchemaNames() {
|
|
159
|
+
return Array.from(this.listSchemas.keys());
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Validate complete schema for consistency and relationships
|
|
163
|
+
*/
|
|
164
|
+
validateCompleteSchema() {
|
|
165
|
+
const errors = [];
|
|
166
|
+
const warnings = [];
|
|
167
|
+
// Validate each entity schema
|
|
168
|
+
for (const name of Array.from(this.entitySchemas.keys())) {
|
|
169
|
+
const schema = this.entitySchemas.get(name);
|
|
170
|
+
const result = this.validateEntitySchema(name, schema);
|
|
171
|
+
errors.push(...result.errors);
|
|
172
|
+
warnings.push(...result.warnings);
|
|
173
|
+
}
|
|
174
|
+
// Validate each list schema
|
|
175
|
+
for (const name of Array.from(this.listSchemas.keys())) {
|
|
176
|
+
const schema = this.listSchemas.get(name);
|
|
177
|
+
const result = this.validateListSchema(name, schema);
|
|
178
|
+
errors.push(...result.errors);
|
|
179
|
+
warnings.push(...result.warnings);
|
|
180
|
+
}
|
|
181
|
+
// Validate each indexed-list schema. Same baseline rules as list, plus
|
|
182
|
+
// ZSET-specific checks (scoreField name, maxSize positivity).
|
|
183
|
+
for (const name of Array.from(this.indexedListSchemas.keys())) {
|
|
184
|
+
const schema = this.indexedListSchemas.get(name);
|
|
185
|
+
const result = this.validateIndexedListSchema(name, schema);
|
|
186
|
+
errors.push(...result.errors);
|
|
187
|
+
warnings.push(...result.warnings);
|
|
188
|
+
}
|
|
189
|
+
// Check for circular dependencies
|
|
190
|
+
const circularDeps = this.detectCircularDependencies();
|
|
191
|
+
if (circularDeps.length > 0) {
|
|
192
|
+
warnings.push(`Circular dependencies detected: ${circularDeps.join(', ')}`);
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
isValid: errors.length === 0,
|
|
196
|
+
errors,
|
|
197
|
+
warnings,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Validate individual entity schema
|
|
202
|
+
*/
|
|
203
|
+
validateEntitySchema(name, schema) {
|
|
204
|
+
const errors = [];
|
|
205
|
+
const warnings = [];
|
|
206
|
+
// Validate required fields
|
|
207
|
+
if (!schema.id || typeof schema.id !== 'string') {
|
|
208
|
+
errors.push(`Entity '${name}': id field must be a non-empty string`);
|
|
209
|
+
}
|
|
210
|
+
if (!schema.key || typeof schema.key !== 'function') {
|
|
211
|
+
errors.push(`Entity '${name}': key must be a function`);
|
|
212
|
+
}
|
|
213
|
+
// Validate field definitions
|
|
214
|
+
if (schema.fields) {
|
|
215
|
+
for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
|
|
216
|
+
if (!fieldDef.type ||
|
|
217
|
+
![
|
|
218
|
+
'string',
|
|
219
|
+
'number',
|
|
220
|
+
'boolean',
|
|
221
|
+
'object',
|
|
222
|
+
'array',
|
|
223
|
+
// Extended types preserved losslessly by the default
|
|
224
|
+
// tagged serializer. Listed here purely for schema
|
|
225
|
+
// validation; the engine still does not coerce values
|
|
226
|
+
// based on the declared type.
|
|
227
|
+
'date',
|
|
228
|
+
'bigint',
|
|
229
|
+
'map',
|
|
230
|
+
'set',
|
|
231
|
+
'regexp',
|
|
232
|
+
'buffer',
|
|
233
|
+
].includes(fieldDef.type)) {
|
|
234
|
+
errors.push(`Entity '${name}', field '${fieldName}': invalid field type '${fieldDef.type}'`);
|
|
235
|
+
}
|
|
236
|
+
// Check for internal field prefix conflicts
|
|
237
|
+
if (fieldName.startsWith('__rse_')) {
|
|
238
|
+
errors.push(`Entity '${name}', field '${fieldName}': field names cannot start with '__rse_' (reserved for internal use)`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Validate relationship definitions
|
|
243
|
+
if (schema.relations) {
|
|
244
|
+
for (const [relationName, relationDef] of Object.entries(schema.relations)) {
|
|
245
|
+
const relationErrors = this.validateRelationDefinition(name, relationName, relationDef);
|
|
246
|
+
errors.push(...relationErrors);
|
|
247
|
+
// Check for internal field prefix conflicts
|
|
248
|
+
if (relationName.startsWith('__rse_')) {
|
|
249
|
+
errors.push(`Entity '${name}', relation '${relationName}': relation names cannot start with '__rse_' (reserved for internal use)`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Validate TTL
|
|
254
|
+
if (schema.ttl !== undefined &&
|
|
255
|
+
(typeof schema.ttl !== 'number' || schema.ttl < 0)) {
|
|
256
|
+
errors.push(`Entity '${name}': ttl must be a positive number`);
|
|
257
|
+
}
|
|
258
|
+
return { isValid: errors.length === 0, errors, warnings };
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Validate individual list schema
|
|
262
|
+
*/
|
|
263
|
+
validateListSchema(name, schema) {
|
|
264
|
+
const errors = [];
|
|
265
|
+
const warnings = [];
|
|
266
|
+
// Validate required fields
|
|
267
|
+
if (!schema.entityType || typeof schema.entityType !== 'string') {
|
|
268
|
+
errors.push(`List '${name}': entityType must be a non-empty string`);
|
|
269
|
+
}
|
|
270
|
+
else if (!this.hasEntitySchema(schema.entityType)) {
|
|
271
|
+
errors.push(`List '${name}': referenced entity type '${schema.entityType}' does not exist`);
|
|
272
|
+
}
|
|
273
|
+
if (!schema.key || typeof schema.key !== 'function') {
|
|
274
|
+
errors.push(`List '${name}': key must be a function`);
|
|
275
|
+
}
|
|
276
|
+
if (!schema.idField || typeof schema.idField !== 'string') {
|
|
277
|
+
errors.push(`List '${name}': idField must be a non-empty string`);
|
|
278
|
+
}
|
|
279
|
+
// Validate TTL
|
|
280
|
+
if (schema.ttl !== undefined &&
|
|
281
|
+
(typeof schema.ttl !== 'number' || schema.ttl < 0)) {
|
|
282
|
+
errors.push(`List '${name}': ttl must be a positive number`);
|
|
283
|
+
}
|
|
284
|
+
return { isValid: errors.length === 0, errors, warnings };
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Validate an indexed list schema. Mirrors validateListSchema and adds
|
|
288
|
+
* the ZSET-specific checks. The score field, when given, isn't bound to
|
|
289
|
+
* any field type because we only call `Number(...)` on it at insert time.
|
|
290
|
+
*/
|
|
291
|
+
validateIndexedListSchema(name, schema) {
|
|
292
|
+
const errors = [];
|
|
293
|
+
const warnings = [];
|
|
294
|
+
if (!schema.entityType || typeof schema.entityType !== 'string') {
|
|
295
|
+
errors.push(`Indexed list '${name}': entityType must be a non-empty string`);
|
|
296
|
+
}
|
|
297
|
+
else if (!this.hasEntitySchema(schema.entityType)) {
|
|
298
|
+
errors.push(`Indexed list '${name}': referenced entity type '${schema.entityType}' does not exist`);
|
|
299
|
+
}
|
|
300
|
+
if (!schema.key || typeof schema.key !== 'function') {
|
|
301
|
+
errors.push(`Indexed list '${name}': key must be a function`);
|
|
302
|
+
}
|
|
303
|
+
if (!schema.idField || typeof schema.idField !== 'string') {
|
|
304
|
+
errors.push(`Indexed list '${name}': idField must be a non-empty string`);
|
|
305
|
+
}
|
|
306
|
+
if (schema.scoreField !== undefined &&
|
|
307
|
+
(typeof schema.scoreField !== 'string' || schema.scoreField.length === 0)) {
|
|
308
|
+
errors.push(`Indexed list '${name}': scoreField must be a non-empty string when provided`);
|
|
309
|
+
}
|
|
310
|
+
if (schema.maxSize !== undefined &&
|
|
311
|
+
(!Number.isFinite(schema.maxSize) || schema.maxSize <= 0)) {
|
|
312
|
+
errors.push(`Indexed list '${name}': maxSize must be a positive number`);
|
|
313
|
+
}
|
|
314
|
+
if (schema.ttl !== undefined &&
|
|
315
|
+
(typeof schema.ttl !== 'number' || schema.ttl < 0)) {
|
|
316
|
+
errors.push(`Indexed list '${name}': ttl must be a positive number`);
|
|
317
|
+
}
|
|
318
|
+
return { isValid: errors.length === 0, errors, warnings };
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Validate relationship definition
|
|
322
|
+
*/
|
|
323
|
+
validateRelationDefinition(entityName, relationName, relation) {
|
|
324
|
+
const errors = [];
|
|
325
|
+
// Validate relation type exists
|
|
326
|
+
if (!relation.type || typeof relation.type !== 'string') {
|
|
327
|
+
errors.push(`Entity '${entityName}', relation '${relationName}': type must be a non-empty string`);
|
|
328
|
+
}
|
|
329
|
+
else if (!this.hasEntitySchema(relation.type)) {
|
|
330
|
+
errors.push(`Entity '${entityName}', relation '${relationName}': referenced entity type '${relation.type}' does not exist`);
|
|
331
|
+
}
|
|
332
|
+
// Validate relation kind
|
|
333
|
+
if (!relation.kind || !['one', 'many'].includes(relation.kind)) {
|
|
334
|
+
errors.push(`Entity '${entityName}', relation '${relationName}': kind must be 'one' or 'many'`);
|
|
335
|
+
}
|
|
336
|
+
return errors;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Detect circular dependencies in relationships
|
|
340
|
+
*/
|
|
341
|
+
detectCircularDependencies() {
|
|
342
|
+
const visited = new Set();
|
|
343
|
+
const recursionStack = new Set();
|
|
344
|
+
const cycles = [];
|
|
345
|
+
const dfs = (entityType, path) => {
|
|
346
|
+
if (recursionStack.has(entityType)) {
|
|
347
|
+
const cycleStart = path.indexOf(entityType);
|
|
348
|
+
const cycle = path.slice(cycleStart).concat(entityType);
|
|
349
|
+
cycles.push(cycle.join(' -> '));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (visited.has(entityType)) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
visited.add(entityType);
|
|
356
|
+
recursionStack.add(entityType);
|
|
357
|
+
const schema = this.entitySchemas.get(entityType);
|
|
358
|
+
if (schema?.relations) {
|
|
359
|
+
for (const relation of Object.values(schema.relations)) {
|
|
360
|
+
if (this.hasEntitySchema(relation.type)) {
|
|
361
|
+
dfs(relation.type, [...path, entityType]);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
recursionStack.delete(entityType);
|
|
366
|
+
};
|
|
367
|
+
for (const entityType of Array.from(this.entitySchemas.keys())) {
|
|
368
|
+
if (!visited.has(entityType)) {
|
|
369
|
+
dfs(entityType, []);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return cycles;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Generate Redis key for entity
|
|
376
|
+
*/
|
|
377
|
+
generateEntityKey(entityType, id) {
|
|
378
|
+
const schema = this.getEntitySchema(entityType);
|
|
379
|
+
return schema.key(id);
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Generate Redis key for list
|
|
383
|
+
*/
|
|
384
|
+
generateListKey(listType, ...params) {
|
|
385
|
+
const schema = this.getListSchema(listType);
|
|
386
|
+
return schema.key(...params);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Get entity ID field name
|
|
390
|
+
*/
|
|
391
|
+
getEntityIdField(entityType) {
|
|
392
|
+
const schema = this.getEntitySchema(entityType);
|
|
393
|
+
return schema.id;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Get list ID field name
|
|
397
|
+
*/
|
|
398
|
+
getListIdField(listType) {
|
|
399
|
+
const schema = this.getListSchema(listType);
|
|
400
|
+
return schema.idField;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Check if field is defined in entity schema
|
|
404
|
+
*/
|
|
405
|
+
hasEntityField(entityType, fieldName) {
|
|
406
|
+
const schema = this.getEntitySchema(entityType);
|
|
407
|
+
return schema.fields ? fieldName in schema.fields : false;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Check if relation is defined in entity schema
|
|
411
|
+
*/
|
|
412
|
+
hasEntityRelation(entityType, relationName) {
|
|
413
|
+
const schema = this.getEntitySchema(entityType);
|
|
414
|
+
return schema.relations ? relationName in schema.relations : false;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Get entity relation definition
|
|
418
|
+
*/
|
|
419
|
+
getEntityRelation(entityType, relationName) {
|
|
420
|
+
const schema = this.getEntitySchema(entityType);
|
|
421
|
+
if (!schema.relations || !(relationName in schema.relations)) {
|
|
422
|
+
throw new types_1.InvalidOperationError('getEntityRelation', `Relation '${relationName}' not found in entity '${entityType}'`, { entityType, relationName });
|
|
423
|
+
}
|
|
424
|
+
return schema.relations[relationName];
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
exports.SchemaManager = SchemaManager;
|
|
428
|
+
/**
|
|
429
|
+
* Internal namespace prefix used for membership back-index keys. Kept
|
|
430
|
+
* isolated so user schemas cannot accidentally collide with it.
|
|
431
|
+
*/
|
|
432
|
+
SchemaManager.MEMBERSHIP_PREFIX = '__rse_membership';
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serializer - Pluggable (de)serialization for entity payloads.
|
|
3
|
+
*
|
|
4
|
+
* The default serializer (`TAGGED_SERIALIZER`) is a lossless wrapper
|
|
5
|
+
* around `JSON.stringify` / `JSON.parse` that preserves JS types JSON
|
|
6
|
+
* cannot represent natively. It uses a tag-based envelope only for
|
|
7
|
+
* non-JSON-native values; everything else round-trips identically to
|
|
8
|
+
* plain `JSON.stringify` so the on-wire format is unchanged for
|
|
9
|
+
* ordinary data (strings, numbers, booleans, plain objects, arrays,
|
|
10
|
+
* nulls).
|
|
11
|
+
*
|
|
12
|
+
* Why the engine needs this:
|
|
13
|
+
* - `Date` becomes an ISO string via `Date.prototype.toJSON` and
|
|
14
|
+
* parses back as a string, breaking `post.createdAt.getTime()`.
|
|
15
|
+
* - `BigInt` throws `TypeError` from `JSON.stringify`.
|
|
16
|
+
* - `Map`, `Set`, `RegExp`, `Buffer` serialize to `{}`, losing all
|
|
17
|
+
* contents.
|
|
18
|
+
* - `NaN`, `Infinity`, `-Infinity` become `null`, silently corrupting
|
|
19
|
+
* numeric stats fields.
|
|
20
|
+
*
|
|
21
|
+
* Out of scope (intentionally NOT tagged):
|
|
22
|
+
* - `undefined`. The engine's merge semantics treat
|
|
23
|
+
* "field is undefined" identically to "field is omitted" so that
|
|
24
|
+
* partial updates don't accidentally clobber existing values.
|
|
25
|
+
* See `NULL_UNDEFINED_HANDLING.md`. Tagging undefined would
|
|
26
|
+
* silently change that contract.
|
|
27
|
+
*
|
|
28
|
+
* Backward compatibility:
|
|
29
|
+
* - Reads of plain JSON written by older versions (or by any process
|
|
30
|
+
* without this engine) work unchanged. The reviver only transforms
|
|
31
|
+
* objects that carry the internal tag key.
|
|
32
|
+
* - Writes that contain only JSON-native values produce byte-for-byte
|
|
33
|
+
* identical output to the previous `JSON.stringify` path.
|
|
34
|
+
*
|
|
35
|
+
* Pluggability:
|
|
36
|
+
* - Consumers can opt out by passing `cache.serializer = JSON_SERIALIZER`
|
|
37
|
+
* (raw, no tagging — original behaviour).
|
|
38
|
+
* - Consumers can plug in a third-party codec like `superjson` or
|
|
39
|
+
* `devalue` by passing any object that implements `Serializer`.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* Minimal contract every serializer must satisfy. The engine never
|
|
43
|
+
* inspects intermediate forms; it only ever calls `stringify` to
|
|
44
|
+
* produce the on-wire string and `parse` to recover the structured
|
|
45
|
+
* value.
|
|
46
|
+
*/
|
|
47
|
+
export interface Serializer {
|
|
48
|
+
/** Serialize a structured value to the string Redis stores. */
|
|
49
|
+
stringify(value: any): string;
|
|
50
|
+
/** Deserialize a string read from Redis back to a structured value. */
|
|
51
|
+
parse(value: string): any;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Default serializer. Lossless for `Date`, `BigInt`, `Map`, `Set`,
|
|
55
|
+
* `RegExp`, `Buffer`, `NaN`, `+Infinity`, `-Infinity`. Identical to
|
|
56
|
+
* plain JSON for all other values.
|
|
57
|
+
*/
|
|
58
|
+
export declare const TAGGED_SERIALIZER: Serializer;
|
|
59
|
+
/**
|
|
60
|
+
* Plain JSON serializer. No tagging, no reviver. Use this when you
|
|
61
|
+
* know your data is JSON-native and want zero serialization overhead,
|
|
62
|
+
* or when interoperating with non-engine readers that expect raw JSON.
|
|
63
|
+
*
|
|
64
|
+
* Trade-off: `Date` survives as ISO string only, `BigInt` will throw,
|
|
65
|
+
* `Map`/`Set`/`RegExp`/`Buffer` lose all data, `NaN`/`Infinity` become
|
|
66
|
+
* `null`. This is the historical engine behaviour; kept available for
|
|
67
|
+
* opt-out.
|
|
68
|
+
*/
|
|
69
|
+
export declare const JSON_SERIALIZER: Serializer;
|