@nuskin/contentstack-lib 2.1.0-pa-1117.11 → 2.1.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/.releaserc CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "branches": [
3
- "master", {"name":"PA-1117", "channel":"prerelease", "prerelease":"pa-1117"}
3
+ "master"
4
4
  ],
5
5
  "plugins": [
6
6
  "@semantic-release/release-notes-generator",
package/README.md CHANGED
@@ -54,6 +54,26 @@ const [
54
54
  mergeWithFallback: true
55
55
  });
56
56
 
57
+ // Query a multiple-entry content type by one or more fields.
58
+ const shippingForms = await Stack.getMultiEntriesByFields(
59
+ 'shipping_address_form',
60
+ {
61
+ country_code: 'US',
62
+ locale: 'en-us'
63
+ },
64
+ true
65
+ );
66
+
67
+ // Convenience wrapper when you only expect one result.
68
+ const shippingForm = await Stack.getMultiEntryByFields(
69
+ 'shipping_address_form',
70
+ {
71
+ country_code: 'US',
72
+ locale: 'en-us'
73
+ },
74
+ true
75
+ );
76
+
57
77
  // We could possibly add functionality to return the list of regions markets and languages in a single object structure
58
78
  ```
59
79
 
@@ -16,6 +16,7 @@ const mockPromiseResult = jest.fn();
16
16
  const mockSinglePromiseResult = jest.fn();
17
17
  const mockStack = jest.fn();
18
18
  const mockContainedIn = jest.fn();
19
+ const mockWhere = jest.fn();
19
20
  const mockIncludeReference = jest.fn();
20
21
  const mockFetch = jest.fn();
21
22
 
@@ -55,6 +56,7 @@ beforeEach(() => {
55
56
  })
56
57
  })),
57
58
  containedIn: mockContainedIn,
59
+ where: mockWhere,
58
60
  includeReference: mockIncludeReference,
59
61
  toJSON: jest.fn(() => ({
60
62
  find: mockPromiseResult
@@ -62,48 +64,123 @@ beforeEach(() => {
62
64
  };
63
65
 
64
66
  mockContainedIn.mockImplementation(() => mockQuery);
67
+ mockWhere.mockImplementation(() => mockQuery);
65
68
  mockIncludeReference.mockImplementation(() => mockQuery);
66
69
  mockStack.mockImplementation(() => ({
67
- ContentType: () => ({
70
+ ContentType: (contentType) => ({
68
71
  Query: () => mockQuery,
69
- fetch: mockFetch
72
+ fetch: () => mockFetch(contentType)
70
73
  })
71
74
  }));
72
75
 
73
- mockFetch.mockResolvedValue({
74
- content_type: {
75
- schema: [
76
- {
77
- uid: 'countries',
78
- data_type: 'reference'
79
- },
80
- {
81
- uid: 'details',
82
- data_type: 'group',
76
+ mockFetch.mockImplementation((contentType) => {
77
+ const schemas = {
78
+ shipping_address_form: {
79
+ content_type: {
83
80
  schema: [
84
81
  {
85
- uid: 'address_form',
86
- data_type: 'reference'
87
- }
88
- ]
89
- },
90
- {
91
- uid: 'promo_blocks',
92
- data_type: 'blocks',
93
- blocks: [
82
+ uid: 'countries',
83
+ data_type: 'reference',
84
+ reference_to: 'market'
85
+ },
94
86
  {
95
- uid: 'hero',
87
+ uid: 'details',
88
+ data_type: 'group',
96
89
  schema: [
97
90
  {
98
- uid: 'banner',
99
- data_type: 'reference'
91
+ uid: 'address_form',
92
+ data_type: 'reference',
93
+ reference_to: 'address_form'
94
+ }
95
+ ]
96
+ },
97
+ {
98
+ uid: 'promo_blocks',
99
+ data_type: 'blocks',
100
+ blocks: [
101
+ {
102
+ uid: 'hero',
103
+ schema: [
104
+ {
105
+ uid: 'banner',
106
+ data_type: 'reference',
107
+ reference_to: 'banner'
108
+ }
109
+ ]
100
110
  }
101
111
  ]
102
112
  }
103
113
  ]
104
114
  }
105
- ]
106
- }
115
+ },
116
+ address_form: {
117
+ content_type: {
118
+ schema: [
119
+ {
120
+ uid: 'address_elements',
121
+ data_type: 'reference',
122
+ reference_to: 'address_form_field'
123
+ }
124
+ ]
125
+ }
126
+ },
127
+ market: {
128
+ content_type: {
129
+ schema: []
130
+ }
131
+ },
132
+ banner: {
133
+ content_type: {
134
+ schema: []
135
+ }
136
+ },
137
+ address_form_field: {
138
+ content_type: {
139
+ schema: [
140
+ {
141
+ uid: 'field_config',
142
+ data_type: 'reference',
143
+ reference_to: 'field_config'
144
+ }
145
+ ]
146
+ }
147
+ },
148
+ field_config: {
149
+ content_type: {
150
+ schema: [
151
+ {
152
+ uid: 'validation_rule',
153
+ data_type: 'reference',
154
+ reference_to: 'validation_rule'
155
+ }
156
+ ]
157
+ }
158
+ },
159
+ validation_rule: {
160
+ content_type: {
161
+ schema: [
162
+ {
163
+ uid: 'too_deep_reference',
164
+ data_type: 'reference',
165
+ reference_to: 'too_deep_reference'
166
+ }
167
+ ]
168
+ }
169
+ },
170
+ too_deep_reference: {
171
+ content_type: {
172
+ schema: [
173
+ {
174
+ uid: 'should_not_be_included',
175
+ data_type: 'reference',
176
+ reference_to: 'market'
177
+ }
178
+ ]
179
+ }
180
+ }
181
+ };
182
+
183
+ return Promise.resolve(schemas[contentType] || {content_type: {schema: []}});
107
184
  });
108
185
 
109
186
  jest.mock("contentstack", () => ({Stack: mockStack}));
@@ -113,6 +190,7 @@ afterEach(() => {
113
190
  mockSinglePromiseResult.mockReset();
114
191
  mockPromiseResult.mockReset();
115
192
  mockContainedIn.mockReset();
193
+ mockWhere.mockReset();
116
194
  mockIncludeReference.mockReset();
117
195
  mockFetch.mockReset();
118
196
  //jest.resetAllMocks(); don't reset the mockStack function
@@ -222,10 +300,13 @@ describe("ContentstackApi", () => {
222
300
  const {getStack} = require('../src/api');
223
301
  const Stack = getStack('dev');
224
302
  await Stack.getMultiEntries('shipping_address_form', true, ['United States']);
225
- expect(mockFetch).toHaveBeenCalledTimes(1);
303
+ expect(mockFetch).toHaveBeenCalledTimes(6);
226
304
  expect(mockIncludeReference).toHaveBeenCalledWith([
227
305
  'countries',
228
306
  'details.address_form',
307
+ 'details.address_form.address_elements',
308
+ 'details.address_form.address_elements.field_config',
309
+ 'details.address_form.address_elements.field_config.validation_rule',
229
310
  'promo_blocks.hero.banner'
230
311
  ]);
231
312
  });
@@ -236,16 +317,104 @@ describe("ContentstackApi", () => {
236
317
  const Stack = getStack('test');
237
318
  await Stack.getMultiEntries('shipping_address_form', true, ['United States']);
238
319
  await Stack.getMultiEntries('shipping_address_form', true, ['Canada']);
239
- expect(mockFetch).toHaveBeenCalledTimes(1);
320
+ expect(mockFetch).toHaveBeenCalledTimes(6);
240
321
  expect(mockIncludeReference).toHaveBeenNthCalledWith(1, [
241
322
  'countries',
242
323
  'details.address_form',
324
+ 'details.address_form.address_elements',
325
+ 'details.address_form.address_elements.field_config',
326
+ 'details.address_form.address_elements.field_config.validation_rule',
243
327
  'promo_blocks.hero.banner'
244
328
  ]);
245
329
  expect(mockIncludeReference).toHaveBeenNthCalledWith(2, [
246
330
  'countries',
247
331
  'details.address_form',
332
+ 'details.address_form.address_elements',
333
+ 'details.address_form.address_elements.field_config',
334
+ 'details.address_form.address_elements.field_config.validation_rule',
248
335
  'promo_blocks.hero.banner'
249
336
  ]);
250
337
  });
338
+
339
+ test('getMultiEntries limits automatic reference traversal to four levels deep', async () => {
340
+ mockPromiseResult.mockResolvedValueOnce([[{title: 'United States'}]]);
341
+ const {getStack} = require('../src/api');
342
+ const Stack = getStack('stage');
343
+ await Stack.getMultiEntries('shipping_address_form', true, ['United States']);
344
+ expect(mockIncludeReference).toHaveBeenCalledWith([
345
+ 'countries',
346
+ 'details.address_form',
347
+ 'details.address_form.address_elements',
348
+ 'details.address_form.address_elements.field_config',
349
+ 'details.address_form.address_elements.field_config.validation_rule',
350
+ 'promo_blocks.hero.banner'
351
+ ]);
352
+ expect(mockIncludeReference).not.toHaveBeenCalledWith(expect.arrayContaining([
353
+ 'details.address_form.address_elements.field_config.validation_rule.too_deep_reference',
354
+ 'details.address_form.address_elements.field_config.validation_rule.too_deep_reference.should_not_be_included'
355
+ ]));
356
+ });
357
+
358
+ test('getMultiEntriesByFields applies where filters for scalar values', async () => {
359
+ mockPromiseResult.mockResolvedValueOnce([[{title: 'United States'}]]);
360
+ const {getStack} = require('../src/api');
361
+ const Stack = getStack('prod');
362
+ await Stack.getMultiEntriesByFields('market', {
363
+ country_code: 'US',
364
+ locale: 'en-us'
365
+ });
366
+ expect(mockWhere).toHaveBeenNthCalledWith(1, 'country_code', 'US');
367
+ expect(mockWhere).toHaveBeenNthCalledWith(2, 'locale', 'en-us');
368
+ expect(mockContainedIn).not.toHaveBeenCalled();
369
+ });
370
+
371
+ test('getMultiEntriesByFields applies containedIn for array values', async () => {
372
+ mockPromiseResult.mockResolvedValueOnce([[{title: 'United States'}]]);
373
+ const {getStack} = require('../src/api');
374
+ const Stack = getStack('dev');
375
+ await Stack.getMultiEntriesByFields('market', {
376
+ title: ['United States', 'Canada'],
377
+ locale: 'en-us'
378
+ });
379
+ expect(mockContainedIn).toHaveBeenCalledWith('title', ['United States', 'Canada']);
380
+ expect(mockWhere).toHaveBeenCalledWith('locale', 'en-us');
381
+ });
382
+
383
+ test('getMultiEntriesByFields resolves include refs when requested', async () => {
384
+ mockPromiseResult.mockResolvedValueOnce([[{title: 'United States'}]]);
385
+ const {getStack} = require('../src/api');
386
+ const Stack = getStack('stage');
387
+ await Stack.getMultiEntriesByFields('shipping_address_form', {
388
+ country_code: 'US',
389
+ locale: 'en-us'
390
+ }, true);
391
+ expect(mockIncludeReference).toHaveBeenCalledWith([
392
+ 'countries',
393
+ 'details.address_form',
394
+ 'details.address_form.address_elements',
395
+ 'details.address_form.address_elements.field_config',
396
+ 'details.address_form.address_elements.field_config.validation_rule',
397
+ 'promo_blocks.hero.banner'
398
+ ]);
399
+ });
400
+
401
+ test('getMultiEntriesByFields throws when fields are missing', async () => {
402
+ const {getStack} = require('../src/api');
403
+ const Stack = getStack('dev');
404
+ await expect(Stack.getMultiEntriesByFields('market')).rejects.toStrictEqual(
405
+ new Error('Call to getMultiEntriesByFields: fields are required')
406
+ );
407
+ });
408
+
409
+ test('getMultiEntryByFields returns the first matching entry', async () => {
410
+ mockPromiseResult.mockResolvedValueOnce([[
411
+ {title: 'United States'},
412
+ {title: 'Canada'}
413
+ ]]);
414
+ const {getStack} = require('../src/api');
415
+ const Stack = getStack('test');
416
+ await expect(Stack.getMultiEntryByFields('market', {
417
+ locale: 'en-us'
418
+ })).resolves.toStrictEqual({title: 'United States'});
419
+ });
251
420
  });
package/docs/CHANGELOG.md CHANGED
@@ -1 +1 @@
1
- # [2.1.0-pa-1117.11](https://code.tls.nuskin.io/ns-am/utility/npm/contentstack-lib/compare/v2.1.0-pa-1117.10...v2.1.0-pa-1117.11) (2026-05-21)
1
+ # [2.1.0](https://code.tls.nuskin.io/ns-am/utility/npm/contentstack-lib/compare/v2.0.2...v2.1.0) (2026-06-26)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuskin/contentstack-lib",
3
- "version": "2.1.0-pa-1117.11",
3
+ "version": "2.1.0",
4
4
  "description": "This project contains configuration and api code to access Contentstack, to be shared between the backend (AWS Lambda) and frontend (Vue, etc).",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
package/src/api.js CHANGED
@@ -9,6 +9,8 @@ const {contentstack: prodEnvConfig} = require(`../config/prod.json`);
9
9
  let env = null;
10
10
  let Stack = null;
11
11
  const referenceFieldCache = new Map();
12
+ const contentTypeSchemaCache = new Map();
13
+ const MAX_REFERENCE_DEPTH = 4;
12
14
 
13
15
  const COMMON_LANGUAGES = {
14
16
  'da': 'da-dk',
@@ -174,33 +176,80 @@ async function getSingletonEntries(options) {
174
176
  }
175
177
  }
176
178
 
177
- /**
178
- * Collect reference field paths recursively from a content type schema.
179
- * @param {Object[]} schema
180
- * @param {string=} parentPath
181
- * @return {string[]}
182
- * @private
183
- */
184
- function _collectReferenceFieldPaths(schema = [], parentPath = '') {
185
- return schema.flatMap((field) => {
179
+ async function _getContentTypeSchema(contentType) {
180
+ if (contentTypeSchemaCache.has(contentType)) {
181
+ return contentTypeSchemaCache.get(contentType);
182
+ }
183
+
184
+ const contentTypeSchema = await Stack.ContentType(contentType).fetch();
185
+ const schema = contentTypeSchema.content_type.schema || [];
186
+ contentTypeSchemaCache.set(contentType, schema);
187
+ return schema;
188
+ }
189
+
190
+ async function _collectReferenceFieldPathsForSchema(schema = [], parentPath = '', visited = new Set(), depth = 0) {
191
+ const allPaths = [];
192
+
193
+ for (const field of schema) {
186
194
  const currentPath = parentPath ? `${parentPath}.${field.uid}` : field.uid;
187
- const nestedSchemaPaths = Array.isArray(field.schema) ?
188
- _collectReferenceFieldPaths(field.schema, currentPath) :
189
- [];
190
- const blockPaths = field.data_type === 'blocks' && Array.isArray(field.blocks) ?
191
- field.blocks.flatMap((block) => {
192
- return Array.isArray(block.schema) ?
193
- _collectReferenceFieldPaths(block.schema, `${currentPath}.${block.uid}`) :
194
- [];
195
- }) :
196
- [];
197
-
198
- return [
199
- ...(field.data_type === 'reference' ? [currentPath] : []),
200
- ...nestedSchemaPaths,
201
- ...blockPaths
202
- ];
203
- });
195
+
196
+ if (Array.isArray(field.schema)) {
197
+ const nestedSchemaPaths = await _collectReferenceFieldPathsForSchema(field.schema, currentPath, visited, depth);
198
+ allPaths.push(...nestedSchemaPaths);
199
+ }
200
+
201
+ if (field.data_type === 'blocks' && Array.isArray(field.blocks)) {
202
+ for (const block of field.blocks) {
203
+ if (Array.isArray(block.schema)) {
204
+ const blockPaths = await _collectReferenceFieldPathsForSchema(
205
+ block.schema,
206
+ `${currentPath}.${block.uid}`,
207
+ visited,
208
+ depth
209
+ );
210
+ allPaths.push(...blockPaths);
211
+ }
212
+ }
213
+ }
214
+
215
+ if (field.data_type === 'reference') {
216
+ const currentDepth = depth + 1;
217
+ if (currentDepth > MAX_REFERENCE_DEPTH) {
218
+ continue;
219
+ }
220
+ allPaths.push(currentPath);
221
+ const referenceTargets = Array.isArray(field.reference_to) ?
222
+ field.reference_to :
223
+ field.reference_to ? [field.reference_to] : [];
224
+
225
+ if (currentDepth >= MAX_REFERENCE_DEPTH) {
226
+ continue;
227
+ }
228
+
229
+ for (const referencedContentType of referenceTargets) {
230
+ const nestedReferencePaths = await _collectReferenceFieldPathsForContentType(
231
+ referencedContentType,
232
+ currentPath,
233
+ visited,
234
+ currentDepth
235
+ );
236
+ allPaths.push(...nestedReferencePaths);
237
+ }
238
+ }
239
+ }
240
+
241
+ return [...new Set(allPaths)];
242
+ }
243
+
244
+ async function _collectReferenceFieldPathsForContentType(contentType, parentPath = '', visited = new Set(), depth = 0) {
245
+ const visitKey = `${contentType}|${parentPath}|${depth}`;
246
+ if (visited.has(visitKey)) {
247
+ return [];
248
+ }
249
+ visited.add(visitKey);
250
+
251
+ const schema = await _getContentTypeSchema(contentType);
252
+ return _collectReferenceFieldPathsForSchema(schema, parentPath, visited, depth);
204
253
  }
205
254
 
206
255
  /**
@@ -231,12 +280,26 @@ async function _resolveIncludeRefs(contentType, includeRefs) {
231
280
  return referenceFieldCache.get(contentType);
232
281
  }
233
282
 
234
- const contentTypeSchema = await Stack.ContentType(contentType).fetch();
235
- const referenceFields = _collectReferenceFieldPaths(contentTypeSchema.content_type.schema);
283
+ const referenceFields = await _collectReferenceFieldPathsForContentType(contentType);
236
284
  referenceFieldCache.set(contentType, referenceFields);
237
285
  return referenceFields;
238
286
  }
239
287
 
288
+ /**
289
+ * Apply field filters to a query. Scalar values use where(), arrays use containedIn().
290
+ * @param {Object} query
291
+ * @param {Object<string, string|string[]>} fields
292
+ * @return {Object}
293
+ * @private
294
+ */
295
+ function _applyFieldFilters(query, fields) {
296
+ return Object.entries(fields).reduce((currentQuery, [field, value]) => {
297
+ return Array.isArray(value) ?
298
+ currentQuery.containedIn(field, value) :
299
+ currentQuery.where(field, value);
300
+ }, query);
301
+ }
302
+
240
303
  /**
241
304
  * Gets all the specified entries for a multiple content type
242
305
  * @param {string} contentType
@@ -264,6 +327,51 @@ const getMultiEntries = async (contentType, includeRefs, values) => {
264
327
  }
265
328
  };
266
329
 
330
+ /**
331
+ * Gets entries for a multiple content type filtered by one or more fields.
332
+ * @param {string} contentType
333
+ * @param {Object<string, string|string[]>} fields
334
+ * @param {boolean|string|string[]=} includeRefs
335
+ * @return {Promise<Object[]>}
336
+ */
337
+ const getMultiEntriesByFields = async (contentType, fields, includeRefs) => {
338
+ if (!fields || !Object.keys(fields).length) {
339
+ throw new Error('Call to getMultiEntriesByFields: fields are required');
340
+ }
341
+
342
+ try {
343
+ const resolvedIncludeRefs = await _resolveIncludeRefs(contentType, includeRefs);
344
+ let query = _applyFieldFilters(
345
+ Stack
346
+ .ContentType(contentType)
347
+ .Query(),
348
+ fields
349
+ );
350
+
351
+ if (resolvedIncludeRefs.length) {
352
+ query = query.includeReference(resolvedIncludeRefs);
353
+ }
354
+
355
+ const [results] = await query.toJSON().find();
356
+ return results;
357
+ } catch (err) {
358
+ console.error(err);
359
+ return [];
360
+ }
361
+ };
362
+
363
+ /**
364
+ * Gets the first matching entry for a multiple content type filtered by fields.
365
+ * @param {string} contentType
366
+ * @param {Object<string, string|string[]>} fields
367
+ * @param {boolean|string|string[]=} includeRefs
368
+ * @return {Promise<Object|undefined>}
369
+ */
370
+ const getMultiEntryByFields = async (contentType, fields, includeRefs) => {
371
+ const results = await getMultiEntriesByFields(contentType, fields, includeRefs);
372
+ return results[0];
373
+ };
374
+
267
375
  /**
268
376
  * Returns contentstack Stack object initialized with the api key, deliveryToken, and extended with custom functionality
269
377
  * @param {'dev' | 'test' | 'stage' | 'prod'} envrnmnt
@@ -284,12 +392,15 @@ function getStack(envrnmnt) {
284
392
 
285
393
  env = envrnmnt;
286
394
  referenceFieldCache.clear();
395
+ contentTypeSchemaCache.clear();
287
396
  const {apiKey: api_key, deliveryToken: delivery_token, environment} = getConfig(env);
288
397
  Stack = Contentstack.Stack({api_key, delivery_token, environment});
289
398
 
290
399
  // extend the Stack with a custom function to get translations
291
400
  Stack.getSingletonEntries = getSingletonEntries;
292
401
  Stack.getMultiEntries = getMultiEntries;
402
+ Stack.getMultiEntriesByFields = getMultiEntriesByFields;
403
+ Stack.getMultiEntryByFields = getMultiEntryByFields;
293
404
 
294
405
  return Stack;
295
406
  }