json-rest-api 3.0.0-rc.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 (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 +195 -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 +47 -79
  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 +8 -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 +47 -5
  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 +139 -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
@@ -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,7 @@ 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'
22
24
 
23
25
  /**
24
26
  * Every update advances the resource's lock version by one, whatever the input says.
@@ -192,7 +194,7 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
192
194
  }
193
195
 
194
196
  /** @type {OrdinaryDataReadHelpers['dataGet']} */
195
- const dataGet = async ({ scopeName, context }) => {
197
+ const dataGet = async ({ scopeName, context, runHooks, scoped = false }) => {
196
198
  const storageAdapter = getScopeStorageAdapter(scopeName)
197
199
  context.storageAdapter = storageAdapter
198
200
  const scope = getScope(scopeName)
@@ -207,15 +209,32 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
207
209
  // Enrichment removes fetched dependencies that the caller did not request.
208
210
  context.computedDependencies = fieldSelectionInfo.computedDependencies
209
211
 
210
- // The preceding minimal read has already applied permission and row filters.
211
- 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
+ }
212
230
 
213
231
  const selectionState = await applyFieldSelectionToQuery({
214
232
  query,
215
233
  scope,
216
234
  fieldSelectionInfo,
217
235
  tableName,
218
- useTablePrefix: false,
236
+ // A row policy may join tables, so a scoped read names its columns' table.
237
+ useTablePrefix: scoped,
219
238
  storageAdapter,
220
239
  db,
221
240
  context,
@@ -250,7 +269,7 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
250
269
  return buildJsonApiResponse(scope, records, included, true, scopeName, context)
251
270
  }
252
271
 
253
- // 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.
254
273
  /** @overload @param {DataMinimalRequest & { ids: Array<string | number> }} request @returns {Promise<DataResource[]>} */
255
274
  /** @overload @param {DataMinimalRequest & { ids?: undefined, context: DataReadContext & {id: string | number} }} request @returns {Promise<DataResource | null>} */
256
275
  /** @param {DataMinimalRequest & { ids?: Array<string | number> }} request */
@@ -273,11 +292,12 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
273
292
 
274
293
  log.debug(`[Knex] GET_MINIMAL ${tableName}/${id}`)
275
294
 
276
- // 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.
277
297
  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}`)
298
+ ? table.where(idProperty, id)
299
+ : /** @type {typeof table} */ (table.modify(whereInIdentifiers, idProperty, ids.map(value => storageAdapter.translateFilterValue('id', value))))
300
+ .distinct(`${tableName}.${idProperty}`)
281
301
 
282
302
  if (typeof applyQueryFilters === 'function') {
283
303
  const scopedQueryState = await applyQueryFilters({
@@ -313,7 +333,7 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
313
333
  return [field === 'id' ? 'id' : column, databaseIdentityExpression(db, `${tableName}.${column}`)]
314
334
  }))
315
335
  if (ids !== undefined) {
316
- // Filtering owns predicates; the validation batch owns its identity projection.
336
+ // Filtering owns predicates; the id list owns its identity projection.
317
337
  query.clearSelect().distinct(`${tableName}.${idProperty}`)
318
338
  // Deduplicate policy joins by ID, without comparing JSON or other row values.
319
339
  /** @type {StorageRow[]} */
@@ -447,12 +467,10 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
447
467
  // Calculate pagination metadata with total
448
468
  context.returnMeta.paginationMeta = calculatePaginationMeta(total, page, pageSize)
449
469
  } 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
- }
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 }
456
474
  }
457
475
 
458
476
  // Generate links
@@ -623,29 +641,11 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
623
641
  return
624
642
  }
625
643
 
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
644
  // Updates cannot replace the primary key.
642
645
  delete finalAttributes[idProperty]
643
646
  advanceLockVersion(finalAttributes, context, storageAdapter, db)
644
- if (Object.keys(finalAttributes).length > 0) {
645
- await db(tableName)
646
- .where(idProperty, id)
647
- .update(finalAttributes)
648
- }
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 })
649
649
  }
650
650
 
651
651
  /** @type {OrdinaryDataWriteHelpers['dataPatch']} */
@@ -660,22 +660,6 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
660
660
 
661
661
  log.debug(`[Knex] PATCH ${tableName}/${id}`)
662
662
 
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
663
  // Core has already merged relationship values and applied setters.
680
664
  const attributes = inputRecord.data.attributes || {}
681
665
 
@@ -691,12 +675,8 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
691
675
  delete finalAttributes[idProperty]
692
676
  advanceLockVersion(finalAttributes, context, storageAdapter, db)
693
677
 
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
- }
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 })
700
680
  }
701
681
 
702
682
  /** @type {OrdinaryDataWriteHelpers['dataDelete']} */
@@ -711,36 +691,24 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
711
691
 
712
692
  log.debug(`[Knex] DELETE ${tableName}/${id}`)
713
693
 
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()
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 })
735
698
  await deletePivotReferences(api, scopeName, context)
736
699
 
737
700
  return { success: true }
738
701
  }
739
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
+
740
707
  return {
741
708
  dataRelatedIdsQuery,
742
709
  dataExists,
743
710
  dataGet,
711
+ dataGetScoped,
744
712
  dataQuery,
745
713
  dataAggregate,
746
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,
@@ -306,6 +306,13 @@ export interface DataReadHelpers<GetResult> {
306
306
  dataGetMinimal: DataMinimalReader
307
307
  dataGet: (request: DataReadRequest & {
308
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 }
309
316
  }) => Promise<GetResult>
310
317
  dataQuery: (request: DataReadRequest & {
311
318
  runHooks: NonNullable<DataReadRequest['runHooks']>
@@ -517,7 +524,7 @@ export interface CanonicalDataDependencies {
517
524
  getScopeStorageAdapter(scopeName: string): StorageAdapter | null
518
525
  getDescriptor(scopeName: string): CanonicalDescriptor
519
526
  linkStore: {
520
- invalidateDeletedLinkTargets(scopeName: string, context: DataIdentityContext): Promise<void>
527
+ invalidateDeletedLinkTargets(scopeName: string, context: DataIdentityContext, lockRow: () => Promise<void>): Promise<void>
521
528
  deleteResourceLinks(request: { descriptor: CanonicalDescriptor, scopeName: string, id: string | number, db: StorageDatabase }): Promise<void>
522
529
  }
523
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)
@@ -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.
@@ -2,6 +2,7 @@ import { RestApiResourceError, RestApiValidationError } from '../../lib/rest-api
2
2
  import { assertGenuineSystemFlag, isSystemContext } from '../../lib/system-context.js'
3
3
  import { snapshotResourceConfiguration } from './lib/querying-writing/schema-helpers.js'
4
4
  import { createStorageAdapterUtilities } from './lib/querying/storage-adapter-utils.js'
5
+ import { ANY_OWNER, recordWriteCondition } from './lib/querying/query-constraint.js'
5
6
 
6
7
  const PUBLIC_PRESET_NAME = 'public'
7
8
  // Queries that write or lock the rows they select: shared rows are never theirs.
@@ -286,6 +287,13 @@ function refuseSharedCreate ({ filter, resolvedValue, scopeName }) {
286
287
  })
287
288
  }
288
289
 
290
+ // An optional owner that did not resolve stamps nothing; the row must still not end up shared.
291
+ function refuseSharedResult ({ compiledConfig, record, scopeName }) {
292
+ for (const filter of compiledConfig.filters) {
293
+ if (filter.shared && sharedFieldValue(record, filter) == null) refuseSharedCreate({ filter, resolvedValue: null, scopeName })
294
+ }
295
+ }
296
+
289
297
  function sharedFieldValue (record, filter) {
290
298
  if (filter.relationshipName) {
291
299
  const linkage = record?.relationships?.[filter.relationshipName]?.data
@@ -324,6 +332,7 @@ async function enforceScopedInput ({ compiledConfig, context, scopeName, injectM
324
332
  injectMissing
325
333
  })
326
334
  }
335
+ if (injectMissing) refuseSharedResult({ compiledConfig, record: context.inputRecord?.data, scopeName })
327
336
  }
328
337
 
329
338
  export const AutoFilterPlugin = {
@@ -386,9 +395,18 @@ export const AutoFilterPlugin = {
386
395
  log
387
396
  })
388
397
 
389
- if (resolvedValue === undefined) continue
390
-
391
398
  const columnRef = adapterUtils.translateColumn(scopeName, filter.field, tableName)
399
+ // Every scoped read records the owner a later write of its rows must
400
+ // still match, so a single write that reads first carries it too.
401
+ if (resolvedValue === undefined) {
402
+ // An optional owner that did not resolve: reads are not narrowed, but
403
+ // shared rows stay out of writes and locks.
404
+ if (filter.shared) {
405
+ if (writes) query.whereNotNull(columnRef)
406
+ recordWriteCondition(context, scopeName, filter.field, ANY_OWNER)
407
+ }
408
+ continue
409
+ }
392
410
  const translatedValue = adapterUtils.translateFilterValue(scopeName, filter.field, resolvedValue)
393
411
 
394
412
  if (filter.shared) {
@@ -396,15 +414,15 @@ export const AutoFilterPlugin = {
396
414
  if (translatedValue === null) {
397
415
  if (writes) query.whereRaw('1 = 0')
398
416
  else query.whereNull(columnRef)
399
- } else if (writes) {
400
- query.where(columnRef, translatedValue)
401
417
  } else {
402
- query.where(builder => builder.where(columnRef, translatedValue).orWhereNull(columnRef))
418
+ if (writes) query.where(columnRef, translatedValue)
419
+ else query.where(builder => builder.where(columnRef, translatedValue).orWhereNull(columnRef))
420
+ recordWriteCondition(context, scopeName, filter.field, translatedValue)
403
421
  }
404
- } else if (translatedValue === null) {
405
- query.whereNull(columnRef)
406
422
  } else {
407
- query.where(columnRef, translatedValue)
423
+ if (translatedValue === null) query.whereNull(columnRef)
424
+ else query.where(columnRef, translatedValue)
425
+ recordWriteCondition(context, scopeName, filter.field, translatedValue)
408
426
  }
409
427
  }
410
428
  })
@@ -468,6 +486,7 @@ export const AutoFilterPlugin = {
468
486
  applyResolvedInputValue({ context: { inputRecord }, filter, resolvedValue, injectMissing: true })
469
487
  }
470
488
  }
489
+ for (const inputRecord of context.inputRecords) refuseSharedResult({ compiledConfig, record: inputRecord?.data, scopeName })
471
490
  })
472
491
 
473
492
  // Shared rows are readable by every owner but writable only in a system context.
@@ -486,6 +505,9 @@ export const AutoFilterPlugin = {
486
505
  addHook('beforeDataCallPatch', 'autofilter-shared-row-patch', { batch: true }, refuseSharedRowWrite)
487
506
  addHook('beforeDataCallPut', 'autofilter-shared-row-put', { batch: true }, refuseSharedRowWrite)
488
507
  addHook('beforeDataCallDelete', 'autofilter-shared-row-delete', { batch: true }, refuseSharedRowWrite)
508
+ // Links of a shared row belong to every owner that sees it.
509
+ addHook('beforeDataCallPostRelationship', 'autofilter-shared-row-post-relationship', {}, refuseSharedRowWrite)
510
+ addHook('beforeDataCallDeleteRelationship', 'autofilter-shared-row-delete-relationship', {}, refuseSharedRowWrite)
489
511
 
490
512
  api.autofilter = {
491
513
  getConfig: () => ({
@@ -1,6 +1,7 @@
1
1
  import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
2
2
  import { getEffectiveSortableFields } from '../lib/querying/query-field-sort-helpers.js'
3
3
  import { compileAggregateRequest } from '../lib/querying/aggregate-query.js'
4
+ import { isSystemContext } from '../../../lib/system-context.js'
4
5
 
5
6
  /**
6
7
  * Group a filtered collection and compute measures for each group.
@@ -18,7 +19,8 @@ export default async function aggregateMethod ({ params, context, vars, helpers,
18
19
  schemaInfo: context.schemaInfo,
19
20
  scopeName,
20
21
  defaultLimit: vars.queryDefaultLimit,
21
- maxLimit: vars.queryMaxLimit
22
+ maxLimit: vars.queryMaxLimit,
23
+ system: isSystemContext(context)
22
24
  })
23
25
  const requestContracts = getRequestContracts({
24
26
  scopeName,