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
@@ -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
@@ -699,13 +725,51 @@ export function createCanonicalDataHelpers ({
699
725
  return runAggregateQuery({ source: filteredState.query, sourceTable: tableAlias, storageAdapter, db, plan })
700
726
  }
701
727
 
728
+ /**
729
+ * The canonical rows of one resource with ownership and row policy applied.
730
+ * Many-row writes and row locks are table-storage features; AnyAPI resources
731
+ * get the read-only builder only. Resolves to `{ query, alias }`.
732
+ * @param {{ scopeName: string, context: DataReadContext, runHooks: (name: 'knexQueryFiltering') => unknown, alias?: string, purpose: string, filters?: Record<string, unknown> }} request
733
+ */
734
+ const dataScopedQuery = async ({ scopeName, context, runHooks, alias, purpose, filters }) => {
735
+ const storageAdapter = getScopeStorageAdapter(scopeName)
736
+ if (!storageAdapter) throw new Error(`Storage adapter for resource '${scopeName}' not found`)
737
+ context.storageAdapter = storageAdapter
738
+ const descriptor = getDescriptor(scopeName)
739
+ const db = context.db || context.transaction || api.knex.instance
740
+ const tableAlias = alias || context.schemaInfo.tableName || descriptor.resource
741
+ const filteredState = await withQueryFilteringContext(context, {
742
+ query: storageAdapter.buildBaseQuery({ transaction: db, tableAlias }),
743
+ filters,
744
+ schemaInfo: context.schemaInfo,
745
+ scopeName,
746
+ tableName: tableAlias,
747
+ db,
748
+ queryPurpose: purpose,
749
+ isAnyApi: true,
750
+ adapter: storageAdapter,
751
+ storageAdapter,
752
+ }, async () => {
753
+ await applyBuiltInAnyApiQueryFilters(context)
754
+ await runHooks('knexQueryFiltering')
755
+ })
756
+ // Wrapped: a builder is thenable, so an async function cannot return it bare.
757
+ return { query: filteredState.query, alias: tableAlias }
758
+ }
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
+
702
764
  return {
765
+ dataScopedQuery,
703
766
  dataExists,
704
767
  dataPost,
705
768
  dataPut,
706
769
  dataPatch,
707
770
  dataDelete,
708
771
  dataGet,
772
+ dataGetScoped,
709
773
  dataQuery,
710
774
  dataAggregate,
711
775
  dataGetMinimal