json-rest-api 2.0.0 → 2.1.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/GUIDE/21-schema-and-migrations.md +112 -13
- package/docs/GUIDE/30-backend-capabilities.md +8 -5
- package/docs/GUIDE/33-migrating-to-v2.md +18 -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 +528 -409
- package/plugins/core/lib/anyapi/schema-utils.js +101 -46
- package/plugins/core/lib/anyapi/stored-definition.js +109 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +235 -51
- package/types/anyapi.d.ts +30 -0
- package/types/plugin-options.d.ts +12 -0
|
@@ -1,19 +1,42 @@
|
|
|
1
1
|
import { hasKnexTableIndex } from '../dbIntrospection.js'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
3
|
+
// Default layout of a new any_records table. The registry reads the actual
|
|
4
|
+
// columns, so a migration that adds slots raises the limits for every server.
|
|
5
|
+
export const SLOT_LIMITS = Object.freeze({
|
|
6
|
+
string: 10,
|
|
7
|
+
number: 10,
|
|
8
|
+
boolean: 5,
|
|
9
|
+
date: 5,
|
|
10
|
+
json: 5,
|
|
11
|
+
belongsTo: 5,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const SLOT_COLUMNS = {
|
|
15
|
+
string: index => [`string_${index}`],
|
|
16
|
+
number: index => [`number_${index}`],
|
|
17
|
+
boolean: index => [`boolean_${index}`],
|
|
18
|
+
date: index => [`date_${index}`],
|
|
19
|
+
json: index => [`json_${index}`],
|
|
20
|
+
belongsTo: index => [`rel_${index}_id`, `rel_${index}_type`],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const addSlotColumn = (table, column) => {
|
|
24
|
+
if (column.startsWith('number_')) table.double(column)
|
|
25
|
+
else if (column.startsWith('boolean_')) table.boolean(column)
|
|
26
|
+
else if (column.startsWith('date_')) table.dateTime(column, { precision: 6 })
|
|
27
|
+
else if (column.startsWith('rel_')) table.string(column)
|
|
28
|
+
else table.text(column)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const resolveSlotCounts = (slots = {}) => {
|
|
32
|
+
if (!slots || typeof slots !== 'object' || Array.isArray(slots)) {
|
|
33
|
+
throw new Error('AnyAPI slots must be an object of slot counts')
|
|
34
|
+
}
|
|
35
|
+
for (const [pool, count] of Object.entries(slots)) {
|
|
36
|
+
if (!Object.hasOwn(SLOT_LIMITS, pool)) throw new Error(`Unknown AnyAPI slot pool '${pool}'`)
|
|
37
|
+
if (!Number.isInteger(count) || count < 1) throw new Error(`AnyAPI ${pool} slot count must be a positive integer`)
|
|
38
|
+
}
|
|
39
|
+
return { ...SLOT_LIMITS, ...slots }
|
|
17
40
|
}
|
|
18
41
|
|
|
19
42
|
export const DEFAULT_CANONICAL_CONFIG = {
|
|
@@ -58,32 +81,23 @@ const ensureUniqueIndex = async (knex, tableName, columns, indexName) => {
|
|
|
58
81
|
}
|
|
59
82
|
}
|
|
60
83
|
|
|
61
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Create or upgrade the shared canonical tables. Run it from a migration, or let
|
|
86
|
+
* the plugin run it at start-up. `slots` only adds missing slot columns; lowering
|
|
87
|
+
* a count never drops columns that may hold data.
|
|
88
|
+
* @param {import('knex').Knex} knex
|
|
89
|
+
* @param {{ slots?: Partial<Record<keyof typeof SLOT_LIMITS, number>> }} [options]
|
|
90
|
+
*/
|
|
91
|
+
export const ensureAnyApiSchema = async (knex, { slots } = {}) => {
|
|
92
|
+
const counts = resolveSlotCounts(slots)
|
|
62
93
|
await ensureTable(knex, 'any_records', (table) => {
|
|
63
94
|
table.increments('id').primary()
|
|
64
95
|
table.string('logical_id')
|
|
65
96
|
table.string('tenant_id').notNullable()
|
|
66
97
|
table.string('resource').notNullable()
|
|
67
98
|
|
|
68
|
-
for (
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
for (let index = 1; index <= NUMBER_SLOT_COUNT; index += 1) {
|
|
72
|
-
table.double(`number_${index}`)
|
|
73
|
-
}
|
|
74
|
-
for (let index = 1; index <= BOOLEAN_SLOT_COUNT; index += 1) {
|
|
75
|
-
table.boolean(`boolean_${index}`)
|
|
76
|
-
}
|
|
77
|
-
for (let index = 1; index <= DATE_SLOT_COUNT; index += 1) {
|
|
78
|
-
table.dateTime(`date_${index}`, { precision: 6 })
|
|
79
|
-
}
|
|
80
|
-
for (let index = 1; index <= JSON_SLOT_COUNT; index += 1) {
|
|
81
|
-
table.text(`json_${index}`)
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
for (let index = 1; index <= BELONGS_TO_SLOT_COUNT; index += 1) {
|
|
85
|
-
table.string(`rel_${index}_id`)
|
|
86
|
-
table.string(`rel_${index}_type`)
|
|
99
|
+
for (const [pool, count] of Object.entries(counts)) {
|
|
100
|
+
for (let index = 1; index <= count; index += 1) SLOT_COLUMNS[pool](index).forEach(column => addSlotColumn(table, column))
|
|
87
101
|
}
|
|
88
102
|
|
|
89
103
|
table.dateTime('created_at').defaultTo(knex.fn.now()).notNullable()
|
|
@@ -95,15 +109,25 @@ export const ensureAnyApiSchema = async (knex) => {
|
|
|
95
109
|
table.index(['resource'])
|
|
96
110
|
})
|
|
97
111
|
|
|
112
|
+
if (slots) {
|
|
113
|
+
const existing = new Set(Object.keys(await knex('any_records').columnInfo()))
|
|
114
|
+
const missing = Object.entries(counts).flatMap(([pool, count]) => Array.from({ length: count }, (_, index) => SLOT_COLUMNS[pool](index + 1)))
|
|
115
|
+
.flat()
|
|
116
|
+
.filter(column => !existing.has(column))
|
|
117
|
+
if (missing.length) {
|
|
118
|
+
await knex.schema.alterTable('any_records', (table) => missing.forEach(column => addSlotColumn(table, column)))
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
98
122
|
const client = knex.client.config.client
|
|
99
123
|
if (['pg', 'postgresql', 'mysql', 'mysql2'].includes(client)) {
|
|
100
|
-
const columns = await knex('information_schema.columns')
|
|
124
|
+
const columns = (await knex('information_schema.columns')
|
|
101
125
|
.select({ column: 'column_name', precision: 'datetime_precision' })
|
|
102
126
|
.where('table_schema', knex.raw(client === 'pg' || client === 'postgresql' ? 'current_schema()' : 'DATABASE()'))
|
|
103
|
-
.where('table_name', 'any_records')
|
|
104
|
-
.
|
|
105
|
-
if (columns.length
|
|
106
|
-
throw new Error('AnyAPI temporal storage migration required: any_records
|
|
127
|
+
.where('table_name', 'any_records'))
|
|
128
|
+
.filter(column => /^date_\d+$/.test(column.column))
|
|
129
|
+
if (!columns.length || columns.some(column => Number(column.precision) !== 6)) {
|
|
130
|
+
throw new Error('AnyAPI temporal storage migration required: every any_records date slot must use timestamp/datetime precision 6. See 33-migrating-to-v2.md.')
|
|
107
131
|
}
|
|
108
132
|
}
|
|
109
133
|
|
|
@@ -154,11 +178,20 @@ export const ensureAnyApiSchema = async (knex) => {
|
|
|
154
178
|
table.string('resource').notNullable()
|
|
155
179
|
table.text('schema_json').notNullable()
|
|
156
180
|
table.text('relationships_json').notNullable()
|
|
181
|
+
// Holds the search schema and runtime additions too: MySQL TEXT stops at 64 KB.
|
|
182
|
+
table.text('definition_json', 'longtext')
|
|
183
|
+
table.integer('schema_version').notNullable().defaultTo(1)
|
|
157
184
|
table.dateTime('created_at').defaultTo(knex.fn.now()).notNullable()
|
|
158
185
|
table.dateTime('updated_at').defaultTo(knex.fn.now()).notNullable()
|
|
159
186
|
|
|
160
187
|
table.unique(['tenant_id', 'resource'])
|
|
161
188
|
})
|
|
189
|
+
await ensureColumn(knex, 'any_resource_configs', 'definition_json', (table) => {
|
|
190
|
+
table.text('definition_json', 'longtext')
|
|
191
|
+
})
|
|
192
|
+
await ensureColumn(knex, 'any_resource_configs', 'schema_version', (table) => {
|
|
193
|
+
table.integer('schema_version').notNullable().defaultTo(1)
|
|
194
|
+
})
|
|
162
195
|
|
|
163
196
|
await ensureTable(knex, 'any_field_configs', (table) => {
|
|
164
197
|
table.increments('id').primary()
|
|
@@ -177,8 +210,20 @@ export const ensureAnyApiSchema = async (knex) => {
|
|
|
177
210
|
table.dateTime('updated_at').defaultTo(knex.fn.now()).notNullable()
|
|
178
211
|
|
|
179
212
|
table.unique(['resource_config_id', 'field_name'])
|
|
213
|
+
table.unique(['resource_config_id', 'slot_column'], 'any_field_configs_resource_slot_unique')
|
|
180
214
|
table.index(['resource_config_id'])
|
|
181
215
|
})
|
|
216
|
+
if (!await hasKnexTableIndex(knex, 'any_field_configs', 'any_field_configs_resource_slot_unique')) {
|
|
217
|
+
const shared = await knex('any_field_configs')
|
|
218
|
+
.select('resource_config_id', 'slot_column')
|
|
219
|
+
.groupBy('resource_config_id', 'slot_column')
|
|
220
|
+
.havingRaw('count(*) > 1')
|
|
221
|
+
.first()
|
|
222
|
+
if (shared) {
|
|
223
|
+
throw new Error(`AnyAPI slot metadata repair required: several fields of resource config ${shared.resource_config_id} share ${shared.slot_column}. Reassign them before upgrading.`)
|
|
224
|
+
}
|
|
225
|
+
await ensureUniqueIndex(knex, 'any_field_configs', ['resource_config_id', 'slot_column'], 'any_field_configs_resource_slot_unique')
|
|
226
|
+
}
|
|
182
227
|
|
|
183
228
|
await ensureTable(knex, 'any_relationship_configs', (table) => {
|
|
184
229
|
table.increments('id').primary()
|
|
@@ -204,13 +249,23 @@ export const ensureAnyApiSchema = async (knex) => {
|
|
|
204
249
|
})
|
|
205
250
|
}
|
|
206
251
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
252
|
+
const buildSlotPools = (hasColumns) => Object.fromEntries(Object.keys(SLOT_LIMITS).map(pool => {
|
|
253
|
+
const indexes = []
|
|
254
|
+
for (let index = 1; hasColumns(pool, index); index += 1) indexes.push(index)
|
|
255
|
+
return [pool, pool === 'belongsTo' ? indexes : indexes.map(index => SLOT_COLUMNS[pool](index)[0])]
|
|
256
|
+
}))
|
|
257
|
+
|
|
258
|
+
export const SLOT_POOLS = buildSlotPools((pool, index) => index <= SLOT_LIMITS[pool])
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Read the slot columns any_records actually has. Slots are numbered from 1
|
|
262
|
+
* without gaps; a pool ends at its first missing column.
|
|
263
|
+
* @param {import('knex').Knex | import('knex').Knex.Transaction} db
|
|
264
|
+
*/
|
|
265
|
+
export const readSlotPools = async (db) => {
|
|
266
|
+
const columns = new Set(Object.keys(await db('any_records').columnInfo()))
|
|
267
|
+
if (!columns.size) throw new Error('AnyAPI storage tables are missing; run ensureAnyApiSchema in a migration')
|
|
268
|
+
return buildSlotPools((pool, index) => SLOT_COLUMNS[pool](index).every(column => columns.has(column)))
|
|
214
269
|
}
|
|
215
270
|
|
|
216
271
|
export const TYPE_TO_POOL = new Map([
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Stored resource definitions keep JSON values only. The paths of values JSON
|
|
2
|
+
// cannot hold (callbacks, class instances, RegExps) are recorded, so a server
|
|
3
|
+
// loading the definition knows which fields need code it may not have.
|
|
4
|
+
|
|
5
|
+
const isPlainObject = value => {
|
|
6
|
+
const prototype = Object.getPrototypeOf(value)
|
|
7
|
+
return prototype === Object.prototype || prototype === null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {unknown} value
|
|
12
|
+
* @returns {{ json: any, callbacks: Array<Array<string|number>> }}
|
|
13
|
+
*/
|
|
14
|
+
export const toStoredJson = (value) => {
|
|
15
|
+
const callbacks = []
|
|
16
|
+
const visit = (current, path) => {
|
|
17
|
+
if (current === undefined) return undefined
|
|
18
|
+
if (current === null || typeof current === 'string' || typeof current === 'boolean') return current
|
|
19
|
+
if (typeof current === 'number' && Number.isFinite(current)) return current
|
|
20
|
+
if (Array.isArray(current)) return current.map((item, index) => visit(item, [...path, index]) ?? null)
|
|
21
|
+
if (typeof current === 'object' && isPlainObject(current)) {
|
|
22
|
+
const result = {}
|
|
23
|
+
for (const [key, item] of Object.entries(current)) {
|
|
24
|
+
const stored = visit(item, [...path, key])
|
|
25
|
+
if (stored !== undefined) result[key] = stored
|
|
26
|
+
}
|
|
27
|
+
return result
|
|
28
|
+
}
|
|
29
|
+
callbacks.push(path)
|
|
30
|
+
return undefined
|
|
31
|
+
}
|
|
32
|
+
return { json: visit(value, []), callbacks }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const targetsOf = definition => definition?.oneOf || [definition?.actualField].filter(Boolean)
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Combine a resource's declaration with the fields other servers added at run
|
|
39
|
+
* time. Declared fields win; runtime fields this server added keep its local
|
|
40
|
+
* callbacks. A field whose callbacks exist in no available code is hidden
|
|
41
|
+
* rather than served without them, because a missing serializer or setter
|
|
42
|
+
* would read or write the wrong values.
|
|
43
|
+
* @param {{ declaration?: Record<string, any>, stored?: Record<string, any> | null, local?: { schema?: Record<string, any>, searchSchema?: Record<string, any> }, fromStorage?: boolean }} input
|
|
44
|
+
*/
|
|
45
|
+
export const composeResourceOptions = ({ declaration = {}, stored = null, local = {}, fromStorage = false }) => {
|
|
46
|
+
const hidden = new Set()
|
|
47
|
+
const hiddenFilters = new Set()
|
|
48
|
+
const declaredSchema = declaration.schema || {}
|
|
49
|
+
const declaredSearch = declaration.searchSchema || {}
|
|
50
|
+
const schema = { ...declaredSchema }
|
|
51
|
+
const searchSchema = { ...declaredSearch }
|
|
52
|
+
|
|
53
|
+
if (fromStorage) {
|
|
54
|
+
for (const path of stored?.callbacks || []) {
|
|
55
|
+
if (path[0] === 'schema' && path.length > 1) hidden.add(path[1])
|
|
56
|
+
else if (path[0] === 'searchSchema' && path.length > 1) hiddenFilters.add(path[1])
|
|
57
|
+
else throw new Error(`Resource option '${path.join('.')}' cannot be loaded from storage; declare the resource in code`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const runtime = stored?.runtime || {}
|
|
62
|
+
const needsCode = new Set((runtime.callbacks || []).map(path => `${path[0]}.${path[1]}`))
|
|
63
|
+
const merge = (kind, target, declared, localDefinitions = {}, hide) => {
|
|
64
|
+
for (const [name, definition] of Object.entries(runtime[kind] || {})) {
|
|
65
|
+
if (Object.hasOwn(declared, name)) continue
|
|
66
|
+
if (Object.hasOwn(localDefinitions, name)) target[name] = localDefinitions[name]
|
|
67
|
+
else if (needsCode.has(`${kind}.${name}`)) hide.add(name)
|
|
68
|
+
else target[name] = definition
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
merge('schema', schema, declaredSchema, local.schema, hidden)
|
|
72
|
+
merge('searchSchema', searchSchema, declaredSearch, local.searchSchema, hiddenFilters)
|
|
73
|
+
|
|
74
|
+
for (const name of hidden) delete schema[name]
|
|
75
|
+
for (const [name, definition] of Object.entries(searchSchema)) {
|
|
76
|
+
if (hiddenFilters.has(name) || targetsOf(definition).some(target => hidden.has(target))) {
|
|
77
|
+
hiddenFilters.add(name)
|
|
78
|
+
delete searchSchema[name]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const options = { ...declaration, schema }
|
|
83
|
+
if (Object.keys(searchSchema).length || declaration.searchSchema) options.searchSchema = searchSchema
|
|
84
|
+
if (hidden.size && Array.isArray(declaration.sortableFields)) {
|
|
85
|
+
options.sortableFields = declaration.sortableFields.filter(field => !hidden.has(String(field).replace(/^-/, '')))
|
|
86
|
+
}
|
|
87
|
+
return { options, hidden: [...hidden], hiddenFilters: [...hiddenFilters] }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resources a stored declaration refers to. Polymorphic targets must be
|
|
92
|
+
* registered before the resource itself; the others can follow.
|
|
93
|
+
* @param {Record<string, any>} options
|
|
94
|
+
*/
|
|
95
|
+
export const relatedResources = (options = {}) => {
|
|
96
|
+
const before = new Set()
|
|
97
|
+
const after = new Set()
|
|
98
|
+
for (const definition of Object.values(options.schema || {})) {
|
|
99
|
+
if (definition?.belongsTo) after.add(definition.belongsTo)
|
|
100
|
+
}
|
|
101
|
+
for (const [name, definition] of Object.entries(options.relationships || {})) {
|
|
102
|
+
for (const type of definition?.belongsToPolymorphic?.types || []) before.add(type)
|
|
103
|
+
if (definition?.type === 'hasMany' || definition?.type === 'manyToMany' || definition?.hasMany) {
|
|
104
|
+
after.add(definition.target || definition.hasMany || name)
|
|
105
|
+
}
|
|
106
|
+
if (definition?.through) after.add(definition.through)
|
|
107
|
+
}
|
|
108
|
+
return { before: [...before], after: [...after].filter(name => !before.has(name)) }
|
|
109
|
+
}
|