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.
@@ -1,19 +1,42 @@
1
1
  import { hasKnexTableIndex } from '../dbIntrospection.js'
2
2
 
3
- const STRING_SLOT_COUNT = 10
4
- const NUMBER_SLOT_COUNT = 10
5
- const BOOLEAN_SLOT_COUNT = 5
6
- const DATE_SLOT_COUNT = 5
7
- const JSON_SLOT_COUNT = 5
8
- const BELONGS_TO_SLOT_COUNT = 5
9
-
10
- export const SLOT_LIMITS = {
11
- string: STRING_SLOT_COUNT,
12
- number: NUMBER_SLOT_COUNT,
13
- boolean: BOOLEAN_SLOT_COUNT,
14
- date: DATE_SLOT_COUNT,
15
- json: JSON_SLOT_COUNT,
16
- belongsTo: BELONGS_TO_SLOT_COUNT,
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
- export const ensureAnyApiSchema = async (knex) => {
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 (let index = 1; index <= STRING_SLOT_COUNT; index += 1) {
69
- table.text(`string_${index}`)
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
- .whereIn('column_name', Array.from({ length: DATE_SLOT_COUNT }, (_, index) => `date_${index + 1}`))
105
- if (columns.length !== DATE_SLOT_COUNT || columns.some(column => Number(column.precision) !== 6)) {
106
- throw new Error('AnyAPI temporal storage migration required: any_records.date_1 through date_5 must use timestamp/datetime precision 6. See 33-migrating-to-v2.md.')
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
- export const SLOT_POOLS = {
208
- string: Array.from({ length: STRING_SLOT_COUNT }, (_, index) => `string_${index + 1}`),
209
- number: Array.from({ length: NUMBER_SLOT_COUNT }, (_, index) => `number_${index + 1}`),
210
- boolean: Array.from({ length: BOOLEAN_SLOT_COUNT }, (_, index) => `boolean_${index + 1}`),
211
- date: Array.from({ length: DATE_SLOT_COUNT }, (_, index) => `date_${index + 1}`),
212
- json: Array.from({ length: JSON_SLOT_COUNT }, (_, index) => `json_${index + 1}`),
213
- belongsTo: Array.from({ length: BELONGS_TO_SLOT_COUNT }, (_, index) => index + 1),
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
+ }