@lossless.org/client 1.1.0 → 1.2.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.
@@ -298,6 +298,31 @@ export const getExpectedCollectionTopologyForSchema = (
298
298
  });
299
299
  };
300
300
 
301
+ /**
302
+ * @internal A single-field ascending unique index is what makes a declared
303
+ * identity globally constraining, whatever the index is called.
304
+ */
305
+ export const isIdentityIndexFor = (
306
+ indexArg: INormalizedCollectionModelSchema['indexes'][number],
307
+ fieldArg: string,
308
+ ): boolean =>
309
+ indexArg.options.unique === true &&
310
+ indexArg.key.length === 1 &&
311
+ indexArg.key[0][0] === fieldArg &&
312
+ indexArg.key[0][1] === 1;
313
+
314
+ /**
315
+ * @internal Name of the unique index that backs an identity field in a bound
316
+ * schema. Index-creation fallbacks resolve it here so that a model naming its
317
+ * own backing index never gets a second unique index over the same key.
318
+ */
319
+ export const getIdentityIndexName = (
320
+ schemaArg: INormalizedCollectionModelSchema | undefined,
321
+ fieldArg: string,
322
+ ): string | undefined =>
323
+ schemaArg?.indexes.find((indexArg) => isIdentityIndexFor(indexArg, fieldArg))
324
+ ?.name;
325
+
301
326
  const normalizeActualIndex = (
302
327
  rawIndexArg: unknown,
303
328
  ): IActualIndexNormalization => {
@@ -78,6 +78,7 @@ interface ISmartdataDecoratorMetadata extends DecoratorMetadataObject {
78
78
  saveableProperties?: string[];
79
79
  uniqueIndexes?: string[];
80
80
  identityValueTypes?: Record<string, TSmartdataIdentityValueType>;
81
+ identityIndexNames?: Record<string, string>;
81
82
  regularIndexes?: Array<{field: string, options: IIndexOptions}>;
82
83
  compoundIndexes?: Array<{
83
84
  name: string;
@@ -98,6 +99,14 @@ export interface IUnIOptions {
98
99
  * non-empty string. Numeric identities require an explicit opt-in.
99
100
  */
100
101
  valueType?: TSmartdataIdentityValueType;
102
+ /**
103
+ * Name of the single-field ascending unique index that backs this identity.
104
+ * Defaults to `<field>_1`. A collection whose unique index was created by a
105
+ * migration under another name declares that name here: MongoDB refuses a
106
+ * second index over the same key, so without it the identity could not be
107
+ * declared at all.
108
+ */
109
+ indexName?: string;
101
110
  }
102
111
 
103
112
  /**
@@ -323,11 +332,13 @@ export function unI(optionsArg: IUnIOptions = {}) {
323
332
  typeof optionsArg !== 'object'
324
333
  || optionsArg === null
325
334
  || Array.isArray(optionsArg)
326
- || Object.keys(optionsArg).some((keyArg) => keyArg !== 'valueType')
335
+ || Object.keys(optionsArg).some(
336
+ (keyArg) => keyArg !== 'valueType' && keyArg !== 'indexName',
337
+ )
327
338
  ) {
328
339
  throw new SmartdataPersistenceError(
329
340
  'invalid_configuration',
330
- 'unI options must be an object containing only valueType.',
341
+ 'unI options must be an object containing only valueType and indexName.',
331
342
  );
332
343
  }
333
344
  const valueType = optionsArg.valueType ?? 'string';
@@ -337,6 +348,25 @@ export function unI(optionsArg: IUnIOptions = {}) {
337
348
  'unI valueType must be "string" or "positiveSafeInteger".',
338
349
  );
339
350
  }
351
+ const indexName = optionsArg.indexName;
352
+ if (indexName !== undefined) {
353
+ if (
354
+ typeof indexName !== 'string'
355
+ || indexName.trim().length === 0
356
+ || indexName.includes('\0')
357
+ ) {
358
+ throw new SmartdataPersistenceError(
359
+ 'invalid_configuration',
360
+ 'unI indexName must be a non-empty index name.',
361
+ );
362
+ }
363
+ if (indexName === '_id_') {
364
+ throw new SmartdataPersistenceError(
365
+ 'invalid_configuration',
366
+ 'unI indexName "_id_" is reserved for MongoDB.',
367
+ );
368
+ }
369
+ }
340
370
  return (value: undefined, context: ClassFieldDecoratorContext) => {
341
371
  if (context.kind !== 'field') {
342
372
  throw new Error('unI can only decorate fields');
@@ -365,6 +395,24 @@ export function unI(optionsArg: IUnIOptions = {}) {
365
395
  );
366
396
  }
367
397
  metadata.identityValueTypes![propName] = valueType;
398
+ // An unnamed declaration writes no entry, so a subclass that redeclares
399
+ // the identity without a name keeps the index the base class named. Only
400
+ // a second, different name is a contradiction worth refusing.
401
+ if (indexName !== undefined) {
402
+ if (
403
+ !Object.prototype.hasOwnProperty.call(metadata, 'identityIndexNames')
404
+ ) {
405
+ metadata.identityIndexNames = { ...(metadata.identityIndexNames || {}) };
406
+ }
407
+ const existingIndexName = metadata.identityIndexNames![propName];
408
+ if (existingIndexName && existingIndexName !== indexName) {
409
+ throw new SmartdataPersistenceError(
410
+ 'invalid_configuration',
411
+ `Identity field "${propName}" has divergent indexName declarations.`,
412
+ );
413
+ }
414
+ metadata.identityIndexNames![propName] = indexName;
415
+ }
368
416
 
369
417
  // Also mark as saveable
370
418
  if (!Object.prototype.hasOwnProperty.call(metadata, 'saveableProperties')) {
@@ -4,6 +4,7 @@ import { assertBsonWithoutMutation } from './classes.bsonassertion.js';
4
4
  import { getOrdinaryPersistencePolicy } from './classes.ordinarypersistence.js';
5
5
 
6
6
  import type { SmartdataCollection } from './classes.collection.js';
7
+ import { getIdentityIndexName } from './classes.collectiontopology.js';
7
8
  import { registerCollectionReconnectHandler } from './classes.collectionlifecycle.js';
8
9
  import type { SmartdataSession } from './classes.session.js';
9
10
  import { acquireSmartdataSession } from './classes.session.js';
@@ -1024,10 +1025,17 @@ export class SmartdataExactCollection<
1024
1025
  const missingUniqueIndexes = this.uniqueIndexes.filter(
1025
1026
  (uniqueIndexArg) => !this.collection.uniqueIndexes.includes(uniqueIndexArg),
1026
1027
  );
1028
+ const boundSchema = this.collection.getBoundModelSchema();
1027
1029
  for (const uniqueIndex of missingUniqueIndexes) {
1028
1030
  await targetCollectionArg.createIndex(
1029
1031
  { [uniqueIndex]: 1 },
1030
- { unique: true, name: `${uniqueIndex}_1` },
1032
+ {
1033
+ unique: true,
1034
+ // A model may back its identity with a differently named index;
1035
+ // creating `<field>_1` would duplicate that key.
1036
+ name: getIdentityIndexName(boundSchema, uniqueIndex)
1037
+ ?? `${uniqueIndex}_1`,
1038
+ },
1031
1039
  );
1032
1040
  }
1033
1041
 
@@ -43,6 +43,23 @@ export const isMongoDuplicateKeyError = (
43
43
  );
44
44
  };
45
45
 
46
+ /**
47
+ * MongoDB refuses an index whose key or name collides with an existing one:
48
+ * `IndexOptionsConflict` when an equivalent index already carries another
49
+ * name, `IndexKeySpecsConflict` when the name is taken by another key.
50
+ */
51
+ export const isMongoIndexConflictError = (
52
+ errorArg: unknown,
53
+ ): errorArg is plugins.mongodb.MongoServerError => {
54
+ return (
55
+ errorArg instanceof plugins.mongodb.MongoServerError &&
56
+ (errorArg.code === 85 ||
57
+ errorArg.code === 86 ||
58
+ errorArg.codeName === 'IndexOptionsConflict' ||
59
+ errorArg.codeName === 'IndexKeySpecsConflict')
60
+ );
61
+ };
62
+
46
63
  export const normalizeOrdinaryPersistenceError = (
47
64
  errorArg: unknown,
48
65
  messageArg: string,