@karmaniverous/entity-manager 0.0.8
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/.env +1 -0
- package/README.md +298 -0
- package/dist/default/lib/EntityManager/EntityManager.js +115 -0
- package/dist/default/lib/EntityManager/PrivateEntityManager.js +408 -0
- package/dist/default/lib/index.js +12 -0
- package/dist/package.json +3 -0
- package/lib/EntityManager/EntityManager.js +113 -0
- package/lib/EntityManager/PrivateEntityManager.js +381 -0
- package/lib/index.js +1 -0
- package/package.json +85 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { validate } from 'jsonschema';
|
|
2
|
+
import _ from 'lodash';
|
|
3
|
+
import stringHash from 'string-hash';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* EntityManager config validation schema.
|
|
7
|
+
*
|
|
8
|
+
* @private
|
|
9
|
+
*/
|
|
10
|
+
const configSchema = {
|
|
11
|
+
type: 'object',
|
|
12
|
+
properties: {
|
|
13
|
+
entities: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
patternProperties: {
|
|
16
|
+
'^\\w+$': {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
keys: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
},
|
|
22
|
+
sharding: {
|
|
23
|
+
type: 'object',
|
|
24
|
+
properties: {
|
|
25
|
+
bumps: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
patternProperties: {
|
|
28
|
+
'^\\d+$': { type: 'integer', minimum: 0 },
|
|
29
|
+
},
|
|
30
|
+
additionalProperties: false,
|
|
31
|
+
},
|
|
32
|
+
entityKey: { type: 'function' },
|
|
33
|
+
nibbleBits: {
|
|
34
|
+
type: 'integer',
|
|
35
|
+
minimum: 1,
|
|
36
|
+
maximum: 5,
|
|
37
|
+
},
|
|
38
|
+
nibbles: {
|
|
39
|
+
type: 'integer',
|
|
40
|
+
minimum: 0,
|
|
41
|
+
},
|
|
42
|
+
timestamp: { type: 'function' },
|
|
43
|
+
},
|
|
44
|
+
additionalProperties: false,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
additionalProperties: false,
|
|
51
|
+
},
|
|
52
|
+
shardKeyToken: {
|
|
53
|
+
type: 'string',
|
|
54
|
+
pattern: '^\\w+$',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
additionalProperties: false,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Private EntityManager implementation.
|
|
62
|
+
*
|
|
63
|
+
* @private
|
|
64
|
+
*/
|
|
65
|
+
export class PrivateEntityManager {
|
|
66
|
+
#config;
|
|
67
|
+
#logger;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create a PrivateEntityManager instance.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} options - Options object.
|
|
73
|
+
* @param {object} [options.config] - EntityManager configuration object.
|
|
74
|
+
* @param {object} [options.logger] - Logger instance (defaults to console, must support error & debug methods).
|
|
75
|
+
* @throws {Error} If config is invalid.
|
|
76
|
+
* @throws {Error} If logger is invalid.
|
|
77
|
+
*/
|
|
78
|
+
constructor({ config = {}, logger = console } = {}) {
|
|
79
|
+
// Validate logger.
|
|
80
|
+
if (!logger.error || !logger.debug)
|
|
81
|
+
throw new Error('logger must implement error & debug methods.');
|
|
82
|
+
|
|
83
|
+
this.#logger = logger;
|
|
84
|
+
this.config = config;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get the current config.
|
|
89
|
+
*
|
|
90
|
+
* @returns {object} Current config.
|
|
91
|
+
*/
|
|
92
|
+
get config() {
|
|
93
|
+
return this.#config;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Set the current config.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} value - Config object.
|
|
100
|
+
* @throws {Error} If config is invalid.
|
|
101
|
+
* @throws {Error} If entity bumps do not monotonically increase.
|
|
102
|
+
* @throws {Error} If entity nibbles are greater than minimum bump value.
|
|
103
|
+
*/
|
|
104
|
+
set config(value) {
|
|
105
|
+
// Validate config against schema.
|
|
106
|
+
const validatorResult = validate(value, configSchema);
|
|
107
|
+
if (!validatorResult.valid) {
|
|
108
|
+
validatorResult.errors.forEach((error) =>
|
|
109
|
+
this.logger.error(error.message)
|
|
110
|
+
);
|
|
111
|
+
throw new Error(validatorResult.errors);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Conform config.
|
|
115
|
+
const { entities = {}, shardKeyToken = 'shardId' } = value;
|
|
116
|
+
const conformedConfig = {
|
|
117
|
+
entities: _.mapValues(
|
|
118
|
+
entities,
|
|
119
|
+
({
|
|
120
|
+
keys = {},
|
|
121
|
+
sharding: {
|
|
122
|
+
bumps = {},
|
|
123
|
+
entityKey,
|
|
124
|
+
nibbleBits = 1,
|
|
125
|
+
nibbles = 0,
|
|
126
|
+
timestamp,
|
|
127
|
+
} = {},
|
|
128
|
+
}) => ({
|
|
129
|
+
keys,
|
|
130
|
+
sharding: {
|
|
131
|
+
bumps: _.fromPairs(_.sortBy(_.toPairs(bumps), 0)),
|
|
132
|
+
entityKey,
|
|
133
|
+
nibbleBits,
|
|
134
|
+
nibbles,
|
|
135
|
+
timestamp,
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
),
|
|
139
|
+
shardKeyToken,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Validate entity properties.
|
|
143
|
+
_.some(
|
|
144
|
+
conformedConfig.entities,
|
|
145
|
+
(
|
|
146
|
+
{
|
|
147
|
+
keys,
|
|
148
|
+
sharding: { bumps, entityKey, nibbleBits, nibbles, timestamp },
|
|
149
|
+
},
|
|
150
|
+
entityToken
|
|
151
|
+
) => {
|
|
152
|
+
// Validate entity keys are functions or undefined.
|
|
153
|
+
_.some(keys, (value, key) => {
|
|
154
|
+
if (!_.isNil(value) && !_.isFunction(value)) {
|
|
155
|
+
const message = `${entityToken} key '${key}' must be a function or nil.`;
|
|
156
|
+
this.logger.error(message);
|
|
157
|
+
throw new Error(message);
|
|
158
|
+
} else return false;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Validate sharding bump values increase monotonically with keys.
|
|
162
|
+
_.some(_.toPairs(bumps), ([bump, value], i, c) => {
|
|
163
|
+
const [lastBump, lastValue] = i ? c[i - 1] : [];
|
|
164
|
+
if (value <= lastValue) {
|
|
165
|
+
const message = `${entityToken} sharding bumps do not monotonically increase from '${lastBump}: ${lastValue}' to '${bump}: ${value}'.)`;
|
|
166
|
+
this.logger.error(message);
|
|
167
|
+
throw new Error(message);
|
|
168
|
+
} else return false;
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Validate sharding entityKey is a function or undefined.
|
|
172
|
+
if (!_.isNil(entityKey) && !_.isFunction(entityKey)) {
|
|
173
|
+
const message = `${entityToken} sharding entityKey must be a function or nil`;
|
|
174
|
+
this.logger.error(message);
|
|
175
|
+
throw new Error(message);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Validate sharding nibbles do not exceed 32 bits.
|
|
179
|
+
if (nibbles * nibbleBits > 32) {
|
|
180
|
+
const message = `${entityToken} nibbles (${nibbles} nibbles at ${nibbleBits} nibbleBits) exceed 32 bits`;
|
|
181
|
+
this.logger.error(message);
|
|
182
|
+
throw new Error(message);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Validate sharding nibbles are less than first bump value.
|
|
186
|
+
const firstBumpValue = _.map(bumps)[0];
|
|
187
|
+
if (nibbles >= firstBumpValue) {
|
|
188
|
+
const message = `${entityToken} nibbles (${nibbles}) not less than minimum bump value (${firstBumpValue})`;
|
|
189
|
+
this.logger.error(message);
|
|
190
|
+
throw new Error(message);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Validate last bump value does not exceed 32 bits.
|
|
194
|
+
const lastBumpValue = _.map(bumps).slice(-1);
|
|
195
|
+
if (lastBumpValue * nibbleBits > 32) {
|
|
196
|
+
const message = `${entityToken} maximum bump value (${lastBumpValue} nibbles at ${nibbleBits} nibbleBits) exceed 32 bits`;
|
|
197
|
+
this.logger.error(message);
|
|
198
|
+
throw new Error(message);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Validate sharding timestamp is a function or undefined.
|
|
202
|
+
if (!_.isNil(timestamp) && !_.isFunction(timestamp)) {
|
|
203
|
+
const message = `${entityToken} sharding timestamp must be a function or nil.`;
|
|
204
|
+
this.logger.error(message);
|
|
205
|
+
throw new Error(message);
|
|
206
|
+
} else return false;
|
|
207
|
+
}
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
this.#config = conformedConfig;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Get logger instance.
|
|
215
|
+
*
|
|
216
|
+
* @returns {object} Logger instance.
|
|
217
|
+
*/
|
|
218
|
+
get logger() {
|
|
219
|
+
return this.#logger;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Get shard key token.
|
|
224
|
+
*
|
|
225
|
+
* @returns {string} Shard key token.
|
|
226
|
+
*/
|
|
227
|
+
get shardKeyToken() {
|
|
228
|
+
return this.#config.shardKeyToken;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get entity config.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} entityToken - Entity token.
|
|
235
|
+
* @returns {object} Entity config.
|
|
236
|
+
* @throws {Error} If entityToken is invalid.
|
|
237
|
+
*/
|
|
238
|
+
getEntityConfig(entityToken) {
|
|
239
|
+
this.validateEntityToken(entityToken);
|
|
240
|
+
return this.config.entities[entityToken];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
getKeyGenerator(entityToken, keyToken) {
|
|
244
|
+
const { keys } = this.getEntityConfig(entityToken);
|
|
245
|
+
|
|
246
|
+
if (!_.has(keys, keyToken)) {
|
|
247
|
+
const message = `Key '${keyToken}' does not exist for entity '${entityToken}'.`;
|
|
248
|
+
this.logger.error(message);
|
|
249
|
+
throw new Error(message);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return keys[keyToken];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Get the number of nibbles & nibbleBits for a given entityToken at a given timestamp.
|
|
257
|
+
*
|
|
258
|
+
* @param {string} entityToken - Entity token.
|
|
259
|
+
* @param {number} [timestamp] - Timestamp in milliseconds (defaults to current time).
|
|
260
|
+
* @returns {{nibbleBits: number, nibbles: number}} Result object.
|
|
261
|
+
*/
|
|
262
|
+
getNibbles(entityToken, timestamp = Date.now()) {
|
|
263
|
+
const { nibbleBits, nibbles, bumps } =
|
|
264
|
+
this.getEntityConfig(entityToken).sharding;
|
|
265
|
+
|
|
266
|
+
this.validateTimestamp(timestamp);
|
|
267
|
+
return {
|
|
268
|
+
nibbleBits,
|
|
269
|
+
nibbles: _.findLast(bumps, (value, key) => key <= timestamp) ?? nibbles,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Return a shard key for a given entity token, entity id & timestamp.
|
|
275
|
+
*
|
|
276
|
+
* @param {string} entityToken - Entity token.
|
|
277
|
+
* @param {string} entityKey - Entity id.
|
|
278
|
+
* @param {number} [timestamp] - Timestamp in milliseconds (defaults to current time).
|
|
279
|
+
* @returns {string} shard key.
|
|
280
|
+
*/
|
|
281
|
+
getShardKey(entityToken, entityKey, timestamp = Date.now()) {
|
|
282
|
+
// Get nibbles for entityToken at timestamp (validates entityToken)
|
|
283
|
+
const { nibbleBits, nibbles } = this.getNibbles(entityToken, timestamp);
|
|
284
|
+
|
|
285
|
+
// Calculate shardKey.
|
|
286
|
+
const radix = 2 ** nibbleBits;
|
|
287
|
+
const shardKey = nibbles
|
|
288
|
+
? (stringHash(entityKey) % (nibbles * radix))
|
|
289
|
+
.toString(radix)
|
|
290
|
+
.padStart(nibbles, '0')
|
|
291
|
+
: undefined;
|
|
292
|
+
|
|
293
|
+
if (_.isNil(shardKey))
|
|
294
|
+
this.logger.debug(
|
|
295
|
+
`no shard key generated for ${entityToken} id '${entityKey}' at timestamp ${timestamp}.`
|
|
296
|
+
);
|
|
297
|
+
else
|
|
298
|
+
this.logger.debug(
|
|
299
|
+
`generated shard key '${shardKey}' for ${entityToken} id '${entityKey}' at timestamp ${timestamp}.`
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
return shardKey;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Return an array of shard keys valid for a given entity token & timestamp.
|
|
307
|
+
*
|
|
308
|
+
* @param {string} entityToken - Entity token.
|
|
309
|
+
* @param {number} [timestamp] - Timestamp in milliseconds (defaults to current time).
|
|
310
|
+
* @returns {string[]} shard key space.
|
|
311
|
+
*/
|
|
312
|
+
getShardKeySpace(entityToken, timestamp = Date.now()) {
|
|
313
|
+
const { nibbleBits, nibbles, bumps } =
|
|
314
|
+
this.getEntityConfig(entityToken).sharding;
|
|
315
|
+
if (!nibbles && !_.size(bumps)) return [];
|
|
316
|
+
|
|
317
|
+
this.validateTimestamp(timestamp);
|
|
318
|
+
const nibbleSpace = [
|
|
319
|
+
nibbles,
|
|
320
|
+
..._.map(_.filter(bumps, (value, key) => key <= timestamp)),
|
|
321
|
+
];
|
|
322
|
+
|
|
323
|
+
const radix = 2 ** nibbleBits;
|
|
324
|
+
const shardKeySpace = _.flatten(
|
|
325
|
+
_.map(nibbleSpace, (nibbles) => {
|
|
326
|
+
return nibbles
|
|
327
|
+
? _.range(0, radix ** nibbles).map((nibble) =>
|
|
328
|
+
nibble.toString(radix).padStart(nibbles, '0')
|
|
329
|
+
)
|
|
330
|
+
: undefined;
|
|
331
|
+
})
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
return shardKeySpace;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Tests whether an entityToken is valid.
|
|
339
|
+
*
|
|
340
|
+
* @param {string} entityToken - Entity token.
|
|
341
|
+
* @returns {boolean} true if entityToken is valid.
|
|
342
|
+
*/
|
|
343
|
+
validateEntityToken(entityToken) {
|
|
344
|
+
if (!this.config.entities[entityToken]) {
|
|
345
|
+
const message = `Invalid entityToken: ${entityToken}`;
|
|
346
|
+
this.logger.error(message);
|
|
347
|
+
throw new Error(message);
|
|
348
|
+
} else return true;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Tests whether an item is valid.
|
|
353
|
+
*
|
|
354
|
+
* @param {string} item - Entity token.
|
|
355
|
+
* @returns {boolean} true if entityToken is valid.
|
|
356
|
+
*/
|
|
357
|
+
validateItem(item) {
|
|
358
|
+
if (!_.isPlainObject(item)) {
|
|
359
|
+
const message = `Invalid item: ${item}`;
|
|
360
|
+
this.logger.error(message);
|
|
361
|
+
throw new Error(message);
|
|
362
|
+
} else return true;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Tests whether a timestamp is valid.
|
|
367
|
+
*
|
|
368
|
+
* @param {number} timestamp - timestamp.
|
|
369
|
+
* @param {boolean} [future=false] - true if timestamp must be in the future.
|
|
370
|
+
* @returns {boolean} true if timestamp is valid.
|
|
371
|
+
*/
|
|
372
|
+
validateTimestamp(timestamp, future = false) {
|
|
373
|
+
if (!_.isInteger(timestamp) || (future && timestamp <= Date.now()))
|
|
374
|
+
throw new Error(
|
|
375
|
+
`invalid timestamp (must be an integer${
|
|
376
|
+
future ? ' in the future' : ''
|
|
377
|
+
})`
|
|
378
|
+
);
|
|
379
|
+
else return true;
|
|
380
|
+
}
|
|
381
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { EntityManager } from './EntityManager/EntityManager.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@karmaniverous/entity-manager",
|
|
3
|
+
"version": "0.0.8",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/karmaniverous/entity-manager"
|
|
10
|
+
},
|
|
11
|
+
"author": "Jason G. Williscroft",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/karmaniverous/entity-manager/issues"
|
|
14
|
+
},
|
|
15
|
+
"description": "Configurably decorate entity objects with sharded index keys.",
|
|
16
|
+
"homepage": "https://github.com/karmaniverous/entity-manager#readme",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"dynamo-db",
|
|
19
|
+
"sharding"
|
|
20
|
+
],
|
|
21
|
+
"license": "BSD-3-Clause",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"jsonschema": "^1.4.1",
|
|
24
|
+
"lodash": "^4.17.21",
|
|
25
|
+
"string-hash": "^1.1.3"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@babel/cli": "^7.20.7",
|
|
29
|
+
"@babel/core": "^7.20.7",
|
|
30
|
+
"@babel/eslint-parser": "^7.19.1",
|
|
31
|
+
"@babel/plugin-syntax-import-assertions": "^7.20.0",
|
|
32
|
+
"@babel/preset-env": "^7.20.2",
|
|
33
|
+
"@babel/register": "^7.18.9",
|
|
34
|
+
"@karmaniverous/edge-logger": "^1.0.2",
|
|
35
|
+
"@karmaniverous/get-dotenv": "^0.1.0",
|
|
36
|
+
"@karmaniverous/tagged-templates": "^0.0.2",
|
|
37
|
+
"@types/node": "^18.11.18",
|
|
38
|
+
"babel-plugin-lodash": "^3.3.4",
|
|
39
|
+
"chai": "^4.3.7",
|
|
40
|
+
"concat-md": "^0.5.0",
|
|
41
|
+
"eslint": "^8.30.0",
|
|
42
|
+
"eslint-config-standard": "^17.0.0",
|
|
43
|
+
"eslint-plugin-jsdoc": "^39.7.4",
|
|
44
|
+
"eslint-plugin-mocha": "^10.1.0",
|
|
45
|
+
"jsdoc-to-markdown": "^8.0.0",
|
|
46
|
+
"mocha": "^10.2.0",
|
|
47
|
+
"prettier": "^2.8.1",
|
|
48
|
+
"release-it": "^15.6.0"
|
|
49
|
+
},
|
|
50
|
+
"exports": {
|
|
51
|
+
".": {
|
|
52
|
+
"import": "./lib/index.js",
|
|
53
|
+
"require": "./dist/default/lib/index.js"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"main": "./lib/index.js",
|
|
57
|
+
"mocha": {
|
|
58
|
+
"exclude": [
|
|
59
|
+
"./dist/**",
|
|
60
|
+
"./node_modules/**",
|
|
61
|
+
"./**/*.json"
|
|
62
|
+
],
|
|
63
|
+
"require": [
|
|
64
|
+
"@babel/register"
|
|
65
|
+
],
|
|
66
|
+
"spec": "./**/*.test.!(*.*)"
|
|
67
|
+
},
|
|
68
|
+
"release-it": {
|
|
69
|
+
"github": {
|
|
70
|
+
"release": true
|
|
71
|
+
},
|
|
72
|
+
"npm": {
|
|
73
|
+
"publish": true
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
"scripts": {
|
|
77
|
+
"lint": "eslint lib/**",
|
|
78
|
+
"test": "mocha",
|
|
79
|
+
"build": "babel lib -d dist/default/lib --delete-dir-on-start --config-file ./dist/default/.babelrc",
|
|
80
|
+
"doc": "jsdoc2md -c doc/jsdoc.config.json -f lib/**/*.* -t doc/api-template.hbs > doc/2-api.jsdoc2.md && concat-md doc --hide-anchor-links > README.md",
|
|
81
|
+
"package": "npm run lint && npm run test && npm run build && npm run doc",
|
|
82
|
+
"release": "npm run package && getdotenv -- release-it"
|
|
83
|
+
},
|
|
84
|
+
"type": "module"
|
|
85
|
+
}
|