json-rest-api 2.4.0 → 3.0.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.
Files changed (83) hide show
  1. package/README.md +7 -2
  2. package/docs/API.md +262 -54
  3. package/docs/GUIDE/01-getting-started.md +1 -1
  4. package/docs/GUIDE/07-polymorphic-relationships.md +4 -2
  5. package/docs/GUIDE/09-pagination-and-sorting.md +4 -2
  6. package/docs/GUIDE/13-hooks-and-lifecycle.md +34 -6
  7. package/docs/GUIDE/14-custom-resource-methods.md +3 -2
  8. package/docs/GUIDE/16-autofiltering.md +54 -2
  9. package/docs/GUIDE/17-row-policies.md +15 -5
  10. package/docs/GUIDE/19-managed-transactions.md +8 -9
  11. package/docs/GUIDE/20-transaction-outcomes.md +3 -4
  12. package/docs/GUIDE/21-schema-and-migrations.md +7 -1
  13. package/docs/GUIDE/22-version-field-migrations.md +3 -3
  14. package/docs/GUIDE/27-many-row-writes.md +458 -0
  15. package/docs/GUIDE/28-socketio.md +10 -2
  16. package/docs/GUIDE/30-backend-capabilities.md +42 -2
  17. package/docs/GUIDE/31-positioning.md +3 -1
  18. package/docs/GUIDE/33-migrating-to-v2.md +2 -2
  19. package/docs/GUIDE/34-scoped-reads-and-locks.md +464 -0
  20. package/docs/GUIDE/35-migrating-to-v3.md +325 -0
  21. package/docs/GUIDE/index.md +5 -2
  22. package/docs/QUICKSTART.md +2 -2
  23. package/docs/contributing.md +1 -1
  24. package/index.d.ts +8 -1
  25. package/index.js +4 -0
  26. package/lib/rest-api-errors.js +79 -0
  27. package/lib/runtime/json-rest-api.js +18 -3
  28. package/lib/system-context.js +42 -0
  29. package/package.json +3 -2
  30. package/plugins/core/bulk-operations-plugin.js +54 -436
  31. package/plugins/core/connectors/lib/transport-http-helpers.js +34 -3
  32. package/plugins/core/file-handling-plugin.js +17 -2
  33. package/plugins/core/lib/anyapi/anyapi-registry.js +3 -2
  34. package/plugins/core/lib/anyapi/canonical-link-store.js +5 -2
  35. package/plugins/core/lib/anyapi/canonical-relationship-reader.js +56 -60
  36. package/plugins/core/lib/dbIntrospection.js +106 -6
  37. package/plugins/core/lib/querying/aggregate-query.js +215 -22
  38. package/plugins/core/lib/querying/include-query-helpers.js +11 -13
  39. package/plugins/core/lib/querying/include-to-one.js +54 -59
  40. package/plugins/core/lib/querying/knex-pagination-helpers.js +9 -2
  41. package/plugins/core/lib/querying/query-constraint.d.ts +11 -0
  42. package/plugins/core/lib/querying/query-constraint.js +45 -0
  43. package/plugins/core/lib/querying/read-only-query.js +92 -0
  44. package/plugins/core/lib/querying-writing/compile-schemas.js +14 -0
  45. package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
  46. package/plugins/core/lib/storage/canonical-data-helpers.js +103 -39
  47. package/plugins/core/lib/storage/many-row-data-helpers.js +738 -0
  48. package/plugins/core/lib/storage/ordinary-data-helpers.js +63 -79
  49. package/plugins/core/lib/storage/single-row-writes.js +71 -0
  50. package/plugins/core/lib/storage/storage-adapter.js +18 -8
  51. package/plugins/core/lib/storage/storage-mapping.js +4 -1
  52. package/plugins/core/lib/storage/storage-types.d.ts +21 -3
  53. package/plugins/core/lib/writing/many-to-many-manipulations.js +10 -5
  54. package/plugins/core/lib/writing/relationship-processor.js +33 -37
  55. package/plugins/core/rest-api-anyapi-knex-plugin.js +3 -0
  56. package/plugins/core/rest-api-autofilter-plugin.js +137 -10
  57. package/plugins/core/rest-api-knex-plugin.js +2 -0
  58. package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
  59. package/plugins/core/rest-api-plugin-methods/common.js +144 -84
  60. package/plugins/core/rest-api-plugin-methods/delete-many.js +132 -0
  61. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +22 -0
  62. package/plugins/core/rest-api-plugin-methods/get.js +48 -42
  63. package/plugins/core/rest-api-plugin-methods/many-row-common.js +274 -0
  64. package/plugins/core/rest-api-plugin-methods/patch-many.js +333 -0
  65. package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
  66. package/plugins/core/rest-api-plugin-methods/post-many.js +295 -0
  67. package/plugins/core/rest-api-plugin-methods/put.js +5 -1
  68. package/plugins/core/rest-api-plugin-methods/scoped-reads.js +221 -0
  69. package/plugins/core/rest-api-plugin.js +17 -0
  70. package/plugins/core/rest-api-positioning-plugin.js +22 -4
  71. package/plugins/core/rest-api-query-projections-plugin.js +9 -1
  72. package/plugins/core/rest-api-row-policy-plugin.js +4 -0
  73. package/plugins/core/socketio-plugin.js +80 -2
  74. package/types/autofilter.d.ts +5 -1
  75. package/types/data-access-methods.d.ts +141 -0
  76. package/types/errors.d.ts +45 -0
  77. package/types/hook-context.d.ts +21 -0
  78. package/types/plugin-options.d.ts +3 -1
  79. package/types/resource-methods.d.ts +27 -4
  80. package/types/row-policy.d.ts +3 -0
  81. package/types/runtime.d.ts +7 -1
  82. package/docs/GUIDE/27-bulk-operations.md +0 -277
  83. package/types/bulk-methods.d.ts +0 -63
@@ -9,6 +9,7 @@ import { toJsonApiRecordWithBelongsTo } from '../querying-writing/knex-json-api-
9
9
  import { processIncludes } from '../querying/knex-process-includes.js'
10
10
  import { loadRelationshipIdentifiers } from '../querying/relationship-identifiers.js'
11
11
  import { applyQueryConstraint } 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,21 @@ 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
+
25
+ /**
26
+ * Every update advances the resource's lock version by one, whatever the input says.
27
+ * @param {StorageRow} row
28
+ * @param {{ schemaInfo: { lockVersionField?: string } }} context
29
+ * @param {import('./storage-types.js').StorageAdapter} storageAdapter
30
+ * @param {import('./storage-types.js').StorageDatabase} db
31
+ */
32
+ function advanceLockVersion (row, context, storageAdapter, db) {
33
+ const field = context.schemaInfo.lockVersionField
34
+ if (!field) return
35
+ const column = storageAdapter.translateColumn(field)
36
+ row[column] = db.raw('?? + 1', [column])
37
+ }
22
38
 
23
39
  /** @param {OrdinaryDataDependencies} dependencies */
24
40
  export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeStorageAdapter: findStorageAdapter }) {
@@ -178,7 +194,7 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
178
194
  }
179
195
 
180
196
  /** @type {OrdinaryDataReadHelpers['dataGet']} */
181
- const dataGet = async ({ scopeName, context }) => {
197
+ const dataGet = async ({ scopeName, context, runHooks, scoped = false }) => {
182
198
  const storageAdapter = getScopeStorageAdapter(scopeName)
183
199
  context.storageAdapter = storageAdapter
184
200
  const scope = getScope(scopeName)
@@ -193,15 +209,32 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
193
209
  // Enrichment removes fetched dependencies that the caller did not request.
194
210
  context.computedDependencies = fieldSelectionInfo.computedDependencies
195
211
 
196
- // The preceding minimal read has already applied permission and row filters.
197
- let query = db(tableName).where(idProperty, id)
212
+ // The preceding minimal read has already applied permission and row filters,
213
+ // unless the read is `scoped`: then it applies them itself, as the only read.
214
+ let query = db(tableName).where(scoped ? `${tableName}.${idProperty}` : idProperty, id)
215
+ if (scoped) {
216
+ const filterHooks = runHooks
217
+ if (!filterHooks) throw new Error('A scoped read needs runHooks')
218
+ query = (await withQueryFilteringContext(context, {
219
+ query,
220
+ filters: context.queryParams?.filters,
221
+ schemaInfo: context.schemaInfo,
222
+ scopeName,
223
+ tableName,
224
+ db,
225
+ queryPurpose: 'single',
226
+ adapter: storageAdapter,
227
+ storageAdapter
228
+ }, () => filterHooks('knexQueryFiltering'))).query
229
+ }
198
230
 
199
231
  const selectionState = await applyFieldSelectionToQuery({
200
232
  query,
201
233
  scope,
202
234
  fieldSelectionInfo,
203
235
  tableName,
204
- useTablePrefix: false,
236
+ // A row policy may join tables, so a scoped read names its columns' table.
237
+ useTablePrefix: scoped,
205
238
  storageAdapter,
206
239
  db,
207
240
  context,
@@ -236,7 +269,7 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
236
269
  return buildJsonApiResponse(scope, records, included, true, scopeName, context)
237
270
  }
238
271
 
239
- // Supplying ids returns minimal records for the caller's bounded validation batch.
272
+ // Supplying ids returns minimal records for all of them, in one statement.
240
273
  /** @overload @param {DataMinimalRequest & { ids: Array<string | number> }} request @returns {Promise<DataResource[]>} */
241
274
  /** @overload @param {DataMinimalRequest & { ids?: undefined, context: DataReadContext & {id: string | number} }} request @returns {Promise<DataResource | null>} */
242
275
  /** @param {DataMinimalRequest & { ids?: Array<string | number> }} request */
@@ -259,11 +292,12 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
259
292
 
260
293
  log.debug(`[Knex] GET_MINIMAL ${tableName}/${id}`)
261
294
 
262
- // Knex types exclude unknown bindings; the configured driver still validates serializer results.
295
+ const table = db(tableName)
296
+ // whereInIdentifiers returns the builder it was given, which modify's types lose.
263
297
  let query = ids === undefined
264
- ? db(tableName).where(idProperty, id)
265
- : db(tableName).whereIn(idProperty, /** @type {import('knex').Knex.Value[]} */ (ids.map(value => storageAdapter.translateFilterValue('id', value))))
266
- .distinct(`${tableName}.${idProperty}`)
298
+ ? table.where(idProperty, id)
299
+ : /** @type {typeof table} */ (table.modify(whereInIdentifiers, idProperty, ids.map(value => storageAdapter.translateFilterValue('id', value))))
300
+ .distinct(`${tableName}.${idProperty}`)
267
301
 
268
302
  if (typeof applyQueryFilters === 'function') {
269
303
  const scopedQueryState = await applyQueryFilters({
@@ -299,7 +333,7 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
299
333
  return [field === 'id' ? 'id' : column, databaseIdentityExpression(db, `${tableName}.${column}`)]
300
334
  }))
301
335
  if (ids !== undefined) {
302
- // Filtering owns predicates; the validation batch owns its identity projection.
336
+ // Filtering owns predicates; the id list owns its identity projection.
303
337
  query.clearSelect().distinct(`${tableName}.${idProperty}`)
304
338
  // Deduplicate policy joins by ID, without comparing JSON or other row values.
305
339
  /** @type {StorageRow[]} */
@@ -433,12 +467,10 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
433
467
  // Calculate pagination metadata with total
434
468
  context.returnMeta.paginationMeta = calculatePaginationMeta(total, page, pageSize)
435
469
  } else {
436
- // Without count, we can still provide basic pagination info
437
- context.returnMeta.paginationMeta = {
438
- page,
439
- pageSize
440
- // No total, pageCount, or hasMore when counts are disabled
441
- }
470
+ // Without a count, the page's one extra row answers hasMore.
471
+ const hasMore = records.length > pageSize
472
+ if (hasMore) records.pop()
473
+ context.returnMeta.paginationMeta = { page, pageSize, hasMore }
442
474
  }
443
475
 
444
476
  // Generate links
@@ -609,28 +641,11 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
609
641
  return
610
642
  }
611
643
 
612
- const exists = await db(tableName)
613
- .where(idProperty, id)
614
- .first()
615
-
616
- if (!exists) {
617
- throw new RestApiResourceError(
618
- 'Resource not found',
619
- {
620
- subtype: ERROR_SUBTYPES.NOT_FOUND,
621
- resourceType: scopeName,
622
- resourceId: id
623
- }
624
- )
625
- }
626
-
627
644
  // Updates cannot replace the primary key.
628
645
  delete finalAttributes[idProperty]
629
- if (Object.keys(finalAttributes).length > 0) {
630
- await db(tableName)
631
- .where(idProperty, id)
632
- .update(finalAttributes)
633
- }
646
+ advanceLockVersion(finalAttributes, context, storageAdapter, db)
647
+ const row = foundRow({ byId: () => db(tableName).where(`${tableName}.${idProperty}`, id), context, scopeName, storageAdapter, tableName })
648
+ await updateFoundRow(row, { db, scopeName, id, idColumn: `${tableName}.${idProperty}`, values: finalAttributes })
634
649
  }
635
650
 
636
651
  /** @type {OrdinaryDataWriteHelpers['dataPatch']} */
@@ -645,22 +660,6 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
645
660
 
646
661
  log.debug(`[Knex] PATCH ${tableName}/${id}`)
647
662
 
648
- // Check if record exists
649
- const exists = await db(tableName)
650
- .where(idProperty, id)
651
- .first()
652
-
653
- if (!exists) {
654
- throw new RestApiResourceError(
655
- 'Resource not found',
656
- {
657
- subtype: ERROR_SUBTYPES.NOT_FOUND,
658
- resourceType: scopeName,
659
- resourceId: id
660
- }
661
- )
662
- }
663
-
664
663
  // Core has already merged relationship values and applied setters.
665
664
  const attributes = inputRecord.data.attributes || {}
666
665
 
@@ -674,13 +673,10 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
674
673
 
675
674
  // Remove the idProperty from attributes to prevent updating the primary key
676
675
  delete finalAttributes[idProperty]
676
+ advanceLockVersion(finalAttributes, context, storageAdapter, db)
677
677
 
678
- // Update only if there are changes
679
- if (Object.keys(finalAttributes).length > 0) {
680
- await db(tableName)
681
- .where(idProperty, id)
682
- .update(finalAttributes)
683
- }
678
+ const row = foundRow({ byId: () => db(tableName).where(`${tableName}.${idProperty}`, id), context, scopeName, storageAdapter, tableName })
679
+ await updateFoundRow(row, { db, scopeName, id, idColumn: `${tableName}.${idProperty}`, values: finalAttributes })
684
680
  }
685
681
 
686
682
  /** @type {OrdinaryDataWriteHelpers['dataDelete']} */
@@ -695,36 +691,24 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
695
691
 
696
692
  log.debug(`[Knex] DELETE ${tableName}/${id}`)
697
693
 
698
- // Check if record exists
699
- const exists = await db(tableName)
700
- .where(idProperty, id)
701
- .first()
702
-
703
- if (!exists) {
704
- throw new RestApiResourceError(
705
- 'Resource not found',
706
- {
707
- subtype: ERROR_SUBTYPES.NOT_FOUND,
708
- resourceType: scopeName,
709
- resourceId: id
710
- }
711
- )
712
- }
713
-
714
- // Delete the record
715
- await invalidateDeletedPivotTargets(api, scopeName, context)
716
- await db(tableName)
717
- .where(idProperty, id)
718
- .delete()
694
+ const row = foundRow({ byId: () => db(tableName).where(`${tableName}.${idProperty}`, id), context, scopeName, storageAdapter, tableName })
695
+ // Before the DELETE: an ON DELETE CASCADE would remove the links that tell which parents to advance.
696
+ await invalidateDeletedPivotTargets(api, scopeName, context, () => lockFoundRow(row, { scopeName, id, idColumn: `${tableName}.${idProperty}` }))
697
+ await deleteFoundRow(row, { scopeName, id })
719
698
  await deletePivotReferences(api, scopeName, context)
720
699
 
721
700
  return { success: true }
722
701
  }
723
702
 
703
+ // A get with no reader of the minimal record reads once, scoped.
704
+ /** @type {NonNullable<OrdinaryDataReadHelpers['dataGetScoped']>} */
705
+ const dataGetScoped = ({ scopeName, context, runHooks }) => dataGet({ scopeName, context, runHooks, scoped: true })
706
+
724
707
  return {
725
708
  dataRelatedIdsQuery,
726
709
  dataExists,
727
710
  dataGet,
711
+ dataGetScoped,
728
712
  dataQuery,
729
713
  dataAggregate,
730
714
  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,
@@ -122,6 +122,8 @@ export interface StorageSchemaInfo {
122
122
  storage?: StorageConfig
123
123
  storageInfo?: StorageInfo
124
124
  descriptor?: CanonicalDescriptor
125
+ // A stored integer every library update advances by one.
126
+ lockVersionField?: string
125
127
  }
126
128
 
127
129
  export interface ResourceConversionSchema extends StorageSchemaInfo {
@@ -304,6 +306,13 @@ export interface DataReadHelpers<GetResult> {
304
306
  dataGetMinimal: DataMinimalReader
305
307
  dataGet: (request: DataReadRequest & {
306
308
  context: DataReadContext & { id: string | number }
309
+ // Apply the row filters in this read, when no minimal read preceded it.
310
+ scoped?: boolean
311
+ }) => Promise<GetResult>
312
+ // dataGet({ scoped: true }), for storages that support it.
313
+ dataGetScoped?: (request: DataReadRequest & {
314
+ runHooks: NonNullable<DataReadRequest['runHooks']>
315
+ context: DataReadContext & { id: string | number }
307
316
  }) => Promise<GetResult>
308
317
  dataQuery: (request: DataReadRequest & {
309
318
  runHooks: NonNullable<DataReadRequest['runHooks']>
@@ -324,8 +333,17 @@ export interface DataReadHelpers<GetResult> {
324
333
  // Compiled by compileAggregateRequest: outputs carry SQL aliases.
325
334
  export interface AggregatePlan {
326
335
  scopeName: string
327
- groups: Array<{ name: string; field: string; definition: StorageFieldDefinition; alias: string }>
328
- measures: Array<{ name: string; fn: 'count' | 'countDistinct' | 'sum' | 'avg' | 'min' | 'max'; field: string; definition: StorageFieldDefinition | null; alias: string }>
336
+ // Buckets carry their period and zone; their SQL is written per dialect.
337
+ groups: Array<{
338
+ name: string; field: string; definition: StorageFieldDefinition; alias: string
339
+ bucket?: 'day' | 'week' | 'month' | 'year'
340
+ timezone?: { kind: 'utc' } | { kind: 'offset'; minutes: number } | { kind: 'named'; name: string }
341
+ }>
342
+ // where: the conditions of a conditional measure, ANDed inside a CASE.
343
+ measures: Array<{
344
+ name: string; fn: 'count' | 'countDistinct' | 'sum' | 'avg' | 'min' | 'max'; field: string; definition: StorageFieldDefinition | null; alias: string
345
+ where: Array<{ field: string; definition: StorageFieldDefinition; operator: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'notIn'; value: unknown }> | null
346
+ }>
329
347
  sort: Array<{ alias: string; direction: 'asc' | 'desc' }>
330
348
  page: { size: number; number: number } | null
331
349
  }
@@ -506,7 +524,7 @@ export interface CanonicalDataDependencies {
506
524
  getScopeStorageAdapter(scopeName: string): StorageAdapter | null
507
525
  getDescriptor(scopeName: string): CanonicalDescriptor
508
526
  linkStore: {
509
- invalidateDeletedLinkTargets(scopeName: string, context: DataIdentityContext): Promise<void>
527
+ invalidateDeletedLinkTargets(scopeName: string, context: DataIdentityContext, lockRow: () => Promise<void>): Promise<void>
510
528
  deleteResourceLinks(request: { descriptor: CanonicalDescriptor, scopeName: string, id: string | number, db: StorageDatabase }): Promise<void>
511
529
  }
512
530
  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)
@@ -26,7 +29,8 @@ export async function invalidateDeletedPivotTargets (api, scopeName, context) {
26
29
  }
27
30
  }
28
31
 
29
- export async function deletePivotReferences (api, scopeName, context) {
32
+ // Removes the pivot rows of deleted records: context.id, or every id in `ids`.
33
+ export async function deletePivotReferences (api, scopeName, context, ids = [context.id]) {
30
34
  const targets = new Map()
31
35
  for (const [ownerType, owner] of Object.entries(api.resources)) {
32
36
  for (const relationship of Object.values(owner.vars.schemaInfo?.schemaRelationships || {})) {
@@ -37,9 +41,10 @@ export async function deletePivotReferences (api, scopeName, context) {
37
41
  }
38
42
  }
39
43
  }
40
- const id = api.knex.helpers.getStorageAdapter(scopeName).translateFilterValue('id', context.id)
44
+ const adapter = api.knex.helpers.getStorageAdapter(scopeName)
45
+ const values = ids.map(id => adapter.translateFilterValue('id', id))
41
46
  const db = context.transaction || context.db || api.knex.instance
42
- for (const { tableName, column } of targets.values()) await db(tableName).where(column, id).delete()
47
+ for (const { tableName, column } of targets.values()) await db(tableName).whereIn(column, values).delete()
43
48
  }
44
49
 
45
50
  export async function invalidateRemovedPivotVersions (api, relDef, query, transaction) {
@@ -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.
@@ -312,6 +312,9 @@ export const RestApiAnyapiKnexPlugin = {
312
312
 
313
313
  addHook('resource:added', 'anyapi-register-resource', {}, async ({ context }) => {
314
314
  const { scopeName, scopeOptions = {} } = context
315
+ if (scopeOptions.lockVersionField !== undefined) {
316
+ throw new Error(`lockVersionField in '${scopeName}' needs table storage; AnyAPI resources do not support it`)
317
+ }
315
318
  const scope = api.resources[scopeName]
316
319
  const declaration = snapshotResourceConfiguration(scopeOptions)
317
320
  declarations.set(scopeName, declaration)