@nuskin/contentstack-lib 2.1.0-pa-1117.10 → 2.1.0-pa-1202.1

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", {"name":"PA-1202", "channel":"prerelease", "prerelease":"pa-1202"}
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
 
@@ -15,6 +15,10 @@ const {contentstack: prodEnvConfig} = require(`../config/prod.json`);
15
15
  const mockPromiseResult = jest.fn();
16
16
  const mockSinglePromiseResult = jest.fn();
17
17
  const mockStack = jest.fn();
18
+ const mockContainedIn = jest.fn();
19
+ const mockWhere = jest.fn();
20
+ const mockIncludeReference = jest.fn();
21
+ const mockFetch = jest.fn();
18
22
 
19
23
  function getConfig(env) {
20
24
  let config = {};
@@ -42,34 +46,153 @@ function getConfig(env) {
42
46
  }
43
47
 
44
48
  beforeEach(() => {
45
- mockStack.mockImplementation(() => ({
46
- ContentType: () => ({
47
- Query: () => ({
48
- language: () => ({
49
- includeFallback: () => ({
50
- toJSON: () => ({
51
- find: mockPromiseResult,
52
- findOne: mockSinglePromiseResult
53
- })
54
- })
55
- }),
56
- containedIn: () => ({
57
- includeReference: () => ({
58
- toJSON: () => ({
59
- find: mockPromiseResult
60
- })
61
- })
49
+ const mockQuery = {
50
+ language: jest.fn(() => ({
51
+ includeFallback: () => ({
52
+ toJSON: () => ({
53
+ find: mockPromiseResult,
54
+ findOne: mockSinglePromiseResult
62
55
  })
63
56
  })
57
+ })),
58
+ containedIn: mockContainedIn,
59
+ where: mockWhere,
60
+ includeReference: mockIncludeReference,
61
+ toJSON: jest.fn(() => ({
62
+ find: mockPromiseResult
63
+ }))
64
+ };
65
+
66
+ mockContainedIn.mockImplementation(() => mockQuery);
67
+ mockWhere.mockImplementation(() => mockQuery);
68
+ mockIncludeReference.mockImplementation(() => mockQuery);
69
+ mockStack.mockImplementation(() => ({
70
+ ContentType: (contentType) => ({
71
+ Query: () => mockQuery,
72
+ fetch: () => mockFetch(contentType)
64
73
  })
65
74
  }));
66
75
 
76
+ mockFetch.mockImplementation((contentType) => {
77
+ const schemas = {
78
+ shipping_address_form: {
79
+ content_type: {
80
+ schema: [
81
+ {
82
+ uid: 'countries',
83
+ data_type: 'reference',
84
+ reference_to: 'market'
85
+ },
86
+ {
87
+ uid: 'details',
88
+ data_type: 'group',
89
+ schema: [
90
+ {
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
+ ]
110
+ }
111
+ ]
112
+ }
113
+ ]
114
+ }
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: []}});
184
+ });
185
+
67
186
  jest.mock("contentstack", () => ({Stack: mockStack}));
68
187
  });
69
188
 
70
189
  afterEach(() => {
71
190
  mockSinglePromiseResult.mockReset();
72
191
  mockPromiseResult.mockReset();
192
+ mockContainedIn.mockReset();
193
+ mockWhere.mockReset();
194
+ mockIncludeReference.mockReset();
195
+ mockFetch.mockReset();
73
196
  //jest.resetAllMocks(); don't reset the mockStack function
74
197
  });
75
198
 
@@ -78,7 +201,7 @@ const mockResults = () => {
78
201
  mockPromiseResult.mockResolvedValueOnce(checkoutAdrStrings);
79
202
  mockSinglePromiseResult.mockResolvedValueOnce(singleCheckoutAdrStrings);
80
203
  mockSinglePromiseResult.mockResolvedValueOnce(singleCheckoutCartStrings);
81
- }
204
+ };
82
205
 
83
206
  describe("ContentstackApi", () => {
84
207
  test("getSingletonEntries", async () => {
@@ -152,4 +275,146 @@ describe("ContentstackApi", () => {
152
275
  await Stack.getSingletonEntries({contentTypeUIDs: ['checkout_adr_strings'], language: 'in'});
153
276
  expect(mockSinglePromiseResult).toHaveBeenCalled();
154
277
  });
278
+
279
+ test('getMultiEntries does not include references when includeRefs is false', async () => {
280
+ mockPromiseResult.mockResolvedValueOnce([[{title: 'United States'}]]);
281
+ const {getStack} = require('../src/api');
282
+ const Stack = getStack('dev');
283
+ await Stack.getMultiEntries('market', false, ['United States']);
284
+ expect(mockContainedIn).toHaveBeenCalledWith('title', ['United States']);
285
+ expect(mockIncludeReference).not.toHaveBeenCalled();
286
+ expect(mockFetch).not.toHaveBeenCalled();
287
+ });
288
+
289
+ test('getMultiEntries includes explicit reference paths', async () => {
290
+ mockPromiseResult.mockResolvedValueOnce([[{title: 'United States'}]]);
291
+ const {getStack} = require('../src/api');
292
+ const Stack = getStack('dev');
293
+ await Stack.getMultiEntries('market', ['countries', 'address_form.address_elements'], ['United States']);
294
+ expect(mockIncludeReference).toHaveBeenCalledWith(['countries', 'address_form.address_elements']);
295
+ expect(mockFetch).not.toHaveBeenCalled();
296
+ });
297
+
298
+ test('getMultiEntries discovers reference paths from schema when includeRefs is true', async () => {
299
+ mockPromiseResult.mockResolvedValueOnce([[{title: 'United States'}]]);
300
+ const {getStack} = require('../src/api');
301
+ const Stack = getStack('dev');
302
+ await Stack.getMultiEntries('shipping_address_form', true, ['United States']);
303
+ expect(mockFetch).toHaveBeenCalledTimes(6);
304
+ expect(mockIncludeReference).toHaveBeenCalledWith([
305
+ 'countries',
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',
310
+ 'promo_blocks.hero.banner'
311
+ ]);
312
+ });
313
+
314
+ test('getMultiEntries caches discovered reference paths per content type', async () => {
315
+ mockPromiseResult.mockResolvedValue([[{title: 'United States'}]]);
316
+ const {getStack} = require('../src/api');
317
+ const Stack = getStack('test');
318
+ await Stack.getMultiEntries('shipping_address_form', true, ['United States']);
319
+ await Stack.getMultiEntries('shipping_address_form', true, ['Canada']);
320
+ expect(mockFetch).toHaveBeenCalledTimes(6);
321
+ expect(mockIncludeReference).toHaveBeenNthCalledWith(1, [
322
+ 'countries',
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',
327
+ 'promo_blocks.hero.banner'
328
+ ]);
329
+ expect(mockIncludeReference).toHaveBeenNthCalledWith(2, [
330
+ 'countries',
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',
335
+ 'promo_blocks.hero.banner'
336
+ ]);
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
+ });
155
420
  });
package/docs/CHANGELOG.md CHANGED
@@ -1 +1 @@
1
- # [2.1.0-pa-1117.10](https://code.tls.nuskin.io/ns-am/utility/npm/contentstack-lib/compare/v2.1.0-pa-1117.9...v2.1.0-pa-1117.10) (2026-05-20)
1
+ # [2.1.0-pa-1202.1](https://code.tls.nuskin.io/ns-am/utility/npm/contentstack-lib/compare/v2.0.2...v2.1.0-pa-1202.1) (2026-06-25)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuskin/contentstack-lib",
3
- "version": "2.1.0-pa-1117.10",
3
+ "version": "2.1.0-pa-1202.1",
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
@@ -8,6 +8,9 @@ const {contentstack: prodEnvConfig} = require(`../config/prod.json`);
8
8
 
9
9
  let env = null;
10
10
  let Stack = null;
11
+ const referenceFieldCache = new Map();
12
+ const contentTypeSchemaCache = new Map();
13
+ const MAX_REFERENCE_DEPTH = 4;
11
14
 
12
15
  const COMMON_LANGUAGES = {
13
16
  'da': 'da-dk',
@@ -173,6 +176,130 @@ async function getSingletonEntries(options) {
173
176
  }
174
177
  }
175
178
 
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) {
194
+ const currentPath = parentPath ? `${parentPath}.${field.uid}` : field.uid;
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);
253
+ }
254
+
255
+ /**
256
+ * Resolve reference include paths from a boolean, string, or array input.
257
+ * @param {string} contentType
258
+ * @param {boolean|string|string[]} includeRefs
259
+ * @return {Promise<string[]>}
260
+ * @private
261
+ */
262
+ async function _resolveIncludeRefs(contentType, includeRefs) {
263
+ if (!includeRefs) {
264
+ return [];
265
+ }
266
+
267
+ if (typeof includeRefs === 'string') {
268
+ return [includeRefs];
269
+ }
270
+
271
+ if (Array.isArray(includeRefs)) {
272
+ return includeRefs;
273
+ }
274
+
275
+ if (includeRefs !== true) {
276
+ return [];
277
+ }
278
+
279
+ if (referenceFieldCache.has(contentType)) {
280
+ return referenceFieldCache.get(contentType);
281
+ }
282
+
283
+ const referenceFields = await _collectReferenceFieldPathsForContentType(contentType);
284
+ referenceFieldCache.set(contentType, referenceFields);
285
+ return referenceFields;
286
+ }
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
+
176
303
  /**
177
304
  * Gets all the specified entries for a multiple content type
178
305
  * @param {string} contentType
@@ -181,19 +308,69 @@ async function getSingletonEntries(options) {
181
308
  */
182
309
  const getMultiEntries = async (contentType, includeRefs, values) => {
183
310
  try {
184
- const [results] = await Stack
311
+ const resolvedIncludeRefs = await _resolveIncludeRefs(contentType, includeRefs);
312
+
313
+ let query = Stack
185
314
  .ContentType(contentType)
186
315
  .Query()
187
- .containedIn('title', values)
188
- .includeReference(includeRefs)
189
- .toJSON()
190
- .find();
316
+ .containedIn('title', values);
317
+
318
+ if (resolvedIncludeRefs.length) {
319
+ query = query.includeReference(resolvedIncludeRefs);
320
+ }
321
+
322
+ const [results] = await query.toJSON().find();
191
323
 
192
324
  return results;
193
325
  } catch(err) {
194
326
  console.error(err);
195
327
  }
196
- }
328
+ };
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
+ };
197
374
 
198
375
  /**
199
376
  * Returns contentstack Stack object initialized with the api key, deliveryToken, and extended with custom functionality
@@ -214,12 +391,16 @@ function getStack(envrnmnt) {
214
391
  }
215
392
 
216
393
  env = envrnmnt;
217
- const {apiKey: api_key, deliveryToken: delivery_token, environment} = getConfig(env)
218
- Stack = Contentstack.Stack({api_key, delivery_token, environment})
394
+ referenceFieldCache.clear();
395
+ contentTypeSchemaCache.clear();
396
+ const {apiKey: api_key, deliveryToken: delivery_token, environment} = getConfig(env);
397
+ Stack = Contentstack.Stack({api_key, delivery_token, environment});
219
398
 
220
399
  // extend the Stack with a custom function to get translations
221
400
  Stack.getSingletonEntries = getSingletonEntries;
222
401
  Stack.getMultiEntries = getMultiEntries;
402
+ Stack.getMultiEntriesByFields = getMultiEntriesByFields;
403
+ Stack.getMultiEntryByFields = getMultiEntryByFields;
223
404
 
224
405
  return Stack;
225
406
  }