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
@@ -8,7 +8,8 @@ import { buildJsonApiResponse } from '../querying/knex-json-api-transformers-que
8
8
  import { toJsonApiRecordWithBelongsTo } from '../querying-writing/knex-json-api-transformers.js'
9
9
  import { processIncludes } from '../querying/knex-process-includes.js'
10
10
  import { loadRelationshipIdentifiers } from '../querying/relationship-identifiers.js'
11
- import { applyQueryConstraint } from '../querying/query-constraint.js'
11
+ import { applyQueryConstraint, queryConstraint } from '../querying/query-constraint.js'
12
+ import { deleteFoundRow, foundRow, lockFoundRow, updateFoundRow } from './single-row-writes.js'
12
13
  import { runAggregateQuery } from '../querying/aggregate-query.js'
13
14
  import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
14
15
  import { getOperationDiagnosticContext } from '../../../../lib/error-context.js'
@@ -19,6 +20,8 @@ import { getUrlPrefix } from '../querying/url-helpers.js'
19
20
  import { unwrapQueryBuilderState, withQueryFilteringContext } from '../querying/query-builder-utils.js'
20
21
  import { serializeJsonApiQuery } from '../querying-writing/connectors-query-parser.js'
21
22
  import { applyDatabaseReadOptions, databaseIdentityExpression } from '../querying-writing/database-value-normalizers.js'
23
+ import { whereInIdentifiers } from '../querying/identifier-query.js'
24
+ import { databaseFamily } from './many-row-data-helpers.js'
22
25
 
23
26
  /**
24
27
  * Every update advances the resource's lock version by one, whatever the input says.
@@ -192,7 +195,7 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
192
195
  }
193
196
 
194
197
  /** @type {OrdinaryDataReadHelpers['dataGet']} */
195
- const dataGet = async ({ scopeName, context }) => {
198
+ const dataGet = async ({ scopeName, context, runHooks, scoped = false }) => {
196
199
  const storageAdapter = getScopeStorageAdapter(scopeName)
197
200
  context.storageAdapter = storageAdapter
198
201
  const scope = getScope(scopeName)
@@ -207,15 +210,32 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
207
210
  // Enrichment removes fetched dependencies that the caller did not request.
208
211
  context.computedDependencies = fieldSelectionInfo.computedDependencies
209
212
 
210
- // The preceding minimal read has already applied permission and row filters.
211
- let query = db(tableName).where(idProperty, id)
213
+ // The preceding minimal read has already applied permission and row filters,
214
+ // unless the read is `scoped`: then it applies them itself, as the only read.
215
+ let query = db(tableName).where(scoped ? `${tableName}.${idProperty}` : idProperty, id)
216
+ if (scoped) {
217
+ const filterHooks = runHooks
218
+ if (!filterHooks) throw new Error('A scoped read needs runHooks')
219
+ query = (await withQueryFilteringContext(context, {
220
+ query,
221
+ filters: context.queryParams?.filters,
222
+ schemaInfo: context.schemaInfo,
223
+ scopeName,
224
+ tableName,
225
+ db,
226
+ queryPurpose: 'single',
227
+ adapter: storageAdapter,
228
+ storageAdapter
229
+ }, () => filterHooks('knexQueryFiltering'))).query
230
+ }
212
231
 
213
232
  const selectionState = await applyFieldSelectionToQuery({
214
233
  query,
215
234
  scope,
216
235
  fieldSelectionInfo,
217
236
  tableName,
218
- useTablePrefix: false,
237
+ // A row policy may join tables, so a scoped read names its columns' table.
238
+ useTablePrefix: scoped,
219
239
  storageAdapter,
220
240
  db,
221
241
  context,
@@ -250,7 +270,7 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
250
270
  return buildJsonApiResponse(scope, records, included, true, scopeName, context)
251
271
  }
252
272
 
253
- // Supplying ids returns minimal records for the caller's bounded validation batch.
273
+ // Supplying ids returns minimal records for all of them, in one statement.
254
274
  /** @overload @param {DataMinimalRequest & { ids: Array<string | number> }} request @returns {Promise<DataResource[]>} */
255
275
  /** @overload @param {DataMinimalRequest & { ids?: undefined, context: DataReadContext & {id: string | number} }} request @returns {Promise<DataResource | null>} */
256
276
  /** @param {DataMinimalRequest & { ids?: Array<string | number> }} request */
@@ -261,7 +281,8 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
261
281
  runHooks,
262
282
  applyQueryFilters,
263
283
  filters = context.queryParams?.filters,
264
- queryPurpose = 'single'
284
+ queryPurpose = 'single',
285
+ locked = false
265
286
  }) {
266
287
  const storageAdapter = getScopeStorageAdapter(scopeName)
267
288
  context.storageAdapter = storageAdapter
@@ -273,11 +294,12 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
273
294
 
274
295
  log.debug(`[Knex] GET_MINIMAL ${tableName}/${id}`)
275
296
 
276
- // Knex types exclude unknown bindings; the configured driver still validates serializer results.
297
+ const table = db(tableName)
298
+ // whereInIdentifiers returns the builder it was given, which modify's types lose.
277
299
  let query = ids === undefined
278
- ? db(tableName).where(idProperty, id)
279
- : db(tableName).whereIn(idProperty, /** @type {import('knex').Knex.Value[]} */ (ids.map(value => storageAdapter.translateFilterValue('id', value))))
280
- .distinct(`${tableName}.${idProperty}`)
300
+ ? table.where(idProperty, id)
301
+ : /** @type {typeof table} */ (table.modify(whereInIdentifiers, idProperty, ids.map(value => storageAdapter.translateFilterValue('id', value))))
302
+ .distinct(`${tableName}.${idProperty}`)
281
303
 
282
304
  if (typeof applyQueryFilters === 'function') {
283
305
  const scopedQueryState = await applyQueryFilters({
@@ -312,8 +334,21 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
312
334
  const column = storageAdapter.translateColumn(field)
313
335
  return [field === 'id' ? 'id' : column, databaseIdentityExpression(db, `${tableName}.${column}`)]
314
336
  }))
337
+ if (ids !== undefined && locked && databaseFamily(db) === 'mysql') {
338
+ // InnoDB answers a plain read from the transaction's snapshot and a
339
+ // locking read from the latest rows. Rows this transaction has locked
340
+ // are read with a locking read in the scope's own query block, row
341
+ // policy included, so a row another transaction committed after the
342
+ // snapshot is seen as it was locked. (PostgreSQL's plain and locking
343
+ // reads see the same rows; SQLite has one writer.)
344
+ /** @type {StorageRow[]} */
345
+ const rows = await applyDatabaseReadOptions(query.clearSelect().select(`${tableName}.*`, identities).forShare())
346
+ // A policy join can repeat a row.
347
+ const records = [...new Map(rows.map(row => [String(row.id), row])).values()]
348
+ return records.map(record => toJsonApiRecordWithBelongsTo(scope, record, scopeName))
349
+ }
315
350
  if (ids !== undefined) {
316
- // Filtering owns predicates; the validation batch owns its identity projection.
351
+ // Filtering owns predicates; the id list owns its identity projection.
317
352
  query.clearSelect().distinct(`${tableName}.${idProperty}`)
318
353
  // Deduplicate policy joins by ID, without comparing JSON or other row values.
319
354
  /** @type {StorageRow[]} */
@@ -405,6 +440,9 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
405
440
  })
406
441
  const { pageSize } = paginationInfo
407
442
 
443
+ // Rows this transaction has locked, read as its locking reads see them (see dataGetMinimal).
444
+ const constraint = context[queryConstraint]
445
+ if (constraint?.scopeName === scopeName && constraint.locked && databaseFamily(db) === 'mysql') query.forShare()
408
446
  /** @type {StorageRow[]} */
409
447
  const records = await applyDatabaseReadOptions(query)
410
448
 
@@ -447,12 +485,10 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
447
485
  // Calculate pagination metadata with total
448
486
  context.returnMeta.paginationMeta = calculatePaginationMeta(total, page, pageSize)
449
487
  } else {
450
- // Without count, we can still provide basic pagination info
451
- context.returnMeta.paginationMeta = {
452
- page,
453
- pageSize
454
- // No total, pageCount, or hasMore when counts are disabled
455
- }
488
+ // Without a count, the page's one extra row answers hasMore.
489
+ const hasMore = records.length > pageSize
490
+ if (hasMore) records.pop()
491
+ context.returnMeta.paginationMeta = { page, pageSize, hasMore }
456
492
  }
457
493
 
458
494
  // Generate links
@@ -623,29 +659,11 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
623
659
  return
624
660
  }
625
661
 
626
- const exists = await db(tableName)
627
- .where(idProperty, id)
628
- .first()
629
-
630
- if (!exists) {
631
- throw new RestApiResourceError(
632
- 'Resource not found',
633
- {
634
- subtype: ERROR_SUBTYPES.NOT_FOUND,
635
- resourceType: scopeName,
636
- resourceId: id
637
- }
638
- )
639
- }
640
-
641
662
  // Updates cannot replace the primary key.
642
663
  delete finalAttributes[idProperty]
643
664
  advanceLockVersion(finalAttributes, context, storageAdapter, db)
644
- if (Object.keys(finalAttributes).length > 0) {
645
- await db(tableName)
646
- .where(idProperty, id)
647
- .update(finalAttributes)
648
- }
665
+ const row = foundRow({ byId: () => db(tableName).where(`${tableName}.${idProperty}`, id), context, scopeName, storageAdapter, tableName })
666
+ await updateFoundRow(row, { db, scopeName, id, idColumn: `${tableName}.${idProperty}`, values: finalAttributes })
649
667
  }
650
668
 
651
669
  /** @type {OrdinaryDataWriteHelpers['dataPatch']} */
@@ -660,22 +678,6 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
660
678
 
661
679
  log.debug(`[Knex] PATCH ${tableName}/${id}`)
662
680
 
663
- // Check if record exists
664
- const exists = await db(tableName)
665
- .where(idProperty, id)
666
- .first()
667
-
668
- if (!exists) {
669
- throw new RestApiResourceError(
670
- 'Resource not found',
671
- {
672
- subtype: ERROR_SUBTYPES.NOT_FOUND,
673
- resourceType: scopeName,
674
- resourceId: id
675
- }
676
- )
677
- }
678
-
679
681
  // Core has already merged relationship values and applied setters.
680
682
  const attributes = inputRecord.data.attributes || {}
681
683
 
@@ -691,12 +693,8 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
691
693
  delete finalAttributes[idProperty]
692
694
  advanceLockVersion(finalAttributes, context, storageAdapter, db)
693
695
 
694
- // Update only if there are changes
695
- if (Object.keys(finalAttributes).length > 0) {
696
- await db(tableName)
697
- .where(idProperty, id)
698
- .update(finalAttributes)
699
- }
696
+ const row = foundRow({ byId: () => db(tableName).where(`${tableName}.${idProperty}`, id), context, scopeName, storageAdapter, tableName })
697
+ await updateFoundRow(row, { db, scopeName, id, idColumn: `${tableName}.${idProperty}`, values: finalAttributes })
700
698
  }
701
699
 
702
700
  /** @type {OrdinaryDataWriteHelpers['dataDelete']} */
@@ -711,36 +709,24 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
711
709
 
712
710
  log.debug(`[Knex] DELETE ${tableName}/${id}`)
713
711
 
714
- // Check if record exists
715
- const exists = await db(tableName)
716
- .where(idProperty, id)
717
- .first()
718
-
719
- if (!exists) {
720
- throw new RestApiResourceError(
721
- 'Resource not found',
722
- {
723
- subtype: ERROR_SUBTYPES.NOT_FOUND,
724
- resourceType: scopeName,
725
- resourceId: id
726
- }
727
- )
728
- }
729
-
730
- // Delete the record
731
- await invalidateDeletedPivotTargets(api, scopeName, context)
732
- await db(tableName)
733
- .where(idProperty, id)
734
- .delete()
712
+ const row = foundRow({ byId: () => db(tableName).where(`${tableName}.${idProperty}`, id), context, scopeName, storageAdapter, tableName })
713
+ // Before the DELETE: an ON DELETE CASCADE would remove the links that tell which parents to advance.
714
+ await invalidateDeletedPivotTargets(api, scopeName, context, () => lockFoundRow(row, { scopeName, id, idColumn: `${tableName}.${idProperty}` }))
715
+ await deleteFoundRow(row, { scopeName, id })
735
716
  await deletePivotReferences(api, scopeName, context)
736
717
 
737
718
  return { success: true }
738
719
  }
739
720
 
721
+ // A get with no reader of the minimal record reads once, scoped.
722
+ /** @type {NonNullable<OrdinaryDataReadHelpers['dataGetScoped']>} */
723
+ const dataGetScoped = ({ scopeName, context, runHooks }) => dataGet({ scopeName, context, runHooks, scoped: true })
724
+
740
725
  return {
741
726
  dataRelatedIdsQuery,
742
727
  dataExists,
743
728
  dataGet,
729
+ dataGetScoped,
744
730
  dataQuery,
745
731
  dataAggregate,
746
732
  dataPost,
@@ -0,0 +1,71 @@
1
+ // @ts-check
2
+ // Single patch, put and delete find their row with a scoped read first, on
3
+ // table and AnyAPI storage alike. Their UPDATE or DELETE repeats the owner that
4
+ // read recorded, and its own row count tells whether the row has gone or moved
5
+ // to another owner since.
6
+ import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
7
+ import { ERROR_SUBTYPES } from '../querying-writing/knex-constants.js'
8
+ import { applyWriteConditions } from '../querying/query-constraint.js'
9
+ import { countsMatchedRows } from './many-row-data-helpers.js'
10
+
11
+ /**
12
+ * @param {string} scopeName
13
+ * @param {string | number} id
14
+ */
15
+ const resourceNotFound = (scopeName, id) => new RestApiResourceError('Resource not found', {
16
+ subtype: ERROR_SUBTYPES.NOT_FOUND,
17
+ resourceType: scopeName,
18
+ resourceId: id
19
+ })
20
+
21
+ /**
22
+ * The row a single write found with its scoped read: `byId` looks it up, and
23
+ * the owner that read recorded is added, so a row that has gone or moved to
24
+ * another owner since is not matched.
25
+ * @param {{ byId: () => any, context: any, scopeName: string, storageAdapter: import('./storage-types.js').StorageAdapter, tableName: string }} target
26
+ * @returns {() => any}
27
+ */
28
+ export const foundRow = ({ byId, context, scopeName, storageAdapter, tableName }) => () =>
29
+ applyWriteConditions({ query: byId(), context, scopeName, storageAdapter, tableName })
30
+
31
+ /**
32
+ * Locks the row a single write found; not found when it has gone or moved.
33
+ * @param {() => any} row
34
+ * @param {{ scopeName: string, id: string | number, idColumn: string }} target
35
+ */
36
+ export async function lockFoundRow (row, { scopeName, id, idColumn }) {
37
+ if (!await row().select(idColumn).forUpdate().first()) throw resourceNotFound(scopeName, id)
38
+ }
39
+
40
+ /**
41
+ * Updates the row a single write found, or, with nothing stored to change,
42
+ * locks it. Not found when it has gone or moved.
43
+ * @param {() => any} row
44
+ * @param {{ db: any, scopeName: string, id: string | number, idColumn: string, values: Record<string, unknown> }} update
45
+ * @returns {Promise<number>} The rows the UPDATE counted
46
+ */
47
+ export async function updateFoundRow (row, { db, scopeName, id, idColumn, values }) {
48
+ if (!Object.keys(values).length) {
49
+ await lockFoundRow(row, { scopeName, id, idColumn })
50
+ return 0
51
+ }
52
+ const counted = await row().update(values)
53
+ if (counted) return counted
54
+ // MySQL without FOUND_ROWS counts the rows an UPDATE changed: an unchanged row counts zero.
55
+ if (await countsMatchedRows(db)) throw resourceNotFound(scopeName, id)
56
+ await lockFoundRow(row, { scopeName, id, idColumn })
57
+ return 0
58
+ }
59
+
60
+ /**
61
+ * Deletes the row a single write found. A DELETE counts the rows it removed,
62
+ * FOUND_ROWS or not; not found when it is gone.
63
+ * @param {() => any} row
64
+ * @param {{ scopeName: string, id: string | number }} target
65
+ * @returns {Promise<number>}
66
+ */
67
+ export async function deleteFoundRow (row, { scopeName, id }) {
68
+ const deleted = await row().delete()
69
+ if (!deleted) throw resourceNotFound(scopeName, id)
70
+ return deleted
71
+ }
@@ -14,7 +14,7 @@ import {
14
14
  translateCanonicalAttributesForStorage,
15
15
  } from './canonical-storage-mapping.js'
16
16
  import { assertScalarQueryField } from '../querying-writing/field-utils.js'
17
- import { normalizeValueForDatabaseStorage } from '../querying-writing/database-value-normalizers.js'
17
+ import { assertSingleColumnValue, normalizeValueForDatabaseStorage } from '../querying-writing/database-value-normalizers.js'
18
18
 
19
19
  /** @import { BaseStorageAdapterOptions, CanonicalDescriptor, SelectTranslator, StorageAdapter, StorageAdapterOptions, StorageAdapterLookupOptions, StorageSchemaInfo, StorageFieldDefinition } from './storage-types.js' */
20
20
 
@@ -59,16 +59,22 @@ const normalizeBooleanValue = (value) => {
59
59
  }
60
60
 
61
61
  /**
62
+ * A filter or cursor value, or a list of them, for one column. Objects and
63
+ * nested lists are refused: a MySQL driver could expand them into SQL.
62
64
  * @param {unknown} value
63
65
  * @param {StorageFieldDefinition} [definition]
64
- * @param {{ isRelationship?: boolean, databaseClient?: string, textStorage?: boolean }} [options]
66
+ * @param {{ isRelationship?: boolean, databaseClient?: string, textStorage?: boolean, fieldName?: string, parameter?: string }} [options]
65
67
  * @returns {unknown}
66
68
  */
67
- const normalizeFilterValueForDefinition = (value, definition = {}, { isRelationship = false, databaseClient, textStorage } = {}) => {
69
+ const normalizeFilterValueForDefinition = (value, definition = {}, { isRelationship = false, databaseClient, textStorage, fieldName, parameter = `filters.${fieldName}` } = {}) => {
68
70
  if (value === null || value === undefined) return value
69
71
  if (Array.isArray(value)) {
70
- return value.map(entry => normalizeFilterValueForDefinition(entry, definition, { isRelationship, databaseClient, textStorage }))
72
+ return value.map(entry => {
73
+ assertSingleColumnValue(entry, { fieldName, parameter })
74
+ return normalizeFilterValueForDefinition(entry, definition, { isRelationship, databaseClient, textStorage, fieldName, parameter })
75
+ })
71
76
  }
77
+ assertSingleColumnValue(value, { fieldName, parameter })
72
78
 
73
79
  if (isRelationship) {
74
80
  return normalizeBelongsToValue(value)
@@ -181,7 +187,9 @@ const createTableAdapter = ({ knex, schemaInfo }) => {
181
187
  const definition = schemaInfo.schemaStructure?.[field]
182
188
  return normalizeFilterValueForDefinition(value, definition, {
183
189
  isRelationship: Boolean(definition?.belongsTo || definition?.belongsToPolymorphic),
184
- databaseClient: knex.client.config.client
190
+ databaseClient: knex.client.config.client,
191
+ fieldName: field,
192
+ parameter: 'page'
185
193
  })
186
194
  }
187
195
  /** @type {StorageAdapter['translateFilterValue']} */
@@ -209,7 +217,7 @@ const createTableAdapter = ({ knex, schemaInfo }) => {
209
217
  schemaField?.belongsTo ||
210
218
  schemaField?.belongsToPolymorphic
211
219
  )
212
- return normalizeFilterValueForDefinition(value, definition, { isRelationship, databaseClient: knex.client.config.client })
220
+ return normalizeFilterValueForDefinition(value, definition, { isRelationship, databaseClient: knex.client.config.client, fieldName: field })
213
221
  }
214
222
 
215
223
  return baseAdapter({
@@ -305,7 +313,7 @@ const createCanonicalAdapter = ({ knex, schemaInfo, descriptor }) => {
305
313
  )
306
314
  const definition = searchField || schemaField || {}
307
315
 
308
- return normalizeFilterValueForDefinition(value, definition, { isRelationship, databaseClient: knex.client.config.client, textStorage: fieldsInfo[fieldName]?.slotType === 'string' })
316
+ return normalizeFilterValueForDefinition(value, definition, { isRelationship, databaseClient: knex.client.config.client, textStorage: fieldsInfo[fieldName]?.slotType === 'string', fieldName: field })
309
317
  }
310
318
 
311
319
  /** @type {StorageAdapter['translateCursorValue']} */
@@ -315,7 +323,9 @@ const createCanonicalAdapter = ({ knex, schemaInfo, descriptor }) => {
315
323
  return normalizeFilterValueForDefinition(value, definition, {
316
324
  isRelationship: Boolean(definition?.belongsTo || definition?.belongsToPolymorphic),
317
325
  databaseClient: knex.client.config.client,
318
- textStorage: fieldsInfo[field]?.slotType === 'string'
326
+ textStorage: fieldsInfo[field]?.slotType === 'string',
327
+ fieldName: field,
328
+ parameter: 'page'
319
329
  })
320
330
  }
321
331
 
@@ -1,5 +1,6 @@
1
1
  // @ts-check
2
- import { normalizeValueForDatabaseStorage } from '../querying-writing/database-value-normalizers.js'
2
+ import { assertSingleColumnValue, normalizeValueForDatabaseStorage } from '../querying-writing/database-value-normalizers.js'
3
+ import { SERIALIZATION_CAPABILITIES } from '../querying-writing/database-capabilities.js'
3
4
  import { assertFieldNameMap } from '../querying-writing/field-utils.js'
4
5
 
5
6
  /** @import { FieldStorage, StorageConfig, StorageFieldDefinition, StorageInfo, StorageNaming, StorageRow, StorageSchemaInfo, StorageSerializerDetails, StorageWriteOptions } from './storage-types.js' */
@@ -262,6 +263,8 @@ export const serializeFieldValueForStorage = (value, {
262
263
  }
263
264
  return serialized
264
265
  }
266
+ // Only object and array fields store a structure (as JSON); any other column takes one value.
267
+ if (!(definition.type && SERIALIZATION_CAPABILITIES.structuredTypes.includes(definition.type))) assertSingleColumnValue(value, { fieldName })
265
268
  return normalizeValueForDatabaseStorage(value, definition.type, {
266
269
  databaseClient,
267
270
  textStorage,
@@ -13,6 +13,8 @@ export interface MandatoryQueryConstraint {
13
13
  scopeName: string
14
14
  values?: StorageRow
15
15
  idsQuery?: StorageQuery
16
+ /** The caller's transaction holds row locks on the ids: read them as its locking reads see them. */
17
+ locked?: boolean
16
18
  }
17
19
  export type SelectColumn = string | Knex.Raw<unknown> | StorageQuery | Record<string, string>
18
20
  export type SelectColumns = SelectColumn[] | Record<string, string> | null | undefined
@@ -291,6 +293,8 @@ export type QueryBuilderResult = StorageQuery | { query: QueryBuilderResult }
291
293
  export interface DataMinimalRequest extends DataReadRequest {
292
294
  filters?: StorageRow
293
295
  queryPurpose?: string
296
+ /** With ids: the caller's transaction holds row locks on them, so read them as its locking reads see them. */
297
+ locked?: boolean
294
298
  applyQueryFilters?: (params: QueryFilteringState & { query: StorageQuery }) => Promise<{ query: QueryBuilderResult } | null | undefined>
295
299
  }
296
300
 
@@ -306,6 +310,13 @@ export interface DataReadHelpers<GetResult> {
306
310
  dataGetMinimal: DataMinimalReader
307
311
  dataGet: (request: DataReadRequest & {
308
312
  context: DataReadContext & { id: string | number }
313
+ // Apply the row filters in this read, when no minimal read preceded it.
314
+ scoped?: boolean
315
+ }) => Promise<GetResult>
316
+ // dataGet({ scoped: true }), for storages that support it.
317
+ dataGetScoped?: (request: DataReadRequest & {
318
+ runHooks: NonNullable<DataReadRequest['runHooks']>
319
+ context: DataReadContext & { id: string | number }
309
320
  }) => Promise<GetResult>
310
321
  dataQuery: (request: DataReadRequest & {
311
322
  runHooks: NonNullable<DataReadRequest['runHooks']>
@@ -517,7 +528,7 @@ export interface CanonicalDataDependencies {
517
528
  getScopeStorageAdapter(scopeName: string): StorageAdapter | null
518
529
  getDescriptor(scopeName: string): CanonicalDescriptor
519
530
  linkStore: {
520
- invalidateDeletedLinkTargets(scopeName: string, context: DataIdentityContext): Promise<void>
531
+ invalidateDeletedLinkTargets(scopeName: string, context: DataIdentityContext, lockRow: () => Promise<void>): Promise<void>
521
532
  deleteResourceLinks(request: { descriptor: CanonicalDescriptor, scopeName: string, id: string | number, db: StorageDatabase }): Promise<void>
522
533
  }
523
534
  buildIncludes(request: { parentResources: DataResource[], descriptor: CanonicalDescriptor, context: DataReadContext }): Promise<DataResource[]>
@@ -6,7 +6,10 @@ import { whereInIdentifiers } from '../querying/identifier-query.js'
6
6
  import { applyDatabaseReadOptions, databaseIdentityExpression } from '../querying-writing/database-value-normalizers.js'
7
7
  import { assertVersionedRelationshipTransaction, hasVersionedInverse, invalidateManyToManyVersions } from './resource-version.js'
8
8
 
9
- export async function invalidateDeletedPivotTargets (api, scopeName, context) {
9
+ // Advances the versions of parents linked to a record about to be deleted.
10
+ // When there are such parents, `lockRow` first locks the record's row against
11
+ // link writers, and answers not found when it has gone.
12
+ export async function invalidateDeletedPivotTargets (api, scopeName, context, lockRow) {
10
13
  const relationships = []
11
14
  for (const [parentType, parent] of Object.entries(api.resources)) {
12
15
  if (!parent.vars.schemaInfo?.versionField) continue
@@ -17,7 +20,7 @@ export async function invalidateDeletedPivotTargets (api, scopeName, context) {
17
20
  }
18
21
  if (!relationships.length) return
19
22
  assertVersionedRelationshipTransaction(api, relationships[0], context.transaction)
20
- await lockRelationshipTargets(api, context.transaction, [{ type: scopeName, id: context.id }], { exclusive: true })
23
+ await lockRow()
21
24
  const id = api.knex.helpers.getStorageAdapter(scopeName).translateFilterValue('id', context.id)
22
25
  for (const inverse of relationships) {
23
26
  const pivot = getPivotStorage(api, inverse)
@@ -1,6 +1,6 @@
1
1
  import { RestApiResourceError, RestApiValidationError } from '../../../../lib/rest-api-errors.js'
2
2
  import { validateRelationshipDataCardinality } from '../querying-writing/relationship-contracts.js'
3
- import { RELATIONSHIP_WRITE_BATCH_SIZE } from '../querying-writing/knex-constants.js'
3
+ import { whereInIdentifiers } from '../querying/identifier-query.js'
4
4
  import { applyDatabaseReadOptions, databaseIdentityExpression } from '../querying-writing/database-value-normalizers.js'
5
5
 
6
6
  export const lockRelationshipParent = async ({ context, helpers, scopeName }) => {
@@ -40,47 +40,43 @@ export const lockRelationshipTargets = async (api, transaction, identifiers, { e
40
40
  const resolved = []
41
41
  const resources = new Map()
42
42
  for (const { type, id } of identifiers) {
43
- if (!resources.has(type)) resources.set(type, { adapter: api.knex.helpers.getStorageAdapter(type), seen: new Set(), resolved: new Set() })
44
- const { adapter, seen } = resources.get(type)
43
+ if (!resources.has(type)) resources.set(type, { adapter: api.knex.helpers.getStorageAdapter(type), seen: new Set(), resolved: new Set(), group: [], found: new Set() })
44
+ const { adapter, seen, group } = resources.get(type)
45
45
  const value = adapter.translateFilterValue('id', id)
46
46
  if (seen.has(String(value))) continue
47
47
  seen.add(String(value))
48
- targets.push({ type, id, value, adapter })
48
+ const target = { type, id, value, adapter }
49
+ targets.push(target)
50
+ group.push(target)
49
51
  }
50
- for (let offset = 0; offset < targets.length; offset += RELATIONSHIP_WRITE_BATCH_SIZE) {
51
- const batch = targets.slice(offset, offset + RELATIONSHIP_WRITE_BATCH_SIZE)
52
- const byType = new Map()
53
- for (const target of batch) {
54
- if (!byType.has(target.type)) byType.set(target.type, [])
55
- byType.get(target.type).push(target)
56
- }
57
- const found = new Map()
58
- for (const [type, group] of byType) {
59
- const { adapter } = group[0]
60
- const idColumn = adapter.getIdColumn()
61
- const rows = await applyDatabaseReadOptions(lockTargetRows(adapter.buildBaseQuery({ transaction })
62
- .whereIn(idColumn, group.map(target => target.value))
63
- .orderBy(`${adapter.getTableName()}.${idColumn}`), isExclusive(type)).select({ [idColumn]: databaseIdentityExpression(transaction, idColumn) }))
64
- found.set(type, new Set(rows.map(row => String(row[idColumn]))))
65
- }
66
- for (const { type, id, value, adapter } of batch) {
67
- let storedId = String(value)
68
- // Let the database resolve alternate spellings under its column collation.
69
- if (!found.get(type).has(storedId)) {
70
- const record = await applyDatabaseReadOptions(lockTargetRows(adapter.buildBaseQuery({ transaction })
71
- .where(adapter.getIdColumn(), value), isExclusive(type)).first({ [adapter.getIdColumn()]: databaseIdentityExpression(transaction, adapter.getIdColumn()) }))
72
- if (!record) {
73
- throw new RestApiResourceError(`Related ${type} with id ${id} not found`, {
74
- subtype: 'not_found', resourceType: type, resourceId: id
75
- })
76
- }
77
- storedId = String(record[adapter.getIdColumn()])
78
- }
79
- const identities = resources.get(type).resolved
80
- if (!identities.has(storedId)) {
81
- identities.add(storedId)
82
- resolved.push({ type, id })
52
+ // One statement per target type at any count; whereInIdentifiers binds a long
53
+ // id list as one parameter where the database limits parameters. Types lock
54
+ // in name order and each statement in id order, so two writers naming the same
55
+ // targets in different orders take their locks in the same order.
56
+ for (const [type, { adapter, group, found }] of [...resources].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)) {
57
+ const idColumn = adapter.getIdColumn()
58
+ const rows = await applyDatabaseReadOptions(lockTargetRows(adapter.buildBaseQuery({ transaction })
59
+ .modify(whereInIdentifiers, idColumn, group.map(target => target.value))
60
+ .orderBy(`${adapter.getTableName()}.${idColumn}`), isExclusive(type)).select({ [idColumn]: databaseIdentityExpression(transaction, idColumn) }))
61
+ for (const row of rows) found.add(String(row[idColumn]))
62
+ }
63
+ for (const { type, id, value, adapter } of targets) {
64
+ let storedId = String(value)
65
+ // Let the database resolve alternate spellings under its column collation.
66
+ if (!resources.get(type).found.has(storedId)) {
67
+ const record = await applyDatabaseReadOptions(lockTargetRows(adapter.buildBaseQuery({ transaction })
68
+ .where(adapter.getIdColumn(), value), isExclusive(type)).first({ [adapter.getIdColumn()]: databaseIdentityExpression(transaction, adapter.getIdColumn()) }))
69
+ if (!record) {
70
+ throw new RestApiResourceError(`Related ${type} with id ${id} not found`, {
71
+ subtype: 'not_found', resourceType: type, resourceId: id
72
+ })
83
73
  }
74
+ storedId = String(record[adapter.getIdColumn()])
75
+ }
76
+ const identities = resources.get(type).resolved
77
+ if (!identities.has(storedId)) {
78
+ identities.add(storedId)
79
+ resolved.push({ type, id })
84
80
  }
85
81
  }
86
82
  // Return each database identity once, retaining its first submitted spelling.