json-rest-api 3.0.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/GUIDE/27-many-row-writes.md +7 -1
- package/package.json +1 -1
- package/plugins/core/lib/storage/ordinary-data-helpers.js +20 -2
- package/plugins/core/lib/storage/storage-types.d.ts +4 -0
- package/plugins/core/rest-api-plugin-methods/many-row-common.js +4 -3
- package/plugins/core/rest-api-plugin-methods/post-many.js +12 -2
|
@@ -322,7 +322,13 @@ number of statements per chunk. The records it updates go through a patch's
|
|
|
322
322
|
checks as well as a create's: `checkPermissions` runs with `method: 'patch'`
|
|
323
323
|
and the matched record as `minimalRecord`, the resource is refused if a
|
|
324
324
|
per-record patch hook without `{ batch: true }` runs for it, and a key that
|
|
325
|
-
matches a record the caller cannot see fails the batch as `forbidden`.
|
|
325
|
+
matches a record the caller cannot see fails the batch as `forbidden`. The
|
|
326
|
+
caller's scope is checked on the records as locked: on MySQL and MariaDB, whose
|
|
327
|
+
plain reads keep a REPEATABLE READ transaction's first snapshot, that check is
|
|
328
|
+
a locking read too, so a matching record another transaction committed after
|
|
329
|
+
this one's first read is merged into, not taken for a hidden one. The records a
|
|
330
|
+
merge returns with `returning: 'full'` are read the same way, so such a record
|
|
331
|
+
is returned even when the merge leaves it unchanged.
|
|
326
332
|
|
|
327
333
|
With `returning: 'count'` an upsert counts the rows it inserted, and with
|
|
328
334
|
`merge` the records it updated as well.
|
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@ import { buildJsonApiResponse } from '../querying/knex-json-api-transformers-que
|
|
|
8
8
|
import { toJsonApiRecordWithBelongsTo } from '../querying-writing/knex-json-api-transformers.js'
|
|
9
9
|
import { processIncludes } from '../querying/knex-process-includes.js'
|
|
10
10
|
import { loadRelationshipIdentifiers } from '../querying/relationship-identifiers.js'
|
|
11
|
-
import { applyQueryConstraint } from '../querying/query-constraint.js'
|
|
11
|
+
import { applyQueryConstraint, queryConstraint } from '../querying/query-constraint.js'
|
|
12
12
|
import { deleteFoundRow, foundRow, lockFoundRow, updateFoundRow } from './single-row-writes.js'
|
|
13
13
|
import { runAggregateQuery } from '../querying/aggregate-query.js'
|
|
14
14
|
import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
|
|
@@ -21,6 +21,7 @@ import { unwrapQueryBuilderState, withQueryFilteringContext } from '../querying/
|
|
|
21
21
|
import { serializeJsonApiQuery } from '../querying-writing/connectors-query-parser.js'
|
|
22
22
|
import { applyDatabaseReadOptions, databaseIdentityExpression } from '../querying-writing/database-value-normalizers.js'
|
|
23
23
|
import { whereInIdentifiers } from '../querying/identifier-query.js'
|
|
24
|
+
import { databaseFamily } from './many-row-data-helpers.js'
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Every update advances the resource's lock version by one, whatever the input says.
|
|
@@ -280,7 +281,8 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
280
281
|
runHooks,
|
|
281
282
|
applyQueryFilters,
|
|
282
283
|
filters = context.queryParams?.filters,
|
|
283
|
-
queryPurpose = 'single'
|
|
284
|
+
queryPurpose = 'single',
|
|
285
|
+
locked = false
|
|
284
286
|
}) {
|
|
285
287
|
const storageAdapter = getScopeStorageAdapter(scopeName)
|
|
286
288
|
context.storageAdapter = storageAdapter
|
|
@@ -332,6 +334,19 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
332
334
|
const column = storageAdapter.translateColumn(field)
|
|
333
335
|
return [field === 'id' ? 'id' : column, databaseIdentityExpression(db, `${tableName}.${column}`)]
|
|
334
336
|
}))
|
|
337
|
+
if (ids !== undefined && locked && databaseFamily(db) === 'mysql') {
|
|
338
|
+
// InnoDB answers a plain read from the transaction's snapshot and a
|
|
339
|
+
// locking read from the latest rows. Rows this transaction has locked
|
|
340
|
+
// are read with a locking read in the scope's own query block, row
|
|
341
|
+
// policy included, so a row another transaction committed after the
|
|
342
|
+
// snapshot is seen as it was locked. (PostgreSQL's plain and locking
|
|
343
|
+
// reads see the same rows; SQLite has one writer.)
|
|
344
|
+
/** @type {StorageRow[]} */
|
|
345
|
+
const rows = await applyDatabaseReadOptions(query.clearSelect().select(`${tableName}.*`, identities).forShare())
|
|
346
|
+
// A policy join can repeat a row.
|
|
347
|
+
const records = [...new Map(rows.map(row => [String(row.id), row])).values()]
|
|
348
|
+
return records.map(record => toJsonApiRecordWithBelongsTo(scope, record, scopeName))
|
|
349
|
+
}
|
|
335
350
|
if (ids !== undefined) {
|
|
336
351
|
// Filtering owns predicates; the id list owns its identity projection.
|
|
337
352
|
query.clearSelect().distinct(`${tableName}.${idProperty}`)
|
|
@@ -425,6 +440,9 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
425
440
|
})
|
|
426
441
|
const { pageSize } = paginationInfo
|
|
427
442
|
|
|
443
|
+
// Rows this transaction has locked, read as its locking reads see them (see dataGetMinimal).
|
|
444
|
+
const constraint = context[queryConstraint]
|
|
445
|
+
if (constraint?.scopeName === scopeName && constraint.locked && databaseFamily(db) === 'mysql') query.forShare()
|
|
428
446
|
/** @type {StorageRow[]} */
|
|
429
447
|
const records = await applyDatabaseReadOptions(query)
|
|
430
448
|
|
|
@@ -13,6 +13,8 @@ export interface MandatoryQueryConstraint {
|
|
|
13
13
|
scopeName: string
|
|
14
14
|
values?: StorageRow
|
|
15
15
|
idsQuery?: StorageQuery
|
|
16
|
+
/** The caller's transaction holds row locks on the ids: read them as its locking reads see them. */
|
|
17
|
+
locked?: boolean
|
|
16
18
|
}
|
|
17
19
|
export type SelectColumn = string | Knex.Raw<unknown> | StorageQuery | Record<string, string>
|
|
18
20
|
export type SelectColumns = SelectColumn[] | Record<string, string> | null | undefined
|
|
@@ -291,6 +293,8 @@ export type QueryBuilderResult = StorageQuery | { query: QueryBuilderResult }
|
|
|
291
293
|
export interface DataMinimalRequest extends DataReadRequest {
|
|
292
294
|
filters?: StorageRow
|
|
293
295
|
queryPurpose?: string
|
|
296
|
+
/** With ids: the caller's transaction holds row locks on them, so read them as its locking reads see them. */
|
|
297
|
+
locked?: boolean
|
|
294
298
|
applyQueryFilters?: (params: QueryFilteringState & { query: StorageQuery }) => Promise<{ query: QueryBuilderResult } | null | undefined>
|
|
295
299
|
}
|
|
296
300
|
|
|
@@ -245,9 +245,10 @@ export function validateFilters ({ filters, scopeName, schemaInfo, vars }) {
|
|
|
245
245
|
/**
|
|
246
246
|
* The response of a many-row write: the count of written rows, identifiers,
|
|
247
247
|
* full records read through `query` in the write's transaction, or nothing.
|
|
248
|
-
*
|
|
248
|
+
* `locked`: the transaction holds row locks on the ids, so they are read as locked.
|
|
249
|
+
* @param {{ api: any, scopeName: string, context: any, ids: unknown[], returning: string, format: string, queryParams?: any, locked?: boolean }} request
|
|
249
250
|
*/
|
|
250
|
-
export async function manyRowResponse ({ api, scopeName, context, ids, returning, format, queryParams = {} }) {
|
|
251
|
+
export async function manyRowResponse ({ api, scopeName, context, ids, returning, format, queryParams = {}, locked = false }) {
|
|
251
252
|
if (returning === 'none') return undefined
|
|
252
253
|
const present = ids.filter(id => id !== undefined && id !== null).map(String)
|
|
253
254
|
if (returning === 'count') return { meta: { count: present.length } }
|
|
@@ -263,7 +264,7 @@ export async function manyRowResponse ({ api, scopeName, context, ids, returning
|
|
|
263
264
|
transaction: context.transaction,
|
|
264
265
|
pageSizeLimit: part.length,
|
|
265
266
|
queryParams: { fields: queryParams.fields, include: queryParams.include, sort: ['id'], page: { size: part.length } },
|
|
266
|
-
[queryConstraint]: { scopeName, idsQuery: part }
|
|
267
|
+
[queryConstraint]: { scopeName, idsQuery: part, locked }
|
|
267
268
|
}, { ...context, method: undefined, knexQuery: undefined })
|
|
268
269
|
for (const record of page.data) byId.set(String(record.id), record)
|
|
269
270
|
for (const resource of page.included || []) included.set(`${resource.type}:${resource.id}`, resource)
|
|
@@ -108,8 +108,10 @@ async function mergeRows ({ context, helpers, scope, scopes, runHooks, scopeName
|
|
|
108
108
|
const missing = attributes.flatMap((_row, index) => existing[index] === undefined ? [index] : [])
|
|
109
109
|
|
|
110
110
|
const visible = new Map()
|
|
111
|
+
// dataIdsByKeys locked these rows, and may have found one that another
|
|
112
|
+
// transaction committed after this one's snapshot: read them as locked.
|
|
111
113
|
for (const part of chunk(matched.map(index => existing[index]), VISIBLE_READ_CHUNK)) {
|
|
112
|
-
const records = await helpers.dataGetMinimal({ scopeName, context, runHooks, ids: part, filters: {}, queryPurpose: 'bulk-write' })
|
|
114
|
+
const records = await helpers.dataGetMinimal({ scopeName, context, runHooks, ids: part, filters: {}, queryPurpose: 'bulk-write', locked: true })
|
|
113
115
|
for (const record of records) visible.set(String(record.id), record)
|
|
114
116
|
}
|
|
115
117
|
for (const index of matched) {
|
|
@@ -285,7 +287,15 @@ export default async function postManyMethod ({
|
|
|
285
287
|
const response = countsRows
|
|
286
288
|
? { meta: { count: rows.length } }
|
|
287
289
|
: await manyRowResponse({
|
|
288
|
-
api,
|
|
290
|
+
api,
|
|
291
|
+
scopeName,
|
|
292
|
+
context,
|
|
293
|
+
ids: context.ids || [],
|
|
294
|
+
returning: context.returning,
|
|
295
|
+
format: context.format,
|
|
296
|
+
queryParams: params.queryParams,
|
|
297
|
+
// A merge may match a row committed after this transaction's snapshot and leave it unchanged.
|
|
298
|
+
locked: onConflict?.action === 'merge'
|
|
289
299
|
})
|
|
290
300
|
await commitOwnedTransaction(context)
|
|
291
301
|
return response
|