@adobe/spacecat-shared-data-access 1.59.2 → 1.60.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/CHANGELOG.md +7 -0
- package/package.json +2 -2
- package/src/models/site/config.js +1 -1
- package/src/service/audits/accessPatterns.js +7 -7
- package/src/service/experiments/accessPatterns.js +2 -2
- package/src/service/import-job/accessPatterns.js +1 -1
- package/src/service/import-url/accessPatterns.js +2 -2
- package/src/service/index.js +10 -18
- package/src/service/key-events/accessPatterns.js +3 -3
- package/src/service/organizations/accessPatterns.js +3 -3
- package/src/service/site-candidates/accessPatterns.js +1 -1
- package/src/service/sites/accessPatterns.js +11 -11
- package/src/v2/models/api-key/api-key.collection.js +26 -0
- package/src/v2/models/api-key/api-key.model.js +59 -0
- package/src/v2/models/api-key/api-key.schema.js +82 -0
- package/src/v2/models/api-key/index.d.ts +37 -0
- package/src/v2/models/api-key/index.js +19 -0
- package/src/v2/models/audit/audit.collection.js +26 -0
- package/src/v2/models/audit/audit.model.js +89 -0
- package/src/v2/models/audit/audit.schema.js +66 -0
- package/src/v2/models/audit/index.d.ts +40 -0
- package/src/v2/models/audit/index.js +19 -0
- package/src/v2/models/base/base.collection.js +450 -0
- package/src/v2/models/{base.model.js → base/base.model.js} +109 -89
- package/src/v2/models/base/constants.js +17 -0
- package/src/v2/models/base/entity.registry.js +137 -0
- package/src/v2/models/base/index.d.ts +83 -0
- package/src/v2/models/base/index.js +27 -0
- package/src/v2/models/base/reference.js +159 -0
- package/src/v2/models/base/schema.builder.js +420 -0
- package/src/v2/models/base/schema.js +283 -0
- package/src/v2/models/configuration/configuration.collection.js +39 -0
- package/src/v2/models/configuration/configuration.model.js +160 -0
- package/src/v2/models/configuration/configuration.schema.js +103 -0
- package/src/v2/models/configuration/index.d.ts +111 -0
- package/src/v2/models/configuration/index.js +19 -0
- package/src/v2/models/experiment/experiment.collection.js +26 -0
- package/src/v2/models/experiment/experiment.model.js +28 -0
- package/src/v2/models/experiment/experiment.schema.js +70 -0
- package/src/v2/models/experiment/index.d.ts +49 -0
- package/src/v2/models/experiment/index.js +19 -0
- package/src/v2/models/import-job/import-job.collection.js +45 -0
- package/src/v2/models/import-job/import-job.model.js +55 -0
- package/src/v2/models/import-job/import-job.schema.js +152 -0
- package/src/v2/models/import-job/index.d.ts +51 -0
- package/src/v2/models/import-job/index.js +19 -0
- package/src/v2/models/import-url/import-url.collection.js +26 -0
- package/src/v2/models/import-url/import-url.model.js +28 -0
- package/src/v2/models/import-url/import-url.schema.js +59 -0
- package/src/v2/models/import-url/index.d.ts +35 -0
- package/src/v2/models/import-url/index.js +19 -0
- package/src/v2/models/index.d.ts +11 -99
- package/src/v2/models/index.js +14 -15
- package/src/v2/models/key-event/index.d.ts +28 -0
- package/src/v2/models/key-event/index.js +19 -0
- package/src/v2/models/key-event/key-event.collection.js +26 -0
- package/src/v2/models/key-event/key-event.model.js +37 -0
- package/src/v2/models/key-event/key-event.schema.js +45 -0
- package/src/v2/models/opportunity/index.d.ts +46 -0
- package/src/v2/models/opportunity/index.js +19 -0
- package/src/v2/models/opportunity/opportunity.collection.js +26 -0
- package/src/v2/models/{opportunity.model.js → opportunity/opportunity.model.js} +15 -2
- package/src/v2/models/opportunity/opportunity.schema.js +69 -0
- package/src/v2/models/organization/index.d.ts +28 -0
- package/src/v2/models/organization/index.js +19 -0
- package/src/v2/models/organization/organization.collection.js +26 -0
- package/src/v2/models/organization/organization.model.js +31 -0
- package/src/v2/models/organization/organization.schema.js +51 -0
- package/src/v2/models/site/index.d.ts +43 -0
- package/src/v2/models/site/index.js +20 -0
- package/src/v2/models/site/site.collection.js +28 -0
- package/src/v2/models/site/site.model.js +47 -0
- package/src/v2/models/site/site.schema.js +91 -0
- package/src/v2/models/site-candidate/index.d.ts +38 -0
- package/src/v2/models/site-candidate/index.js +19 -0
- package/src/v2/models/site-candidate/site-candidate.collection.js +27 -0
- package/src/v2/models/site-candidate/site-candidate.model.js +41 -0
- package/src/v2/models/site-candidate/site-candidate.schema.js +59 -0
- package/src/v2/models/site-top-page/index.d.ts +35 -0
- package/src/v2/models/site-top-page/index.js +19 -0
- package/src/v2/models/site-top-page/site-top-page.collection.js +44 -0
- package/src/v2/models/site-top-page/site-top-page.model.js +28 -0
- package/src/v2/models/site-top-page/site-top-page.schema.js +65 -0
- package/src/v2/models/suggestion/index.d.ts +34 -0
- package/src/v2/models/suggestion/index.js +19 -0
- package/src/v2/models/suggestion/suggestion.collection.js +55 -0
- package/src/v2/models/{suggestion.model.js → suggestion/suggestion.model.js} +16 -1
- package/src/v2/models/suggestion/suggestion.schema.js +53 -0
- package/src/v2/readme.md +201 -256
- package/src/v2/util/accessor.utils.js +158 -0
- package/src/v2/util/guards.d.ts +7 -0
- package/src/v2/util/guards.js +21 -4
- package/src/v2/util/index.js +1 -0
- package/src/v2/util/patcher.js +54 -25
- package/src/v2/util/util.js +84 -0
- package/src/v2/models/base.collection.js +0 -275
- package/src/v2/models/model.factory.js +0 -74
- package/src/v2/models/opportunity.collection.js +0 -74
- package/src/v2/models/suggestion.collection.js +0 -104
- package/src/v2/schema/opportunity.schema.js +0 -159
- package/src/v2/schema/suggestion.schema.js +0 -132
- package/src/v2/util/reference.js +0 -41
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2024 Adobe. All rights reserved.
|
|
3
|
+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
* you may not use this file except in compliance with the License. You may obtain a copy
|
|
5
|
+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
*
|
|
7
|
+
* Unless required by applicable law or agreed to in writing, software distributed under
|
|
8
|
+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
|
9
|
+
* OF ANY KIND, either express or implied. See the License for the specific language
|
|
10
|
+
* governing permissions and limitations under the License.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { hasText } from '@adobe/spacecat-shared-utils';
|
|
14
|
+
import {
|
|
15
|
+
entityNameToCollectionName,
|
|
16
|
+
entityNameToIdName,
|
|
17
|
+
isNonEmptyArray,
|
|
18
|
+
keyNamesToMethodName,
|
|
19
|
+
referenceToBaseMethodName,
|
|
20
|
+
} from '../../util/util.js';
|
|
21
|
+
|
|
22
|
+
class Reference {
|
|
23
|
+
static TYPES = {
|
|
24
|
+
BELONGS_TO: 'belongs_to',
|
|
25
|
+
HAS_MANY: 'has_many',
|
|
26
|
+
HAS_ONE: 'has_one',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
static fromJSON(json) {
|
|
30
|
+
return new Reference(json.type, json.target, json.options);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static isValidType(type) {
|
|
34
|
+
return Object.values(Reference.TYPES).includes(type);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
constructor(type, target, options = {}) {
|
|
38
|
+
if (!Reference.isValidType(type)) {
|
|
39
|
+
throw new Error(`Invalid reference type: ${type}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!hasText(target)) {
|
|
43
|
+
throw new Error('Invalid target');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.type = type;
|
|
47
|
+
this.target = target;
|
|
48
|
+
this.options = options;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
getSortKeys() {
|
|
52
|
+
return this.options.sortKeys;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
getTarget() {
|
|
56
|
+
return this.target;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getType() {
|
|
60
|
+
return this.type;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
isRemoveDependents() {
|
|
64
|
+
return this.options.removeDependents;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
toAccessorConfigs(registry, entity) {
|
|
68
|
+
const { log } = registry;
|
|
69
|
+
const accessorConfigs = [];
|
|
70
|
+
|
|
71
|
+
const target = this.getTarget();
|
|
72
|
+
const type = this.getType();
|
|
73
|
+
|
|
74
|
+
const baseMethodName = referenceToBaseMethodName(this);
|
|
75
|
+
const collectionName = entityNameToCollectionName(target);
|
|
76
|
+
const targetCollection = registry.getCollection(collectionName);
|
|
77
|
+
|
|
78
|
+
switch (type) {
|
|
79
|
+
case Reference.TYPES.BELONGS_TO: {
|
|
80
|
+
const foreignKeyName = entityNameToIdName(target);
|
|
81
|
+
const foreignKeyValue = entity.record[foreignKeyName];
|
|
82
|
+
|
|
83
|
+
// belongs_to: direct findById
|
|
84
|
+
accessorConfigs.push({
|
|
85
|
+
name: baseMethodName,
|
|
86
|
+
requiredKeys: [],
|
|
87
|
+
foreignKey: { name: foreignKeyName, value: foreignKeyValue },
|
|
88
|
+
byId: true,
|
|
89
|
+
});
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
case Reference.TYPES.HAS_ONE: {
|
|
94
|
+
const foreignKeyName = entityNameToIdName(entity.entityName);
|
|
95
|
+
const foreignKeyValue = entity.getId();
|
|
96
|
+
|
|
97
|
+
// has_one yields a single record.
|
|
98
|
+
accessorConfigs.push({
|
|
99
|
+
name: baseMethodName,
|
|
100
|
+
requiredKeys: [],
|
|
101
|
+
foreignKey: { name: foreignKeyName, value: foreignKeyValue },
|
|
102
|
+
});
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
case Reference.TYPES.HAS_MANY: {
|
|
107
|
+
const foreignKeyName = entityNameToIdName(entity.entityName);
|
|
108
|
+
const foreignKeyValue = entity.getId();
|
|
109
|
+
|
|
110
|
+
// has_many yields multiple records.
|
|
111
|
+
accessorConfigs.push({
|
|
112
|
+
name: baseMethodName,
|
|
113
|
+
requiredKeys: [],
|
|
114
|
+
all: true,
|
|
115
|
+
foreignKey: { name: foreignKeyName, value: foreignKeyValue },
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const belongsToRef = targetCollection.schema.getReferenceByTypeAndTarget(
|
|
119
|
+
Reference.TYPES.BELONGS_TO,
|
|
120
|
+
entity.schema.getModelName(),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
if (!belongsToRef) {
|
|
124
|
+
log.warn(`Reciprocal reference not found for ${entity.schema.getModelName()} to ${target}`);
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const sortKeys = belongsToRef.getSortKeys();
|
|
129
|
+
if (!isNonEmptyArray(sortKeys)) {
|
|
130
|
+
log.debug(`No sort keys defined for ${entity.schema.getModelName()} to ${target}`);
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (let i = 1; i <= sortKeys.length; i += 1) {
|
|
135
|
+
const subset = sortKeys.slice(0, i);
|
|
136
|
+
accessorConfigs.push({
|
|
137
|
+
name: keyNamesToMethodName(subset, `${baseMethodName}By`),
|
|
138
|
+
requiredKeys: subset,
|
|
139
|
+
all: true,
|
|
140
|
+
foreignKey: { name: foreignKeyName, value: foreignKeyValue },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
default:
|
|
148
|
+
throw new Error(`Unsupported reference type: ${type}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return accessorConfigs.map((config) => ({
|
|
152
|
+
...config,
|
|
153
|
+
collection: targetCollection,
|
|
154
|
+
context: entity,
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export default Reference;
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2024 Adobe. All rights reserved.
|
|
3
|
+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
* you may not use this file except in compliance with the License. You may obtain a copy
|
|
5
|
+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
*
|
|
7
|
+
* Unless required by applicable law or agreed to in writing, software distributed under
|
|
8
|
+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
|
9
|
+
* OF ANY KIND, either express or implied. See the License for the specific language
|
|
10
|
+
* governing permissions and limitations under the License.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { hasText, isInteger, isNonEmptyObject } from '@adobe/spacecat-shared-utils';
|
|
14
|
+
|
|
15
|
+
import { v4 as uuid, validate as uuidValidate } from 'uuid';
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
capitalize,
|
|
19
|
+
decapitalize,
|
|
20
|
+
entityNameToAllPKValue,
|
|
21
|
+
entityNameToIdName, isNonEmptyArray,
|
|
22
|
+
} from '../../util/util.js';
|
|
23
|
+
|
|
24
|
+
import { INDEX_TYPES } from './constants.js';
|
|
25
|
+
import BaseModel from './base.model.js';
|
|
26
|
+
import BaseCollection from './base.collection.js';
|
|
27
|
+
import Reference from './reference.js';
|
|
28
|
+
import Schema from './schema.js';
|
|
29
|
+
|
|
30
|
+
const DEFAULT_SERVICE_NAME = 'SpaceCat';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* ID attribute configuration object.
|
|
34
|
+
* Ensures a UUID-based "primary key".
|
|
35
|
+
* @type {object}
|
|
36
|
+
*/
|
|
37
|
+
const ID_ATTRIBUTE_DATA = {
|
|
38
|
+
type: 'string',
|
|
39
|
+
required: true,
|
|
40
|
+
readOnly: true,
|
|
41
|
+
// https://electrodb.dev/en/modeling/attributes/#default
|
|
42
|
+
default: () => uuid(),
|
|
43
|
+
// https://electrodb.dev/en/modeling/attributes/#attribute-validation
|
|
44
|
+
validate: (value) => uuidValidate(value),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* CreatedAt attribute configuration object.
|
|
49
|
+
* Automatically sets to current date/time at creation.
|
|
50
|
+
* @type {object}
|
|
51
|
+
*/
|
|
52
|
+
const CREATED_AT_ATTRIBUTE_DATA = {
|
|
53
|
+
type: 'string',
|
|
54
|
+
readOnly: true,
|
|
55
|
+
required: true,
|
|
56
|
+
default: () => new Date().toISOString(),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* UpdatedAt attribute configuration object.
|
|
61
|
+
* Automatically updates to current date/time whenever the entity is modified.
|
|
62
|
+
* @type {object}
|
|
63
|
+
*/
|
|
64
|
+
const UPDATED_AT_ATTRIBUTE_DATA = {
|
|
65
|
+
type: 'string',
|
|
66
|
+
required: true,
|
|
67
|
+
readOnly: true,
|
|
68
|
+
watch: '*',
|
|
69
|
+
default: () => new Date().toISOString(),
|
|
70
|
+
set: () => new Date().toISOString(),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Certain index names (primary, all) are reserved and cannot be reused. */
|
|
74
|
+
const RESERVED_INDEX_NAMES = [INDEX_TYPES.PRIMARY, INDEX_TYPES.ALL];
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Constructs a fully qualified index name.
|
|
78
|
+
* @param {string} service - The name of the service.
|
|
79
|
+
* @param {string} entity - The name of the entity.
|
|
80
|
+
* @param {string} name - The index name (e.g., 'all', 'byForeignKey').
|
|
81
|
+
* @returns {string} The fully qualified index name.
|
|
82
|
+
*/
|
|
83
|
+
const createdIndexName = (service, entity, name) => `${service.toLowerCase()}-data-${entity}-${name}`;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Sorts an indexes object by its keys alphabetically.
|
|
87
|
+
* @param {object} indexes - An object whose keys are index names and values are index definitions.
|
|
88
|
+
* @returns {object} A new object with the same entries, but keys sorted alphabetically.
|
|
89
|
+
*/
|
|
90
|
+
const sortIndexes = (indexes) => Object.fromEntries(
|
|
91
|
+
Object.entries(indexes).sort((a, b) => a[0].localeCompare(b[0])),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Assigns GSI field names to indexes that don't have them yet.
|
|
96
|
+
* Ensures that if an "all" index exists, it uses gsi1 (already assigned)
|
|
97
|
+
* and other indexes continue numbering from gsi2 onwards.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} indexes - Object of indexes that require naming.
|
|
100
|
+
* @param {object|null} all - The "all" index object if present, null otherwise.
|
|
101
|
+
*/
|
|
102
|
+
const numberGSIsIndexes = (indexes, all) => {
|
|
103
|
+
// if there's an "all" index, we start indexing subsequent GSIs from 2,
|
|
104
|
+
// because "all" index already occupies gsi1.
|
|
105
|
+
// if no "all" index exists, start from 1.
|
|
106
|
+
let gsiCounter = isNonEmptyObject(all) ? 1 : 0;
|
|
107
|
+
|
|
108
|
+
Object.values(indexes).forEach((index) => { /* eslint-disable no-param-reassign */
|
|
109
|
+
// only assign new field names and number through if none are provided.
|
|
110
|
+
if (!index.pk.field || !index.sk.field) {
|
|
111
|
+
gsiCounter += 1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
index.pk.field = index.pk.field || `gsi${gsiCounter}pk`;
|
|
115
|
+
index.sk.field = index.sk.field || `gsi${gsiCounter}sk`;
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The SchemaBuilder class allows for constructing a schema definition
|
|
121
|
+
* including attributes, indexes, and references to other entities.
|
|
122
|
+
* Index ordering is enforced at build time for deterministic output:
|
|
123
|
+
* - primary index first
|
|
124
|
+
* - "all" index second (if present)
|
|
125
|
+
* - all "belongs_to" indexes sorted alphabetically next
|
|
126
|
+
* - all "other" indexes sorted alphabetically last
|
|
127
|
+
*/
|
|
128
|
+
class SchemaBuilder {
|
|
129
|
+
/**
|
|
130
|
+
* Creates a new SchemaBuilder instance.
|
|
131
|
+
*
|
|
132
|
+
* @param {BaseModel} modelClass - The model class for this entity.
|
|
133
|
+
* @param {BaseCollection} collectionClass - The collection class for this entity.
|
|
134
|
+
* @param {number} schemaVersion - A positive integer representing the schema's version.
|
|
135
|
+
* @throws {Error} If entityName is not a non-empty string.
|
|
136
|
+
* @throws {Error} If schemaVersion is not a positive integer.
|
|
137
|
+
* @throws {Error} If serviceName is not a non-empty string.
|
|
138
|
+
*/
|
|
139
|
+
constructor(modelClass, collectionClass, schemaVersion = 1) {
|
|
140
|
+
if (!modelClass || !(modelClass.prototype instanceof BaseModel)) {
|
|
141
|
+
throw new Error('modelClass must be a subclass of BaseModel.');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!collectionClass || !(collectionClass.prototype instanceof BaseCollection)) {
|
|
145
|
+
throw new Error('collectionClass must be a subclass of BaseCollection.');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!isInteger(schemaVersion) || schemaVersion < 1) {
|
|
149
|
+
throw new Error('schemaVersion is required and must be a positive integer.');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
this.modelClass = modelClass;
|
|
153
|
+
this.collectionClass = collectionClass;
|
|
154
|
+
this.schemaVersion = schemaVersion;
|
|
155
|
+
this.entityName = modelClass.name;
|
|
156
|
+
this.serviceName = DEFAULT_SERVICE_NAME;
|
|
157
|
+
|
|
158
|
+
this.idName = entityNameToIdName(this.entityName);
|
|
159
|
+
|
|
160
|
+
this.rawIndexes = {
|
|
161
|
+
primary: null,
|
|
162
|
+
all: null,
|
|
163
|
+
belongs_to: {},
|
|
164
|
+
other: {},
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
this.attributes = {};
|
|
168
|
+
|
|
169
|
+
// will be populated by build() from rawIndexes
|
|
170
|
+
this.indexes = {};
|
|
171
|
+
|
|
172
|
+
// this is not part of the ElectroDB schema spec, but we use it to store reference data
|
|
173
|
+
this.references = [];
|
|
174
|
+
|
|
175
|
+
this.#initialize();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#initialize() {
|
|
179
|
+
this.addAttribute(this.idName, ID_ATTRIBUTE_DATA);
|
|
180
|
+
this.addAttribute('createdAt', CREATED_AT_ATTRIBUTE_DATA);
|
|
181
|
+
this.addAttribute('updatedAt', UPDATED_AT_ATTRIBUTE_DATA);
|
|
182
|
+
// todo: add createdBy, updatedBy and auto-set from auth context
|
|
183
|
+
|
|
184
|
+
// set up the primary index directly
|
|
185
|
+
// primary index fields are fixed and known upfront
|
|
186
|
+
this.rawIndexes.primary = {
|
|
187
|
+
pk: { field: 'pk', composite: [this.idName] },
|
|
188
|
+
sk: { field: 'sk', composite: [] },
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
#internalAddIndex(name, partitionKey, sortKey, type) {
|
|
193
|
+
const indexFullName = createdIndexName(this.serviceName, this.entityName, name);
|
|
194
|
+
|
|
195
|
+
// store index config without assigning fields yet
|
|
196
|
+
// the fields will be assigned in build phase based on sorting and presence of "all" index
|
|
197
|
+
this.rawIndexes[type][name] = {
|
|
198
|
+
...(indexFullName && { index: indexFullName }),
|
|
199
|
+
pk: { ...partitionKey },
|
|
200
|
+
sk: { ...sortKey },
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Adds a new attribute to the schema definition.
|
|
206
|
+
*
|
|
207
|
+
* @param {string} name - The attribute name.
|
|
208
|
+
* @param {object} data - The attribute definition (type, required, validation, etc.).
|
|
209
|
+
* @returns {SchemaBuilder} Returns this builder for method chaining.
|
|
210
|
+
* @throws {Error} If name is not non-empty or data is not an object.
|
|
211
|
+
*/
|
|
212
|
+
addAttribute(name, data) {
|
|
213
|
+
if (!hasText(name)) {
|
|
214
|
+
throw new Error('Attribute name is required and must be non-empty.');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!isNonEmptyObject(data)) {
|
|
218
|
+
throw new Error(`Attribute data for "${name}" is required and must be a non-empty object.`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.attributes[name] = data;
|
|
222
|
+
|
|
223
|
+
return this;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Adds an "all" index based on composite attributes.
|
|
228
|
+
* The "all" index is a special index listing all entities, sorted by given attributes.
|
|
229
|
+
* Useful for global queries across all entities of this type.
|
|
230
|
+
* Will overwrite any existing "all" index.
|
|
231
|
+
*
|
|
232
|
+
* @param {...string} attributeNames - The attribute names forming the composite sort key.
|
|
233
|
+
* @returns {SchemaBuilder} Returns this builder for method chaining.
|
|
234
|
+
* @throws {Error} If no attribute names are provided.
|
|
235
|
+
*/
|
|
236
|
+
addAllIndexWithComposite(...attributeNames) {
|
|
237
|
+
if (attributeNames.length === 0) {
|
|
238
|
+
throw new Error('At least one composite attribute name is required.');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
this.rawIndexes.all = {
|
|
242
|
+
index: createdIndexName(this.serviceName, this.entityName, INDEX_TYPES.ALL),
|
|
243
|
+
pk: { field: 'gsi1pk', template: entityNameToAllPKValue(this.entityName) },
|
|
244
|
+
sk: { field: 'gsi1sk', composite: attributeNames },
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
return this;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Adds an "all" index with a template-based sort key.
|
|
252
|
+
* Useful if a single value template defines how entries are sorted.
|
|
253
|
+
*
|
|
254
|
+
* @param {string} fieldName - The sort key field name.
|
|
255
|
+
* @param {string} template - A template string defining how to generate the sort key value.
|
|
256
|
+
* @returns {SchemaBuilder} Returns this builder for method chaining.
|
|
257
|
+
* @throws {Error} If fieldName or template are not valid strings.
|
|
258
|
+
*/
|
|
259
|
+
addAllIndexWithTemplateField(fieldName, template) {
|
|
260
|
+
if (!hasText(fieldName)) {
|
|
261
|
+
throw new Error('fieldName is required and must be a non-empty string.');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (!hasText(template)) {
|
|
265
|
+
throw new Error('template is required and must be a non-empty string.');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
this.rawIndexes.all = {
|
|
269
|
+
index: createdIndexName(this.serviceName, this.entityName, 'all'),
|
|
270
|
+
pk: { field: 'gsi1pk', template: entityNameToAllPKValue(this.entityName) },
|
|
271
|
+
sk: { field: fieldName, template },
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
return this;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Adds a generic secondary index (GSI).
|
|
279
|
+
*
|
|
280
|
+
* @param {string} name - The index name. Cannot be 'primary' or 'all'.
|
|
281
|
+
* @param {object} partitionKey - The partition key definition
|
|
282
|
+
* (e.g., { composite: [attributeName] }).
|
|
283
|
+
* @param {object} sortKey - The sort key definition.
|
|
284
|
+
* @returns {SchemaBuilder} Returns this builder for method chaining.
|
|
285
|
+
* @throws {Error} If index name is reserved or pk/sk configs are invalid.
|
|
286
|
+
*/
|
|
287
|
+
addIndex(name, partitionKey, sortKey) {
|
|
288
|
+
if (!hasText(name)) {
|
|
289
|
+
throw new Error('Index name is required and must be a non-empty string.');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (RESERVED_INDEX_NAMES.includes(name)) {
|
|
293
|
+
throw new Error(`Index name "${name}" is reserved.`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (!isNonEmptyObject(partitionKey)) {
|
|
297
|
+
throw new Error('Partition key configuration (pk) is required and must be a non-empty object.');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (!isNonEmptyObject(sortKey)) {
|
|
301
|
+
throw new Error('Sort key configuration (sk) is required and must be a non-empty object.');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
this.#internalAddIndex(name, partitionKey, sortKey, INDEX_TYPES.OTHER);
|
|
305
|
+
|
|
306
|
+
return this;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Adds a reference to another entity, potentially creating a belongs_to index.
|
|
311
|
+
*
|
|
312
|
+
* @param {string} type - One of Reference.TYPES (BELONGS_TO, HAS_MANY, HAS_ONE).
|
|
313
|
+
* @param {string} entityName - The referenced entity name.
|
|
314
|
+
* @param {Array<string>} [sortKeys=['updatedAt']] - The attributes to form the sort key.
|
|
315
|
+
* @param {object} [options] - Additional reference options.
|
|
316
|
+
* @param {boolean} [options.required=true] - Whether the reference is required. Only applies to
|
|
317
|
+
* BELONGS_TO references.
|
|
318
|
+
* @param {boolean} [options.removeDependents=false] - Whether to remove dependent entities
|
|
319
|
+
* on delete. Only applies to HAS_MANY and HAS_ONE references.
|
|
320
|
+
* @returns {SchemaBuilder} Returns this builder for method chaining.
|
|
321
|
+
* @throws {Error} If type or entityName are invalid.
|
|
322
|
+
*/
|
|
323
|
+
addReference(type, entityName, sortKeys = [], options = {}) {
|
|
324
|
+
if (!Reference.isValidType(type)) {
|
|
325
|
+
throw new Error(`Invalid referenceType: "${type}".`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (!hasText(entityName)) {
|
|
329
|
+
throw new Error('entityName for reference is required and must be a non-empty string.');
|
|
330
|
+
}
|
|
331
|
+
const reference = {
|
|
332
|
+
type,
|
|
333
|
+
target: entityName,
|
|
334
|
+
options: { sortKeys },
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
if ([
|
|
338
|
+
Reference.TYPES.HAS_MANY,
|
|
339
|
+
Reference.TYPES.HAS_ONE,
|
|
340
|
+
].includes(type)) {
|
|
341
|
+
reference.options.removeDependents = options.removeDependents ?? false;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (type === Reference.TYPES.BELONGS_TO) {
|
|
345
|
+
reference.options.required = options.required ?? true;
|
|
346
|
+
|
|
347
|
+
// for a BELONGS_TO reference, we add a foreign key attribute
|
|
348
|
+
// and a corresponding "belongs_to" index to facilitate lookups by that foreign key.
|
|
349
|
+
const foreignKeyName = entityNameToIdName(entityName);
|
|
350
|
+
|
|
351
|
+
this.addAttribute(foreignKeyName, {
|
|
352
|
+
type: 'string',
|
|
353
|
+
required: reference.options.required,
|
|
354
|
+
validate: (
|
|
355
|
+
value,
|
|
356
|
+
) => (reference.options.required ? uuidValidate(value) : !value || uuidValidate(value)),
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
this.#internalAddIndex(
|
|
360
|
+
`by${capitalize(foreignKeyName)}`,
|
|
361
|
+
{ composite: [decapitalize(foreignKeyName)] },
|
|
362
|
+
{ composite: isNonEmptyArray(sortKeys) ? sortKeys : ['updatedAt'] },
|
|
363
|
+
INDEX_TYPES.BELONGS_TO,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
this.references.push(Reference.fromJSON(reference));
|
|
368
|
+
|
|
369
|
+
return this;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Builds the final indexes object by:
|
|
374
|
+
* - Sorting and merging belongs_to and other indexes
|
|
375
|
+
* - Assigning GSI fields to indexes after final order is determined
|
|
376
|
+
*
|
|
377
|
+
* @private
|
|
378
|
+
*/
|
|
379
|
+
#buildIndexes() {
|
|
380
|
+
// eslint-disable-next-line camelcase
|
|
381
|
+
const { belongs_to, other } = this.rawIndexes;
|
|
382
|
+
|
|
383
|
+
// belongs_to indexes come before other indexes
|
|
384
|
+
const indexes = {
|
|
385
|
+
...sortIndexes(belongs_to),
|
|
386
|
+
...sortIndexes(other),
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
numberGSIsIndexes(indexes, this.rawIndexes.all);
|
|
390
|
+
|
|
391
|
+
this.indexes = {
|
|
392
|
+
primary: this.rawIndexes.primary,
|
|
393
|
+
...(this.rawIndexes.all && { all: this.rawIndexes.all }),
|
|
394
|
+
...indexes,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Finalizes the schema by building and ordering indexes.
|
|
400
|
+
*
|
|
401
|
+
* @returns {object} The fully constructed schema object.
|
|
402
|
+
*/
|
|
403
|
+
build() {
|
|
404
|
+
this.#buildIndexes();
|
|
405
|
+
|
|
406
|
+
return new Schema(
|
|
407
|
+
this.modelClass,
|
|
408
|
+
this.collectionClass,
|
|
409
|
+
{
|
|
410
|
+
serviceName: this.serviceName,
|
|
411
|
+
schemaVersion: this.schemaVersion,
|
|
412
|
+
attributes: this.attributes,
|
|
413
|
+
indexes: this.indexes,
|
|
414
|
+
references: this.references,
|
|
415
|
+
},
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export default SchemaBuilder;
|