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
@@ -188,11 +188,9 @@ export const applyScopeFiltersToIncludeQuery = async ({
188
188
  }
189
189
  }
190
190
 
191
- // Linked ids checked per visibility query: a 1,000-row page costs one query and
192
- // one row-policy run per linked type, and any number of ids stays bounded SQL.
193
- const VISIBILITY_BATCH_SIZE = 1000
194
-
195
- export const filterVisibleIdentifiers = async ({ identifiers, scopes, knex, context }) => {
191
+ // One query and one row-policy run per linked type, however many ids are linked.
192
+ // With `scoped: false`, the identifiers whose records exist, visible or not.
193
+ export const filterVisibleIdentifiers = async ({ identifiers, scopes, knex, context, scoped = true }) => {
196
194
  const idsByType = new Map()
197
195
  for (const { type, id } of identifiers) {
198
196
  if (!idsByType.has(type)) idsByType.set(type, new Set())
@@ -209,12 +207,12 @@ export const filterVisibleIdentifiers = async ({ identifiers, scopes, knex, cont
209
207
  const visibleIds = new Set()
210
208
  visible.set(type, visibleIds)
211
209
  const values = [...ids].map(id => adapter.translateFilterValue('id', id))
212
- for (let start = 0; start < values.length; start += VISIBILITY_BATCH_SIZE) {
213
- // whereInIdentifiers binds a long id list as one parameter where the database allows it.
214
- const query = adapter.buildBaseQuery({ transaction: context.transaction })
215
- .modify(whereInIdentifiers, column, values.slice(start, start + VISIBILITY_BATCH_SIZE))
216
- .distinct({ [idColumn]: adapter.isCanonical() ? column : databaseIdentityExpression(knex, column) })
217
- const state = await applyScopeFiltersToIncludeQuery({
210
+ // whereInIdentifiers binds a long id list as one parameter where the database limits parameters.
211
+ const query = adapter.buildBaseQuery({ transaction: context.transaction })
212
+ .modify(whereInIdentifiers, column, values)
213
+ .distinct({ [idColumn]: adapter.isCanonical() ? column : databaseIdentityExpression(knex, column) })
214
+ const state = scoped
215
+ ? await applyScopeFiltersToIncludeQuery({
218
216
  query,
219
217
  scopes,
220
218
  scopeName: type,
@@ -223,8 +221,8 @@ export const filterVisibleIdentifiers = async ({ identifiers, scopes, knex, cont
223
221
  tableName,
224
222
  queryPurpose: 'relationship-identifiers'
225
223
  })
226
- for (const row of await state.query) visibleIds.add(String(row[idColumn]))
227
- }
224
+ : { query: applyDatabaseReadOptions(query) }
225
+ for (const row of await state.query) visibleIds.add(String(row[idColumn]))
228
226
  }
229
227
  return identifiers.filter(({ type, id }) => visible.get(type)?.has(String(id)))
230
228
  }
@@ -6,7 +6,6 @@ import {
6
6
  RELATIONSHIPS_KEY,
7
7
  RELATIONSHIP_METADATA_KEY,
8
8
  COMPUTED_DEPENDENCIES_KEY,
9
- RELATIONSHIP_READ_BATCH_SIZE,
10
9
  } from '../querying-writing/knex-constants.js'
11
10
  import { getOperationDiagnosticContext, wrapUnexpectedError } from '../../../../lib/error-context.js'
12
11
  import { getPolymorphicLinkage } from '../querying-writing/relationship-contracts.js'
@@ -109,36 +108,34 @@ export const loadBelongsTo = async (scope, deps) => {
109
108
 
110
109
  // Load the target records
111
110
  const targetRecords = []
112
- for (let offset = 0; offset < uniqueIds.length; offset += RELATIONSHIP_READ_BATCH_SIZE) {
113
- let query = knex(targetTableName).whereIn(targetIdColumn, uniqueIds.slice(offset, offset + RELATIONSHIP_READ_BATCH_SIZE))
114
- const selectionState = await applyFieldSelectionToQuery({
115
- query,
116
- scope: targetScopeObject,
117
- fieldSelectionInfo,
118
- tableName: targetTableName,
119
- useTablePrefix: false,
120
- storageAdapter,
121
- db: knex,
122
- context: {
123
- ...(requestContext || {}),
124
- scopeName: targetScope,
125
- schemaInfo: targetScopeObject.vars.schemaInfo,
126
- storageAdapter,
127
- },
128
- scopeName: targetScope
129
- })
130
- query = selectionState.query
131
- const scopedQueryState = await applyScopeFiltersToIncludeQuery({
132
- query,
133
- scopes,
111
+ let query = knex(targetTableName).modify(whereInIdentifiers, targetIdColumn, uniqueIds)
112
+ const selectionState = await applyFieldSelectionToQuery({
113
+ query,
114
+ scope: targetScopeObject,
115
+ fieldSelectionInfo,
116
+ tableName: targetTableName,
117
+ useTablePrefix: false,
118
+ storageAdapter,
119
+ db: knex,
120
+ context: {
121
+ ...(requestContext || {}),
134
122
  scopeName: targetScope,
135
- requestContext,
136
- db: knex,
137
- tableName: targetTableName
138
- })
139
- query = scopedQueryState.query
140
- for (const record of await query) targetRecords.push(record)
141
- }
123
+ schemaInfo: targetScopeObject.vars.schemaInfo,
124
+ storageAdapter,
125
+ },
126
+ scopeName: targetScope
127
+ })
128
+ query = selectionState.query
129
+ const scopedQueryState = await applyScopeFiltersToIncludeQuery({
130
+ query,
131
+ scopes,
132
+ scopeName: targetScope,
133
+ requestContext,
134
+ db: knex,
135
+ tableName: targetTableName
136
+ })
137
+ query = scopedQueryState.query
138
+ for (const record of await query) targetRecords.push(record)
142
139
 
143
140
  // Load relationship metadata for all target records
144
141
  await loadRelationshipMetadata(scopes, targetRecords, targetScope)
@@ -450,36 +447,34 @@ export const loadPolymorphicBelongsTo = async (scope, deps) => {
450
447
  // Query for this type
451
448
  const targetIdColumn = getIdColumnForScope(scopes, targetType)
452
449
  const targetRecords = []
453
- for (let offset = 0; offset < targetIds.length; offset += RELATIONSHIP_READ_BATCH_SIZE) {
454
- let query = knex(targetTable).whereIn(targetIdColumn, targetIds.slice(offset, offset + RELATIONSHIP_READ_BATCH_SIZE))
455
- const selectionState = await applyFieldSelectionToQuery({
456
- query,
457
- scope: targetScopeObject,
458
- fieldSelectionInfo,
459
- tableName: targetTable,
460
- useTablePrefix: false,
461
- storageAdapter,
462
- db: knex,
463
- context: {
464
- ...(requestContext || {}),
465
- scopeName: targetType,
466
- schemaInfo: targetScopeObject.vars.schemaInfo,
467
- storageAdapter,
468
- },
469
- scopeName: targetType
470
- })
471
- query = selectionState.query
472
- const scopedQueryState = await applyScopeFiltersToIncludeQuery({
473
- query,
474
- scopes,
450
+ let query = knex(targetTable).modify(whereInIdentifiers, targetIdColumn, targetIds)
451
+ const selectionState = await applyFieldSelectionToQuery({
452
+ query,
453
+ scope: targetScopeObject,
454
+ fieldSelectionInfo,
455
+ tableName: targetTable,
456
+ useTablePrefix: false,
457
+ storageAdapter,
458
+ db: knex,
459
+ context: {
460
+ ...(requestContext || {}),
475
461
  scopeName: targetType,
476
- requestContext,
477
- db: knex,
478
- tableName: targetTable
479
- })
480
- query = scopedQueryState.query
481
- for (const record of await query) targetRecords.push(record)
482
- }
462
+ schemaInfo: targetScopeObject.vars.schemaInfo,
463
+ storageAdapter,
464
+ },
465
+ scopeName: targetType
466
+ })
467
+ query = selectionState.query
468
+ const scopedQueryState = await applyScopeFiltersToIncludeQuery({
469
+ query,
470
+ scopes,
471
+ scopeName: targetType,
472
+ requestContext,
473
+ db: knex,
474
+ tableName: targetTable
475
+ })
476
+ query = scopedQueryState.query
477
+ for (const record of await query) targetRecords.push(record)
483
478
 
484
479
  // Load relationship metadata for all target records
485
480
  await loadRelationshipMetadata(scopes, targetRecords, targetType)
@@ -41,7 +41,9 @@ export const applyPaginationToQuery = ({ query, page = {}, vars, pageSizeLimit,
41
41
  ))
42
42
  }
43
43
 
44
- query.limit(pageSize + (mode === 'cursor' ? 1 : 0))
44
+ // One extra row tells whether another page follows: always for cursors, and
45
+ // for numbered pages when no count query answers it.
46
+ query.limit(pageSize + (mode === 'cursor' || (mode === 'offset' && !vars.enablePaginationCounts) ? 1 : 0))
45
47
  if (mode === 'offset') query.offset((pageNumber - 1) * pageSize)
46
48
  return { mode, page: pageNumber, pageSize, before }
47
49
  }
@@ -89,7 +91,7 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
89
91
  // Allow empty urlPrefix to generate relative links
90
92
  // if (!urlPrefix) return null;
91
93
 
92
- const { page, pageCount, pageSize } = paginationMeta
94
+ const { page, pageCount, pageSize, hasMore } = paginationMeta
93
95
  const links = {}
94
96
 
95
97
  const baseUrl = `${urlPrefix}/${scopeName}`
@@ -107,6 +109,11 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
107
109
  if (page < pageCount) {
108
110
  links.next = buildJsonApiLink(baseUrl, queryParams, { number: page + 1, size: pageSize })
109
111
  }
112
+ } else if (hasMore !== undefined) {
113
+ // Without counts there is no last page, but the extra row tells about the next one.
114
+ links.first = buildJsonApiLink(baseUrl, queryParams, { number: 1, size: pageSize })
115
+ if (page > 1) links.prev = buildJsonApiLink(baseUrl, queryParams, { number: page - 1, size: pageSize })
116
+ if (hasMore) links.next = buildJsonApiLink(baseUrl, queryParams, { number: page + 1, size: pageSize })
110
117
  }
111
118
 
112
119
  return links
@@ -10,3 +10,14 @@ export declare function applyQueryConstraint(options: {
10
10
  storageAdapter: StorageAdapter
11
11
  tableName: string
12
12
  }): void
13
+
14
+ export declare const writeConditions: unique symbol
15
+
16
+ /** Repeats the ownership conditions the scoped reads of `scopeName` recorded on `context`. */
17
+ export declare function applyWriteConditions<Query extends StorageQuery>(options: {
18
+ query: Query
19
+ context: { [writeConditions]?: Record<string, Record<string, unknown>> }
20
+ scopeName: string
21
+ storageAdapter: StorageAdapter
22
+ tableName: string
23
+ }): Query
@@ -25,3 +25,48 @@ export function applyQueryConstraint ({ query, context, scopeName, storageAdapte
25
25
  })
26
26
  }
27
27
  }
28
+
29
+ // Ownership values a scope filter applied to a read, by resource and field: the
30
+ // owner a write of the rows read must still match. The by-id UPDATE, DELETE and
31
+ // lock statements, single or many-row, repeat them as plain column predicates,
32
+ // so a row that moved to another owner after the read is neither written nor
33
+ // locked.
34
+ export const writeConditions = Symbol('writeConditions')
35
+ // A condition value meaning "any owner but not the shared (NULL) rows".
36
+ export const ANY_OWNER = Symbol('anyOwner')
37
+
38
+ /**
39
+ * @param {{ [writeConditions]?: Record<string, Record<string, unknown>> }} context
40
+ * @param {string} scopeName
41
+ * @param {string} field
42
+ * @param {unknown} value
43
+ */
44
+ export function recordWriteCondition (context, scopeName, field, value) {
45
+ const byScope = context[writeConditions] ||= {}
46
+ byScope[scopeName] ||= {}
47
+ byScope[scopeName][field] = value
48
+ }
49
+
50
+ /**
51
+ * @param {{ [writeConditions]?: Record<string, Record<string, unknown>> }} context
52
+ * @param {string} scopeName
53
+ */
54
+ export const hasWriteConditions = (context, scopeName) => Object.keys(context[writeConditions]?.[scopeName] || {}).length > 0
55
+
56
+ /**
57
+ * @param {object} options
58
+ * @param {StorageQuery} options.query
59
+ * @param {{ [writeConditions]?: Record<string, Record<string, unknown>> }} options.context
60
+ * @param {string} options.scopeName
61
+ * @param {StorageAdapter} options.storageAdapter
62
+ * @param {string} options.tableName
63
+ */
64
+ export function applyWriteConditions ({ query, context, scopeName, storageAdapter, tableName }) {
65
+ for (const [field, value] of Object.entries(context[writeConditions]?.[scopeName] || {})) {
66
+ const column = `${tableName}.${storageAdapter.translateColumn(field)}`
67
+ if (value === ANY_OWNER) query.whereNotNull(column)
68
+ else if (value === null) query.whereNull(column)
69
+ else query.where(column, value)
70
+ }
71
+ return query
72
+ }
@@ -0,0 +1,92 @@
1
+ // @ts-check
2
+ import { RestApiScopedQueryReadOnlyError } from '../../../../lib/rest-api-errors.js'
3
+
4
+ // Calls that write, lock, change the source table, clear its predicates, or
5
+ // add rows from outside the scope (common table expressions and set operations).
6
+ const FORBIDDEN_METHODS = new Set([
7
+ 'insert', 'update', 'del', 'delete', 'truncate', 'upsert', 'increment', 'decrement',
8
+ 'onConflict', 'merge', 'ignore', 'returning',
9
+ 'forUpdate', 'forShare', 'forNoKeyUpdate', 'forKeyShare', 'skipLocked', 'noWait',
10
+ 'from', 'fromRaw', 'table', 'into', 'withSchema', 'clear', 'clearWhere',
11
+ 'with', 'withRecursive', 'withMaterialized', 'withNotMaterialized', 'withWrapped',
12
+ 'union', 'unionAll', 'intersect', 'except'
13
+ ])
14
+ // The statement kinds a SELECT-only builder may run.
15
+ const READ_METHODS = new Set(['select', 'first', 'pluck'])
16
+
17
+ /** @param {unknown} value */
18
+ const isQueryBuilder = value => value !== null && typeof value === 'object' &&
19
+ typeof /** @type {{ toSQL?: unknown }} */ (value).toSQL === 'function' &&
20
+ typeof /** @type {{ where?: unknown }} */ (value).where === 'function'
21
+
22
+ // Knex's own methods reach the client and replace the statement lists through
23
+ // `this`; code outside them may not. One counter serves every builder because a
24
+ // method of one builder can compile another (a subquery) through its client.
25
+ let insideBuilderMethod = 0
26
+ // The compiled query's state, which only Knex's own methods may replace.
27
+ const BUILDER_STATE = new Set(['client', '_single', '_statements'])
28
+
29
+ // Outside Knex's methods the statement lists read as frozen copies, so code
30
+ // cannot push, splice or edit a predicate in place. The single values (table,
31
+ // limit, offset and the like) read as a plain copy: Knex's SQLite compiler
32
+ // rewrites the copy's limit when it compiles the builder as a subquery, and a
33
+ // change to the copy reaches no query.
34
+ /** @param {unknown} value */
35
+ const stateCopy = value => {
36
+ if (Array.isArray(value)) return Object.freeze(value.map(entry => entry && typeof entry === 'object' ? Object.freeze({ ...entry }) : entry))
37
+ return value && typeof value === 'object' ? { ...value } : value
38
+ }
39
+
40
+ /**
41
+ * A SELECT-only view of a Knex query builder. Reads, joins, subqueries and
42
+ * execution work as usual; writes, row locks, set operations, common table
43
+ * expressions, calls that change the source table or clear its predicates, and
44
+ * access to the Knex client throw RestApiScopedQueryReadOnlyError. Builders it
45
+ * returns, including clones, are read-only too. It guards against mistakes, not
46
+ * hostile code: code that means to bypass it can use Knex directly.
47
+ * @template {object} Builder
48
+ * @param {Builder} builder
49
+ * @param {{ resourceType?: string }} [options]
50
+ * @returns {Builder}
51
+ */
52
+ export function readOnlyQueryBuilder (builder, { resourceType = 'query' } = {}) {
53
+ if (!isQueryBuilder(builder)) throw new TypeError('readOnlyQueryBuilder needs a Knex query builder')
54
+ /** @param {string} method @returns {never} */
55
+ const refuse = method => { throw new RestApiScopedQueryReadOnlyError({ resourceType, method }) }
56
+ /** @type {Builder} */
57
+ const proxy = new Proxy(builder, {
58
+ get (target, property, receiver) {
59
+ if (typeof property === 'string' && FORBIDDEN_METHODS.has(property)) return () => refuse(property)
60
+ if (property === 'client' && !insideBuilderMethod) refuse('client')
61
+ const value = Reflect.get(target, property, receiver)
62
+ if ((property === '_single' || property === '_statements') && !insideBuilderMethod) return stateCopy(value)
63
+ if (typeof value !== 'function' || property === 'constructor') return value
64
+ return /** @this {unknown} */ function (/** @type {unknown[]} */ ...args) {
65
+ insideBuilderMethod++
66
+ let result
67
+ try {
68
+ // Knex methods return `this`, which is the proxy; new builders get wrapped.
69
+ result = value.apply(this === undefined ? receiver : this, args)
70
+ } finally {
71
+ insideBuilderMethod--
72
+ }
73
+ return result !== receiver && result !== target && isQueryBuilder(result)
74
+ ? readOnlyQueryBuilder(result, { resourceType })
75
+ : result
76
+ }
77
+ },
78
+ set (target, property, value) {
79
+ if (property === '_method' && !READ_METHODS.has(value)) refuse(String(value))
80
+ if (property === 'client' || (BUILDER_STATE.has(String(property)) && !insideBuilderMethod)) refuse(`set ${String(property)}`)
81
+ return Reflect.set(target, property, value)
82
+ },
83
+ defineProperty (_target, property) { return refuse(`defineProperty ${String(property)}`) },
84
+ // Deletes follow the rules of set: lodash's isPlainObject, which Knex's MySQL
85
+ // compiler runs on a subquery value, sets and then deletes Symbol.toStringTag.
86
+ deleteProperty (target, property) {
87
+ if (property === 'client' || property === '_method' || (BUILDER_STATE.has(String(property)) && !insideBuilderMethod)) refuse(`delete ${String(property)}`)
88
+ return Reflect.deleteProperty(target, property)
89
+ }
90
+ })
91
+ return proxy
92
+ }
@@ -238,6 +238,19 @@ export async function compileSchemas (scope, deps) {
238
238
  }
239
239
  }
240
240
 
241
+ // An integer every library update advances by one, for compare-and-set writes.
242
+ const lockVersionField = scopeOptions.lockVersionField
243
+ if (lockVersionField !== undefined) {
244
+ const definition = typeof lockVersionField === 'string' && Object.hasOwn(schemaObject.structure, lockVersionField)
245
+ ? schemaObject.structure[lockVersionField]
246
+ : undefined
247
+ if (!lockVersionField || lockVersionField === idProperty || lockVersionField === versionField || !definition ||
248
+ !['integer', 'number'].includes(definition.type) || definition.nullable === true ||
249
+ definition.computed || definition.virtual || definition.belongsTo || definition.getter || definition.setter || definition.storage?.serialize) {
250
+ throw new Error(`lockVersionField in '${scopeName}' must name a stored, non-nullable integer attribute without getters, setters, serializers or relationships`)
251
+ }
252
+ }
253
+
241
254
  // Cache everything
242
255
  const schemaInfo = {
243
256
 
@@ -253,6 +266,7 @@ export async function compileSchemas (scope, deps) {
253
266
  outputFields,
254
267
  outputRelationships,
255
268
  ...(versionField === undefined ? {} : { versionField }),
269
+ ...(lockVersionField === undefined ? {} : { lockVersionField }),
256
270
  tableName: scopeOptions.tableName || scopeName,
257
271
  idProperty,
258
272
  storage,
@@ -207,6 +207,25 @@ export function normalizeDateValue (value, type, {
207
207
  })
208
208
  }
209
209
 
210
+ /**
211
+ * Refuses an object or array as the value of a single column. Drivers do not
212
+ * store one as written, and MySQL drivers that format queries on the client
213
+ * (mysql2 before 3.17, mysql) expand a plain object into `key` = value pairs,
214
+ * which rewrites the statement: a value for one column could set others.
215
+ * Dates and byte arrays (Buffer, Uint8Array) are single column values.
216
+ * @param {unknown} value
217
+ * @param {{ fieldName?: string, parameter?: string }} [options] `parameter` is the input path the error names
218
+ * @returns {void}
219
+ */
220
+ export function assertSingleColumnValue (value, { fieldName, parameter = `data.attributes.${fieldName}` } = {}) {
221
+ if (value === null || typeof value !== 'object' || value instanceof Date || value instanceof Uint8Array) return
222
+ const message = `Field '${fieldName}' takes a single value, not ${Array.isArray(value) ? 'an array' : 'an object'}. Declare it with type 'object' or 'array', or give it a storage serializer.`
223
+ throw new RestApiValidationError(message, {
224
+ fields: [parameter],
225
+ violations: [{ field: parameter, rule: 'single_value', message }]
226
+ })
227
+ }
228
+
210
229
  /**
211
230
  * Converts structured JSON and temporal values to the values expected by
212
231
  * database drivers. JSON schema validation keeps date and dateTime values as
@@ -343,6 +362,19 @@ export function normalizeStructuredValue (value, type, { fieldName, resourceType
343
362
  }
344
363
  }
345
364
 
365
+ // Drivers return BIGINT and some other numeric columns as strings. A value a
366
+ // JavaScript number holds exactly, so that it prints back as the same digits
367
+ // (trailing fraction zeros aside), becomes one; any other, and any integer past
368
+ // 2^53 even when it prints back, keeps its digits as a string. Decimals
369
+ // (precision and scale) keep theirs, to avoid rounding.
370
+ const isDecimalField = definition => definition.precision !== undefined && definition.scale !== undefined
371
+ /** @param {string} value */
372
+ const storedNumber = value => {
373
+ const number = Number(value)
374
+ const digits = (value.includes('.') ? value.replace(/\.?0+$/, '') : value).replace(/^-0$/, '0')
375
+ return Number.isFinite(number) && (!Number.isInteger(number) || Number.isSafeInteger(number)) && String(number) === digits ? number : value
376
+ }
377
+
346
378
  /**
347
379
  * Normalizes database values in an attributes object
348
380
  *
@@ -386,6 +418,13 @@ export function normalizeAttributes (attributes, schemaStructure, {
386
418
  }
387
419
  }
388
420
 
421
+ // Stored values only: an id stays a string, and a getter, computed or
422
+ // virtual field keeps the value its code made.
423
+ if (['number', 'integer'].includes(fieldDef.type) && typeof value === 'string' && fieldName !== 'id' && !isDecimalField(fieldDef) &&
424
+ typeof fieldDef.getter !== 'function' && fieldDef.computed !== true && fieldDef.virtual !== true) {
425
+ normalized[fieldName] = storedNumber(value)
426
+ }
427
+
389
428
  // Normalize boolean values
390
429
  if (fieldDef.type === 'boolean') {
391
430
  if (value === 1 || value === '1') {
@@ -426,9 +465,7 @@ export function normalizeAttributes (attributes, schemaStructure, {
426
465
  export function normalizeStoredFieldValue (value, definition, { fieldName, resourceType }) {
427
466
  if (value == null) return null
428
467
  if (definition.type === 'id') return String(value)
429
- const decimal = definition.precision !== undefined && definition.scale !== undefined
430
- // Drivers return some numeric columns as strings; decimals keep theirs to avoid rounding.
431
- if (['number', 'integer'].includes(definition.type) && typeof value === 'string' && !decimal) return Number(value)
468
+ if (['number', 'integer'].includes(definition.type) && typeof value === 'string' && !isDecimalField(definition)) return storedNumber(value)
432
469
  return normalizeAttributes({ [fieldName]: value }, { [fieldName]: definition }, { resourceType })[fieldName]
433
470
  }
434
471