@adobe/spacecat-shared-data-access 4.20.0 → 4.21.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 CHANGED
@@ -1,3 +1,9 @@
1
+ ## [@adobe/spacecat-shared-data-access-v4.21.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v4.20.0...@adobe/spacecat-shared-data-access-v4.21.0) (2026-08-13)
2
+
3
+ ### Features
4
+
5
+ * **data-access:** paginated sites query filtered by entitlement tier/productCode ([#1877](https://github.com/adobe/spacecat-shared/issues/1877)) ([00e5e59](https://github.com/adobe/spacecat-shared/commit/00e5e592f1d4990e1578deb8287e84f3f0d0cf1b))
6
+
1
7
  ## [@adobe/spacecat-shared-data-access-v4.20.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v4.19.0...@adobe/spacecat-shared-data-access-v4.20.0) (2026-08-12)
2
8
 
3
9
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-data-access",
3
- "version": "4.20.0",
3
+ "version": "4.21.0",
4
4
  "description": "Shared modules of the Spacecat Services - Data Access",
5
5
  "type": "module",
6
6
  "engines": {
@@ -14,6 +14,13 @@ import { hasText, isValidHelixPreviewUrl, isValidUrl } from '@adobe/spacecat-sha
14
14
 
15
15
  import DataAccessError from '../../errors/data-access.error.js';
16
16
  import BaseCollection from '../base/base.collection.js';
17
+ import {
18
+ applyWhere,
19
+ decodeCursor,
20
+ DEFAULT_PAGE_SIZE,
21
+ encodeCursor,
22
+ toDbField,
23
+ } from '../../util/postgrest.utils.js';
17
24
 
18
25
  import Site, { AEM_CS_HOST, getAuthoringType } from './site.model.js';
19
26
 
@@ -206,6 +213,133 @@ class SiteCollection extends BaseCollection {
206
213
  return sites;
207
214
  }
208
215
 
216
+ /**
217
+ * Returns sites filtered by entitlement tier and/or product code, paginated,
218
+ * and composable with a caller-supplied `where` and `orderBy` — all in a
219
+ * SINGLE PostgREST query via a two-level inner-join embed
220
+ * (sites -> site_enrollments -> entitlements).
221
+ *
222
+ * PostgREST resource embedding NESTS matching children under each parent row
223
+ * (it does not flatten into a cartesian product), so `.range()` offset
224
+ * pagination stays correct over DISTINCT parent sites even when a site has
225
+ * multiple matching enrollments. `!inner` at both embed levels turns the
226
+ * embedded filter into an INNER JOIN, excluding sites with no matching
227
+ * enrollment/entitlement (a plain embedded filter is a LEFT JOIN on older
228
+ * PostgREST servers, which would return non-matching sites with an empty
229
+ * embed instead of dropping them).
230
+ *
231
+ * Kept symmetric with `Site.all(..., { returnCursor: true, limit })`: it
232
+ * honors the EXACT `limit` passed (no silent cap, no +1) because the
233
+ * api-service caller does N+1 hasMore detection (passes `limit =
234
+ * effectiveLimit + 1` and slices), and returns `{ data, cursor }` when
235
+ * `returnCursor` is set, else a bare array. This lets the controller call it
236
+ * almost identically to `Site.all`.
237
+ *
238
+ * @param {object} [filter]
239
+ * @param {string} [filter.tier] - Entitlement tier (e.g. 'PAID', 'FREE_TRIAL').
240
+ * @param {string} [filter.productCode] - Entitlement product code (e.g. 'LLMO').
241
+ * At least one of `tier` / `productCode` is required.
242
+ * @param {object} [options]
243
+ * @param {Function} [options.where] - Caller `where` fn `(attrs, op) => expr`
244
+ * applied to the sites table (e.g. baseURL substring / deliveryType / isLive).
245
+ * @param {object} [options.orderBy] - `{ attribute, direction }`; defaults to
246
+ * `updatedAt` desc. Always followed by an `id` tiebreaker for stable paging.
247
+ * @param {number} [options.limit] - Max parent rows to return (exact). A
248
+ * non-positive or non-integer value falls back to DEFAULT_PAGE_SIZE.
249
+ * @param {string} [options.cursor] - Base64 offset cursor (see decodeCursor).
250
+ * @param {boolean} [options.returnCursor] - Return `{ data, cursor }` shape.
251
+ * @returns {Promise<Site[] | { data: Site[], cursor: string|null }>}
252
+ */
253
+ async allByEnrollmentFiltered(
254
+ { tier, productCode } = {},
255
+ {
256
+ where, orderBy, limit, cursor, returnCursor,
257
+ } = {},
258
+ ) {
259
+ if (!hasText(tier) && !hasText(productCode)) {
260
+ throw new DataAccessError('tier or productCode is required', this);
261
+ }
262
+
263
+ // The embed must be selected for PostgREST to filter on it. The nested
264
+ // array PostgREST returns per site is stripped below before hydrating the
265
+ // Site model (the Site model has no such attribute).
266
+ const select = '*, site_enrollments!inner(entitlements!inner(tier, product_code))';
267
+
268
+ let query = this.postgrestService
269
+ .from(this.tableName)
270
+ .select(select);
271
+
272
+ if (hasText(tier)) {
273
+ query = query.eq('site_enrollments.entitlements.tier', tier);
274
+ }
275
+ if (hasText(productCode)) {
276
+ query = query.eq('site_enrollments.entitlements.product_code', productCode);
277
+ }
278
+
279
+ // Caller-supplied where composes on the sites table
280
+ // (base_url / delivery_type / is_live).
281
+ query = applyWhere(query, where, this.fieldMaps.toDbMap);
282
+
283
+ // Ordering mirrors base.collection #queryPage: a primary sort (default
284
+ // updatedAt desc) plus a stable id tiebreaker so page boundaries never
285
+ // straddle equal sort keys. An explicit orderBy is validated the same way
286
+ // Site.all does — a clear error beats an opaque PostgREST 400 (unknown
287
+ // column) or a silently-wrong sort direction.
288
+ const hasOrderBy = hasText(orderBy?.attribute);
289
+ let orderField = 'updated_at';
290
+ let ascending = false;
291
+ if (hasOrderBy) {
292
+ const { toDbMap } = this.fieldMaps;
293
+ if (!Object.prototype.hasOwnProperty.call(toDbMap, orderBy.attribute)) {
294
+ throw new DataAccessError(`unknown orderBy attribute: ${orderBy.attribute}`, this);
295
+ }
296
+ const direction = orderBy.direction === undefined
297
+ ? 'asc'
298
+ : String(orderBy.direction).toLowerCase();
299
+ if (direction !== 'asc' && direction !== 'desc') {
300
+ throw new DataAccessError(`invalid orderBy direction: ${orderBy.direction}`, this);
301
+ }
302
+ orderField = toDbField(orderBy.attribute, toDbMap);
303
+ ascending = direction === 'asc';
304
+ }
305
+ query = query.order(orderField, { ascending });
306
+ const idField = this.fieldMaps.toDbMap[this.idName];
307
+ if (idField !== orderField) {
308
+ query = query.order(idField, { ascending });
309
+ }
310
+
311
+ // Honor the exact (positive) limit (no cap, no +1) so the api-service N+1
312
+ // hasMore detection stays symmetric with Site.all's postgrest path. A
313
+ // non-positive or non-integer limit falls back to DEFAULT_PAGE_SIZE so a
314
+ // caller-supplied 0/negative can't produce an inverted PostgREST range.
315
+ const effectiveLimit = Number.isInteger(limit) && limit > 0 ? limit : DEFAULT_PAGE_SIZE;
316
+ const offset = decodeCursor(cursor);
317
+ query = query.range(offset, offset + effectiveLimit - 1);
318
+
319
+ const { data, error } = await query;
320
+ if (error) {
321
+ this.log.error(`[SiteCollection] Failed to query sites by enrollment filter - ${error.message}`, error);
322
+ throw new DataAccessError('Failed to query sites by enrollment filter', this, error);
323
+ }
324
+
325
+ const instances = (data || []).map((row) => {
326
+ // Drop the embed PostgREST nests on each parent row so it cannot leak
327
+ // onto the hydrated Site record.
328
+ const siteRow = { ...row };
329
+ delete siteRow.site_enrollments;
330
+ return this.createInstanceFromRow(siteRow);
331
+ });
332
+
333
+ if (returnCursor) {
334
+ const nextCursor = instances.length === effectiveLimit
335
+ ? encodeCursor(offset + effectiveLimit)
336
+ : null;
337
+ return { data: instances, cursor: nextCursor };
338
+ }
339
+
340
+ return instances;
341
+ }
342
+
209
343
  async allByOrganizationIdAndProjectName(organizationId, projectName) {
210
344
  if (!hasText(organizationId)) {
211
345
  throw new DataAccessError('organizationId is required', this);