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.
Files changed (29) hide show
  1. package/docs/API.md +80 -0
  2. package/docs/GUIDE/17-row-policies.md +1 -0
  3. package/docs/GUIDE/21-schema-and-migrations.md +154 -14
  4. package/docs/GUIDE/30-backend-capabilities.md +8 -5
  5. package/docs/GUIDE/33-migrating-to-v2.md +27 -7
  6. package/index.d.ts +1 -0
  7. package/index.js +2 -0
  8. package/lib/knex-transaction.js +10 -4
  9. package/package.json +1 -1
  10. package/plugins/core/lib/anyapi/anyapi-registry.js +569 -408
  11. package/plugins/core/lib/anyapi/schema-utils.js +101 -51
  12. package/plugins/core/lib/anyapi/stored-definition.js +112 -0
  13. package/plugins/core/lib/querying/aggregate-query.js +170 -0
  14. package/plugins/core/lib/querying-writing/compile-schemas.js +17 -0
  15. package/plugins/core/lib/querying-writing/database-value-normalizers.js +16 -0
  16. package/plugins/core/lib/querying-writing/default-data-helpers.js +4 -0
  17. package/plugins/core/lib/querying-writing/field-types.js +43 -0
  18. package/plugins/core/lib/querying-writing/request-contracts.js +1 -1
  19. package/plugins/core/lib/storage/canonical-data-helpers.js +29 -0
  20. package/plugins/core/lib/storage/ordinary-data-helpers.js +25 -0
  21. package/plugins/core/lib/storage/storage-types.d.ts +21 -0
  22. package/plugins/core/rest-api-anyapi-knex-plugin.js +331 -54
  23. package/plugins/core/rest-api-plugin-methods/aggregate.js +41 -0
  24. package/plugins/core/rest-api-plugin-methods/common.js +16 -1
  25. package/plugins/core/rest-api-plugin.js +2 -0
  26. package/types/anyapi.d.ts +32 -0
  27. package/types/plugin-options.d.ts +12 -0
  28. package/types/resource-methods.d.ts +19 -0
  29. 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.