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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "json-rest-api",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "main": "index.js",
5
5
  "types": "index.d.ts",
6
6
  "type": "module",
@@ -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
- * @param {{ api: any, scopeName: string, context: any, ids: unknown[], returning: string, format: string, queryParams?: any }} request
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, scopeName, context, ids: context.ids || [], returning: context.returning, format: context.format, queryParams: params.queryParams
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