@adobe/spacecat-shared-data-access 4.19.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 +12 -0
- package/CLAUDE.md +1 -1
- package/package.json +1 -1
- package/src/models/base/base.collection.js +19 -2
- package/src/models/base/index.d.ts +1 -0
- package/src/models/site/site.collection.js +134 -0
- package/src/util/postgrest.utils.js +37 -30
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
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
|
+
|
|
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)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **data-access:** compound AND filter and orderBy column for postgrest queries ([#1874](https://github.com/adobe/spacecat-shared/issues/1874)) ([620b37e](https://github.com/adobe/spacecat-shared/commit/620b37eb1bc252c9bdd01661037de6fd7e0bfe35))
|
|
12
|
+
|
|
1
13
|
## [@adobe/spacecat-shared-data-access-v4.19.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v4.18.0...@adobe/spacecat-shared-data-access-v4.19.0) (2026-08-11)
|
|
2
14
|
|
|
3
15
|
### Features
|
package/CLAUDE.md
CHANGED
|
@@ -178,7 +178,7 @@ This is the same client instance used internally by entity collections — same
|
|
|
178
178
|
async findByStatus(status) {
|
|
179
179
|
return this.all(
|
|
180
180
|
(attrs, op) => op.eq(attrs.status, status),
|
|
181
|
-
{ limit: 100,
|
|
181
|
+
{ limit: 100, orderBy: { attribute: 'createdAt', direction: 'desc' } }
|
|
182
182
|
);
|
|
183
183
|
}
|
|
184
184
|
```
|
package/package.json
CHANGED
|
@@ -428,8 +428,25 @@ class BaseCollection {
|
|
|
428
428
|
this.#logAndThrowError(`Failed to query [${this.entityName}]: query proxy [${options.index}] not found`);
|
|
429
429
|
}
|
|
430
430
|
|
|
431
|
-
const
|
|
432
|
-
|
|
431
|
+
const hasExplicitOrderBy = isObject(options.orderBy) && hasText(options.orderBy.attribute);
|
|
432
|
+
let explicitAscending;
|
|
433
|
+
if (hasExplicitOrderBy) {
|
|
434
|
+
const { toDbMap } = this.fieldMaps;
|
|
435
|
+
if (!Object.prototype.hasOwnProperty.call(toDbMap, options.orderBy.attribute)) {
|
|
436
|
+
this.#logAndThrowError(`Failed to query [${this.entityName}]: unknown orderBy attribute [${options.orderBy.attribute}]`);
|
|
437
|
+
}
|
|
438
|
+
const direction = options.orderBy.direction === undefined
|
|
439
|
+
? 'asc'
|
|
440
|
+
: String(options.orderBy.direction).toLowerCase();
|
|
441
|
+
if (direction !== 'asc' && direction !== 'desc') {
|
|
442
|
+
this.#logAndThrowError(`Failed to query [${this.entityName}]: invalid orderBy direction [${options.orderBy.direction}]`);
|
|
443
|
+
}
|
|
444
|
+
explicitAscending = direction === 'asc';
|
|
445
|
+
}
|
|
446
|
+
const orderFields = hasExplicitOrderBy
|
|
447
|
+
? [this.#toDbField(options.orderBy.attribute)]
|
|
448
|
+
: this.#getOrderFields(indexName, keys);
|
|
449
|
+
const ascending = hasExplicitOrderBy ? explicitAscending : options.order === 'asc';
|
|
433
450
|
let query = this.postgrestService
|
|
434
451
|
.from(this.tableName)
|
|
435
452
|
.select(select);
|
|
@@ -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);
|
|
@@ -152,6 +152,7 @@ const applyWhere = (query, whereFn, toDbMap) => {
|
|
|
152
152
|
like: (field, value) => ({ type: 'like', field, value }),
|
|
153
153
|
ilike: (field, value) => ({ type: 'ilike', field, value }),
|
|
154
154
|
contains: (field, value) => ({ type: 'contains', field, value }),
|
|
155
|
+
and: (...conditions) => ({ type: 'and', conditions: conditions.filter(Boolean) }),
|
|
155
156
|
};
|
|
156
157
|
|
|
157
158
|
const expression = whereFn(attrs, op);
|
|
@@ -159,37 +160,43 @@ const applyWhere = (query, whereFn, toDbMap) => {
|
|
|
159
160
|
return query;
|
|
160
161
|
}
|
|
161
162
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
163
|
+
const applyExpr = (q, expr) => {
|
|
164
|
+
switch (expr.type) {
|
|
165
|
+
case 'and':
|
|
166
|
+
return (expr.conditions || []).reduce((acc, cond) => applyExpr(acc, cond), q);
|
|
167
|
+
case 'eq':
|
|
168
|
+
return q.eq(expr.field, expr.value);
|
|
169
|
+
case 'ne':
|
|
170
|
+
return q.neq(expr.field, expr.value);
|
|
171
|
+
case 'gt':
|
|
172
|
+
return q.gt(expr.field, expr.value);
|
|
173
|
+
case 'gte':
|
|
174
|
+
return q.gte(expr.field, expr.value);
|
|
175
|
+
case 'lt':
|
|
176
|
+
return q.lt(expr.field, expr.value);
|
|
177
|
+
case 'lte':
|
|
178
|
+
return q.lte(expr.field, expr.value);
|
|
179
|
+
case 'in':
|
|
180
|
+
return q.in(
|
|
181
|
+
expr.field,
|
|
182
|
+
Array.isArray(expr.value) ? expr.value : [expr.value],
|
|
183
|
+
);
|
|
184
|
+
case 'is':
|
|
185
|
+
return q.is(expr.field, expr.value);
|
|
186
|
+
case 'like':
|
|
187
|
+
return q.like(expr.field, expr.value);
|
|
188
|
+
case 'ilike':
|
|
189
|
+
return q.ilike(expr.field, expr.value);
|
|
190
|
+
case 'contains': {
|
|
191
|
+
const value = Array.isArray(expr.value) ? expr.value : [expr.value];
|
|
192
|
+
return q.contains(expr.field, value);
|
|
193
|
+
}
|
|
194
|
+
default:
|
|
195
|
+
throw new Error(`Unsupported where operator: ${expr.type}`);
|
|
189
196
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
return applyExpr(query, expression);
|
|
193
200
|
};
|
|
194
201
|
|
|
195
202
|
export {
|