json-rest-api 3.0.0-rc.0 → 3.0.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.
Files changed (55) hide show
  1. package/README.md +2 -2
  2. package/docs/API.md +60 -27
  3. package/docs/GUIDE/07-polymorphic-relationships.md +4 -2
  4. package/docs/GUIDE/09-pagination-and-sorting.md +4 -2
  5. package/docs/GUIDE/13-hooks-and-lifecycle.md +6 -1
  6. package/docs/GUIDE/16-autofiltering.md +1 -1
  7. package/docs/GUIDE/17-row-policies.md +8 -6
  8. package/docs/GUIDE/21-schema-and-migrations.md +7 -1
  9. package/docs/GUIDE/27-many-row-writes.md +201 -28
  10. package/docs/GUIDE/30-backend-capabilities.md +16 -2
  11. package/docs/GUIDE/33-migrating-to-v2.md +2 -2
  12. package/docs/GUIDE/34-scoped-reads-and-locks.md +165 -26
  13. package/docs/GUIDE/35-migrating-to-v3.md +141 -10
  14. package/lib/rest-api-errors.js +6 -5
  15. package/package.json +2 -2
  16. package/plugins/core/connectors/lib/transport-http-helpers.js +31 -3
  17. package/plugins/core/file-handling-plugin.js +2 -1
  18. package/plugins/core/lib/anyapi/anyapi-registry.js +3 -2
  19. package/plugins/core/lib/anyapi/canonical-link-store.js +5 -2
  20. package/plugins/core/lib/anyapi/canonical-relationship-reader.js +56 -60
  21. package/plugins/core/lib/dbIntrospection.js +106 -6
  22. package/plugins/core/lib/querying/aggregate-query.js +12 -6
  23. package/plugins/core/lib/querying/include-query-helpers.js +11 -13
  24. package/plugins/core/lib/querying/include-to-one.js +54 -59
  25. package/plugins/core/lib/querying/knex-pagination-helpers.js +9 -2
  26. package/plugins/core/lib/querying/query-constraint.d.ts +11 -0
  27. package/plugins/core/lib/querying/query-constraint.js +45 -0
  28. package/plugins/core/lib/querying/read-only-query.js +50 -13
  29. package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
  30. package/plugins/core/lib/storage/canonical-data-helpers.js +72 -41
  31. package/plugins/core/lib/storage/many-row-data-helpers.js +409 -124
  32. package/plugins/core/lib/storage/ordinary-data-helpers.js +67 -81
  33. package/plugins/core/lib/storage/single-row-writes.js +71 -0
  34. package/plugins/core/lib/storage/storage-adapter.js +18 -8
  35. package/plugins/core/lib/storage/storage-mapping.js +4 -1
  36. package/plugins/core/lib/storage/storage-types.d.ts +12 -1
  37. package/plugins/core/lib/writing/many-to-many-manipulations.js +5 -2
  38. package/plugins/core/lib/writing/relationship-processor.js +33 -37
  39. package/plugins/core/rest-api-autofilter-plugin.js +30 -8
  40. package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
  41. package/plugins/core/rest-api-plugin-methods/common.js +117 -77
  42. package/plugins/core/rest-api-plugin-methods/delete-many.js +54 -28
  43. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +22 -0
  44. package/plugins/core/rest-api-plugin-methods/get.js +48 -42
  45. package/plugins/core/rest-api-plugin-methods/many-row-common.js +51 -8
  46. package/plugins/core/rest-api-plugin-methods/patch-many.js +81 -34
  47. package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
  48. package/plugins/core/rest-api-plugin-methods/post-many.js +149 -21
  49. package/plugins/core/rest-api-plugin-methods/put.js +5 -1
  50. package/plugins/core/rest-api-plugin-methods/scoped-reads.js +72 -27
  51. package/plugins/core/rest-api-plugin.js +2 -1
  52. package/plugins/core/rest-api-positioning-plugin.js +1 -1
  53. package/types/data-access-methods.d.ts +32 -11
  54. package/types/errors.d.ts +5 -4
  55. package/types/hook-context.d.ts +2 -0
@@ -1,12 +1,15 @@
1
1
  // @ts-check
2
2
  import { RestApiScopedQueryReadOnlyError } from '../../../../lib/rest-api-errors.js'
3
3
 
4
- // Calls that write, lock, change the source table or drop the scope predicates.
4
+ // Calls that write, lock, change the source table, clear its predicates, or
5
+ // add rows from outside the scope (common table expressions and set operations).
5
6
  const FORBIDDEN_METHODS = new Set([
6
7
  'insert', 'update', 'del', 'delete', 'truncate', 'upsert', 'increment', 'decrement',
7
8
  'onConflict', 'merge', 'ignore', 'returning',
8
9
  'forUpdate', 'forShare', 'forNoKeyUpdate', 'forKeyShare', 'skipLocked', 'noWait',
9
- 'from', 'table', 'into', 'withSchema', 'clear', 'clearWhere'
10
+ 'from', 'fromRaw', 'table', 'into', 'withSchema', 'clear', 'clearWhere',
11
+ 'with', 'withRecursive', 'withMaterialized', 'withNotMaterialized', 'withWrapped',
12
+ 'union', 'unionAll', 'intersect', 'except'
10
13
  ])
11
14
  // The statement kinds a SELECT-only builder may run.
12
15
  const READ_METHODS = new Set(['select', 'first', 'pluck'])
@@ -16,11 +19,31 @@ const isQueryBuilder = value => value !== null && typeof value === 'object' &&
16
19
  typeof /** @type {{ toSQL?: unknown }} */ (value).toSQL === 'function' &&
17
20
  typeof /** @type {{ where?: unknown }} */ (value).where === 'function'
18
21
 
22
+ // Knex's own methods reach the client and replace the statement lists through
23
+ // `this`; code outside them may not. One counter serves every builder because a
24
+ // method of one builder can compile another (a subquery) through its client.
25
+ let insideBuilderMethod = 0
26
+ // The compiled query's state, which only Knex's own methods may replace.
27
+ const BUILDER_STATE = new Set(['client', '_single', '_statements'])
28
+
29
+ // Outside Knex's methods the statement lists read as frozen copies, so code
30
+ // cannot push, splice or edit a predicate in place. The single values (table,
31
+ // limit, offset and the like) read as a plain copy: Knex's SQLite compiler
32
+ // rewrites the copy's limit when it compiles the builder as a subquery, and a
33
+ // change to the copy reaches no query.
34
+ /** @param {unknown} value */
35
+ const stateCopy = value => {
36
+ if (Array.isArray(value)) return Object.freeze(value.map(entry => entry && typeof entry === 'object' ? Object.freeze({ ...entry }) : entry))
37
+ return value && typeof value === 'object' ? { ...value } : value
38
+ }
39
+
19
40
  /**
20
41
  * A SELECT-only view of a Knex query builder. Reads, joins, subqueries and
21
- * execution work as usual; writes, row locks and calls that change the source
22
- * table or clear its predicates throw RestApiScopedQueryReadOnlyError. Builders
23
- * it returns, including clones, are read-only too.
42
+ * execution work as usual; writes, row locks, set operations, common table
43
+ * expressions, calls that change the source table or clear its predicates, and
44
+ * access to the Knex client throw RestApiScopedQueryReadOnlyError. Builders it
45
+ * returns, including clones, are read-only too. It guards against mistakes, not
46
+ * hostile code: code that means to bypass it can use Knex directly.
24
47
  * @template {object} Builder
25
48
  * @param {Builder} builder
26
49
  * @param {{ resourceType?: string }} [options]
@@ -28,27 +51,41 @@ const isQueryBuilder = value => value !== null && typeof value === 'object' &&
28
51
  */
29
52
  export function readOnlyQueryBuilder (builder, { resourceType = 'query' } = {}) {
30
53
  if (!isQueryBuilder(builder)) throw new TypeError('readOnlyQueryBuilder needs a Knex query builder')
54
+ /** @param {string} method @returns {never} */
55
+ const refuse = method => { throw new RestApiScopedQueryReadOnlyError({ resourceType, method }) }
31
56
  /** @type {Builder} */
32
57
  const proxy = new Proxy(builder, {
33
58
  get (target, property, receiver) {
34
- if (typeof property === 'string' && FORBIDDEN_METHODS.has(property)) {
35
- return () => { throw new RestApiScopedQueryReadOnlyError({ resourceType, method: property }) }
36
- }
59
+ if (typeof property === 'string' && FORBIDDEN_METHODS.has(property)) return () => refuse(property)
60
+ if (property === 'client' && !insideBuilderMethod) refuse('client')
37
61
  const value = Reflect.get(target, property, receiver)
62
+ if ((property === '_single' || property === '_statements') && !insideBuilderMethod) return stateCopy(value)
38
63
  if (typeof value !== 'function' || property === 'constructor') return value
39
64
  return /** @this {unknown} */ function (/** @type {unknown[]} */ ...args) {
40
- // Knex methods return `this`, which is the proxy; new builders get wrapped.
41
- const result = value.apply(this === undefined ? receiver : this, args)
65
+ insideBuilderMethod++
66
+ let result
67
+ try {
68
+ // Knex methods return `this`, which is the proxy; new builders get wrapped.
69
+ result = value.apply(this === undefined ? receiver : this, args)
70
+ } finally {
71
+ insideBuilderMethod--
72
+ }
42
73
  return result !== receiver && result !== target && isQueryBuilder(result)
43
74
  ? readOnlyQueryBuilder(result, { resourceType })
44
75
  : result
45
76
  }
46
77
  },
47
78
  set (target, property, value) {
48
- if (property === '_method' && !READ_METHODS.has(value)) {
49
- throw new RestApiScopedQueryReadOnlyError({ resourceType, method: String(value) })
50
- }
79
+ if (property === '_method' && !READ_METHODS.has(value)) refuse(String(value))
80
+ if (property === 'client' || (BUILDER_STATE.has(String(property)) && !insideBuilderMethod)) refuse(`set ${String(property)}`)
51
81
  return Reflect.set(target, property, value)
82
+ },
83
+ defineProperty (_target, property) { return refuse(`defineProperty ${String(property)}`) },
84
+ // Deletes follow the rules of set: lodash's isPlainObject, which Knex's MySQL
85
+ // compiler runs on a subquery value, sets and then deletes Symbol.toStringTag.
86
+ deleteProperty (target, property) {
87
+ if (property === 'client' || property === '_method' || (BUILDER_STATE.has(String(property)) && !insideBuilderMethod)) refuse(`delete ${String(property)}`)
88
+ return Reflect.deleteProperty(target, property)
52
89
  }
53
90
  })
54
91
  return proxy
@@ -207,6 +207,25 @@ export function normalizeDateValue (value, type, {
207
207
  })
208
208
  }
209
209
 
210
+ /**
211
+ * Refuses an object or array as the value of a single column. Drivers do not
212
+ * store one as written, and MySQL drivers that format queries on the client
213
+ * (mysql2 before 3.17, mysql) expand a plain object into `key` = value pairs,
214
+ * which rewrites the statement: a value for one column could set others.
215
+ * Dates and byte arrays (Buffer, Uint8Array) are single column values.
216
+ * @param {unknown} value
217
+ * @param {{ fieldName?: string, parameter?: string }} [options] `parameter` is the input path the error names
218
+ * @returns {void}
219
+ */
220
+ export function assertSingleColumnValue (value, { fieldName, parameter = `data.attributes.${fieldName}` } = {}) {
221
+ if (value === null || typeof value !== 'object' || value instanceof Date || value instanceof Uint8Array) return
222
+ const message = `Field '${fieldName}' takes a single value, not ${Array.isArray(value) ? 'an array' : 'an object'}. Declare it with type 'object' or 'array', or give it a storage serializer.`
223
+ throw new RestApiValidationError(message, {
224
+ fields: [parameter],
225
+ violations: [{ field: parameter, rule: 'single_value', message }]
226
+ })
227
+ }
228
+
210
229
  /**
211
230
  * Converts structured JSON and temporal values to the values expected by
212
231
  * database drivers. JSON schema validation keeps date and dateTime values as
@@ -343,6 +362,19 @@ export function normalizeStructuredValue (value, type, { fieldName, resourceType
343
362
  }
344
363
  }
345
364
 
365
+ // Drivers return BIGINT and some other numeric columns as strings. A value a
366
+ // JavaScript number holds exactly, so that it prints back as the same digits
367
+ // (trailing fraction zeros aside), becomes one; any other, and any integer past
368
+ // 2^53 even when it prints back, keeps its digits as a string. Decimals
369
+ // (precision and scale) keep theirs, to avoid rounding.
370
+ const isDecimalField = definition => definition.precision !== undefined && definition.scale !== undefined
371
+ /** @param {string} value */
372
+ const storedNumber = value => {
373
+ const number = Number(value)
374
+ const digits = (value.includes('.') ? value.replace(/\.?0+$/, '') : value).replace(/^-0$/, '0')
375
+ return Number.isFinite(number) && (!Number.isInteger(number) || Number.isSafeInteger(number)) && String(number) === digits ? number : value
376
+ }
377
+
346
378
  /**
347
379
  * Normalizes database values in an attributes object
348
380
  *
@@ -386,6 +418,13 @@ export function normalizeAttributes (attributes, schemaStructure, {
386
418
  }
387
419
  }
388
420
 
421
+ // Stored values only: an id stays a string, and a getter, computed or
422
+ // virtual field keeps the value its code made.
423
+ if (['number', 'integer'].includes(fieldDef.type) && typeof value === 'string' && fieldName !== 'id' && !isDecimalField(fieldDef) &&
424
+ typeof fieldDef.getter !== 'function' && fieldDef.computed !== true && fieldDef.virtual !== true) {
425
+ normalized[fieldName] = storedNumber(value)
426
+ }
427
+
389
428
  // Normalize boolean values
390
429
  if (fieldDef.type === 'boolean') {
391
430
  if (value === 1 || value === '1') {
@@ -426,9 +465,7 @@ export function normalizeAttributes (attributes, schemaStructure, {
426
465
  export function normalizeStoredFieldValue (value, definition, { fieldName, resourceType }) {
427
466
  if (value == null) return null
428
467
  if (definition.type === 'id') return String(value)
429
- const decimal = definition.precision !== undefined && definition.scale !== undefined
430
- // Drivers return some numeric columns as strings; decimals keep theirs to avoid rounding.
431
- if (['number', 'integer'].includes(definition.type) && typeof value === 'string' && !decimal) return Number(value)
468
+ if (['number', 'integer'].includes(definition.type) && typeof value === 'string' && !isDecimalField(definition)) return storedNumber(value)
432
469
  return normalizeAttributes({ [fieldName]: value }, { [fieldName]: definition }, { resourceType })[fieldName]
433
470
  }
434
471
 
@@ -14,6 +14,8 @@ import { translateCanonicalAttributesForStorage, getCanonicalResourceId, getCano
14
14
  import { unwrapQueryBuilderState, withQueryFilteringContext } from '../querying/query-builder-utils.js'
15
15
  import { applyInsertReturning } from '../querying-writing/database-capabilities.js'
16
16
  import { serializeJsonApiQuery } from '../querying-writing/connectors-query-parser.js'
17
+ import { deleteFoundRow, foundRow, lockFoundRow, updateFoundRow } from './single-row-writes.js'
18
+ import { whereInIdentifiers } from '../querying/identifier-query.js'
17
19
 
18
20
  /** @param {StorageRow} row @param {CanonicalDescriptor} descriptor */
19
21
  function requireCanonicalResourceId (row, descriptor) {
@@ -91,6 +93,25 @@ export function createCanonicalDataHelpers ({
91
93
  return descriptors
92
94
  }
93
95
 
96
+ // The canonical row of the record a single write found with its scoped read.
97
+ /** @param {string} scopeName @param {{ db: import('./storage-types.js').StorageDatabase, id: string | number }} context */
98
+ const foundRecord = (scopeName, context) => {
99
+ const storageAdapter = getScopeStorageAdapter(scopeName)
100
+ if (!storageAdapter) throw new Error(`Storage adapter for resource '${scopeName}' not found`)
101
+ const descriptor = getDescriptor(scopeName)
102
+ const { canonical } = descriptor
103
+ return foundRow({
104
+ byId: () => context.db(canonical.tableName)
105
+ .where(getCanonicalResourceIdColumn(descriptor), context.id)
106
+ .where(canonical.resourceColumn, descriptor.resource)
107
+ .where(canonical.tenantColumn, descriptor.tenant),
108
+ context,
109
+ scopeName,
110
+ storageAdapter,
111
+ tableName: canonical.tableName
112
+ })
113
+ }
114
+
94
115
  /** @type {CanonicalDataWriteHelpers['dataExists']} */
95
116
  const dataExists = async ({ scopeName, context }) => {
96
117
  const storageAdapter = getScopeStorageAdapter(scopeName)
@@ -188,13 +209,7 @@ export function createCanonicalDataHelpers ({
188
209
  return 1
189
210
  }
190
211
 
191
- const result = await context.db(canonical.tableName)
192
- .where(logicalIdColumn, id)
193
- .where(canonical.resourceColumn, descriptor.resource)
194
- .where(canonical.tenantColumn, descriptor.tenant)
195
- .update(writeRow)
196
-
197
- return result
212
+ return updateFoundRow(foundRecord(scopeName, context), { db: context.db, scopeName, id, idColumn: logicalIdColumn, values: writeRow })
198
213
  }
199
214
 
200
215
  /** @type {CanonicalDataWriteHelpers['dataPatch']} */
@@ -204,7 +219,6 @@ export function createCanonicalDataHelpers ({
204
219
  context.storageAdapter = storageAdapter
205
220
  }
206
221
  const descriptor = getDescriptor(scopeName)
207
- const { canonical } = descriptor
208
222
  const id = context.id
209
223
  const logicalIdColumn = getCanonicalResourceIdColumn(descriptor)
210
224
  const attributes = context.inputRecord?.data?.attributes || {}
@@ -216,17 +230,7 @@ export function createCanonicalDataHelpers ({
216
230
  Object.entries(row).filter(([, value]) => value !== undefined)
217
231
  )
218
232
 
219
- if (Object.keys(updateRow).length === 0) {
220
- return 0
221
- }
222
-
223
- const result = await context.db(canonical.tableName)
224
- .where(logicalIdColumn, id)
225
- .where(canonical.resourceColumn, descriptor.resource)
226
- .where(canonical.tenantColumn, descriptor.tenant)
227
- .update(updateRow)
228
-
229
- return result
233
+ return updateFoundRow(foundRecord(scopeName, context), { db: context.db, scopeName, id, idColumn: logicalIdColumn, values: updateRow })
230
234
  }
231
235
 
232
236
  /** @type {CanonicalDataWriteHelpers['dataDelete']} */
@@ -236,23 +240,18 @@ export function createCanonicalDataHelpers ({
236
240
  context.storageAdapter = storageAdapter
237
241
  }
238
242
  const descriptor = getDescriptor(scopeName)
239
- const { canonical } = descriptor
240
243
  const id = context.id
241
- const logicalIdColumn = getCanonicalResourceIdColumn(descriptor)
242
-
243
- await linkStore.invalidateDeletedLinkTargets(scopeName, context)
244
- const result = await context.db(canonical.tableName)
245
- .where(logicalIdColumn, id)
246
- .where(canonical.resourceColumn, descriptor.resource)
247
- .where(canonical.tenantColumn, descriptor.tenant)
248
- .delete()
244
+ const row = foundRecord(scopeName, context)
249
245
 
246
+ const idColumn = getCanonicalResourceIdColumn(descriptor)
247
+ await linkStore.invalidateDeletedLinkTargets(scopeName, context, () => lockFoundRow(row, { scopeName, id, idColumn }))
248
+ const deleted = await deleteFoundRow(row, { scopeName, id })
250
249
  await linkStore.deleteResourceLinks({ descriptor, scopeName, id, db: context.db })
251
250
 
252
- return result
251
+ return deleted
253
252
  }
254
253
 
255
- // Supplying ids returns minimal records for the caller's bounded validation batch.
254
+ // Supplying ids returns minimal records for all of them, in one statement.
256
255
  /** @overload @param {DataMinimalRequest & { ids: Array<string | number> }} request @returns {Promise<DataResource[]>} */
257
256
  /** @overload @param {DataMinimalRequest & { ids?: undefined, context: DataReadContext & {id: string | number} }} request @returns {Promise<DataResource | null>} */
258
257
  /** @param {DataMinimalRequest & { ids?: Array<string | number> }} request */
@@ -279,8 +278,7 @@ export function createCanonicalDataHelpers ({
279
278
  .where(canonical.tenantColumn, descriptor.tenant)
280
279
  if (ids === undefined) query.where(logicalIdColumn, id)
281
280
  else {
282
- // Knex types exclude unknown bindings; the configured driver still validates serializer results.
283
- query.whereIn(logicalIdColumn, /** @type {import('knex').Knex.Value[]} */ (ids.map(value => storageAdapter.translateFilterValue('id', value))))
281
+ query.modify(whereInIdentifiers, logicalIdColumn, ids.map(value => storageAdapter.translateFilterValue('id', value)))
284
282
  .distinct(`${canonical.tableName}.${logicalIdColumn}`)
285
283
  }
286
284
 
@@ -332,7 +330,7 @@ export function createCanonicalDataHelpers ({
332
330
  return minimal
333
331
  }
334
332
  if (ids !== undefined) {
335
- // Filtering owns predicates; the validation batch owns its identity projection.
333
+ // Filtering owns predicates; the id list owns its identity projection.
336
334
  query.clearSelect().distinct(`${canonical.tableName}.${logicalIdColumn}`)
337
335
  /** @type {StorageRow[]} */
338
336
  const rows = await applyDatabaseReadOptions(storageAdapter.buildBaseQuery({ transaction: context.db }).whereIn(logicalIdColumn, query))
@@ -343,7 +341,7 @@ export function createCanonicalDataHelpers ({
343
341
  }
344
342
 
345
343
  /** @type {CanonicalDataReadHelpers['dataGet']} */
346
- const dataGet = async ({ scopeName, context }) => {
344
+ const dataGet = async ({ scopeName, context, runHooks, scoped = false }) => {
347
345
  const storageAdapter = getScopeStorageAdapter(scopeName)
348
346
  if (!storageAdapter) throw new Error(`Storage adapter for resource '${scopeName}' not found`)
349
347
  context.storageAdapter = storageAdapter
@@ -357,17 +355,40 @@ export function createCanonicalDataHelpers ({
357
355
  const fieldSelectionInfo = await buildFieldSelection(scope, { context })
358
356
  context.computedDependencies = fieldSelectionInfo.computedDependencies
359
357
 
358
+ // The preceding minimal read has already applied permission and row filters,
359
+ // unless the read is `scoped`: then it applies them itself, as the only read,
360
+ // and names its columns' table, since a row policy may join tables.
361
+ const column = (/** @type {string} */ name) => scoped ? `${canonical.tableName}.${name}` : name
360
362
  let query = context.db(canonical.tableName)
361
- .where(logicalIdColumn, id)
362
- .where(canonical.resourceColumn, descriptor.resource)
363
- .where(canonical.tenantColumn, descriptor.tenant)
363
+ .where(column(logicalIdColumn), id)
364
+ .where(column(canonical.resourceColumn), descriptor.resource)
365
+ .where(column(canonical.tenantColumn), descriptor.tenant)
366
+ if (scoped) {
367
+ const filterHooks = runHooks
368
+ if (!filterHooks) throw new Error('A scoped read needs runHooks')
369
+ query = (await withQueryFilteringContext(context, {
370
+ query,
371
+ filters: context.queryParams?.filters,
372
+ schemaInfo: context.schemaInfo,
373
+ scopeName,
374
+ tableName: canonical.tableName,
375
+ db: context.db,
376
+ queryPurpose: 'single',
377
+ isAnyApi: true,
378
+ adapter: storageAdapter,
379
+ storageAdapter
380
+ }, async () => {
381
+ await applyBuiltInAnyApiQueryFilters(context)
382
+ await filterHooks('knexQueryFiltering')
383
+ })).query
384
+ }
364
385
 
365
386
  const selectionState = await applyFieldSelectionToQuery({
366
387
  query,
367
388
  scope,
368
389
  fieldSelectionInfo,
369
390
  tableName: canonical.tableName,
370
- useTablePrefix: false,
391
+ useTablePrefix: scoped,
371
392
  storageAdapter,
372
393
  db: context.db,
373
394
  context,
@@ -512,6 +533,11 @@ export function createCanonicalDataHelpers ({
512
533
  let rows = await applyDatabaseReadOptions(queryBuilder)
513
534
  let cursorRecords = null
514
535
  let hasMore = false
536
+ // Without a count, a numbered page's one extra row answers hasMore.
537
+ if (paginationInfo.mode === 'offset' && !scope.vars.enablePaginationCounts) {
538
+ hasMore = rows.length > paginationInfo.pageSize
539
+ if (hasMore) rows = rows.slice(0, paginationInfo.pageSize)
540
+ }
515
541
 
516
542
  if (paginationInfo.mode === 'cursor') {
517
543
  const { pageSize } = paginationInfo
@@ -615,7 +641,7 @@ export function createCanonicalDataHelpers ({
615
641
  const total = Number(countResult?.count ?? countResult?.total ?? 0)
616
642
  paginationMeta = calculatePaginationMeta(total, page, pageSize)
617
643
  } else {
618
- paginationMeta = { page, pageSize }
644
+ paginationMeta = { page, pageSize, hasMore }
619
645
  }
620
646
 
621
647
  context.returnMeta.paginationMeta = paginationMeta
@@ -702,7 +728,7 @@ export function createCanonicalDataHelpers ({
702
728
  /**
703
729
  * The canonical rows of one resource with ownership and row policy applied.
704
730
  * Many-row writes and row locks are table-storage features; AnyAPI resources
705
- * get the read-only builder only.
731
+ * get the read-only builder only. Resolves to `{ query, alias }`.
706
732
  * @param {{ scopeName: string, context: DataReadContext, runHooks: (name: 'knexQueryFiltering') => unknown, alias?: string, purpose: string, filters?: Record<string, unknown> }} request
707
733
  */
708
734
  const dataScopedQuery = async ({ scopeName, context, runHooks, alias, purpose, filters }) => {
@@ -728,9 +754,13 @@ export function createCanonicalDataHelpers ({
728
754
  await runHooks('knexQueryFiltering')
729
755
  })
730
756
  // Wrapped: a builder is thenable, so an async function cannot return it bare.
731
- return { query: filteredState.query }
757
+ return { query: filteredState.query, alias: tableAlias }
732
758
  }
733
759
 
760
+ // A get with no reader of the minimal record reads once, scoped.
761
+ /** @type {NonNullable<CanonicalDataReadHelpers['dataGetScoped']>} */
762
+ const dataGetScoped = ({ scopeName, context, runHooks }) => dataGet({ scopeName, context, runHooks, scoped: true })
763
+
734
764
  return {
735
765
  dataScopedQuery,
736
766
  dataExists,
@@ -739,6 +769,7 @@ export function createCanonicalDataHelpers ({
739
769
  dataPatch,
740
770
  dataDelete,
741
771
  dataGet,
772
+ dataGetScoped,
742
773
  dataQuery,
743
774
  dataAggregate,
744
775
  dataGetMinimal