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
|
@@ -213,7 +213,7 @@ export const validateCompleteReplacePayload = ({
|
|
|
213
213
|
if (!fieldDef) continue
|
|
214
214
|
if (fieldName === idProperty) continue
|
|
215
215
|
if (Object.hasOwn(belongsToUpdates, fieldName)) continue
|
|
216
|
-
if (fieldDef.computed === true || fieldDef.virtual === true) continue
|
|
216
|
+
if (fieldDef.computed === true || fieldDef.virtual === true || fieldDef.retired === true) continue
|
|
217
217
|
if (fieldDef.type === undefined) continue
|
|
218
218
|
|
|
219
219
|
if (fieldDef.belongsTo && fieldDef.as) {
|
|
@@ -296,8 +296,16 @@ export const validateResourceAttributesBeforeWrite = async ({
|
|
|
296
296
|
/** @type {StorageRow} */
|
|
297
297
|
const attributesForValidation = { ...attributesToValidate }
|
|
298
298
|
const foreignKeyViolations = []
|
|
299
|
+
const retiredViolations = []
|
|
299
300
|
for (const [field, value] of Object.entries(attributesToValidate)) {
|
|
300
301
|
const definition = schemaStructure[field]
|
|
302
|
+
if (definition?.retired === true && value !== undefined) {
|
|
303
|
+
retiredViolations.push({
|
|
304
|
+
field: `data.attributes.${field}`,
|
|
305
|
+
rule: 'retired_field',
|
|
306
|
+
message: `Field '${field}' is retired and accepts no new values`
|
|
307
|
+
})
|
|
308
|
+
}
|
|
301
309
|
if (!definition?.belongsTo || !definition.as || Object.hasOwn(belongsToUpdates, field)) continue
|
|
302
310
|
delete attributesForValidation[field]
|
|
303
311
|
if (value === undefined) continue
|
|
@@ -307,6 +315,13 @@ export const validateResourceAttributesBeforeWrite = async ({
|
|
|
307
315
|
message: `Foreign key field '${field}' should not be in attributes. Use 'data.relationships.${definition.as}' instead.`
|
|
308
316
|
})
|
|
309
317
|
}
|
|
318
|
+
const [firstRetired] = retiredViolations
|
|
319
|
+
if (firstRetired) {
|
|
320
|
+
throw new RestApiValidationError(firstRetired.message, {
|
|
321
|
+
fields: retiredViolations.map(violation => violation.field),
|
|
322
|
+
violations: retiredViolations
|
|
323
|
+
})
|
|
324
|
+
}
|
|
310
325
|
if (foreignKeyViolations.length) {
|
|
311
326
|
throw new RestApiValidationError('Foreign key fields cannot be set directly in attributes', {
|
|
312
327
|
fields: foreignKeyViolations.map(violation => violation.field),
|
|
@@ -10,6 +10,7 @@ import registerRelationshipRoutes from './rest-api-plugin-hooks/register-relatio
|
|
|
10
10
|
|
|
11
11
|
// Import method functions
|
|
12
12
|
import queryMethod from './rest-api-plugin-methods/query.js'
|
|
13
|
+
import aggregateMethod from './rest-api-plugin-methods/aggregate.js'
|
|
13
14
|
import getMethod from './rest-api-plugin-methods/get.js'
|
|
14
15
|
import postMethod from './rest-api-plugin-methods/post.js'
|
|
15
16
|
import putMethod from './rest-api-plugin-methods/put.js'
|
|
@@ -90,6 +91,7 @@ export const RestApiPlugin = {
|
|
|
90
91
|
|
|
91
92
|
// Main REST methods
|
|
92
93
|
addResourceMethod('query', withAvailableContext(queryMethod))
|
|
94
|
+
addResourceMethod('aggregate', withAvailableContext(aggregateMethod))
|
|
93
95
|
addResourceMethod('get', withAvailableContext(getMethod))
|
|
94
96
|
addResourceMethod('post', withWriteOutcome(postMethod))
|
|
95
97
|
addResourceMethod('put', withWriteOutcome(putMethod))
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Knex } from 'knex'
|
|
2
|
+
|
|
3
|
+
export type AnyapiSlotPool = 'string' | 'number' | 'boolean' | 'date' | 'json' | 'belongsTo'
|
|
4
|
+
export type AnyapiSlotCounts = Partial<Record<AnyapiSlotPool, number>>
|
|
5
|
+
|
|
6
|
+
export interface AnyapiSchemaChange {
|
|
7
|
+
tenant: string
|
|
8
|
+
resource: string
|
|
9
|
+
version: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AnyapiRegistryOptions {
|
|
13
|
+
knex: Knex
|
|
14
|
+
log?: Partial<Record<'debug' | 'info' | 'warn' | 'error', (...args: unknown[]) => unknown>>
|
|
15
|
+
// Descriptors cached across every tenant API that shares the registry.
|
|
16
|
+
cacheSize?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Canonical metadata shared by tenant APIs that use one Knex instance. */
|
|
20
|
+
export class AnyapiRegistry {
|
|
21
|
+
constructor(options: AnyapiRegistryOptions)
|
|
22
|
+
readonly knex: Knex
|
|
23
|
+
readonly cacheSize: number
|
|
24
|
+
ensureSchema(options?: { slots?: AnyapiSlotCounts }): Promise<void>
|
|
25
|
+
getSchemaVersion(tenant: string, resource: string, options?: { transaction?: Knex.Transaction }): Promise<number | null>
|
|
26
|
+
// Retired fields keep their slots and values and reject new writes.
|
|
27
|
+
retireFields(params: { tenant: string; resource: string; fields: string[] }, options?: { transaction?: Knex.Transaction }): Promise<Record<string, unknown>>
|
|
28
|
+
invalidateDescriptor(tenant: string, resource: string): void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Create or upgrade the canonical tables; counts only ever add slot columns. */
|
|
32
|
+
export function ensureAnyApiSchema(knex: Knex, options?: { slots?: AnyapiSlotCounts }): Promise<void>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { DirectResourceId, ResourceFormat, WriteReturning } from './representations.js'
|
|
2
2
|
import type { RemovedResourceOptions } from './resource-methods.js'
|
|
3
3
|
import type { Knex } from 'knex'
|
|
4
|
+
import type { AnyapiRegistry, AnyapiSchemaChange, AnyapiSlotCounts } from './anyapi.js'
|
|
4
5
|
|
|
5
6
|
export type RestApiPluginOptions = RemovedResourceOptions & {
|
|
6
7
|
format?: ResourceFormat
|
|
@@ -42,6 +43,17 @@ export interface RestApiKnexPluginOptions {
|
|
|
42
43
|
}
|
|
43
44
|
export interface RestApiAnyapiKnexPluginOptions extends RestApiKnexPluginOptions {
|
|
44
45
|
tenantId?: string
|
|
46
|
+
// Share one registry across tenant APIs on the same Knex instance.
|
|
47
|
+
registry?: AnyapiRegistry
|
|
48
|
+
// false when migrations own the canonical tables.
|
|
49
|
+
ensureSchema?: boolean
|
|
50
|
+
// false leaves a shared Knex instance open when this API is released.
|
|
51
|
+
ownsKnex?: boolean
|
|
52
|
+
slots?: AnyapiSlotCounts
|
|
53
|
+
// Called after a committed schema change, for example to notify other servers.
|
|
54
|
+
onSchemaChanged?: (change: AnyapiSchemaChange) => void | Promise<void>
|
|
55
|
+
// Milliseconds between schema version checks; 0 checks on every request.
|
|
56
|
+
schemaCheckInterval?: number
|
|
45
57
|
}
|
|
46
58
|
export interface BulkOperationsPluginOptions {
|
|
47
59
|
maxBulkOperations?: number
|
|
@@ -23,6 +23,24 @@ export interface QueryParams extends SelectionParams {
|
|
|
23
23
|
sort?: string[]
|
|
24
24
|
page?: PageParams
|
|
25
25
|
}
|
|
26
|
+
export type AggregateFunction = 'count' | 'countDistinct' | 'sum' | 'avg' | 'min' | 'max'
|
|
27
|
+
// One function per measure; only count accepts '*'.
|
|
28
|
+
export type AggregateMeasure = { count: string } | { [Function in Exclude<AggregateFunction, 'count'>]: { [Name in Function]: string } }[Exclude<AggregateFunction, 'count'>]
|
|
29
|
+
export interface AggregateParams extends TransactionParam {
|
|
30
|
+
groupBy?: string[]
|
|
31
|
+
measures: Record<string, AggregateMeasure>
|
|
32
|
+
queryParams?: {
|
|
33
|
+
filters?: Record<string, unknown>
|
|
34
|
+
// Grouped field or measure names, '-' for descending.
|
|
35
|
+
sort?: string[]
|
|
36
|
+
// Grouped results only.
|
|
37
|
+
page?: { size?: number; number?: number }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export interface AggregateResult {
|
|
41
|
+
data: Array<Record<string, unknown>>
|
|
42
|
+
meta?: { page: { size: number; number: number; hasMore: boolean } }
|
|
43
|
+
}
|
|
26
44
|
export interface TransactionParam {
|
|
27
45
|
transaction?: Knex.Transaction
|
|
28
46
|
}
|
|
@@ -78,6 +96,7 @@ export interface ResourceCoreMethods<
|
|
|
78
96
|
query<Format extends ResourceFormat = DefaultFormat>(
|
|
79
97
|
params?: RemovedResourceOptions & TransactionParam & { format?: Format; queryParams?: QueryParams }, context?: Context
|
|
80
98
|
): Promise<CollectionResult<Format, Fields, Type>>
|
|
99
|
+
aggregate(params: AggregateParams, context?: Context): Promise<AggregateResult>
|
|
81
100
|
post<Format extends ResourceFormat = DefaultFormat, Returning extends WriteReturning = DefaultReturning>(
|
|
82
101
|
params: WriteOptions<Format, Returning> & ResourceWriteInput<Input, Type> & {
|
|
83
102
|
expectedVersion?: never
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { StorageFieldDefinition, StorageQuery } from '../plugins/core/lib/storage/storage-types.js'
|
|
2
2
|
import type { FileStorage } from './file-storage.js'
|
|
3
3
|
|
|
4
|
-
export type ResourceFieldType = 'string' | 'number' | 'integer' | 'boolean' | 'id' |
|
|
4
|
+
export type ResourceFieldType = 'string' | 'email' | 'uuid' | 'number' | 'integer' | 'boolean' | 'id' |
|
|
5
5
|
'date' | 'time' | 'dateTime' | 'epochSeconds' | 'epochMilliseconds' |
|
|
6
6
|
'array' | 'object' | 'file' | 'blob' | 'serialize' | 'none'
|
|
7
7
|
|
|
@@ -46,6 +46,8 @@ export interface ResourceFieldOptions {
|
|
|
46
46
|
normallyHidden?: boolean
|
|
47
47
|
virtual?: boolean
|
|
48
48
|
computed?: boolean
|
|
49
|
+
// Readable and filterable, but rejects new values.
|
|
50
|
+
retired?: boolean
|
|
49
51
|
include?: ResourceRelationshipOptions['include']
|
|
50
52
|
search?: boolean | ResourceSearchOptions | Record<string, ResourceSearchOptions>
|
|
51
53
|
dependencies?: readonly string[]
|
|
@@ -113,7 +115,7 @@ type ObjectValue<Field, Mode extends Direction> =
|
|
|
113
115
|
: Field extends { values: infer Values } ? Record<string, NestedValue<Values, Mode>> : Record<string, unknown>
|
|
114
116
|
|
|
115
117
|
type BaseValue<Field, Mode extends Direction> =
|
|
116
|
-
Field extends { type: 'string' | 'file' | 'date' | 'time' | 'dateTime' } ? string
|
|
118
|
+
Field extends { type: 'string' | 'email' | 'uuid' | 'file' | 'date' | 'time' | 'dateTime' } ? string
|
|
117
119
|
: Field extends { type: 'number'; precision: number; scale: number } ? Mode extends 'output' ? string | number : number
|
|
118
120
|
: Field extends { type: 'number' | 'integer' | 'epochSeconds' | 'epochMilliseconds' } ? number
|
|
119
121
|
: Field extends { type: 'boolean' } ? boolean
|
|
@@ -147,7 +149,7 @@ type StoredNull<Field> = Field extends { required: true } | { nullable: false }
|
|
|
147
149
|
// Attribute helpers exclude the conventional ID and relationship backing fields.
|
|
148
150
|
// Hooks/defaults may supply required values, so caller input stays optional.
|
|
149
151
|
export type InferInput<Schema extends object> = {
|
|
150
|
-
-readonly [Key in keyof Schema as Schema[Key] extends { computed: true } ? never : AttributeKey<Schema, Key>]?: FieldValue<Schema[Key], 'input'>
|
|
152
|
+
-readonly [Key in keyof Schema as Schema[Key] extends { computed: true } | { retired: true } ? never : AttributeKey<Schema, Key>]?: FieldValue<Schema[Key], 'input'>
|
|
151
153
|
}
|
|
152
154
|
|
|
153
155
|
// Resource result types add optionality for sparse fields and permissions.
|