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.
- package/README.md +7 -2
- package/docs/API.md +262 -54
- package/docs/GUIDE/01-getting-started.md +1 -1
- package/docs/GUIDE/07-polymorphic-relationships.md +4 -2
- package/docs/GUIDE/09-pagination-and-sorting.md +4 -2
- package/docs/GUIDE/13-hooks-and-lifecycle.md +34 -6
- package/docs/GUIDE/14-custom-resource-methods.md +3 -2
- package/docs/GUIDE/16-autofiltering.md +54 -2
- package/docs/GUIDE/17-row-policies.md +15 -5
- package/docs/GUIDE/19-managed-transactions.md +8 -9
- package/docs/GUIDE/20-transaction-outcomes.md +3 -4
- package/docs/GUIDE/21-schema-and-migrations.md +7 -1
- package/docs/GUIDE/22-version-field-migrations.md +3 -3
- package/docs/GUIDE/27-many-row-writes.md +458 -0
- package/docs/GUIDE/28-socketio.md +10 -2
- package/docs/GUIDE/30-backend-capabilities.md +42 -2
- package/docs/GUIDE/31-positioning.md +3 -1
- package/docs/GUIDE/33-migrating-to-v2.md +2 -2
- package/docs/GUIDE/34-scoped-reads-and-locks.md +464 -0
- package/docs/GUIDE/35-migrating-to-v3.md +325 -0
- package/docs/GUIDE/index.md +5 -2
- package/docs/QUICKSTART.md +2 -2
- package/docs/contributing.md +1 -1
- package/index.d.ts +8 -1
- package/index.js +4 -0
- package/lib/rest-api-errors.js +79 -0
- package/lib/runtime/json-rest-api.js +18 -3
- package/lib/system-context.js +42 -0
- package/package.json +3 -2
- package/plugins/core/bulk-operations-plugin.js +54 -436
- package/plugins/core/connectors/lib/transport-http-helpers.js +34 -3
- package/plugins/core/file-handling-plugin.js +17 -2
- package/plugins/core/lib/anyapi/anyapi-registry.js +3 -2
- package/plugins/core/lib/anyapi/canonical-link-store.js +5 -2
- package/plugins/core/lib/anyapi/canonical-relationship-reader.js +56 -60
- package/plugins/core/lib/dbIntrospection.js +106 -6
- package/plugins/core/lib/querying/aggregate-query.js +215 -22
- package/plugins/core/lib/querying/include-query-helpers.js +11 -13
- package/plugins/core/lib/querying/include-to-one.js +54 -59
- package/plugins/core/lib/querying/knex-pagination-helpers.js +9 -2
- package/plugins/core/lib/querying/query-constraint.d.ts +11 -0
- package/plugins/core/lib/querying/query-constraint.js +45 -0
- package/plugins/core/lib/querying/read-only-query.js +92 -0
- package/plugins/core/lib/querying-writing/compile-schemas.js +14 -0
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
- package/plugins/core/lib/storage/canonical-data-helpers.js +103 -39
- package/plugins/core/lib/storage/many-row-data-helpers.js +738 -0
- package/plugins/core/lib/storage/ordinary-data-helpers.js +63 -79
- package/plugins/core/lib/storage/single-row-writes.js +71 -0
- package/plugins/core/lib/storage/storage-adapter.js +18 -8
- package/plugins/core/lib/storage/storage-mapping.js +4 -1
- package/plugins/core/lib/storage/storage-types.d.ts +21 -3
- package/plugins/core/lib/writing/many-to-many-manipulations.js +10 -5
- package/plugins/core/lib/writing/relationship-processor.js +33 -37
- package/plugins/core/rest-api-anyapi-knex-plugin.js +3 -0
- package/plugins/core/rest-api-autofilter-plugin.js +137 -10
- package/plugins/core/rest-api-knex-plugin.js +2 -0
- package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
- package/plugins/core/rest-api-plugin-methods/common.js +144 -84
- package/plugins/core/rest-api-plugin-methods/delete-many.js +132 -0
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +22 -0
- package/plugins/core/rest-api-plugin-methods/get.js +48 -42
- package/plugins/core/rest-api-plugin-methods/many-row-common.js +274 -0
- package/plugins/core/rest-api-plugin-methods/patch-many.js +333 -0
- package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
- package/plugins/core/rest-api-plugin-methods/post-many.js +295 -0
- package/plugins/core/rest-api-plugin-methods/put.js +5 -1
- package/plugins/core/rest-api-plugin-methods/scoped-reads.js +221 -0
- package/plugins/core/rest-api-plugin.js +17 -0
- package/plugins/core/rest-api-positioning-plugin.js +22 -4
- package/plugins/core/rest-api-query-projections-plugin.js +9 -1
- package/plugins/core/rest-api-row-policy-plugin.js +4 -0
- package/plugins/core/socketio-plugin.js +80 -2
- package/types/autofilter.d.ts +5 -1
- package/types/data-access-methods.d.ts +141 -0
- package/types/errors.d.ts +45 -0
- package/types/hook-context.d.ts +21 -0
- package/types/plugin-options.d.ts +3 -1
- package/types/resource-methods.d.ts +27 -4
- package/types/row-policy.d.ts +3 -0
- package/types/runtime.d.ts +7 -1
- package/docs/GUIDE/27-bulk-operations.md +0 -277
- package/types/bulk-methods.d.ts +0 -63
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import { RestApiValidationError } from '../../lib/rest-api-errors.js'
|
|
1
|
+
import { RestApiResourceError, RestApiValidationError } from '../../lib/rest-api-errors.js'
|
|
2
|
+
import { assertGenuineSystemFlag, isSystemContext } from '../../lib/system-context.js'
|
|
2
3
|
import { snapshotResourceConfiguration } from './lib/querying-writing/schema-helpers.js'
|
|
3
4
|
import { createStorageAdapterUtilities } from './lib/querying/storage-adapter-utils.js'
|
|
5
|
+
import { ANY_OWNER, recordWriteCondition } from './lib/querying/query-constraint.js'
|
|
4
6
|
|
|
5
7
|
const PUBLIC_PRESET_NAME = 'public'
|
|
8
|
+
// Queries that write or lock the rows they select: shared rows are never theirs.
|
|
9
|
+
const WRITE_PURPOSES = new Set(['bulk-write', 'lock', 'claim'])
|
|
6
10
|
|
|
7
11
|
function valuesEqual (left, right) {
|
|
8
12
|
if (left === right) return true
|
|
@@ -108,12 +112,20 @@ function normalizeFilterDefinition ({ filterDef, resolvers, schemaStructure, sco
|
|
|
108
112
|
? fieldDef.as
|
|
109
113
|
: null
|
|
110
114
|
|
|
115
|
+
if (filterDef.shared !== undefined && typeof filterDef.shared !== 'boolean') {
|
|
116
|
+
throw new Error(`Autofilter field '${field}' on resource '${scopeName}' must set shared to true or false.`)
|
|
117
|
+
}
|
|
118
|
+
if (filterDef.shared === true && fieldDef.nullable !== true) {
|
|
119
|
+
throw new Error(`Autofilter field '${field}' on resource '${scopeName}' is shared, so it must be nullable: NULL marks a shared row.`)
|
|
120
|
+
}
|
|
121
|
+
|
|
111
122
|
return {
|
|
112
123
|
field,
|
|
113
124
|
fieldDef,
|
|
114
125
|
resolve,
|
|
115
126
|
resolverName,
|
|
116
127
|
required: filterDef.required !== false,
|
|
128
|
+
shared: filterDef.shared === true,
|
|
117
129
|
relationshipName,
|
|
118
130
|
relationshipType: relationshipName ? fieldDef.belongsTo : null,
|
|
119
131
|
inputPath: relationshipName
|
|
@@ -247,8 +259,56 @@ function applyResolvedInputValue ({ context, filter, resolvedValue, injectMissin
|
|
|
247
259
|
}
|
|
248
260
|
}
|
|
249
261
|
|
|
262
|
+
// A system context owns no workspace or user, so a create must say whose row it is.
|
|
263
|
+
function requireExplicitOwnership ({ compiledConfig, inputRecord, scopeName, index }) {
|
|
264
|
+
const data = inputRecord?.data || {}
|
|
265
|
+
const missing = compiledConfig.filters.filter(filter => {
|
|
266
|
+
if (filter.relationshipName && data.relationships?.[filter.relationshipName]?.data !== undefined) return false
|
|
267
|
+
return data.attributes?.[filter.field] === undefined
|
|
268
|
+
})
|
|
269
|
+
if (!missing.length) return
|
|
270
|
+
const prefix = index === undefined ? '' : `data.${index}.`
|
|
271
|
+
const violations = missing.map(filter => ({
|
|
272
|
+
field: `${prefix}${filter.inputPath}`,
|
|
273
|
+
rule: 'system_ownership_required',
|
|
274
|
+
message: `A create in a system context must set '${filter.field}' explicitly${filter.shared ? ' (null for a shared row)' : ''}`
|
|
275
|
+
}))
|
|
276
|
+
throw new RestApiValidationError(`Creating '${scopeName}' records in a system context needs explicit ownership values`, {
|
|
277
|
+
fields: violations.map(violation => violation.field),
|
|
278
|
+
violations
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Without an owner value a shared filter would stamp NULL, creating a row every owner sees.
|
|
283
|
+
function refuseSharedCreate ({ filter, resolvedValue, scopeName }) {
|
|
284
|
+
if (!filter.shared || resolvedValue !== null) return
|
|
285
|
+
throw new RestApiResourceError(`Shared ${scopeName} records can only be created in a system context`, {
|
|
286
|
+
subtype: 'forbidden', resourceType: scopeName
|
|
287
|
+
})
|
|
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
|
+
|
|
297
|
+
function sharedFieldValue (record, filter) {
|
|
298
|
+
if (filter.relationshipName) {
|
|
299
|
+
const linkage = record?.relationships?.[filter.relationshipName]?.data
|
|
300
|
+
if (linkage !== undefined) return linkage === null ? null : linkage.id
|
|
301
|
+
}
|
|
302
|
+
return record?.attributes?.[filter.field]
|
|
303
|
+
}
|
|
304
|
+
|
|
250
305
|
async function enforceScopedInput ({ compiledConfig, context, scopeName, injectMissing, api, helpers, scopes, vars, log }) {
|
|
251
306
|
if (!compiledConfig || compiledConfig.filters.length === 0) return
|
|
307
|
+
assertGenuineSystemFlag(context)
|
|
308
|
+
if (isSystemContext(context)) {
|
|
309
|
+
if (injectMissing) requireExplicitOwnership({ compiledConfig, inputRecord: context.inputRecord, scopeName })
|
|
310
|
+
return
|
|
311
|
+
}
|
|
252
312
|
|
|
253
313
|
for (const filter of compiledConfig.filters) {
|
|
254
314
|
const resolvedValue = await resolveFilterValue({
|
|
@@ -263,6 +323,7 @@ async function enforceScopedInput ({ compiledConfig, context, scopeName, injectM
|
|
|
263
323
|
})
|
|
264
324
|
|
|
265
325
|
if (resolvedValue === undefined) continue
|
|
326
|
+
if (injectMissing) refuseSharedCreate({ filter, resolvedValue, scopeName })
|
|
266
327
|
|
|
267
328
|
applyResolvedInputValue({
|
|
268
329
|
context,
|
|
@@ -271,6 +332,7 @@ async function enforceScopedInput ({ compiledConfig, context, scopeName, injectM
|
|
|
271
332
|
injectMissing
|
|
272
333
|
})
|
|
273
334
|
}
|
|
335
|
+
if (injectMissing) refuseSharedResult({ compiledConfig, record: context.inputRecord?.data, scopeName })
|
|
274
336
|
}
|
|
275
337
|
|
|
276
338
|
export const AutoFilterPlugin = {
|
|
@@ -304,14 +366,19 @@ export const AutoFilterPlugin = {
|
|
|
304
366
|
resolvers: state.resolvers,
|
|
305
367
|
presets: state.presets
|
|
306
368
|
})
|
|
369
|
+
// Many-row writes refuse these fields and require them in upsert conflict keys.
|
|
370
|
+
schemaInfo.ownershipFields = (schemaInfo.autofilter?.filters || []).map(filter => filter.field)
|
|
307
371
|
})
|
|
308
372
|
|
|
309
373
|
addHook('knexQueryFiltering', 'autofilter-scope', {}, async ({ context }) => {
|
|
310
|
-
const { query, tableName, scopeName } = context.knexQuery || {}
|
|
374
|
+
const { query, tableName, scopeName, queryPurpose } = context.knexQuery || {}
|
|
311
375
|
if (!query || !scopeName) return
|
|
312
376
|
|
|
313
377
|
const compiledConfig = scopes[scopeName]?.vars?.schemaInfo?.autofilter
|
|
314
378
|
if (!compiledConfig || compiledConfig.filters.length === 0) return
|
|
379
|
+
assertGenuineSystemFlag(context)
|
|
380
|
+
if (isSystemContext(context)) return
|
|
381
|
+
const writes = WRITE_PURPOSES.has(queryPurpose)
|
|
315
382
|
const adapterUtils = createStorageAdapterUtilities({ context }, {
|
|
316
383
|
getStorageAdapter: helpers.getStorageAdapter
|
|
317
384
|
})
|
|
@@ -328,20 +395,39 @@ export const AutoFilterPlugin = {
|
|
|
328
395
|
log
|
|
329
396
|
})
|
|
330
397
|
|
|
331
|
-
if (resolvedValue === undefined) continue
|
|
332
|
-
|
|
333
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
|
+
}
|
|
334
410
|
const translatedValue = adapterUtils.translateFilterValue(scopeName, filter.field, resolvedValue)
|
|
335
411
|
|
|
336
|
-
if (
|
|
337
|
-
|
|
412
|
+
if (filter.shared) {
|
|
413
|
+
// Reads see the owner's rows and the shared (NULL) ones; writes and locks only the owner's.
|
|
414
|
+
if (translatedValue === null) {
|
|
415
|
+
if (writes) query.whereRaw('1 = 0')
|
|
416
|
+
else query.whereNull(columnRef)
|
|
417
|
+
} else {
|
|
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)
|
|
421
|
+
}
|
|
338
422
|
} else {
|
|
339
|
-
query.
|
|
423
|
+
if (translatedValue === null) query.whereNull(columnRef)
|
|
424
|
+
else query.where(columnRef, translatedValue)
|
|
425
|
+
recordWriteCondition(context, scopeName, filter.field, translatedValue)
|
|
340
426
|
}
|
|
341
427
|
}
|
|
342
428
|
})
|
|
343
429
|
|
|
344
|
-
addHook('beforeProcessingPost', 'autofilter-stamp-post', {}, async ({ context, scopeName }) => {
|
|
430
|
+
addHook('beforeProcessingPost', 'autofilter-stamp-post', { batch: true }, async ({ context, scopeName }) => {
|
|
345
431
|
await enforceScopedInput({
|
|
346
432
|
compiledConfig: scopes[scopeName]?.vars?.schemaInfo?.autofilter,
|
|
347
433
|
context,
|
|
@@ -369,7 +455,7 @@ export const AutoFilterPlugin = {
|
|
|
369
455
|
})
|
|
370
456
|
})
|
|
371
457
|
|
|
372
|
-
addHook('beforeProcessingPatch', 'autofilter-validate-patch', {}, async ({ context, scopeName }) => {
|
|
458
|
+
addHook('beforeProcessingPatch', 'autofilter-validate-patch', { batch: true }, async ({ context, scopeName }) => {
|
|
373
459
|
await enforceScopedInput({
|
|
374
460
|
compiledConfig: scopes[scopeName]?.vars?.schemaInfo?.autofilter,
|
|
375
461
|
context,
|
|
@@ -383,6 +469,46 @@ export const AutoFilterPlugin = {
|
|
|
383
469
|
})
|
|
384
470
|
})
|
|
385
471
|
|
|
472
|
+
// Batch form of autofilter-stamp-post: resolve each owner value once, stamp every row.
|
|
473
|
+
addHook('beforeProcessingPostMany', 'autofilter-stamp-post-many', {}, async ({ context, scopeName }) => {
|
|
474
|
+
const compiledConfig = scopes[scopeName]?.vars?.schemaInfo?.autofilter
|
|
475
|
+
if (!compiledConfig || compiledConfig.filters.length === 0) return
|
|
476
|
+
assertGenuineSystemFlag(context)
|
|
477
|
+
if (isSystemContext(context)) {
|
|
478
|
+
context.inputRecords.forEach((inputRecord, index) => requireExplicitOwnership({ compiledConfig, inputRecord, scopeName, index }))
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
for (const filter of compiledConfig.filters) {
|
|
482
|
+
const resolvedValue = await resolveFilterValue({ filter, context, scopeName, api, helpers, scopes, vars, log })
|
|
483
|
+
if (resolvedValue === undefined) continue
|
|
484
|
+
refuseSharedCreate({ filter, resolvedValue, scopeName })
|
|
485
|
+
for (const inputRecord of context.inputRecords) {
|
|
486
|
+
applyResolvedInputValue({ context: { inputRecord }, filter, resolvedValue, injectMissing: true })
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
for (const inputRecord of context.inputRecords) refuseSharedResult({ compiledConfig, record: inputRecord?.data, scopeName })
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
// Shared rows are readable by every owner but writable only in a system context.
|
|
493
|
+
const refuseSharedRowWrite = ({ context, scopeName }) => {
|
|
494
|
+
const compiledConfig = scopes[scopeName]?.vars?.schemaInfo?.autofilter
|
|
495
|
+
const sharedFilters = compiledConfig?.filters.filter(filter => filter.shared) || []
|
|
496
|
+
if (!sharedFilters.length || !context.minimalRecord || isSystemContext(context)) return
|
|
497
|
+
for (const filter of sharedFilters) {
|
|
498
|
+
if (sharedFieldValue(context.minimalRecord, filter) === null) {
|
|
499
|
+
throw new RestApiResourceError(`Shared ${scopeName} records can only be changed in a system context`, {
|
|
500
|
+
subtype: 'forbidden', resourceType: scopeName, resourceId: context.id
|
|
501
|
+
})
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
addHook('beforeDataCallPatch', 'autofilter-shared-row-patch', { batch: true }, refuseSharedRowWrite)
|
|
506
|
+
addHook('beforeDataCallPut', 'autofilter-shared-row-put', { batch: true }, refuseSharedRowWrite)
|
|
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)
|
|
511
|
+
|
|
386
512
|
api.autofilter = {
|
|
387
513
|
getConfig: () => ({
|
|
388
514
|
presets: Object.keys(state.presets),
|
|
@@ -398,7 +524,8 @@ export const AutoFilterPlugin = {
|
|
|
398
524
|
filters: compiledConfig.filters.map((filter) => ({
|
|
399
525
|
field: filter.field,
|
|
400
526
|
resolver: filter.resolverName,
|
|
401
|
-
required: filter.required
|
|
527
|
+
required: filter.required,
|
|
528
|
+
shared: filter.shared
|
|
402
529
|
}))
|
|
403
530
|
}
|
|
404
531
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createOrdinaryDataHelpers } from './lib/storage/ordinary-data-helpers.js'
|
|
2
|
+
import { createManyRowDataHelpers } from './lib/storage/many-row-data-helpers.js'
|
|
2
3
|
import { createKnexTransaction } from '../../lib/knex-transaction.js'
|
|
3
4
|
import { createEnhancedLogger } from '../../lib/enhanced-logger.js'
|
|
4
5
|
import { throwMissingPackage } from '../../lib/missing-package.js'
|
|
@@ -160,6 +161,7 @@ export const RestApiKnexPlugin = {
|
|
|
160
161
|
helpers.newTransaction = context => createKnexTransaction(knex, context)
|
|
161
162
|
|
|
162
163
|
Object.assign(helpers, createOrdinaryDataHelpers({ api, scopes, knex, log, getScopeStorageAdapter }))
|
|
164
|
+
Object.assign(helpers, createManyRowDataHelpers({ api, scopes, log, getScopeStorageAdapter }))
|
|
163
165
|
|
|
164
166
|
log.info('RestApiKnexPlugin installed - basic CRUD operations ready')
|
|
165
167
|
}
|
|
@@ -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,
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
* Schema, WriteContext
|
|
7
7
|
* } from './lifecycle-types.js' */
|
|
8
8
|
/** @import { StorageRow } from '../lib/storage/storage-types.js' */
|
|
9
|
-
/** @import { DiagnosticWriter, EnhancedDiagnosticLogger } from '../../../lib/logger-types.js' */
|
|
10
9
|
/** @import { RuntimeLogger } from '../../../types/runtime.js' */
|
|
11
10
|
import { initializeResourceVersion } from '../lib/writing/resource-version.js'
|
|
12
11
|
import {
|
|
@@ -16,7 +15,6 @@ import {
|
|
|
16
15
|
} from '../../../lib/rest-api-errors.js'
|
|
17
16
|
import { transformSimplifiedToJsonApi } from '../lib/querying-writing/simplified-helpers.js'
|
|
18
17
|
import { normalizeRelationshipIdentifiers } from '../lib/querying-writing/resource-id-normalization.js'
|
|
19
|
-
import { createEnhancedLogger } from '../../../lib/enhanced-logger.js'
|
|
20
18
|
import { rejectRemovedOptions, resolveFormat, resolveReturning } from '../lib/querying-writing/response-options.js'
|
|
21
19
|
import {
|
|
22
20
|
getRequestContracts,
|
|
@@ -27,26 +25,11 @@ import { normalizeRecordAttributes } from '../lib/querying-writing/database-valu
|
|
|
27
25
|
import { beginWriteTransaction, commitTransaction, getOperationDiagnosticContext, isTransactionOwner, rollbackAfterError, wrapUnexpectedError } from '../../../lib/error-context.js'
|
|
28
26
|
import { filterVisibleIdentifiers } from '../lib/querying/include-query-helpers.js'
|
|
29
27
|
import { findRelationshipDefinition } from '../lib/querying-writing/relationship-contracts.js'
|
|
30
|
-
import { RELATIONSHIP_READ_BATCH_SIZE } from '../lib/querying-writing/knex-constants.js'
|
|
31
28
|
import { getResourceFieldset, parseFieldset } from '../lib/querying-writing/field-utils.js'
|
|
32
29
|
|
|
33
30
|
// A trusted connector callback, evaluated after normal write validation and authorization.
|
|
34
31
|
export const writePrecondition = Symbol('writePrecondition')
|
|
35
32
|
|
|
36
|
-
/**
|
|
37
|
-
* Gets an enhanced logger instance with full error details and stack traces
|
|
38
|
-
* @param {RuntimeLogger} log - The base logger instance
|
|
39
|
-
* @param {{ schemaInfo?: LifecycleSchema }} context - Request context carrying compiled field visibility
|
|
40
|
-
* @returns {EnhancedDiagnosticLogger & { error: DiagnosticWriter }} Enhanced logger instance
|
|
41
|
-
*/
|
|
42
|
-
export const getEnhancedLogger = (log, context = {}) => {
|
|
43
|
-
return createEnhancedLogger(log, {
|
|
44
|
-
logFullErrors: true,
|
|
45
|
-
includeStack: true,
|
|
46
|
-
schemaInfo: context.schemaInfo
|
|
47
|
-
})
|
|
48
|
-
}
|
|
49
|
-
|
|
50
33
|
/**
|
|
51
34
|
* Resolves write input, response options and the managed transaction.
|
|
52
35
|
* @param {Pick<LifecycleArguments, 'params' | 'vars' | 'scopes' | 'scopeName' | 'api' | 'helpers' | 'runHooks'> & { context: LifecycleContext<Resource | Resource[] | null, unknown> }} options
|
|
@@ -107,11 +90,18 @@ export const handleWriteMethodError = async (error, context, method, scopeName,
|
|
|
107
90
|
await rollbackAfterError(error, context, context.transaction)
|
|
108
91
|
const cleanupErrors = context.cleanupErrors || (context.cleanupErrors = [])
|
|
109
92
|
|
|
93
|
+
// The resource, the operation and error codes only: input, error messages and
|
|
94
|
+
// SQL can carry the values being written (payment state, ciphertext).
|
|
95
|
+
/** @param {unknown} value */
|
|
96
|
+
const codeOf = value => {
|
|
97
|
+
const { code, name } = /** @type {{ code?: unknown, name?: unknown }} */ (Object(value))
|
|
98
|
+
return typeof code === 'string' || typeof code === 'number' ? code : typeof name === 'string' ? name : null
|
|
99
|
+
}
|
|
110
100
|
try {
|
|
111
|
-
await
|
|
101
|
+
await log.error(`Error in ${method} method`, {
|
|
112
102
|
...getOperationDiagnosticContext(context, { phase: 'writeFailure', scopeName, method: context.method ?? method.toLowerCase() }),
|
|
113
|
-
|
|
114
|
-
cleanupErrors
|
|
103
|
+
code: codeOf(error),
|
|
104
|
+
cleanupErrors: cleanupErrors.map(({ phase, error }) => ({ phase, code: codeOf(error) }))
|
|
115
105
|
})
|
|
116
106
|
} catch (error) {
|
|
117
107
|
cleanupErrors.push({ phase: 'logging', error })
|
|
@@ -212,6 +202,8 @@ export const validateCompleteReplacePayload = ({
|
|
|
212
202
|
for (const [fieldName, fieldDef] of Object.entries(schemaStructure)) {
|
|
213
203
|
if (!fieldDef) continue
|
|
214
204
|
if (fieldName === idProperty) continue
|
|
205
|
+
// The library owns the lock version: every update advances it, whatever the input says.
|
|
206
|
+
if (fieldName === schemaInfo.lockVersionField) continue
|
|
215
207
|
if (Object.hasOwn(belongsToUpdates, fieldName)) continue
|
|
216
208
|
if (fieldDef.computed === true || fieldDef.virtual === true || fieldDef.retired === true) continue
|
|
217
209
|
if (fieldDef.type === undefined) continue
|
|
@@ -292,7 +284,11 @@ export const validateResourceAttributesBeforeWrite = async ({
|
|
|
292
284
|
...(hasLogicalIdField && resourceId !== undefined ? { id: resourceId } : {})
|
|
293
285
|
}
|
|
294
286
|
|
|
295
|
-
// Physical foreign keys
|
|
287
|
+
// Physical foreign keys, and the type and id fields behind a polymorphic
|
|
288
|
+
// belongs-to, may enter storage only through relationship planning.
|
|
289
|
+
/** @type {Map<string, string>} */
|
|
290
|
+
const polymorphicFields = new Map(Object.entries(context.schemaInfo.schemaRelationships || {}).flatMap(([name, relationship]) =>
|
|
291
|
+
relationship.belongsToPolymorphic ? [[relationship.belongsToPolymorphic.typeField, name], [relationship.belongsToPolymorphic.idField, name]] : []))
|
|
296
292
|
/** @type {StorageRow} */
|
|
297
293
|
const attributesForValidation = { ...attributesToValidate }
|
|
298
294
|
const foreignKeyViolations = []
|
|
@@ -306,13 +302,14 @@ export const validateResourceAttributesBeforeWrite = async ({
|
|
|
306
302
|
message: `Field '${field}' is retired and accepts no new values`
|
|
307
303
|
})
|
|
308
304
|
}
|
|
309
|
-
|
|
305
|
+
const relationshipName = definition?.belongsTo && definition.as ? definition.as : polymorphicFields.get(field)
|
|
306
|
+
if (!relationshipName || Object.hasOwn(belongsToUpdates, field)) continue
|
|
310
307
|
delete attributesForValidation[field]
|
|
311
308
|
if (value === undefined) continue
|
|
312
309
|
foreignKeyViolations.push({
|
|
313
310
|
field: `data.attributes.${field}`,
|
|
314
311
|
rule: 'foreign_key_in_attributes',
|
|
315
|
-
message: `Foreign key field '${field}' should not be in attributes. Use 'data.relationships.${
|
|
312
|
+
message: `Foreign key field '${field}' should not be in attributes. Use 'data.relationships.${relationshipName}' instead.`
|
|
316
313
|
})
|
|
317
314
|
}
|
|
318
315
|
const [firstRetired] = retiredViolations
|
|
@@ -387,17 +384,25 @@ export const validateResourceAttributesBeforeWrite = async ({
|
|
|
387
384
|
*/
|
|
388
385
|
export const validateRelationshipAccess = async (context, inputRecord, helpers, api) => {
|
|
389
386
|
if (!inputRecord?.data?.relationships) return
|
|
390
|
-
|
|
391
|
-
// Targets of every relationship, so each type is read once per record.
|
|
392
387
|
const unique = new Map()
|
|
393
|
-
|
|
388
|
+
collectRelationshipTargets(inputRecord, api, unique)
|
|
389
|
+
await validateRelationshipTargets(context, [...unique.values()], helpers, api)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Normalizes a document's relationship identifiers and adds each target to
|
|
394
|
+
* `unique`, so a batch of documents reads every related type once.
|
|
395
|
+
* @param {InputDocument} inputRecord
|
|
396
|
+
* @param {LifecycleApi} api
|
|
397
|
+
* @param {Map<string, Identifier>} unique
|
|
398
|
+
*/
|
|
399
|
+
export const collectRelationshipTargets = (inputRecord, api, unique) => {
|
|
400
|
+
const relationships = /** @type {NonNullable<InputDocument['data']['relationships']>} */ (inputRecord?.data?.relationships || {})
|
|
401
|
+
for (const [relName, relData] of Object.entries(relationships)) {
|
|
394
402
|
if (!relData?.data) continue
|
|
395
403
|
|
|
396
404
|
const normalizedRelationshipData = /** @type {Identifier | Identifier[]} */ (normalizeRelationshipIdentifiers(relData.data, { api }))
|
|
397
|
-
|
|
398
|
-
...relData,
|
|
399
|
-
data: normalizedRelationshipData
|
|
400
|
-
}
|
|
405
|
+
relationships[relName] = { ...relData, data: normalizedRelationshipData }
|
|
401
406
|
|
|
402
407
|
// Handle both single and array relationships
|
|
403
408
|
const relatedItems = Array.isArray(normalizedRelationshipData)
|
|
@@ -406,60 +411,69 @@ export const validateRelationshipAccess = async (context, inputRecord, helpers,
|
|
|
406
411
|
|
|
407
412
|
for (const item of relatedItems) unique.set(JSON.stringify([item.type, item.id]), item)
|
|
408
413
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Checks that every target is visible to the caller, with one scoped read per
|
|
418
|
+
* related type, however many targets there are.
|
|
419
|
+
* @param {ResourceContext} context
|
|
420
|
+
* @param {Identifier[]} targets
|
|
421
|
+
* @param {LifecycleHelpers} helpers
|
|
422
|
+
* @param {LifecycleApi} api
|
|
423
|
+
*/
|
|
424
|
+
export const validateRelationshipTargets = async (context, targets, helpers, api) => {
|
|
425
|
+
/** @type {Map<string, Identifier[]>} */
|
|
426
|
+
const byType = new Map()
|
|
427
|
+
for (const item of targets) {
|
|
428
|
+
const items = byType.get(item.type)
|
|
429
|
+
if (items) items.push(item)
|
|
430
|
+
else byType.set(item.type, [item])
|
|
431
|
+
}
|
|
432
|
+
const lookups = new Map()
|
|
433
|
+
for (const [type, items] of byType) {
|
|
434
|
+
const relatedScope = api.resources[type]
|
|
435
|
+
if (!relatedScope) throw new Error(`Unknown resource type: ${type}`)
|
|
436
|
+
const getContext = {
|
|
437
|
+
...context,
|
|
438
|
+
id: undefined,
|
|
439
|
+
minimalRecord: undefined,
|
|
440
|
+
schemaInfo: relatedScope.vars.schemaInfo,
|
|
441
|
+
scopeName: type,
|
|
442
|
+
queryParams: {},
|
|
443
|
+
method: 'get',
|
|
444
|
+
isUpdate: false
|
|
418
445
|
}
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
...
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
scopeName: type,
|
|
429
|
-
queryParams: {},
|
|
430
|
-
method: 'get',
|
|
431
|
-
isUpdate: false
|
|
432
|
-
}
|
|
433
|
-
const lookup = {
|
|
434
|
-
scopeName: type,
|
|
435
|
-
context: getContext,
|
|
436
|
-
/** @param {import('../lib/storage/storage-types.js').QueryFilteringState} filterParams */
|
|
437
|
-
applyQueryFilters: filterParams => relatedScope.applyQueryFilters({
|
|
438
|
-
...filterParams, schemaInfo: getContext.schemaInfo
|
|
439
|
-
}, getContext),
|
|
440
|
-
filters: {},
|
|
441
|
-
queryPurpose: 'relationship-validation'
|
|
442
|
-
}
|
|
443
|
-
const records = await helpers.dataGetMinimal({ ...lookup, ids: items.map(item => item.id) })
|
|
444
|
-
lookups.set(type, { relatedScope, lookup, records: new Map(records.map(record => [String(record.id), record])) })
|
|
446
|
+
const lookup = {
|
|
447
|
+
scopeName: type,
|
|
448
|
+
context: getContext,
|
|
449
|
+
/** @param {import('../lib/storage/storage-types.js').QueryFilteringState} filterParams */
|
|
450
|
+
applyQueryFilters: filterParams => relatedScope.applyQueryFilters({
|
|
451
|
+
...filterParams, schemaInfo: getContext.schemaInfo
|
|
452
|
+
}, getContext),
|
|
453
|
+
filters: {},
|
|
454
|
+
queryPurpose: 'relationship-validation'
|
|
445
455
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
456
|
+
const records = new Map()
|
|
457
|
+
for (const record of await helpers.dataGetMinimal({ ...lookup, ids: items.map(item => item.id) })) records.set(String(record.id), record)
|
|
458
|
+
lookups.set(type, { relatedScope, lookup, records })
|
|
459
|
+
}
|
|
460
|
+
// In the order given, so the first missing or forbidden target is the one reported.
|
|
461
|
+
for (const item of targets) {
|
|
462
|
+
const { relatedScope, lookup, records } = lookups.get(item.type)
|
|
463
|
+
// A database collation can equate IDs with different spellings.
|
|
464
|
+
const record = records.get(String(item.id)) || await helpers.dataGetMinimal({
|
|
465
|
+
...lookup, context: { ...lookup.context, id: item.id }
|
|
466
|
+
})
|
|
467
|
+
if (!record) {
|
|
468
|
+
throw new RestApiResourceError(
|
|
469
|
+
`Cannot create relationship to non-existent ${item.type} with id ${item.id}`,
|
|
470
|
+
{ subtype: 'not_found', resourceType: item.type, resourceId: item.id }
|
|
471
|
+
)
|
|
462
472
|
}
|
|
473
|
+
await relatedScope.checkPermissions({
|
|
474
|
+
method: 'get',
|
|
475
|
+
originalContext: { ...lookup.context, id: item.id, minimalRecord: record }
|
|
476
|
+
})
|
|
463
477
|
}
|
|
464
478
|
}
|
|
465
479
|
|
|
@@ -637,15 +651,61 @@ export const filterToOneResponseLinkage = async (context, scopes) => {
|
|
|
637
651
|
if ((definition?.belongsTo || definition?.belongsToPolymorphic) && relationship.data) linkages.push(relationship)
|
|
638
652
|
}
|
|
639
653
|
}
|
|
640
|
-
|
|
654
|
+
// A target the response already includes is no secret, so it needs no read.
|
|
655
|
+
const key = (/** @type {Identifier} */ { type, id }) => `${type}:${id}`
|
|
656
|
+
const included = new Set((document?.included || []).map(key))
|
|
657
|
+
// The links collected above are to-one links with a target.
|
|
658
|
+
const unchecked = linkages.filter(relationship => !included.has(key(/** @type {Identifier} */ (relationship.data))))
|
|
659
|
+
if (unchecked.length === 0) return
|
|
641
660
|
const visible = new Set(await filterVisibleIdentifiers({
|
|
642
|
-
identifiers:
|
|
661
|
+
identifiers: unchecked.map(relationship => relationship.data), scopes, knex: context.db, context
|
|
643
662
|
}))
|
|
644
|
-
for (const relationship of
|
|
663
|
+
for (const relationship of unchecked) {
|
|
645
664
|
if (!visible.has(relationship.data)) relationship.data = null
|
|
646
665
|
}
|
|
647
666
|
}
|
|
648
667
|
|
|
668
|
+
/**
|
|
669
|
+
* A read shows a link whose target its reader cannot see as null (above), so a
|
|
670
|
+
* reader that writes the record back sends that null for a link it never saw.
|
|
671
|
+
* The write keeps such a link: a belongs-to that `updates` sets to null, whose
|
|
672
|
+
* stored target exists but is hidden from the writer, is dropped from
|
|
673
|
+
* `updates` and from the input, and stays as stored. A link to
|
|
674
|
+
* a visible target, or to one that no longer exists, is cleared as asked.
|
|
675
|
+
* @param {{ context: ResourceContext, scopes: Record<string, LifecycleResource>, schemaInfo: any, rows: Array<{ updates: Record<string, unknown>, stored: any, inputRecord?: any }> }} request
|
|
676
|
+
*/
|
|
677
|
+
export const keepHiddenLinks = async ({ context, scopes, schemaInfo, rows }) => {
|
|
678
|
+
const links = [
|
|
679
|
+
...Object.entries(schemaInfo.schemaStructure)
|
|
680
|
+
.filter(([, field]) => field.belongsTo && field.as)
|
|
681
|
+
.map(([name, field]) => ({ name: field.as, fields: [name] })),
|
|
682
|
+
...Object.entries(schemaInfo.schemaRelationships || {})
|
|
683
|
+
.filter(([, relationship]) => relationship.belongsToPolymorphic)
|
|
684
|
+
.map(([name, { belongsToPolymorphic: { typeField, idField } }]) => ({ name, fields: [typeField, idField] }))
|
|
685
|
+
]
|
|
686
|
+
const cleared = []
|
|
687
|
+
for (const row of rows) {
|
|
688
|
+
for (const link of links) {
|
|
689
|
+
const data = row.stored?.relationships?.[link.name]?.data
|
|
690
|
+
if (data && link.fields.every(field => Object.hasOwn(row.updates, field) && row.updates[field] === null)) cleared.push({ row, link, data })
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
if (!cleared.length) return
|
|
694
|
+
const visible = new Set(await filterVisibleIdentifiers({ identifiers: cleared.map(entry => entry.data), scopes, knex: context.db, context }))
|
|
695
|
+
const hidden = cleared.filter(entry => !visible.has(entry.data))
|
|
696
|
+
if (!hidden.length) return
|
|
697
|
+
const existing = new Set(await filterVisibleIdentifiers({ identifiers: hidden.map(entry => entry.data), scopes, knex: context.db, context, scoped: false }))
|
|
698
|
+
for (const { row, link, data } of hidden) {
|
|
699
|
+
if (!existing.has(data)) continue
|
|
700
|
+
for (const field of link.fields) {
|
|
701
|
+
delete row.updates[field]
|
|
702
|
+
// Validation has merged the belongs-to updates into the input attributes.
|
|
703
|
+
delete row.inputRecord?.data?.attributes?.[field]
|
|
704
|
+
}
|
|
705
|
+
delete row.inputRecord?.data?.relationships?.[link.name]
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
649
709
|
/** @param {ResourceContext} context @param {Record<string, LifecycleResource>} scopes @param {string[]} [resourceTypes] */
|
|
650
710
|
export const validateRequestedIncludes = (context, scopes, resourceTypes = [context.scopeName]) => {
|
|
651
711
|
const paths = context.queryParams?.include || []
|