@nymphjs/nymph 1.0.0-beta.9 → 1.0.0-beta.90

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.
Files changed (96) hide show
  1. package/CHANGELOG.md +432 -0
  2. package/README.md +10 -20
  3. package/dist/Entity.d.ts +277 -10
  4. package/dist/Entity.js +543 -148
  5. package/dist/Entity.js.map +1 -1
  6. package/dist/Entity.test.js +53 -13
  7. package/dist/Entity.test.js.map +1 -1
  8. package/dist/Entity.types.d.ts +249 -23
  9. package/dist/Entity.types.js +1 -2
  10. package/dist/Nymph.d.ts +431 -12
  11. package/dist/Nymph.js +692 -180
  12. package/dist/Nymph.js.map +1 -1
  13. package/dist/Nymph.types.d.ts +65 -7
  14. package/dist/Nymph.types.js +3 -5
  15. package/dist/Nymph.types.js.map +1 -1
  16. package/dist/conf/d.d.ts +43 -0
  17. package/dist/conf/d.js +1 -2
  18. package/dist/conf/defaults.d.ts +1 -1
  19. package/dist/conf/defaults.js +26 -3
  20. package/dist/conf/defaults.js.map +1 -1
  21. package/dist/conf/index.d.ts +2 -2
  22. package/dist/conf/index.js +2 -8
  23. package/dist/conf/index.js.map +1 -1
  24. package/dist/driver/NymphDriver.d.ts +137 -39
  25. package/dist/driver/NymphDriver.js +344 -194
  26. package/dist/driver/NymphDriver.js.map +1 -1
  27. package/dist/driver/index.d.ts +2 -2
  28. package/dist/driver/index.js +3 -23
  29. package/dist/driver/index.js.map +1 -1
  30. package/dist/driver/testArtifacts.d.ts +2 -2
  31. package/dist/driver/testArtifacts.js +557 -92
  32. package/dist/driver/testArtifacts.js.map +1 -1
  33. package/dist/errors/ClassNotAvailableError.js +1 -5
  34. package/dist/errors/ClassNotAvailableError.js.map +1 -1
  35. package/dist/errors/EntityConflictError.js +1 -5
  36. package/dist/errors/EntityConflictError.js.map +1 -1
  37. package/dist/errors/EntityInvalidDataError.d.ts +6 -0
  38. package/dist/errors/EntityInvalidDataError.js +8 -6
  39. package/dist/errors/EntityInvalidDataError.js.map +1 -1
  40. package/dist/errors/EntityIsSleepingReferenceError.js +1 -5
  41. package/dist/errors/EntityIsSleepingReferenceError.js.map +1 -1
  42. package/dist/errors/EntityUniqueConstraintError.d.ts +3 -0
  43. package/dist/errors/EntityUniqueConstraintError.js +7 -0
  44. package/dist/errors/EntityUniqueConstraintError.js.map +1 -0
  45. package/dist/errors/InvalidParametersError.js +1 -5
  46. package/dist/errors/InvalidParametersError.js.map +1 -1
  47. package/dist/errors/InvalidStateError.d.ts +3 -0
  48. package/dist/errors/InvalidStateError.js +7 -0
  49. package/dist/errors/InvalidStateError.js.map +1 -0
  50. package/dist/errors/NotConfiguredError.js +1 -5
  51. package/dist/errors/NotConfiguredError.js.map +1 -1
  52. package/dist/errors/QueryFailedError.js +2 -5
  53. package/dist/errors/QueryFailedError.js.map +1 -1
  54. package/dist/errors/UnableToConnectError.js +1 -5
  55. package/dist/errors/UnableToConnectError.js.map +1 -1
  56. package/dist/errors/index.d.ts +10 -8
  57. package/dist/errors/index.js +10 -24
  58. package/dist/errors/index.js.map +1 -1
  59. package/dist/index.d.ts +11 -11
  60. package/dist/index.js +14 -34
  61. package/dist/index.js.map +1 -1
  62. package/dist/nymphJoiProps.js +10 -15
  63. package/dist/nymphJoiProps.js.map +1 -1
  64. package/dist/testArtifacts.d.ts +18 -6
  65. package/dist/testArtifacts.js +32 -37
  66. package/dist/testArtifacts.js.map +1 -1
  67. package/dist/testMocks.d.ts +15 -12
  68. package/dist/testMocks.js +40 -29
  69. package/dist/testMocks.js.map +1 -1
  70. package/dist/utils.d.ts +4 -4
  71. package/dist/utils.js +40 -29
  72. package/dist/utils.js.map +1 -1
  73. package/jest.config.js +11 -2
  74. package/package.json +22 -19
  75. package/src/Entity.test.ts +52 -7
  76. package/src/Entity.ts +458 -147
  77. package/src/Entity.types.ts +64 -74
  78. package/src/Nymph.ts +754 -321
  79. package/src/Nymph.types.ts +131 -26
  80. package/src/conf/d.ts +25 -0
  81. package/src/conf/defaults.ts +34 -1
  82. package/src/conf/index.ts +2 -2
  83. package/src/driver/NymphDriver.ts +362 -359
  84. package/src/driver/index.ts +2 -2
  85. package/src/driver/testArtifacts.ts +609 -185
  86. package/src/errors/EntityUniqueConstraintError.ts +6 -0
  87. package/src/errors/InvalidStateError.ts +6 -0
  88. package/src/errors/index.ts +12 -8
  89. package/src/index.ts +11 -11
  90. package/src/nymphJoiProps.ts +2 -2
  91. package/src/testArtifacts.ts +19 -20
  92. package/src/testMocks.ts +60 -35
  93. package/src/type/locutus.d.ts +3 -0
  94. package/src/utils.ts +39 -18
  95. package/tsconfig.json +5 -3
  96. package/typedoc.json +4 -0
@@ -1,20 +1,23 @@
1
- import fs from 'fs';
2
- import { difference } from 'lodash';
1
+ import fs from 'node:fs';
2
+ import { difference } from 'lodash-es';
3
3
  import ReadLines from 'n-readlines';
4
- import strtotime from 'locutus/php/datetime/strtotime';
5
- import { guid } from '@nymphjs/guid';
4
+ import strtotime from 'locutus/php/datetime/strtotime.js';
6
5
 
7
- import type Nymph from '../Nymph';
8
- import {
6
+ import type Nymph from '../Nymph.js';
7
+ import type { Selector, Options, FormattedSelector } from '../Nymph.types.js';
8
+ import Entity, { type EntityInstanceType } from '../Entity.js';
9
+ import type {
9
10
  EntityConstructor,
10
11
  EntityData,
11
12
  EntityInterface,
12
13
  EntityReference,
13
14
  SerializedEntityData,
14
- } from '../Entity.types';
15
- import { InvalidParametersError, UnableToConnectError } from '../errors';
16
- import { Selector, Options, FormattedSelector } from '../Nymph.types';
17
- import { xor } from '../utils';
15
+ } from '../Entity.types.js';
16
+ import {
17
+ InvalidParametersError,
18
+ UnableToConnectError,
19
+ } from '../errors/index.js';
20
+ import { xor } from '../utils.js';
18
21
 
19
22
  // from: https://stackoverflow.com/a/6969486/664915
20
23
  function escapeRegExp(string: string): string {
@@ -28,7 +31,7 @@ export default abstract class NymphDriver {
28
31
  protected nymph: Nymph = new Proxy({} as Nymph, {
29
32
  get() {
30
33
  throw new Error(
31
- 'Attempted access of Nymph instance before driver initialization!'
34
+ 'Attempted access of Nymph instance before driver initialization!',
32
35
  );
33
36
  },
34
37
  });
@@ -67,13 +70,14 @@ export default abstract class NymphDriver {
67
70
  abstract connect(): Promise<boolean>;
68
71
 
69
72
  abstract isConnected(): boolean;
73
+ protected abstract internalTransaction(name: string): Promise<any>;
70
74
  abstract startTransaction(name: string): Promise<Nymph>;
71
75
  abstract commit(name: string): Promise<boolean>;
72
76
  abstract rollback(name: string): Promise<boolean>;
73
77
  abstract inTransaction(): Promise<boolean>;
74
78
  abstract deleteEntityByID(
75
79
  guid: string,
76
- classConstructor: EntityConstructor
80
+ classConstructor: EntityConstructor,
77
81
  ): Promise<boolean>;
78
82
  abstract deleteEntityByID(guid: string, className?: string): Promise<boolean>;
79
83
  abstract deleteUID(name: string): Promise<boolean>;
@@ -83,9 +87,19 @@ export default abstract class NymphDriver {
83
87
  */
84
88
  abstract disconnect(): Promise<boolean>;
85
89
 
86
- protected abstract exportEntities(
87
- writeLine: (line: string) => void
88
- ): Promise<void>;
90
+ /**
91
+ * Detect whether the database needs to be migrated.
92
+ *
93
+ * If true, the database should be exported with an old version of Nymph, then
94
+ * imported into a fresh database with this version.
95
+ */
96
+ abstract needsMigration(): Promise<boolean>;
97
+
98
+ abstract exportDataIterator(): AsyncGenerator<
99
+ { type: 'comment' | 'uid' | 'entity'; content: string },
100
+ void,
101
+ undefined | false
102
+ >;
89
103
  abstract getEntities<T extends EntityConstructor = EntityConstructor>(
90
104
  options: Options<T> & { return: 'count' },
91
105
  ...selectors: Selector[]
@@ -97,37 +111,21 @@ export default abstract class NymphDriver {
97
111
  abstract getEntities<T extends EntityConstructor = EntityConstructor>(
98
112
  options?: Options<T>,
99
113
  ...selectors: Selector[]
100
- ): Promise<ReturnType<T['factorySync']>[]>;
114
+ ): Promise<EntityInstanceType<T>[]>;
101
115
  abstract getEntities<T extends EntityConstructor = EntityConstructor>(
102
116
  options?: Options<T>,
103
117
  ...selectors: Selector[]
104
- ): Promise<ReturnType<T['factorySync']>[] | string[] | number>;
105
- protected abstract getEntitiesSync<
106
- T extends EntityConstructor = EntityConstructor
107
- >(
108
- options: Options<T> & { return: 'count' },
109
- ...selectors: Selector[]
110
- ): number;
111
- protected abstract getEntitiesSync<
112
- T extends EntityConstructor = EntityConstructor
113
- >(
114
- options: Options<T> & { return: 'guid' },
115
- ...selectors: Selector[]
116
- ): string[];
117
- protected abstract getEntitiesSync<
118
- T extends EntityConstructor = EntityConstructor
119
- >(
120
- options?: Options<T>,
121
- ...selectors: Selector[]
122
- ): ReturnType<T['factorySync']>[];
123
- protected abstract getEntitiesSync<
124
- T extends EntityConstructor = EntityConstructor
125
- >(
126
- options?: Options<T>,
127
- ...selectors: Selector[]
128
- ): ReturnType<T['factorySync']>[] | string[] | number;
118
+ ): Promise<EntityInstanceType<T>[] | string[] | number>;
129
119
  abstract getUID(name: string): Promise<number | null>;
130
- abstract import(filename: string): Promise<boolean>;
120
+ abstract importEntity(entity: {
121
+ guid: string;
122
+ cdate: number;
123
+ mdate: number;
124
+ tags: string[];
125
+ sdata: SerializedEntityData;
126
+ etype: string;
127
+ }): Promise<void>;
128
+ abstract importUID(uid: { name: string; value: number }): Promise<void>;
131
129
  abstract newUID(name: string): Promise<number | null>;
132
130
  abstract renameUID(oldName: string, newName: string): Promise<boolean>;
133
131
  abstract saveEntity(entity: EntityInterface): Promise<boolean>;
@@ -147,7 +145,7 @@ export default abstract class NymphDriver {
147
145
  protected posixRegexMatch(
148
146
  pattern: string,
149
147
  subject: string,
150
- caseInsensitive = false
148
+ caseInsensitive = false,
151
149
  ) {
152
150
  const posixClasses = [
153
151
  // '[[:<:]]',
@@ -204,9 +202,9 @@ export default abstract class NymphDriver {
204
202
  if (!fhandle) {
205
203
  throw new InvalidParametersError('Provided filename is not writeable.');
206
204
  }
207
- await this.exportEntities((line: string) => {
208
- fs.writeSync(fhandle, `${line}\n`);
209
- });
205
+ for await (let entry of this.exportDataIterator()) {
206
+ fs.writeSync(fhandle, entry.content);
207
+ }
210
208
  fs.closeSync(fhandle);
211
209
  } catch (e: any) {
212
210
  return false;
@@ -215,130 +213,155 @@ export default abstract class NymphDriver {
215
213
  }
216
214
 
217
215
  public async exportPrint() {
218
- await this.exportEntities((line: string) => {
219
- console.log(line);
220
- });
216
+ for await (let entry of this.exportDataIterator()) {
217
+ console.log(entry.content);
218
+ }
221
219
  return true;
222
220
  }
223
221
 
224
- protected async importFromFile(
225
- filename: string,
226
- saveEntityCallback: (
227
- guid: string,
228
- tags: string[],
229
- sdata: SerializedEntityData,
230
- etype: string
231
- ) => Promise<void>,
232
- saveUIDCallback: (name: string, value: number) => Promise<void>,
233
- startTransactionCallback: (() => Promise<void>) | null = null,
234
- commitTransactionCallback: (() => Promise<void>) | null = null
222
+ public async importDataIterator(
223
+ lines: Iterable<string>,
224
+ transaction?: boolean,
235
225
  ) {
236
- let rl: ReadLines;
237
- try {
238
- rl = new ReadLines(filename);
239
- } catch (e: any) {
240
- throw new InvalidParametersError('Provided filename is unreadable.');
226
+ const first = lines[Symbol.iterator]().next();
227
+
228
+ if (first.done || first.value !== '#nex2') {
229
+ throw new Error('Tried to import a file that is not a NEX v2 file.');
241
230
  }
242
- let guid: string | null = null;
243
- let line: Buffer | false = false;
244
- let sdata: SerializedEntityData = {};
245
- let tags: string[] = [];
246
- let etype = '__undefined';
247
- if (startTransactionCallback) {
248
- await startTransactionCallback();
231
+
232
+ if (transaction) {
233
+ await this.internalTransaction('nymph-import');
249
234
  }
250
- while ((line = rl.next())) {
251
- const text = line.toString('utf8');
252
- if (text.match(/^\s*#/)) {
253
- continue;
254
- }
255
- const entityMatch = text.match(
256
- /^\s*{([0-9A-Fa-f]+)}<([-\w_]+)>\[([^\]]*)\]\s*$/
257
- );
258
- const propMatch = text.match(/^\s*([^=]+)\s*=\s*(.*\S)\s*$/);
259
- const uidMatch = text.match(/^\s*<([^>]+)>\[(\d+)\]\s*$/);
260
- if (uidMatch) {
261
- // Add the UID.
262
- await saveUIDCallback(uidMatch[1], Number(uidMatch[2]));
263
- } else if (entityMatch) {
264
- // Save the current entity.
265
- if (guid) {
266
- await saveEntityCallback(guid, tags, sdata, etype);
267
- guid = null;
268
- tags = [];
269
- sdata = {};
270
- etype = '__undefined';
235
+ try {
236
+ let guid: string | null = null;
237
+ let sdata: SerializedEntityData = {};
238
+ let tags: string[] = [];
239
+ let etype = '__undefined';
240
+ for (let line of lines) {
241
+ if (line.match(/^\s*#/)) {
242
+ continue;
271
243
  }
272
- // Record the new entity's info.
273
- guid = entityMatch[1];
274
- etype = entityMatch[2];
275
- tags = entityMatch[3].split(',');
276
- } else if (propMatch) {
277
- // Add the variable to the new entity.
278
- if (guid) {
279
- sdata[propMatch[1]] = propMatch[2];
244
+ const entityMatch = line.match(
245
+ /^\s*{([0-9A-Fa-f]+)}<([-\w_]+)>\[([^\]]*)\]\s*$/,
246
+ );
247
+ const propMatch = line.match(/^\s*([^=]+)\s*=\s*(.*\S)\s*$/);
248
+ const uidMatch = line.match(/^\s*<([^>]+)>\[(\d+)\]\s*$/);
249
+ if (uidMatch) {
250
+ // Add the UID.
251
+ await this.importUID({
252
+ name: uidMatch[1],
253
+ value: Number(uidMatch[2]),
254
+ });
255
+ } else if (entityMatch) {
256
+ // Save the current entity.
257
+ if (guid) {
258
+ const cdate = Number(JSON.parse(sdata.cdate));
259
+ delete sdata.cdate;
260
+ const mdate = Number(JSON.parse(sdata.mdate));
261
+ delete sdata.mdate;
262
+ await this.importEntity({ guid, cdate, mdate, tags, sdata, etype });
263
+ guid = null;
264
+ tags = [];
265
+ sdata = {};
266
+ etype = '__undefined';
267
+ }
268
+ // Record the new entity's info.
269
+ guid = entityMatch[1];
270
+ etype = entityMatch[2];
271
+ tags = entityMatch[3].split(',');
272
+ } else if (propMatch) {
273
+ // Add the variable to the new entity.
274
+ if (guid) {
275
+ sdata[propMatch[1]] = propMatch[2];
276
+ }
280
277
  }
278
+ // Clear the entity cache.
279
+ this.entityCache = {};
281
280
  }
282
- // Clear the entity cache.
283
- this.entityCache = {};
284
- }
285
- // Save the last entity.
286
- if (guid) {
287
- await saveEntityCallback(guid, tags, sdata, etype);
288
- }
289
- if (commitTransactionCallback) {
290
- await commitTransactionCallback();
281
+ // Save the last entity.
282
+ if (guid) {
283
+ const cdate = Number(JSON.parse(sdata.cdate));
284
+ delete sdata.cdate;
285
+ const mdate = Number(JSON.parse(sdata.mdate));
286
+ delete sdata.mdate;
287
+ await this.importEntity({ guid, cdate, mdate, tags, sdata, etype });
288
+ }
289
+ if (transaction) {
290
+ await this.commit('nymph-import');
291
+ }
292
+ } catch (e: any) {
293
+ await this.rollback('nymph-import');
294
+ throw e;
291
295
  }
292
296
  return true;
293
297
  }
294
298
 
299
+ public async importData(text: string, transaction?: boolean) {
300
+ return await this.importDataIterator(text.split('\n'), transaction);
301
+ }
302
+
303
+ public async import(filename: string, transaction?: boolean) {
304
+ let rl: ReadLines;
305
+ try {
306
+ rl = new ReadLines(filename);
307
+ } catch (e: any) {
308
+ throw new InvalidParametersError('Provided filename is unreadable.');
309
+ }
310
+
311
+ const lines = {
312
+ *[Symbol.iterator]() {
313
+ let line: false | Buffer;
314
+ while ((line = rl.next())) {
315
+ yield line.toString('utf8');
316
+ }
317
+ },
318
+ };
319
+
320
+ return await this.importDataIterator(lines, transaction);
321
+ }
322
+
295
323
  public checkData(
296
324
  data: EntityData,
297
325
  sdata: SerializedEntityData,
298
326
  selectors: Selector[],
299
327
  guid: string | null = null,
300
- tags: string[] | null = null
328
+ tags: string[] | null = null,
301
329
  ) {
302
- const formattedSelectors = this.formatSelectors(selectors).selectors;
330
+ try {
331
+ const formattedSelectors = this.formatSelectors(selectors).selectors;
303
332
 
304
- for (const curSelector of formattedSelectors) {
305
- const type = curSelector.type;
306
- const typeIsNot = type === '!&' || type === '!|';
307
- const typeIsOr = type === '|' || type === '!|';
308
- let pass = !typeIsOr;
309
- for (const k in curSelector) {
310
- const key = k as keyof Selector;
311
- const value = curSelector[key];
333
+ for (const curSelector of formattedSelectors) {
334
+ const type = curSelector.type;
335
+ const typeIsNot = type === '!&' || type === '!|';
336
+ const typeIsOr = type === '|' || type === '!|';
337
+ let pass = !typeIsOr;
338
+ for (const k in curSelector) {
339
+ const key = k as keyof Selector;
340
+ const value = curSelector[key];
312
341
 
313
- if (key === 'type' || value == null) {
314
- continue;
315
- }
342
+ if (key === 'type' || value == null) {
343
+ continue;
344
+ }
316
345
 
317
- const clauseNot = key.substr(0, 1) === '!';
318
- if (key === 'selector' || key === '!selector') {
319
- const tmpArr = (Array.isArray(value) ? value : [value]) as Selector[];
320
- pass = xor(
321
- this.checkData(data, sdata, tmpArr, guid, tags),
322
- xor(typeIsNot, clauseNot)
323
- );
324
- } else {
325
- if (key === 'qref' || key === '!qref') {
326
- throw new Error("Can't use checkData on qref clauses.");
346
+ const clauseNot = key.substring(0, 1) === '!';
347
+ if (key === 'selector' || key === '!selector') {
348
+ const tmpArr = (
349
+ Array.isArray(value) ? value : [value]
350
+ ) as Selector[];
351
+ pass = xor(
352
+ this.checkData(data, sdata, tmpArr, guid, tags),
353
+ xor(typeIsNot, clauseNot),
354
+ );
327
355
  } else {
328
- // Check if it doesn't pass any for &, check if it
329
- // passes any for |.
330
- for (const curValue of value) {
331
- if (curValue == null) {
332
- continue;
333
- }
356
+ if (key === 'qref' || key === '!qref') {
357
+ throw new Error("Can't use checkData on qref clauses.");
358
+ } else {
359
+ // Check if it doesn't pass any for &, check if it passes any for |.
360
+ for (const curValue of value) {
361
+ if (curValue == null) {
362
+ continue;
363
+ }
334
364
 
335
- if (
336
- ((key === 'guid' || key === '!guid') && guid == null) ||
337
- ((key === 'tag' || key === '!tag') && tags == null)
338
- ) {
339
- // Skip because it has already been checked (by the query).
340
- pass = true;
341
- } else {
342
365
  const propName = (curValue as string[])[0];
343
366
 
344
367
  // Unserialize the data for this variable.
@@ -356,7 +379,7 @@ export default abstract class NymphDriver {
356
379
  case '!tag':
357
380
  pass = xor(
358
381
  (tags ?? []).indexOf(propName) !== -1,
359
- xor(typeIsNot, clauseNot)
382
+ xor(typeIsNot, clauseNot),
360
383
  );
361
384
  break;
362
385
  case 'defined':
@@ -367,7 +390,7 @@ export default abstract class NymphDriver {
367
390
  case '!truthy':
368
391
  pass = xor(
369
392
  propName in data && data[propName],
370
- xor(typeIsNot, clauseNot)
393
+ xor(typeIsNot, clauseNot),
371
394
  );
372
395
  break;
373
396
  case 'ref':
@@ -377,9 +400,9 @@ export default abstract class NymphDriver {
377
400
  propName in data &&
378
401
  this.entityReferenceSearch(
379
402
  data[propName],
380
- testRefValue
403
+ testRefValue,
381
404
  ),
382
- xor(typeIsNot, clauseNot)
405
+ xor(typeIsNot, clauseNot),
383
406
  );
384
407
  break;
385
408
  case 'equal':
@@ -391,7 +414,7 @@ export default abstract class NymphDriver {
391
414
  propName in data &&
392
415
  JSON.stringify(data[propName]) ===
393
416
  JSON.stringify(testEqualValue),
394
- xor(typeIsNot, clauseNot)
417
+ xor(typeIsNot, clauseNot),
395
418
  );
396
419
  break;
397
420
  case 'contain':
@@ -402,9 +425,9 @@ export default abstract class NymphDriver {
402
425
  pass = xor(
403
426
  propName in data &&
404
427
  JSON.stringify(data[propName]).indexOf(
405
- JSON.stringify(testContainValue)
428
+ JSON.stringify(testContainValue),
406
429
  ) !== -1,
407
- xor(typeIsNot, clauseNot)
430
+ xor(typeIsNot, clauseNot),
408
431
  );
409
432
  break;
410
433
  case 'like':
@@ -419,9 +442,9 @@ export default abstract class NymphDriver {
419
442
  escapeRegExp(testLikeValue)
420
443
  .replace('%', () => '.*')
421
444
  .replace('_', () => '.') +
422
- '$'
445
+ '$',
423
446
  ).test(data[propName]),
424
- xor(typeIsNot, clauseNot)
447
+ xor(typeIsNot, clauseNot),
425
448
  );
426
449
  break;
427
450
  case 'match':
@@ -433,7 +456,7 @@ export default abstract class NymphDriver {
433
456
  pass = xor(
434
457
  propName in data &&
435
458
  this.posixRegexMatch(testMatchValue, data[propName]),
436
- xor(typeIsNot, clauseNot)
459
+ xor(typeIsNot, clauseNot),
437
460
  );
438
461
  break;
439
462
  case 'imatch':
@@ -447,9 +470,9 @@ export default abstract class NymphDriver {
447
470
  this.posixRegexMatch(
448
471
  testIMatchValue,
449
472
  data[propName],
450
- true
473
+ true,
451
474
  ),
452
- xor(typeIsNot, clauseNot)
475
+ xor(typeIsNot, clauseNot),
453
476
  );
454
477
  break;
455
478
  case 'gt':
@@ -459,7 +482,7 @@ export default abstract class NymphDriver {
459
482
  )[1] as number;
460
483
  pass = xor(
461
484
  propName in data && data[propName] > testGTValue,
462
- xor(typeIsNot, clauseNot)
485
+ xor(typeIsNot, clauseNot),
463
486
  );
464
487
  break;
465
488
  case 'gte':
@@ -469,7 +492,7 @@ export default abstract class NymphDriver {
469
492
  )[1] as number;
470
493
  pass = xor(
471
494
  propName in data && data[propName] >= testGTEValue,
472
- xor(typeIsNot, clauseNot)
495
+ xor(typeIsNot, clauseNot),
473
496
  );
474
497
  break;
475
498
  case 'lt':
@@ -479,7 +502,7 @@ export default abstract class NymphDriver {
479
502
  )[1] as number;
480
503
  pass = xor(
481
504
  propName in data && data[propName] < testLTValue,
482
- xor(typeIsNot, clauseNot)
505
+ xor(typeIsNot, clauseNot),
483
506
  );
484
507
  break;
485
508
  case 'lte':
@@ -489,26 +512,33 @@ export default abstract class NymphDriver {
489
512
  )[1] as number;
490
513
  pass = xor(
491
514
  propName in data && data[propName] <= testLTEValue,
492
- xor(typeIsNot, clauseNot)
515
+ xor(typeIsNot, clauseNot),
493
516
  );
494
517
  break;
495
518
  }
496
- }
497
- if (!xor(typeIsOr, pass)) {
498
- break;
519
+
520
+ if (!xor(typeIsOr, pass)) {
521
+ break;
522
+ }
499
523
  }
500
524
  }
501
525
  }
526
+ if (!xor(typeIsOr, pass)) {
527
+ break;
528
+ }
502
529
  }
503
- if (!xor(typeIsOr, pass)) {
504
- break;
530
+ if (!pass) {
531
+ return false;
505
532
  }
506
533
  }
507
- if (!pass) {
508
- return false;
509
- }
534
+ return true;
535
+ } catch (e: any) {
536
+ this.nymph.config.debugError(
537
+ 'nymph',
538
+ `Failed to check entity data: ${e}`,
539
+ );
540
+ throw e;
510
541
  }
511
- return true;
512
542
  }
513
543
 
514
544
  /**
@@ -547,7 +577,7 @@ export default abstract class NymphDriver {
547
577
  | EntityInterface
548
578
  | EntityReference
549
579
  | string
550
- | (EntityInterface | EntityReference | string)[]
580
+ | (EntityInterface | EntityReference | string)[],
551
581
  ) {
552
582
  // Get the GUID, if the passed $entity is an object.
553
583
  const guids: string[] = [];
@@ -578,7 +608,7 @@ export default abstract class NymphDriver {
578
608
  return true;
579
609
  }
580
610
  // Search through arrays and objects looking for the reference.
581
- if (Array.isArray(value) || typeof value === 'object') {
611
+ if (value != null && (Array.isArray(value) || typeof value === 'object')) {
582
612
  for (const key in value) {
583
613
  if (this.entityReferenceSearch(value[key], guids)) {
584
614
  return true;
@@ -590,7 +620,7 @@ export default abstract class NymphDriver {
590
620
 
591
621
  public formatSelectors(
592
622
  selectors: Selector[],
593
- options: Options = {}
623
+ options: Options = {},
594
624
  ): {
595
625
  selectors: FormattedSelector[];
596
626
  qrefs: [Options, ...FormattedSelector[]][];
@@ -625,10 +655,9 @@ export default abstract class NymphDriver {
625
655
  for (let i = 0; i < tmpArr.length; i++) {
626
656
  const name = tmpArr[i][0];
627
657
  const [qrefOptions, ...qrefSelectors] = tmpArr[i][1];
628
- const QrefEntityClass =
629
- typeof qrefOptions.class === 'string'
630
- ? this.nymph.getEntityClass(qrefOptions.class)
631
- : qrefOptions.class ?? this.nymph.getEntityClass('Entity');
658
+ const QrefEntityClass = qrefOptions.class
659
+ ? this.nymph.getEntityClass(qrefOptions.class)
660
+ : this.nymph.getEntityClass('Entity');
632
661
  const newOptions = {
633
662
  ...qrefOptions,
634
663
  class: QrefEntityClass,
@@ -637,7 +666,7 @@ export default abstract class NymphDriver {
637
666
  const newSelectors = this.formatSelectors(qrefSelectors, options);
638
667
  qrefs.push(
639
668
  [newOptions, ...newSelectors.selectors],
640
- ...newSelectors.qrefs
669
+ ...newSelectors.qrefs,
641
670
  );
642
671
  formatArr[i] = [name, [newOptions, ...newSelectors.selectors]];
643
672
  }
@@ -685,12 +714,12 @@ export default abstract class NymphDriver {
685
714
 
686
715
  protected iterateSelectorsForQuery(
687
716
  selectors: FormattedSelector[],
688
- callback: (
689
- key: string,
690
- value: any,
691
- typeIsOr: boolean,
692
- typeIsNot: boolean
693
- ) => string
717
+ callback: (data: {
718
+ key: string;
719
+ value: any;
720
+ typeIsOr: boolean;
721
+ typeIsNot: boolean;
722
+ }) => string,
694
723
  ) {
695
724
  const queryParts = [];
696
725
  for (const curSelector of selectors) {
@@ -703,7 +732,7 @@ export default abstract class NymphDriver {
703
732
  if (key === 'type') {
704
733
  continue;
705
734
  }
706
- let curQuery = callback(key, value, typeIsOr, typeIsNot);
735
+ let curQuery = callback({ key, value, typeIsOr, typeIsNot });
707
736
  if (curQuery) {
708
737
  if (curSelectorQuery) {
709
738
  curSelectorQuery += typeIsOr ? ' OR ' : ' AND ';
@@ -719,71 +748,14 @@ export default abstract class NymphDriver {
719
748
  return queryParts;
720
749
  }
721
750
 
722
- /**
723
- * Get the first entity to match all options/selectors, synchronously.
724
- *
725
- * This should really only be used internally by the Entity class. It's a bad
726
- * idea to get entities synchronously. The Entity class uses this to load
727
- * sleeping references.
728
- *
729
- * @param options The options.
730
- * @param selectors Unlimited optional selectors to search for, or a single GUID. If none are given, all entities are searched for the given options.
731
- * @returns An entity, or null on failure or nothing found.
732
- */
733
- public getEntitySync<T extends EntityConstructor = EntityConstructor>(
734
- options: Options<T> & { return: 'count' },
735
- ...selectors: Selector[]
736
- ): number;
737
- public getEntitySync<T extends EntityConstructor = EntityConstructor>(
738
- options: Options<T> & { return: 'guid' },
739
- ...selectors: Selector[]
740
- ): string | null;
741
- public getEntitySync<T extends EntityConstructor = EntityConstructor>(
742
- options: Options<T>,
743
- ...selectors: Selector[]
744
- ): ReturnType<T['factorySync']> | null;
745
- public getEntitySync<T extends EntityConstructor = EntityConstructor>(
746
- options: Options<T> & { return: 'count' },
747
- guid: string
748
- ): number;
749
- public getEntitySync<T extends EntityConstructor = EntityConstructor>(
750
- options: Options<T> & { return: 'guid' },
751
- guid: string
752
- ): string | null;
753
- public getEntitySync<T extends EntityConstructor = EntityConstructor>(
754
- options: Options<T>,
755
- guid: string
756
- ): ReturnType<T['factorySync']> | null;
757
- public getEntitySync<T extends EntityConstructor = EntityConstructor>(
758
- options: Options<T> = {},
759
- ...selectors: Selector[] | string[]
760
- ): ReturnType<T['factorySync']> | string | number | null {
761
- // Set up options and selectors.
762
- if (typeof selectors[0] === 'string') {
763
- selectors = [{ type: '&', guid: selectors[0] }];
764
- }
765
- options.limit = 1;
766
- const entities = this.getEntitiesSync(
767
- options,
768
- ...(selectors as Selector[])
769
- );
770
- if (options.return === 'count') {
771
- return entities as unknown as number;
772
- }
773
- if (!entities || !entities.length) {
774
- return null;
775
- }
776
- return entities[0];
777
- }
778
-
779
- protected getEntitesRowLike<T extends EntityConstructor = EntityConstructor>(
751
+ protected getEntitiesRowLike<T extends EntityConstructor = EntityConstructor>(
780
752
  options: Options<T> & { return: 'count' },
781
753
  selectors: Selector[],
782
- performQueryCallback: (
783
- options: Options<T>,
784
- formattedSelectors: FormattedSelector[],
785
- etype: string
786
- ) => {
754
+ performQueryCallback: (data: {
755
+ options: Options<T>;
756
+ selectors: FormattedSelector[];
757
+ etype: string;
758
+ }) => {
787
759
  result: any;
788
760
  },
789
761
  rowFetchCallback: () => any,
@@ -798,16 +770,16 @@ export default abstract class NymphDriver {
798
770
  getDataNameAndSValueCallback: (row: any) => {
799
771
  name: string;
800
772
  svalue: string;
801
- }
773
+ },
802
774
  ): { result: any; process: () => number | Error };
803
- protected getEntitesRowLike<T extends EntityConstructor = EntityConstructor>(
775
+ protected getEntitiesRowLike<T extends EntityConstructor = EntityConstructor>(
804
776
  options: Options<T> & { return: 'guid' },
805
777
  selectors: Selector[],
806
- performQueryCallback: (
807
- options: Options<T>,
808
- formattedSelectors: FormattedSelector[],
809
- etype: string
810
- ) => {
778
+ performQueryCallback: (data: {
779
+ options: Options<T>;
780
+ selectors: FormattedSelector[];
781
+ etype: string;
782
+ }) => {
811
783
  result: any;
812
784
  },
813
785
  rowFetchCallback: () => any,
@@ -822,16 +794,16 @@ export default abstract class NymphDriver {
822
794
  getDataNameAndSValueCallback: (row: any) => {
823
795
  name: string;
824
796
  svalue: string;
825
- }
797
+ },
826
798
  ): { result: any; process: () => string[] | Error };
827
- protected getEntitesRowLike<T extends EntityConstructor = EntityConstructor>(
799
+ protected getEntitiesRowLike<T extends EntityConstructor = EntityConstructor>(
828
800
  options: Options<T>,
829
801
  selectors: Selector[],
830
- performQueryCallback: (
831
- options: Options<T>,
832
- formattedSelectors: FormattedSelector[],
833
- etype: string
834
- ) => {
802
+ performQueryCallback: (data: {
803
+ options: Options<T>;
804
+ selectors: FormattedSelector[];
805
+ etype: string;
806
+ }) => {
835
807
  result: any;
836
808
  },
837
809
  rowFetchCallback: () => any,
@@ -846,16 +818,19 @@ export default abstract class NymphDriver {
846
818
  getDataNameAndSValueCallback: (row: any) => {
847
819
  name: string;
848
820
  svalue: string;
849
- }
850
- ): { result: any; process: () => ReturnType<T['factorySync']>[] | Error };
851
- protected getEntitesRowLike<T extends EntityConstructor = EntityConstructor>(
821
+ },
822
+ ): {
823
+ result: any;
824
+ process: () => EntityInstanceType<T>[] | Error;
825
+ };
826
+ protected getEntitiesRowLike<T extends EntityConstructor = EntityConstructor>(
852
827
  options: Options<T>,
853
828
  selectors: Selector[],
854
- performQueryCallback: (
855
- options: Options<T>,
856
- formattedSelectors: FormattedSelector[],
857
- etype: string
858
- ) => {
829
+ performQueryCallback: (data: {
830
+ options: Options<T>;
831
+ selectors: FormattedSelector[];
832
+ etype: string;
833
+ }) => {
859
834
  result: any;
860
835
  },
861
836
  rowFetchCallback: () => any,
@@ -870,10 +845,10 @@ export default abstract class NymphDriver {
870
845
  getDataNameAndSValueCallback: (row: any) => {
871
846
  name: string;
872
847
  svalue: string;
873
- }
848
+ },
874
849
  ): {
875
850
  result: any;
876
- process: () => ReturnType<T['factorySync']>[] | string[] | number | Error;
851
+ process: () => EntityInstanceType<T>[] | string[] | number | Error;
877
852
  } {
878
853
  if (!this.isConnected()) {
879
854
  throw new UnableToConnectError('not connected to DB');
@@ -886,12 +861,12 @@ export default abstract class NymphDriver {
886
861
  ['&', '!&', '|', '!|'].indexOf(selector.type) === -1
887
862
  ) {
888
863
  throw new InvalidParametersError(
889
- 'Invalid query selector passed: ' + JSON.stringify(selector)
864
+ 'Invalid query selector passed: ' + JSON.stringify(selector),
890
865
  );
891
866
  }
892
867
  }
893
868
 
894
- let entities: ReturnType<T['factorySync']>[] | string[] = [];
869
+ let entities: EntityInstanceType<T>[] | string[] = [];
895
870
  let count = 0;
896
871
  const EntityClass =
897
872
  options.class ?? (this.nymph.getEntityClass('Entity') as T);
@@ -901,6 +876,7 @@ export default abstract class NymphDriver {
901
876
  if (
902
877
  this.nymph.config.cache &&
903
878
  !options.skipCache &&
879
+ selectors.length &&
904
880
  'guid' in selectors[0] &&
905
881
  typeof selectors[0].guid === 'number'
906
882
  ) {
@@ -912,10 +888,10 @@ export default abstract class NymphDriver {
912
888
  (Object.keys(selectors[0]).length === 2 ||
913
889
  (Object.keys(selectors[0]).length === 3 && 'tag' in selectors[0]))
914
890
  ) {
915
- const entity = this.pullCache<ReturnType<T['factorySync']>>(
891
+ const entity = this.pullCache<EntityInstanceType<T>>(
916
892
  selectors[0]['guid'],
917
893
  EntityClass.class,
918
- !!options.skipAc
894
+ !!options.skipAc,
919
895
  );
920
896
  if (
921
897
  entity != null &&
@@ -924,7 +900,7 @@ export default abstract class NymphDriver {
924
900
  entity.hasTag(
925
901
  ...(Array.isArray(selectors[0].tag)
926
902
  ? selectors[0].tag
927
- : [selectors[0].tag])
903
+ : [selectors[0].tag]),
928
904
  ))
929
905
  ) {
930
906
  const guid = entity.guid;
@@ -936,8 +912,8 @@ export default abstract class NymphDriver {
936
912
  ? 1
937
913
  : 0
938
914
  : options.return === 'guid'
939
- ? [guid]
940
- : [entity],
915
+ ? [guid]
916
+ : [entity],
941
917
  };
942
918
  }
943
919
  }
@@ -946,15 +922,16 @@ export default abstract class NymphDriver {
946
922
  try {
947
923
  const formattedSelectors = this.formatSelectors(selectors, options);
948
924
  this.nymph.runQueryCallbacks(options, formattedSelectors.selectors);
949
- for (let qref of formattedSelectors.qrefs) {
950
- const [options, ...selectors] = qref;
925
+ for (let i = 0; i < formattedSelectors.qrefs.length; i++) {
926
+ const [options, ...selectors] = formattedSelectors.qrefs[i];
951
927
  this.nymph.runQueryCallbacks(options, selectors);
928
+ formattedSelectors.qrefs[i] = [options, ...selectors];
952
929
  }
953
- const { result } = performQueryCallback(
930
+ const { result } = performQueryCallback({
954
931
  options,
955
- formattedSelectors.selectors,
956
- etype
957
- );
932
+ selectors: formattedSelectors.selectors,
933
+ etype,
934
+ });
958
935
 
959
936
  return {
960
937
  result,
@@ -994,9 +971,7 @@ export default abstract class NymphDriver {
994
971
  // Make sure that $row is incremented :)
995
972
  row = rowFetchCallback();
996
973
  }
997
- const entity = EntityClass.factorySync() as ReturnType<
998
- T['factorySync']
999
- >;
974
+ const entity = EntityClass.factorySync() as EntityInstanceType<T>;
1000
975
  entity.$nymph = this.nymph;
1001
976
  if (options.skipAc != null) {
1002
977
  entity.$useSkipAc(!!options.skipAc);
@@ -1009,7 +984,7 @@ export default abstract class NymphDriver {
1009
984
  if (this.putDataCounter == 100) {
1010
985
  throw new Error('Infinite loop detected in Entity loading.');
1011
986
  }
1012
- entity.$putData(data, sdata);
987
+ entity.$putData(data, sdata, 'server');
1013
988
  this.putDataCounter--;
1014
989
  if (this.nymph.config.cache) {
1015
990
  this.pushCache(guid, cdate, mdate, tags, data, sdata);
@@ -1036,88 +1011,115 @@ export default abstract class NymphDriver {
1036
1011
 
1037
1012
  protected async saveEntityRowLike(
1038
1013
  entity: EntityInterface,
1039
- saveNewEntityCallback: (
1040
- entity: EntityInterface,
1041
- guid: string,
1042
- tags: string[],
1043
- data: EntityData,
1044
- sdata: SerializedEntityData,
1045
- cdate: number,
1046
- etype: string
1047
- ) => Promise<boolean>,
1048
- saveExistingEntityCallback: (
1049
- entity: EntityInterface,
1050
- guid: string,
1051
- tags: string[],
1052
- data: EntityData,
1053
- sdata: SerializedEntityData,
1054
- mdate: number,
1055
- etype: string
1056
- ) => Promise<boolean>,
1014
+ saveNewEntityCallback: (data: {
1015
+ entity: EntityInterface;
1016
+ guid: string;
1017
+ tags: string[];
1018
+ data: EntityData;
1019
+ sdata: SerializedEntityData;
1020
+ uniques: string[];
1021
+ cdate: number;
1022
+ etype: string;
1023
+ }) => Promise<boolean>,
1024
+ saveExistingEntityCallback: (data: {
1025
+ entity: EntityInterface;
1026
+ guid: string;
1027
+ tags: string[];
1028
+ data: EntityData;
1029
+ sdata: SerializedEntityData;
1030
+ uniques: string[];
1031
+ mdate: number;
1032
+ etype: string;
1033
+ }) => Promise<boolean>,
1057
1034
  startTransactionCallback: (() => Promise<void>) | null = null,
1058
1035
  commitTransactionCallback:
1059
1036
  | ((success: boolean) => Promise<boolean>)
1060
- | null = null
1037
+ | null = null,
1061
1038
  ) {
1039
+ const originalGuid = entity.guid;
1040
+ const originalCdate = entity.cdate;
1041
+ const originalMdate = entity.mdate;
1062
1042
  // Get a modified date.
1063
- const mdate = Date.now();
1043
+ let mdate = Date.now();
1044
+ if (entity.mdate != null) {
1045
+ if (this.nymph.config.updateMDate === false) {
1046
+ mdate = entity.mdate;
1047
+ } else if (typeof this.nymph.config.updateMDate === 'number') {
1048
+ mdate = entity.mdate + this.nymph.config.updateMDate;
1049
+ }
1050
+ }
1064
1051
  const tags = difference(entity.tags, ['']);
1065
1052
  const data = entity.$getData();
1066
1053
  const sdata = entity.$getSData();
1054
+ const uniques = await entity.$getUniques();
1067
1055
  const EntityClass = entity.constructor as EntityConstructor;
1068
1056
  const etype = EntityClass.ETYPE;
1069
1057
  if (startTransactionCallback) {
1070
1058
  await startTransactionCallback();
1071
1059
  }
1072
1060
  let success = false;
1073
- if (entity.guid == null) {
1074
- const cdate = mdate;
1075
- const newId = guid();
1076
- success = await saveNewEntityCallback(
1077
- entity,
1078
- newId,
1079
- tags,
1080
- data,
1081
- sdata,
1082
- cdate,
1083
- etype
1084
- );
1085
- if (success) {
1086
- entity.guid = newId;
1087
- entity.cdate = cdate;
1088
- entity.mdate = mdate;
1061
+ try {
1062
+ if (entity.guid == null) {
1063
+ const cdate = mdate;
1064
+ const newId = entity.$getGuaranteedGUID();
1065
+ success = await saveNewEntityCallback({
1066
+ entity,
1067
+ guid: newId,
1068
+ tags,
1069
+ data,
1070
+ sdata,
1071
+ uniques,
1072
+ cdate,
1073
+ etype,
1074
+ });
1075
+ if (success) {
1076
+ entity.guid = newId;
1077
+ entity.cdate = cdate;
1078
+ entity.mdate = mdate;
1079
+ }
1080
+ } else {
1081
+ // Removed any cached versions of this entity.
1082
+ if (this.nymph.config.cache) {
1083
+ this.cleanCache(entity.guid);
1084
+ }
1085
+ success = await saveExistingEntityCallback({
1086
+ entity,
1087
+ guid: entity.guid,
1088
+ tags,
1089
+ data,
1090
+ sdata,
1091
+ uniques,
1092
+ mdate,
1093
+ etype,
1094
+ });
1095
+ if (success) {
1096
+ entity.mdate = mdate;
1097
+ }
1089
1098
  }
1090
- } else {
1091
- // Removed any cached versions of this entity.
1092
- if (this.nymph.config.cache) {
1093
- this.cleanCache(entity.guid);
1099
+ if (commitTransactionCallback) {
1100
+ success = await commitTransactionCallback(success);
1094
1101
  }
1095
- success = await saveExistingEntityCallback(
1096
- entity,
1097
- entity.guid,
1098
- tags,
1099
- data,
1100
- sdata,
1101
- mdate,
1102
- etype
1103
- );
1104
- if (success) {
1105
- entity.mdate = mdate;
1102
+ // Cache the entity.
1103
+ if (success && this.nymph.config.cache) {
1104
+ this.pushCache(
1105
+ entity.guid as string,
1106
+ entity.cdate as number,
1107
+ entity.mdate as number,
1108
+ entity.tags,
1109
+ data,
1110
+ sdata,
1111
+ );
1106
1112
  }
1113
+ } catch (e: any) {
1114
+ entity.guid = originalGuid;
1115
+ entity.cdate = originalCdate;
1116
+ entity.mdate = originalMdate;
1117
+ throw e;
1107
1118
  }
1108
- if (commitTransactionCallback) {
1109
- success = await commitTransactionCallback(success);
1110
- }
1111
- // Cache the entity.
1112
- if (success && this.nymph.config.cache) {
1113
- this.pushCache(
1114
- entity.guid as string,
1115
- entity.cdate as number,
1116
- entity.mdate as number,
1117
- entity.tags,
1118
- data,
1119
- sdata
1120
- );
1119
+ if (!success) {
1120
+ entity.guid = originalGuid;
1121
+ entity.cdate = originalCdate;
1122
+ entity.mdate = originalMdate;
1121
1123
  }
1122
1124
  return success;
1123
1125
  }
@@ -1130,10 +1132,10 @@ export default abstract class NymphDriver {
1130
1132
  * @param useSkipAc Whether to tell the entity to use skip_ac.
1131
1133
  * @returns The entity or null if it's not cached.
1132
1134
  */
1133
- protected pullCache<T extends EntityInterface = EntityInterface>(
1135
+ protected pullCache<T extends Entity>(
1134
1136
  guid: string,
1135
1137
  className: string,
1136
- useSkipAc = false
1138
+ useSkipAc = false,
1137
1139
  ): T | null {
1138
1140
  // Increment the entity access count.
1139
1141
  if (!(guid in this.entityCount)) {
@@ -1151,7 +1153,8 @@ export default abstract class NymphDriver {
1151
1153
  entity.tags = this.entityCache[guid]['tags'];
1152
1154
  entity.$putData(
1153
1155
  this.entityCache[guid]['data'],
1154
- this.entityCache[guid]['sdata']
1156
+ this.entityCache[guid]['sdata'],
1157
+ 'server',
1155
1158
  );
1156
1159
  return entity;
1157
1160
  }
@@ -1175,7 +1178,7 @@ export default abstract class NymphDriver {
1175
1178
  mdate: number,
1176
1179
  tags: string[],
1177
1180
  data: EntityData,
1178
- sdata: SerializedEntityData
1181
+ sdata: SerializedEntityData,
1179
1182
  ) {
1180
1183
  if (guid == null) {
1181
1184
  return;
@@ -1204,7 +1207,7 @@ export default abstract class NymphDriver {
1204
1207
  // Find which entity has been accessed the least.
1205
1208
  const least =
1206
1209
  Object.entries(this.entityCount)
1207
- .sort(([guidA, countA], [guidB, countB]) => countA - countB)
1210
+ .sort(([_guidA, countA], [_guidB, countB]) => countA - countB)
1208
1211
  .pop()?.[0] ?? null;
1209
1212
  if (least == null) {
1210
1213
  // This should never happen.