json-rest-api 2.0.0 → 2.2.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/docs/API.md +80 -0
- package/docs/GUIDE/17-row-policies.md +1 -0
- package/docs/GUIDE/21-schema-and-migrations.md +154 -14
- package/docs/GUIDE/30-backend-capabilities.md +8 -5
- package/docs/GUIDE/33-migrating-to-v2.md +27 -7
- package/index.d.ts +1 -0
- package/index.js +2 -0
- package/lib/knex-transaction.js +10 -4
- package/package.json +1 -1
- package/plugins/core/lib/anyapi/anyapi-registry.js +569 -408
- package/plugins/core/lib/anyapi/schema-utils.js +101 -51
- package/plugins/core/lib/anyapi/stored-definition.js +112 -0
- package/plugins/core/lib/querying/aggregate-query.js +170 -0
- package/plugins/core/lib/querying-writing/compile-schemas.js +17 -0
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +16 -0
- package/plugins/core/lib/querying-writing/default-data-helpers.js +4 -0
- package/plugins/core/lib/querying-writing/field-types.js +43 -0
- package/plugins/core/lib/querying-writing/request-contracts.js +1 -1
- package/plugins/core/lib/storage/canonical-data-helpers.js +29 -0
- package/plugins/core/lib/storage/ordinary-data-helpers.js +25 -0
- package/plugins/core/lib/storage/storage-types.d.ts +21 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +331 -54
- package/plugins/core/rest-api-plugin-methods/aggregate.js +41 -0
- package/plugins/core/rest-api-plugin-methods/common.js +16 -1
- package/plugins/core/rest-api-plugin.js +2 -0
- package/types/anyapi.d.ts +32 -0
- package/types/plugin-options.d.ts +12 -0
- package/types/resource-methods.d.ts +19 -0
- package/types/resource-schema.d.ts +5 -3
|
@@ -5,6 +5,7 @@ import { applySortDescriptorOrder, buildEffectiveSortList, parseSortEntry, resol
|
|
|
5
5
|
import { normalizeId, resolveFieldInfo } from '../anyapi/utils/descriptor-helpers.js'
|
|
6
6
|
import { decorateResourceLinks, getAllowedQueryFieldNames } from '../anyapi/canonical-relationship-reader.js'
|
|
7
7
|
import { applyQueryConstraint } from '../querying/query-constraint.js'
|
|
8
|
+
import { runAggregateQuery } from '../querying/aggregate-query.js'
|
|
8
9
|
import { calculatePaginationMeta, generatePaginationLinks, generateCursorPaginationLinks, buildCursorMeta, applyPaginationToQuery } from '../querying/knex-pagination-helpers.js'
|
|
9
10
|
import { getUrlPrefix, buildJsonApiLink } from '../querying/url-helpers.js'
|
|
10
11
|
import { applyFieldSelectionToQuery, buildFieldSelection } from '../querying-writing/knex-field-helpers.js'
|
|
@@ -670,6 +671,33 @@ export function createCanonicalDataHelpers ({
|
|
|
670
671
|
return response
|
|
671
672
|
}
|
|
672
673
|
|
|
674
|
+
/** @type {CanonicalDataReadHelpers['dataAggregate']} */
|
|
675
|
+
const dataAggregate = async ({ scopeName, context, runHooks, plan }) => {
|
|
676
|
+
const storageAdapter = getScopeStorageAdapter(scopeName)
|
|
677
|
+
if (!storageAdapter) throw new Error(`Storage adapter for resource '${scopeName}' not found`)
|
|
678
|
+
context.storageAdapter = storageAdapter
|
|
679
|
+
const descriptor = getDescriptor(scopeName)
|
|
680
|
+
const db = context.db || context.transaction || api.knex.instance
|
|
681
|
+
const tableAlias = context.schemaInfo.tableName || descriptor.resource
|
|
682
|
+
|
|
683
|
+
const filteredState = await withQueryFilteringContext(context, {
|
|
684
|
+
query: storageAdapter.buildBaseQuery({ transaction: db, tableAlias }),
|
|
685
|
+
filters: context.queryParams.filters,
|
|
686
|
+
schemaInfo: context.schemaInfo,
|
|
687
|
+
scopeName,
|
|
688
|
+
tableName: tableAlias,
|
|
689
|
+
db,
|
|
690
|
+
queryPurpose: 'aggregate',
|
|
691
|
+
isAnyApi: true,
|
|
692
|
+
adapter: storageAdapter,
|
|
693
|
+
storageAdapter,
|
|
694
|
+
}, async () => {
|
|
695
|
+
await applyBuiltInAnyApiQueryFilters(context)
|
|
696
|
+
await runHooks('knexQueryFiltering')
|
|
697
|
+
})
|
|
698
|
+
return runAggregateQuery({ source: filteredState.query, sourceTable: tableAlias, storageAdapter, db, plan })
|
|
699
|
+
}
|
|
700
|
+
|
|
673
701
|
return {
|
|
674
702
|
dataExists,
|
|
675
703
|
dataPost,
|
|
@@ -678,6 +706,7 @@ export function createCanonicalDataHelpers ({
|
|
|
678
706
|
dataDelete,
|
|
679
707
|
dataGet,
|
|
680
708
|
dataQuery,
|
|
709
|
+
dataAggregate,
|
|
681
710
|
dataGetMinimal
|
|
682
711
|
}
|
|
683
712
|
}
|
|
@@ -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 { runAggregateQuery } from '../querying/aggregate-query.js'
|
|
12
13
|
import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
|
|
13
14
|
import { getOperationDiagnosticContext } from '../../../../lib/error-context.js'
|
|
14
15
|
import { applyInsertReturning } from '../querying-writing/database-capabilities.js'
|
|
@@ -505,6 +506,29 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
505
506
|
return buildJsonApiResponse(scope, records, included, false, scopeName, context)
|
|
506
507
|
}
|
|
507
508
|
|
|
509
|
+
/** @type {OrdinaryDataReadHelpers['dataAggregate']} */
|
|
510
|
+
const dataAggregate = async ({ scopeName, context, runHooks, plan }) => {
|
|
511
|
+
const storageAdapter = getScopeStorageAdapter(scopeName)
|
|
512
|
+
context.storageAdapter = storageAdapter
|
|
513
|
+
const tableName = storageAdapter.getTableName()
|
|
514
|
+
const db = context.db || api.knex.instance
|
|
515
|
+
|
|
516
|
+
const filteredState = await withQueryFilteringContext(context, {
|
|
517
|
+
query: db(tableName),
|
|
518
|
+
filters: context.queryParams.filters,
|
|
519
|
+
schemaInfo: context.schemaInfo,
|
|
520
|
+
scopeName,
|
|
521
|
+
tableName,
|
|
522
|
+
db,
|
|
523
|
+
queryPurpose: 'aggregate',
|
|
524
|
+
adapter: storageAdapter,
|
|
525
|
+
storageAdapter,
|
|
526
|
+
}, async () => {
|
|
527
|
+
await runHooks('knexQueryFiltering')
|
|
528
|
+
})
|
|
529
|
+
return runAggregateQuery({ source: filteredState.query, sourceTable: tableName, storageAdapter, db, plan })
|
|
530
|
+
}
|
|
531
|
+
|
|
508
532
|
/** @type {OrdinaryDataWriteHelpers['dataPost']} */
|
|
509
533
|
const dataPost = async ({ scopeName, context }) => {
|
|
510
534
|
const storageAdapter = getScopeStorageAdapter(scopeName)
|
|
@@ -701,6 +725,7 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
|
|
|
701
725
|
dataExists,
|
|
702
726
|
dataGet,
|
|
703
727
|
dataQuery,
|
|
728
|
+
dataAggregate,
|
|
704
729
|
dataPost,
|
|
705
730
|
dataPut,
|
|
706
731
|
dataPatch,
|
|
@@ -307,6 +307,27 @@ export interface DataReadHelpers<GetResult> {
|
|
|
307
307
|
queryParams: NonNullable<DataReadContext['queryParams']>
|
|
308
308
|
}
|
|
309
309
|
}) => Promise<DataDocument<DataResource[]>>
|
|
310
|
+
// The plan comes from compileAggregateRequest; filters are in context.queryParams.
|
|
311
|
+
dataAggregate: (request: DataReadRequest & {
|
|
312
|
+
runHooks: NonNullable<DataReadRequest['runHooks']>
|
|
313
|
+
context: DataReadContext & {
|
|
314
|
+
queryParams: NonNullable<DataReadContext['queryParams']>
|
|
315
|
+
}
|
|
316
|
+
plan: AggregatePlan
|
|
317
|
+
}) => Promise<AggregateResult>
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Compiled by compileAggregateRequest: outputs carry SQL aliases.
|
|
321
|
+
export interface AggregatePlan {
|
|
322
|
+
scopeName: string
|
|
323
|
+
groups: Array<{ name: string; field: string; definition: StorageFieldDefinition; alias: string }>
|
|
324
|
+
measures: Array<{ name: string; fn: 'count' | 'countDistinct' | 'sum' | 'avg' | 'min' | 'max'; field: string; definition: StorageFieldDefinition | null; alias: string }>
|
|
325
|
+
sort: Array<{ alias: string; direction: 'asc' | 'desc' }>
|
|
326
|
+
page: { size: number; number: number } | null
|
|
327
|
+
}
|
|
328
|
+
export interface AggregateResult {
|
|
329
|
+
data: Array<Record<string, unknown>>
|
|
330
|
+
meta?: { page: { size: number; number: number; hasMore: boolean } }
|
|
310
331
|
}
|
|
311
332
|
|
|
312
333
|
export type OrdinaryDataReadHelpers = DataReadHelpers<DataDocument<DataResource>>
|
|
@@ -5,15 +5,30 @@ import { createKnexTransaction } from '../../lib/knex-transaction.js'
|
|
|
5
5
|
import { createEnhancedLogger } from '../../lib/enhanced-logger.js'
|
|
6
6
|
import { ensureAnyApiSchema } from './lib/anyapi/schema-utils.js'
|
|
7
7
|
import { AnyapiRegistry } from './lib/anyapi/anyapi-registry.js'
|
|
8
|
-
import {
|
|
8
|
+
import { composeResourceOptions, relatedResources } from './lib/anyapi/stored-definition.js'
|
|
9
|
+
import { RestApiResourceError, RestApiValidationError } from '../../lib/rest-api-errors.js'
|
|
9
10
|
import { compileSchemas } from './lib/querying-writing/compile-schemas.js'
|
|
10
11
|
import { snapshotResourceConfiguration } from './lib/querying-writing/schema-helpers.js'
|
|
11
12
|
import { assertFieldNameMap } from './lib/querying-writing/field-utils.js'
|
|
12
13
|
import { polymorphicFiltersHook, crossTableFiltersHook, basicFiltersHook } from './lib/querying/knex-query-helpers.js'
|
|
13
14
|
import { createStorageAdapterLookup } from './lib/storage/storage-adapter.js'
|
|
14
|
-
import {
|
|
15
|
+
import { translateCanonicalAttributesForStorage } from './lib/storage/canonical-storage-mapping.js'
|
|
16
|
+
import { applyDatabaseReadOptions, normalizeStoredFieldValue } from './lib/querying-writing/database-value-normalizers.js'
|
|
15
17
|
|
|
16
18
|
const DEFAULT_TENANT = 'default'
|
|
19
|
+
const MIGRATION_BATCH_SIZE = 500
|
|
20
|
+
// Methods that read a published schema; schema checks wrap these.
|
|
21
|
+
const REQUEST_METHODS = ['query', 'aggregate', 'get', 'post', 'put', 'patch', 'delete', 'getRelated', 'getRelationship', 'postRelationship', 'patchRelationship', 'deleteRelationship']
|
|
22
|
+
|
|
23
|
+
const optionsSignature = options => JSON.stringify([
|
|
24
|
+
Object.keys(options.schema || {}).sort(),
|
|
25
|
+
Object.keys(options.searchSchema || {}).sort(),
|
|
26
|
+
Object.entries(options.schema || {}).filter(([, definition]) => definition?.retired === true).map(([name]) => name).sort()
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
// A validation failure may come from a schema another server already changed.
|
|
30
|
+
// Writes that may have committed are never repeated.
|
|
31
|
+
const isStaleSchemaRejection = error => error?.type === 'rest_api_validation' && !['committed', 'unknown'].includes(error.transactionOutcome)
|
|
17
32
|
|
|
18
33
|
export const RestApiAnyapiKnexPlugin = {
|
|
19
34
|
name: 'rest-api-anyapi-knex',
|
|
@@ -24,14 +39,23 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
24
39
|
const options = pluginOptions || {}
|
|
25
40
|
const knex = options.knex
|
|
26
41
|
const tenantId = options.tenantId || DEFAULT_TENANT
|
|
42
|
+
const { ensureSchema = true, ownsKnex = true, onSchemaChanged, schemaCheckInterval, slots } = options
|
|
27
43
|
|
|
28
44
|
if (!knex) {
|
|
29
45
|
throw new Error('AnyapiKnexPlugin requires a knex instance')
|
|
30
46
|
}
|
|
47
|
+
if (options.registry !== undefined && (!(options.registry instanceof AnyapiRegistry) || options.registry.knex !== knex)) {
|
|
48
|
+
throw new Error('AnyapiKnexPlugin registry must be an AnyapiRegistry for the same knex instance')
|
|
49
|
+
}
|
|
50
|
+
if (onSchemaChanged !== undefined && typeof onSchemaChanged !== 'function') {
|
|
51
|
+
throw new Error('AnyapiKnexPlugin onSchemaChanged must be a function')
|
|
52
|
+
}
|
|
53
|
+
if (schemaCheckInterval !== undefined && !(Number.isFinite(schemaCheckInterval) && schemaCheckInterval >= 0)) {
|
|
54
|
+
throw new Error('AnyapiKnexPlugin schemaCheckInterval must be a non-negative number of milliseconds')
|
|
55
|
+
}
|
|
31
56
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const registry = new AnyapiRegistry({ knex, log })
|
|
57
|
+
const registry = options.registry || new AnyapiRegistry({ knex, log })
|
|
58
|
+
if (ensureSchema) await registry.ensureSchema({ slots })
|
|
35
59
|
|
|
36
60
|
api.anyapi = api.anyapi || {}
|
|
37
61
|
api.anyapi.registry = registry
|
|
@@ -39,7 +63,7 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
39
63
|
api.knex = {
|
|
40
64
|
instance: knex,
|
|
41
65
|
helpers: {},
|
|
42
|
-
capabilities: await
|
|
66
|
+
capabilities: await registry.getCapabilities(log),
|
|
43
67
|
}
|
|
44
68
|
|
|
45
69
|
const getScopeStorageAdapter = createStorageAdapterLookup({
|
|
@@ -50,13 +74,21 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
50
74
|
api.knex.helpers.getStorageAdapter = getScopeStorageAdapter
|
|
51
75
|
helpers.getStorageAdapter = getScopeStorageAdapter
|
|
52
76
|
|
|
53
|
-
|
|
77
|
+
// Owned copies of what this server declared and added, callbacks included.
|
|
78
|
+
const declarations = new Map()
|
|
79
|
+
const runtimeDeclarations = new Map()
|
|
80
|
+
const storageLoaded = new Set()
|
|
81
|
+
const compiledSignatures = new Map()
|
|
82
|
+
const lastChecked = new Map()
|
|
83
|
+
const reloads = new Map()
|
|
84
|
+
const checks = new Map()
|
|
85
|
+
const loading = new Map()
|
|
54
86
|
|
|
55
87
|
/** @type {import('../../lib/transaction-types.js').TransactionFactory} */
|
|
56
88
|
helpers.newTransaction = context => createKnexTransaction(knex, context)
|
|
57
89
|
|
|
58
90
|
addHook('release', 'anyapi-knex-release', {}, async ({ api }) => {
|
|
59
|
-
if (api.knex?.instance) {
|
|
91
|
+
if (ownsKnex && api.knex?.instance) {
|
|
60
92
|
await api.knex.instance.destroy()
|
|
61
93
|
}
|
|
62
94
|
})
|
|
@@ -76,11 +108,38 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
76
108
|
api, knex, getDescriptor, getScopeStorageAdapter, linkStore
|
|
77
109
|
})
|
|
78
110
|
|
|
79
|
-
const
|
|
111
|
+
const publishedVersion = scopeName => api.resources[scopeName]?.vars?.schemaInfo?.descriptor?.schemaVersion ?? 0
|
|
112
|
+
|
|
113
|
+
const composeOptions = (scopeName, descriptor) => composeResourceOptions({
|
|
114
|
+
declaration: declarations.get(scopeName),
|
|
115
|
+
stored: descriptor.definition,
|
|
116
|
+
local: runtimeDeclarations.get(scopeName),
|
|
117
|
+
fromStorage: storageLoaded.has(scopeName)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const compileOptions = async (scopeName, scopeOptions) => {
|
|
121
|
+
const compiled = { scopeOptions, vars: { idProperty: api.resources[scopeName].vars.idProperty } }
|
|
122
|
+
await compileSchemas(compiled, { context: { scopeName }, runHooks: (name, context) => api.runHooks(name, context) })
|
|
123
|
+
return compiled.vars.schemaInfo
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Publish a descriptor with the schema it implies. Recompile only when the
|
|
127
|
+
// field set changed, so schema hooks do not rerun on every refresh.
|
|
128
|
+
const publish = async (scopeName, descriptor, { compiled } = {}) => {
|
|
80
129
|
const scope = api.resources[scopeName]
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
if (
|
|
130
|
+
if (publishedVersion(scopeName) > descriptor.schemaVersion) return
|
|
131
|
+
const { options: scopeOptions, hidden, hiddenFilters } = composeOptions(scopeName, descriptor)
|
|
132
|
+
if (hidden.length || hiddenFilters.length) {
|
|
133
|
+
await log.warn(`AnyAPI resource '${scopeName}' hides stored fields whose callbacks this server does not declare`, {
|
|
134
|
+
tenant: tenantId, scopeName, fields: hidden, filters: hiddenFilters
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
const signature = optionsSignature(scopeOptions)
|
|
138
|
+
let schemaInfo = scope.vars.schemaInfo
|
|
139
|
+
if (compiled?.signature === signature) schemaInfo = compiled.schemaInfo
|
|
140
|
+
else if (compiledSignatures.get(scopeName) !== signature) schemaInfo = await compileOptions(scopeName, scopeOptions)
|
|
141
|
+
compiledSignatures.set(scopeName, signature)
|
|
142
|
+
|
|
84
143
|
descriptor.idProperty = schemaInfo.idProperty
|
|
85
144
|
scope.vars.schemaInfo = {
|
|
86
145
|
...schemaInfo,
|
|
@@ -88,10 +147,124 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
88
147
|
canonicalFieldMap: descriptor.canonicalFieldMap,
|
|
89
148
|
}
|
|
90
149
|
getScopeStorageAdapter(scopeName)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
150
|
+
lastChecked.set(scopeName, Date.now())
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Reloads of one resource run in order, so an older read never wins.
|
|
154
|
+
const reload = (scopeName) => {
|
|
155
|
+
const run = (reloads.get(scopeName) || Promise.resolve()).catch(() => {}).then(async () => {
|
|
156
|
+
registry.invalidateDescriptor(tenantId, scopeName)
|
|
157
|
+
const descriptor = await registry.getDescriptor(tenantId, scopeName)
|
|
158
|
+
if (!descriptor) {
|
|
159
|
+
throw new RestApiResourceError(`Resource '${scopeName}' is not registered in AnyAPI storage`, {
|
|
160
|
+
subtype: 'not_found', resourceType: 'scope', resourceId: scopeName
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
await publish(scopeName, descriptor)
|
|
164
|
+
return publishedVersion(scopeName)
|
|
94
165
|
})
|
|
166
|
+
reloads.set(scopeName, run)
|
|
167
|
+
run.finally(() => { if (reloads.get(scopeName) === run) reloads.delete(scopeName) }).catch(() => {})
|
|
168
|
+
return run
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const notifySchemaChanged = async (scopeName, version) => {
|
|
172
|
+
if (!onSchemaChanged) return
|
|
173
|
+
try {
|
|
174
|
+
await onSchemaChanged({ tenant: tenantId, resource: scopeName, version })
|
|
175
|
+
} catch (error) {
|
|
176
|
+
// The change is committed; other servers still catch up through schema checks.
|
|
177
|
+
await log.logError('AnyAPI onSchemaChanged failed', error, { tenant: tenantId, scopeName, version })
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const ensureFresh = (scopeName, maxAge) => {
|
|
182
|
+
if (maxAge > 0 && Date.now() - (lastChecked.get(scopeName) ?? 0) < maxAge) return false
|
|
183
|
+
if (!checks.has(scopeName)) {
|
|
184
|
+
const check = (async () => {
|
|
185
|
+
lastChecked.set(scopeName, Date.now())
|
|
186
|
+
const version = await registry.getSchemaVersion(tenantId, scopeName)
|
|
187
|
+
if (version === null || version <= publishedVersion(scopeName)) return false
|
|
188
|
+
await reload(scopeName)
|
|
189
|
+
return true
|
|
190
|
+
})()
|
|
191
|
+
checks.set(scopeName, check)
|
|
192
|
+
check.finally(() => checks.delete(scopeName)).catch(() => {})
|
|
193
|
+
}
|
|
194
|
+
return checks.get(scopeName)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const watchSchema = (scope) => {
|
|
198
|
+
for (const name of REQUEST_METHODS) {
|
|
199
|
+
const method = scope[name]
|
|
200
|
+
if (typeof method !== 'function') continue
|
|
201
|
+
scope[name] = async function (params = {}, context = {}) {
|
|
202
|
+
// A caller transaction may hold uncommitted metadata; never publish from it.
|
|
203
|
+
if (params?.transaction) return method.call(this, params, context)
|
|
204
|
+
await ensureFresh(scope.name, schemaCheckInterval)
|
|
205
|
+
try {
|
|
206
|
+
return await method.call(this, params, context)
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (!isStaleSchemaRejection(error)) throw error
|
|
209
|
+
let reloaded = false
|
|
210
|
+
try { reloaded = await ensureFresh(scope.name, 0) } catch { throw error }
|
|
211
|
+
if (!reloaded) throw error
|
|
212
|
+
return method.call(this, params, context)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const loadResource = async (name, chain = new Set()) => {
|
|
219
|
+
if (typeof name !== 'string' || !name) throw new TypeError('loadResource requires a resource name')
|
|
220
|
+
if (Object.hasOwn(api.resources, name)) return api.resources[name]
|
|
221
|
+
if (chain.has(name)) return null
|
|
222
|
+
if (!loading.has(name)) {
|
|
223
|
+
const load = (async () => {
|
|
224
|
+
const descriptor = await registry.getDescriptor(tenantId, name)
|
|
225
|
+
if (!descriptor) return null
|
|
226
|
+
if (!descriptor.definition?.options) {
|
|
227
|
+
throw new Error(`AnyAPI resource '${name}' has no stored definition; declare it in code or register it again with this version`)
|
|
228
|
+
}
|
|
229
|
+
const { options: declaration } = composeResourceOptions({
|
|
230
|
+
declaration: descriptor.definition.options,
|
|
231
|
+
stored: { callbacks: descriptor.definition.callbacks },
|
|
232
|
+
fromStorage: true
|
|
233
|
+
})
|
|
234
|
+
const { before, after } = relatedResources(declaration)
|
|
235
|
+
const nextChain = new Set([...chain, name])
|
|
236
|
+
for (const target of before) await loadResource(target, nextChain)
|
|
237
|
+
if (Object.hasOwn(api.resources, name)) return api.resources[name]
|
|
238
|
+
storageLoaded.add(name)
|
|
239
|
+
try {
|
|
240
|
+
await api.addResource(name, declaration)
|
|
241
|
+
} catch (error) {
|
|
242
|
+
// A failed registration must not leave a half-published resource behind.
|
|
243
|
+
storageLoaded.delete(name)
|
|
244
|
+
declarations.delete(name)
|
|
245
|
+
delete api.resources[name]
|
|
246
|
+
throw error
|
|
247
|
+
}
|
|
248
|
+
for (const target of after) await loadResource(target, nextChain)
|
|
249
|
+
return api.resources[name]
|
|
250
|
+
})()
|
|
251
|
+
loading.set(name, load)
|
|
252
|
+
load.finally(() => loading.delete(name)).catch(() => {})
|
|
253
|
+
}
|
|
254
|
+
return loading.get(name)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
api.anyapi.loadResource = name => loadResource(name)
|
|
258
|
+
api.anyapi.loadResources = async () => {
|
|
259
|
+
for (const { resource } of await registry.listResources(tenantId)) await loadResource(resource)
|
|
260
|
+
return api.resources
|
|
261
|
+
}
|
|
262
|
+
api.anyapi.getSchemaVersion = resource => registry.getSchemaVersion(tenantId, resource)
|
|
263
|
+
api.anyapi.handleSchemaChange = async ({ tenant, resource, version } = {}) => {
|
|
264
|
+
if ((tenant !== undefined && tenant !== tenantId) || typeof resource !== 'string' || !Object.hasOwn(api.resources, resource)) return false
|
|
265
|
+
if (version !== undefined && publishedVersion(resource) >= version) return false
|
|
266
|
+
await reload(resource)
|
|
267
|
+
return true
|
|
95
268
|
}
|
|
96
269
|
|
|
97
270
|
const buildSearchMembershipQuery = ({ sourceScopeName, relationshipName, targetScopeName, foreignKey, otherKey, db }) => {
|
|
@@ -131,28 +304,77 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
131
304
|
addHook('resource:added', 'anyapi-register-resource', {}, async ({ context }) => {
|
|
132
305
|
const { scopeName, scopeOptions = {} } = context
|
|
133
306
|
const scope = api.resources[scopeName]
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
307
|
+
const declaration = snapshotResourceConfiguration(scopeOptions)
|
|
308
|
+
declarations.set(scopeName, declaration)
|
|
309
|
+
compiledSignatures.set(scopeName, optionsSignature(declaration))
|
|
310
|
+
let descriptor
|
|
311
|
+
let changed = false
|
|
312
|
+
if (storageLoaded.has(scopeName)) {
|
|
313
|
+
descriptor = await registry.getDescriptor(tenantId, scopeName)
|
|
314
|
+
if (!descriptor) throw new Error(`Descriptor not found for resource '${scopeName}'`)
|
|
315
|
+
} else {
|
|
316
|
+
const { schemaStructure, computed, schemaRelationships, idProperty } = scope.vars.schemaInfo
|
|
317
|
+
;({ descriptor, changed } = await registry.register({
|
|
318
|
+
tenant: tenantId,
|
|
319
|
+
resource: scopeName,
|
|
320
|
+
schema: { ...schemaStructure, ...computed },
|
|
321
|
+
relationships: schemaRelationships,
|
|
322
|
+
canonicalFieldMap: scopeOptions.canonicalFieldsMap || null,
|
|
323
|
+
idProperty,
|
|
324
|
+
declaration
|
|
325
|
+
}))
|
|
326
|
+
}
|
|
327
|
+
await publish(scopeName, descriptor)
|
|
328
|
+
if (schemaCheckInterval !== undefined) watchSchema(scope)
|
|
329
|
+
if (changed) await notifySchemaChanged(scopeName, descriptor.schemaVersion)
|
|
149
330
|
})
|
|
150
331
|
|
|
151
332
|
addResourceMethod('createKnexTable', async ({ scopeName }) => {
|
|
152
|
-
await ensureAnyApiSchema(knex)
|
|
153
|
-
await
|
|
333
|
+
if (ensureSchema) await ensureAnyApiSchema(knex, { slots })
|
|
334
|
+
const descriptor = await registry.getDescriptor(tenantId, scopeName)
|
|
335
|
+
if (!descriptor) throw new Error(`Descriptor not found for resource '${scopeName}'`)
|
|
336
|
+
await publish(scopeName, descriptor)
|
|
154
337
|
})
|
|
155
338
|
|
|
339
|
+
addResourceMethod('reloadSchema', async ({ scopeName }) => reload(scopeName))
|
|
340
|
+
|
|
341
|
+
// Compile the resource with the additions, so allocation stores enriched definitions.
|
|
342
|
+
const prepareAdditions = async (scopeName, { fields, searchSchema = {}, canonicalFieldsMap }) => {
|
|
343
|
+
const current = composeOptions(scopeName, api.resources[scopeName].vars.schemaInfo.descriptor).options
|
|
344
|
+
const candidate = snapshotResourceConfiguration({
|
|
345
|
+
...current,
|
|
346
|
+
schema: { ...current.schema, ...fields },
|
|
347
|
+
searchSchema: { ...current.searchSchema, ...searchSchema }
|
|
348
|
+
})
|
|
349
|
+
const schemaInfo = await compileOptions(scopeName, candidate)
|
|
350
|
+
const allocation = {
|
|
351
|
+
tenant: tenantId,
|
|
352
|
+
resource: scopeName,
|
|
353
|
+
fields: Object.fromEntries(Object.keys(fields).map(fieldName => [fieldName, {
|
|
354
|
+
definition: schemaInfo.schemaStructure[fieldName] || schemaInfo.computed[fieldName],
|
|
355
|
+
declaration: fields[fieldName],
|
|
356
|
+
canonicalField: canonicalFieldsMap?.[fieldName]
|
|
357
|
+
}])),
|
|
358
|
+
searchSchema
|
|
359
|
+
}
|
|
360
|
+
return { candidate, schemaInfo, allocation }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const publishChange = async (scopeName, { fields = {}, searchSchema = {}, compiled } = {}) => {
|
|
364
|
+
registry.invalidateDescriptor(tenantId, scopeName)
|
|
365
|
+
if (Object.keys(fields).length || Object.keys(searchSchema).length) {
|
|
366
|
+
const local = runtimeDeclarations.get(scopeName) || {}
|
|
367
|
+
runtimeDeclarations.set(scopeName, {
|
|
368
|
+
schema: { ...local.schema, ...fields },
|
|
369
|
+
searchSchema: { ...local.searchSchema, ...searchSchema }
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
// The fresh descriptor also carries changes other servers made meanwhile.
|
|
373
|
+
const descriptor = await registry.getDescriptor(tenantId, scopeName)
|
|
374
|
+
await publish(scopeName, descriptor, { compiled })
|
|
375
|
+
await notifySchemaChanged(scopeName, descriptor.schemaVersion)
|
|
376
|
+
}
|
|
377
|
+
|
|
156
378
|
addResourceMethod('addKnexFields', async ({ scopeName, params }) => {
|
|
157
379
|
if (!params?.fields || typeof params.fields !== 'object' || Array.isArray(params.fields)) {
|
|
158
380
|
throw new Error('fields parameter is required for addKnexFields')
|
|
@@ -160,35 +382,90 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
160
382
|
assertFieldNameMap(params.fields, `added fields in '${scopeName}'`)
|
|
161
383
|
assertFieldNameMap(params.searchSchema, `added search schema in '${scopeName}'`)
|
|
162
384
|
assertFieldNameMap(params.canonicalFieldsMap, `added canonical fields in '${scopeName}'`)
|
|
163
|
-
const scope = api.resources[scopeName]
|
|
164
|
-
const storedBefore = scopeOptionsRegistry.get(scopeName) || {}
|
|
165
385
|
const unknown = Object.keys(params.canonicalFieldsMap || {}).filter(field => !Object.hasOwn(params.fields, field))
|
|
166
386
|
if (unknown.length) {
|
|
167
387
|
throw new Error(`canonicalFieldsMap contains unknown fields for addKnexFields: ${unknown}`)
|
|
168
388
|
}
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
389
|
+
const fields = snapshotResourceConfiguration(params.fields)
|
|
390
|
+
const searchSchema = snapshotResourceConfiguration(params.searchSchema || {})
|
|
391
|
+
const { candidate, schemaInfo, allocation } = await prepareAdditions(scopeName, { fields, searchSchema, canonicalFieldsMap: params.canonicalFieldsMap })
|
|
392
|
+
await api.transaction(transaction => registry.allocateFields(allocation, { transaction }))
|
|
393
|
+
await publishChange(scopeName, { fields, searchSchema, compiled: { signature: optionsSignature(candidate), schemaInfo } })
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
const retirable = (scopeName, fieldName) => {
|
|
397
|
+
const definition = api.resources[scopeName].vars.schemaInfo.schemaStructure[fieldName]
|
|
398
|
+
if (!definition || fieldName === 'id' || definition.virtual === true || definition.belongsTo) {
|
|
399
|
+
throw new Error(`'${scopeName}.${fieldName}' is not a stored attribute`)
|
|
400
|
+
}
|
|
401
|
+
return definition
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
addResourceMethod('retireKnexFields', async ({ scopeName, params }) => {
|
|
405
|
+
const fields = Array.isArray(params) ? params : params?.fields
|
|
406
|
+
if (!Array.isArray(fields) || !fields.length) throw new Error('retireKnexFields requires an array of field names')
|
|
407
|
+
fields.forEach(fieldName => retirable(scopeName, fieldName))
|
|
408
|
+
await registry.retireFields({ tenant: tenantId, resource: scopeName, fields })
|
|
409
|
+
await publishChange(scopeName)
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
// Change a field's type: copy its values into a new field, then retire it.
|
|
413
|
+
addResourceMethod('migrateField', async ({ scopeName, params }) => {
|
|
414
|
+
const { from, to, transform } = params || {}
|
|
415
|
+
const { name, ...declaration } = to || {}
|
|
416
|
+
if (typeof from !== 'string' || typeof name !== 'string' || !name) {
|
|
417
|
+
throw new Error('migrateField requires from and to: { name, ...definition }')
|
|
418
|
+
}
|
|
419
|
+
if (transform !== undefined && typeof transform !== 'function') throw new Error('migrateField transform must be a function')
|
|
420
|
+
const fromDefinition = retirable(scopeName, from)
|
|
421
|
+
if (fromDefinition.retired) throw new Error(`Field '${scopeName}.${from}' is already retired`)
|
|
422
|
+
if (declaration.computed || declaration.virtual || declaration.belongsTo || declaration.getter || declaration.setter || declaration.storage) {
|
|
423
|
+
throw new Error(`migrateField target '${name}' must be a plain stored attribute; add other fields with addKnexFields`)
|
|
424
|
+
}
|
|
425
|
+
assertFieldNameMap({ [name]: declaration }, `migrated field in '${scopeName}'`)
|
|
426
|
+
const fields = snapshotResourceConfiguration({ [name]: declaration })
|
|
427
|
+
const { schemaInfo, allocation } = await prepareAdditions(scopeName, { fields })
|
|
428
|
+
|
|
429
|
+
await api.transaction(async (transaction) => {
|
|
430
|
+
const descriptor = await registry.allocateFields(allocation, { transaction })
|
|
431
|
+
await copyFieldValues({ transaction, descriptor, schemaInfo, from, fromDefinition, to: name, transform })
|
|
432
|
+
await registry.retireFields({ tenant: tenantId, resource: scopeName, fields: [from] }, { transaction })
|
|
186
433
|
})
|
|
187
|
-
|
|
188
|
-
scopeOptionsRegistry.set(scopeName, updatedStored)
|
|
189
|
-
await refreshStorageDescriptor(scopeName, compiled.vars.schemaInfo)
|
|
434
|
+
await publishChange(scopeName, { fields })
|
|
190
435
|
})
|
|
191
436
|
|
|
437
|
+
const copyFieldValues = async ({ transaction, descriptor, schemaInfo, from, fromDefinition, to, transform }) => {
|
|
438
|
+
const { canonical } = descriptor
|
|
439
|
+
const source = descriptor.fields[from].slot
|
|
440
|
+
const target = descriptor.fields[to].slot
|
|
441
|
+
const idColumn = canonical.logicalIdColumn
|
|
442
|
+
for (let lastId = null; ;) {
|
|
443
|
+
const query = transaction(canonical.tableName)
|
|
444
|
+
.where(canonical.tenantColumn, tenantId)
|
|
445
|
+
.where(canonical.resourceColumn, descriptor.resource)
|
|
446
|
+
.orderBy('id')
|
|
447
|
+
.limit(MIGRATION_BATCH_SIZE)
|
|
448
|
+
.select('id', idColumn, source)
|
|
449
|
+
if (lastId !== null) query.where('id', '>', lastId)
|
|
450
|
+
const rows = await applyDatabaseReadOptions(query)
|
|
451
|
+
if (!rows.length) return
|
|
452
|
+
for (const row of rows) {
|
|
453
|
+
lastId = row.id
|
|
454
|
+
const value = normalizeStoredFieldValue(row[source], fromDefinition, { fieldName: from, resourceType: descriptor.resource })
|
|
455
|
+
const next = transform ? await transform(value, { id: String(row[idColumn]) }) : value
|
|
456
|
+
const { validatedObject, errors } = await schemaInfo.schemaInstance.patch({ [to]: next ?? null })
|
|
457
|
+
if (Object.keys(errors).length) {
|
|
458
|
+
const message = `Record '${row[idColumn]}' cannot migrate '${from}' to '${to}': ${Object.values(errors)[0].message}`
|
|
459
|
+
throw new RestApiValidationError(message, { fields: [to], violations: [{ field: to, rule: 'field_migration', message }] })
|
|
460
|
+
}
|
|
461
|
+
const stored = translateCanonicalAttributesForStorage({ [to]: validatedObject[to] ?? null }, descriptor, {
|
|
462
|
+
schemaInfo, databaseClient: knex.client.config.client
|
|
463
|
+
})
|
|
464
|
+
await transaction(canonical.tableName).where('id', row.id).update({ [target]: stored[target] ?? null })
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
192
469
|
addResourceMethod('alterKnexFields', async () => {
|
|
193
470
|
throw new Error('alterKnexFields is not supported by AnyAPI Knex plugin yet')
|
|
194
471
|
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
|
|
2
|
+
import { getEffectiveSortableFields } from '../lib/querying/query-field-sort-helpers.js'
|
|
3
|
+
import { compileAggregateRequest } from '../lib/querying/aggregate-query.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Group a filtered collection and compute measures for each group.
|
|
7
|
+
* Filters are validated like query filters; permissions are checked as a
|
|
8
|
+
* query, and row policies and autofilters see queryPurpose 'aggregate'.
|
|
9
|
+
* Returns plain rows in either format; grouped results are paged.
|
|
10
|
+
*/
|
|
11
|
+
export default async function aggregateMethod ({ params, context, vars, helpers, scope, runHooks, scopeName, api }) {
|
|
12
|
+
context.method = 'aggregate'
|
|
13
|
+
context.scopeName = scopeName
|
|
14
|
+
context.schemaInfo = scope.vars.schemaInfo
|
|
15
|
+
|
|
16
|
+
const plan = compileAggregateRequest({
|
|
17
|
+
params,
|
|
18
|
+
schemaInfo: context.schemaInfo,
|
|
19
|
+
scopeName,
|
|
20
|
+
defaultLimit: vars.queryDefaultLimit,
|
|
21
|
+
maxLimit: vars.queryMaxLimit
|
|
22
|
+
})
|
|
23
|
+
const requestContracts = getRequestContracts({
|
|
24
|
+
scopeName,
|
|
25
|
+
schemaInfo: context.schemaInfo,
|
|
26
|
+
includeDepthLimit: vars.includeDepthLimit,
|
|
27
|
+
sortableFields: getEffectiveSortableFields(vars)
|
|
28
|
+
})
|
|
29
|
+
const validated = validateRequestContractOrThrow(
|
|
30
|
+
requestContracts.query,
|
|
31
|
+
{ queryParams: { filters: params.queryParams?.filters ?? {} } },
|
|
32
|
+
'Aggregate filters are invalid'
|
|
33
|
+
)
|
|
34
|
+
context.queryParams = { filters: validated.queryParams?.filters || {} }
|
|
35
|
+
|
|
36
|
+
context.transaction = params.transaction
|
|
37
|
+
context.db = context.transaction || api.knex.instance
|
|
38
|
+
|
|
39
|
+
await scope.checkPermissions({ method: 'query', originalContext: context })
|
|
40
|
+
return helpers.dataAggregate({ scopeName, context, runHooks, plan })
|
|
41
|
+
}
|