json-rest-api 2.2.0 → 2.3.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 +57 -1
- package/docs/GUIDE/30-backend-capabilities.md +3 -1
- package/docs/GUIDE/33-migrating-to-v2.md +11 -0
- package/package.json +1 -1
- package/plugins/core/lib/anyapi/anyapi-registry.js +38 -25
- package/plugins/core/lib/anyapi/schema-utils.js +168 -48
- package/plugins/core/lib/dbIntrospection.js +28 -0
- package/plugins/core/lib/querying/knex-query-helpers.js +14 -4
- package/plugins/core/lib/storage/canonical-storage-mapping.js +11 -0
- package/plugins/core/lib/storage/storage-types.d.ts +2 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +25 -9
- package/types/anyapi.d.ts +11 -2
- package/types/plugin-options.d.ts +2 -1
|
@@ -457,7 +457,8 @@ Slot limits belong to the database: every server reads the slot columns that
|
|
|
457
457
|
date, 5 JSON and 5 belongsTo slots. `ensureAnyApiSchema(knex, { slots })`, or the
|
|
458
458
|
plugin's `slots` option when it ensures the schema, adds missing columns. Lower
|
|
459
459
|
counts never drop columns. A server whose pool is exhausted rereads the layout
|
|
460
|
-
once, so a migration that adds slots takes effect without a restart.
|
|
460
|
+
once, so a migration that adds slots takes effect without a restart. Indexed
|
|
461
|
+
slots have their own counts; see [AnyAPI indexed fields](#anyapi-indexed-fields).
|
|
461
462
|
|
|
462
463
|
On PostgreSQL, adding a nullable column without a default does not rewrite the
|
|
463
464
|
table. MySQL can add columns instantly from 8.0.29; earlier versions may copy the
|
|
@@ -498,6 +499,61 @@ lookups return null for absent metadata; operations requiring that relationship
|
|
|
498
499
|
descriptor reject a missing descriptor. Schema errors and database failures
|
|
499
500
|
are distinct from a valid relationship with no matching records.
|
|
500
501
|
|
|
502
|
+
### AnyAPI indexed fields
|
|
503
|
+
|
|
504
|
+
All AnyAPI resources share `any_records`, so plain slots have no indexes: a filter
|
|
505
|
+
or sort on one reads every record of that tenant's resource. Indexed slots
|
|
506
|
+
(`indexed_string_N`, `indexed_number_N`, `indexed_boolean_N`, `indexed_date_N`)
|
|
507
|
+
each carry an index on `(tenant_id, resource, slot)`. A field declared
|
|
508
|
+
`indexed: true` takes the first free indexed slot of its type:
|
|
509
|
+
|
|
510
|
+
```js
|
|
511
|
+
await api.addResource('contacts', {
|
|
512
|
+
schema: {
|
|
513
|
+
email: { type: 'email', indexed: true, search: true },
|
|
514
|
+
score: { type: 'integer', indexed: true },
|
|
515
|
+
notes: { type: 'string' }
|
|
516
|
+
}
|
|
517
|
+
})
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
New tables get 5 string, 3 number and 2 date indexed slots, and no boolean ones.
|
|
521
|
+
`ensureAnyApiSchema(knex, { indexedSlots: { string: 8, boolean: 2 } })`, or the
|
|
522
|
+
plugin's `indexedSlots` option, adds more; JSON slots cannot be indexed. When a
|
|
523
|
+
resource has no free indexed slot of a type, registration and `addKnexFields`
|
|
524
|
+
fail with an error naming `indexedSlots`. The registry rereads the layout once,
|
|
525
|
+
so a migration that adds slots takes effect without a restart. Every index adds
|
|
526
|
+
work to each write to `any_records`, whatever the resource, so reserve indexed
|
|
527
|
+
slots for fields that large resources filter or sort on.
|
|
528
|
+
|
|
529
|
+
Indexed string slots are `VARCHAR(255)`, so MySQL can index them whole. A field
|
|
530
|
+
whose `max` is larger cannot take one, and longer values fail validation with a
|
|
531
|
+
`storage_range` violation on every database. Lengths count characters, not bytes.
|
|
532
|
+
|
|
533
|
+
Slots never move on their own. A field keeps its slot when a declaration adds or
|
|
534
|
+
drops `indexed`, and a server whose declaration marks a field indexed that is
|
|
535
|
+
stored in a plain slot logs a warning. To index such a field, copy it into a new
|
|
536
|
+
indexed field:
|
|
537
|
+
|
|
538
|
+
```js
|
|
539
|
+
await api.resources.contacts.migrateField({
|
|
540
|
+
from: 'phone',
|
|
541
|
+
to: { name: 'phoneKey', type: 'string', nullable: true, indexed: true, search: true }
|
|
542
|
+
})
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
Belongs-to slots are always indexed by parent ID, and `any_links` by each side's
|
|
546
|
+
record ID, so child lookups, includes and many-to-many reads stay inside one
|
|
547
|
+
tenant's rows.
|
|
548
|
+
|
|
549
|
+
`ensureAnyApiSchema` adds missing slot columns and indexes to existing tables too.
|
|
550
|
+
MySQL builds indexes in place and PostgreSQL concurrently, so writes continue; on
|
|
551
|
+
PostgreSQL one server builds at a time and the others start without waiting. A
|
|
552
|
+
PostgreSQL pool with a single connection builds without `CONCURRENTLY`, which
|
|
553
|
+
blocks writes. Either way the build reads the whole table, so for a large
|
|
554
|
+
`any_records` run `ensureAnyApiSchema` from a migration before deploying. An
|
|
555
|
+
interrupted concurrent build leaves an invalid index, which the next run rebuilds.
|
|
556
|
+
|
|
501
557
|
## Live Table Snapshots
|
|
502
558
|
|
|
503
559
|
Use `introspectKnexTableSnapshot()` to inspect the physical table shape:
|
|
@@ -67,7 +67,9 @@ AnyAPI field additions persist slot metadata and their definitions;
|
|
|
67
67
|
and keeps the mapping of populated fields a declaration omits; changing a populated
|
|
68
68
|
field's storage type needs an explicit data migration. Servers sharing a database
|
|
69
69
|
see each other's additions after a reload, notification or schema check, and slot
|
|
70
|
-
limits follow the columns the database has.
|
|
70
|
+
limits follow the columns the database has. Plain slots have no indexes; fields
|
|
71
|
+
declared `indexed: true` take [indexed slots](21-schema-and-migrations.md#anyapi-indexed-fields),
|
|
72
|
+
and belongs-to slots are always indexed. Old canonical temporal storage needs
|
|
71
73
|
the documented data migration, not just a package update. See
|
|
72
74
|
[table helpers and migration limits](21-schema-and-migrations.md#anyapi-across-several-servers).
|
|
73
75
|
|
|
@@ -1485,6 +1485,17 @@ are, and omitted populated fields keep their data without being published.
|
|
|
1485
1485
|
Fields added before this release that the declaration does not repeat are kept
|
|
1486
1486
|
and published from their stored definitions.
|
|
1487
1487
|
|
|
1488
|
+
`ensureAnyApiSchema` also adds indexed slot columns (`indexed_string_N` and so on),
|
|
1489
|
+
indexes on `(tenant_id, resource, slot)` for them and for every belongs-to slot,
|
|
1490
|
+
and `(tenant_id, left_id)` and `(tenant_id, right_id)` indexes on `any_links`. On
|
|
1491
|
+
an existing table the plugin builds them at start-up without blocking writes, but
|
|
1492
|
+
the build reads the whole table: for a large one, run `ensureAnyApiSchema` in a
|
|
1493
|
+
migration before deploying. On AnyAPI, `indexed: true` now places fields. A new
|
|
1494
|
+
field declaring it takes an indexed slot, and registration fails when none is
|
|
1495
|
+
free, when the field is an `object` or `array`, or when a string field's `max`
|
|
1496
|
+
exceeds 255. Existing fields stay in their slots; see
|
|
1497
|
+
[AnyAPI indexed fields](21-schema-and-migrations.md#anyapi-indexed-fields).
|
|
1498
|
+
|
|
1488
1499
|
Explicit maps take effect during registration, before `createKnexTable()`.
|
|
1489
1500
|
Repeating that method no longer re-registers the initial schema and discards
|
|
1490
1501
|
additions. Keep the expanded declaration and complete map in app source for
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createKnexTransaction, readCommittedTransaction } from '../../../../lib/knex-transaction.js'
|
|
2
2
|
import { applyInsertReturning, getDatabaseCapabilities } from '../querying-writing/database-capabilities.js'
|
|
3
|
-
import { DEFAULT_CANONICAL_CONFIG, TYPE_TO_POOL, ensureAnyApiSchema, readSlotPools } from './schema-utils.js'
|
|
3
|
+
import { DEFAULT_CANONICAL_CONFIG, INDEXED_STRING_LENGTH, TYPE_TO_POOL, ensureAnyApiSchema, readSlotPools } from './schema-utils.js'
|
|
4
4
|
import { toStoredJson } from './stored-definition.js'
|
|
5
5
|
import { beginWriteTransaction, commitTransaction, endWriteOperation, getOperationDiagnosticContext, rollbackAfterError, wrapUnexpectedError, wrapWriteError } from '../../../../lib/error-context.js'
|
|
6
6
|
import { createEnhancedLogger } from '../../../../lib/enhanced-logger.js'
|
|
@@ -46,17 +46,13 @@ const BELONGS_TO_ALIAS = (fieldName, fieldDef) => {
|
|
|
46
46
|
return fieldName
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
const
|
|
50
|
-
string: /^string_(\d+)$/,
|
|
51
|
-
number: /^number_(\d+)$/,
|
|
52
|
-
boolean: /^boolean_(\d+)$/,
|
|
53
|
-
date: /^date_(\d+)$/,
|
|
54
|
-
json: /^json_(\d+)$/,
|
|
55
|
-
}
|
|
49
|
+
const SIMPLE_SLOT_PATTERN = /^(indexed_)?(string|number|boolean|date|json)_(\d+)$/
|
|
56
50
|
|
|
57
51
|
const BELONGS_TO_ID_PATTERN = /^rel_(\d+)_id$/
|
|
58
52
|
|
|
59
|
-
const slotsExhausted = pool => Object.assign(new Error(
|
|
53
|
+
const slotsExhausted = (pool, indexed) => Object.assign(new Error(indexed
|
|
54
|
+
? `No available indexed ${pool} slots remaining; add them with ensureAnyApiSchema(knex, { indexedSlots: { ${pool}: count } })`
|
|
55
|
+
: `No available ${pool} slots remaining`), { slotsExhausted: true })
|
|
60
56
|
|
|
61
57
|
const slotPoolFor = (fieldDef = {}) => {
|
|
62
58
|
if (fieldDef.belongsTo) return 'belongsTo'
|
|
@@ -65,17 +61,18 @@ const slotPoolFor = (fieldDef = {}) => {
|
|
|
65
61
|
|
|
66
62
|
const belongsToColumns = index => [`rel_${index}_id`, `rel_${index}_type`]
|
|
67
63
|
|
|
64
|
+
const isIndexedSlot = column => column.startsWith('indexed_')
|
|
65
|
+
|
|
66
|
+
const slotColumnsOf = (pools, pool, indexed) => (indexed ? pools.indexed?.[pool] : pools[pool]) || []
|
|
67
|
+
|
|
68
68
|
const parseSimpleSlotColumn = (column, pools) => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
return { slotType: type, slotIndex: Number(match[1]) }
|
|
76
|
-
}
|
|
69
|
+
const match = SIMPLE_SLOT_PATTERN.exec(column)
|
|
70
|
+
if (!match) throw new Error(`Invalid canonical slot column '${column}'`)
|
|
71
|
+
const [, indexed, slotType, slotIndex] = match
|
|
72
|
+
if (!slotColumnsOf(pools, slotType, Boolean(indexed)).includes(column)) {
|
|
73
|
+
throw new Error(`Canonical slot '${column}' is out of range`)
|
|
77
74
|
}
|
|
78
|
-
|
|
75
|
+
return { slotType, slotIndex: Number(slotIndex) }
|
|
79
76
|
}
|
|
80
77
|
|
|
81
78
|
const parseBelongsToSlots = ({ idSlot, typeSlot }, pools) => {
|
|
@@ -148,9 +145,9 @@ export class AnyapiRegistry {
|
|
|
148
145
|
return this.#shared.get(key)
|
|
149
146
|
}
|
|
150
147
|
|
|
151
|
-
ensureSchema ({ slots } = {}) {
|
|
152
|
-
return this.#once(`schema:${JSON.stringify(slots ?? null)}`, async () => {
|
|
153
|
-
await ensureAnyApiSchema(this.knex, { slots })
|
|
148
|
+
ensureSchema ({ slots, indexedSlots } = {}) {
|
|
149
|
+
return this.#once(`schema:${JSON.stringify([slots ?? null, indexedSlots ?? null])}`, async () => {
|
|
150
|
+
await ensureAnyApiSchema(this.knex, { slots, indexedSlots })
|
|
154
151
|
this.#shared.delete('slots')
|
|
155
152
|
})
|
|
156
153
|
}
|
|
@@ -757,9 +754,10 @@ export class AnyapiRegistry {
|
|
|
757
754
|
const previous = previousDescriptor?.fields?.[entry.fieldName]
|
|
758
755
|
if (entry.override || !previous || previous.slotType !== slotPoolFor(entry.fieldDef)) continue
|
|
759
756
|
const columns = previous.slotType === 'belongsTo' ? belongsToColumns(previous.slotIndex) : [previous.slot]
|
|
757
|
+
// A field keeps its slot even when its `indexed` declaration changed.
|
|
760
758
|
const available = previous.slotType === 'belongsTo'
|
|
761
759
|
? pools.belongsTo.includes(previous.slotIndex)
|
|
762
|
-
: pools
|
|
760
|
+
: slotColumnsOf(pools, previous.slotType, isIndexedSlot(previous.slot)).includes(previous.slot)
|
|
763
761
|
if (!available || columns.some(column => used.has(column))) continue
|
|
764
762
|
columns.forEach(column => used.add(column))
|
|
765
763
|
entry.override = previous.slotType === 'belongsTo' ? { idSlot: columns[0], typeSlot: columns[1] } : { slotColumn: previous.slot }
|
|
@@ -869,6 +867,8 @@ export class AnyapiRegistry {
|
|
|
869
867
|
slot: row.slot_column,
|
|
870
868
|
slotType: row.slot_type,
|
|
871
869
|
slotIndex: row.slot_index,
|
|
870
|
+
// belongsTo slots are always indexed by parent id.
|
|
871
|
+
indexed: row.slot_type === 'belongsTo' || isIndexedSlot(row.slot_column),
|
|
872
872
|
nullable: row.nullable === 1 || row.nullable === true,
|
|
873
873
|
required: row.required === 1 || row.required === true,
|
|
874
874
|
target: row.target_resource,
|
|
@@ -998,16 +998,29 @@ export class AnyapiRegistry {
|
|
|
998
998
|
`Canonical slot '${slotColumn}' does not match expected type '${pool}' for field '${fieldDef.fieldName}'`
|
|
999
999
|
)
|
|
1000
1000
|
}
|
|
1001
|
+
this.#assertFitsSlot(fieldDef, pool, slotColumn)
|
|
1001
1002
|
used.add(slotColumn)
|
|
1002
1003
|
return { slotType: pool, slotIndex, slotColumn }
|
|
1003
1004
|
}
|
|
1004
1005
|
|
|
1005
|
-
const
|
|
1006
|
+
const indexed = fieldDef.indexed === true
|
|
1007
|
+
if (indexed && !pools.indexed?.[pool]) {
|
|
1008
|
+
throw new Error(`Field '${fieldDef.fieldName}' is declared indexed, but AnyAPI ${pool} slots cannot be indexed`)
|
|
1009
|
+
}
|
|
1010
|
+
const columns = slotColumnsOf(pools, pool, indexed)
|
|
1011
|
+
const slotColumn = columns.find(column => !used.has(column))
|
|
1006
1012
|
if (!slotColumn) {
|
|
1007
|
-
throw slotsExhausted(pool)
|
|
1013
|
+
throw slotsExhausted(pool, indexed)
|
|
1008
1014
|
}
|
|
1015
|
+
this.#assertFitsSlot(fieldDef, pool, slotColumn)
|
|
1009
1016
|
used.add(slotColumn)
|
|
1010
|
-
return { slotType: pool, slotIndex:
|
|
1017
|
+
return { slotType: pool, slotIndex: columns.indexOf(slotColumn) + 1, slotColumn }
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
#assertFitsSlot (fieldDef, pool, slotColumn) {
|
|
1021
|
+
if (pool === 'string' && isIndexedSlot(slotColumn) && Number(fieldDef.max) > INDEXED_STRING_LENGTH) {
|
|
1022
|
+
throw new Error(`Field '${fieldDef.fieldName}' allows ${fieldDef.max} characters, but indexed string slots hold at most ${INDEXED_STRING_LENGTH}`)
|
|
1023
|
+
}
|
|
1011
1024
|
}
|
|
1012
1025
|
|
|
1013
1026
|
#assignBelongsTo (fieldDef, used, canonicalOverride, pools) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { hasKnexTableIndex } from '../dbIntrospection.js'
|
|
1
|
+
import { hasKnexTableIndex, listKnexTableIndexes } from '../dbIntrospection.js'
|
|
2
2
|
|
|
3
|
-
// Default layout of
|
|
4
|
-
//
|
|
3
|
+
// Default layout of any_records. The registry reads the actual columns, so a
|
|
4
|
+
// migration that adds slots raises the limits for every server.
|
|
5
5
|
export const SLOT_LIMITS = Object.freeze({
|
|
6
6
|
string: 10,
|
|
7
7
|
number: 10,
|
|
@@ -11,6 +11,18 @@ export const SLOT_LIMITS = Object.freeze({
|
|
|
11
11
|
belongsTo: 5,
|
|
12
12
|
})
|
|
13
13
|
|
|
14
|
+
// Indexed slots carry a (tenant_id, resource, slot) index. Fields declared
|
|
15
|
+
// `indexed: true` take them; every index costs each write to any_records.
|
|
16
|
+
export const INDEXED_SLOT_LIMITS = Object.freeze({
|
|
17
|
+
string: 5,
|
|
18
|
+
number: 3,
|
|
19
|
+
boolean: 0,
|
|
20
|
+
date: 2,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// Indexed string slots are VARCHAR so MySQL can index them whole.
|
|
24
|
+
export const INDEXED_STRING_LENGTH = 255
|
|
25
|
+
|
|
14
26
|
const SLOT_COLUMNS = {
|
|
15
27
|
string: index => [`string_${index}`],
|
|
16
28
|
number: index => [`number_${index}`],
|
|
@@ -20,25 +32,37 @@ const SLOT_COLUMNS = {
|
|
|
20
32
|
belongsTo: index => [`rel_${index}_id`, `rel_${index}_type`],
|
|
21
33
|
}
|
|
22
34
|
|
|
35
|
+
export const indexedSlotColumn = (pool, index) => `indexed_${pool}_${index}`
|
|
36
|
+
|
|
23
37
|
const addSlotColumn = (table, column) => {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
else if (
|
|
27
|
-
else if (
|
|
38
|
+
const [, indexed, pool] = /^(indexed_)?([a-z]+)_/.exec(column)
|
|
39
|
+
if (pool === 'number') table.double(column)
|
|
40
|
+
else if (pool === 'boolean') table.boolean(column)
|
|
41
|
+
else if (pool === 'date') table.dateTime(column, { precision: 6 })
|
|
42
|
+
else if (pool === 'rel' || indexed) table.string(column, INDEXED_STRING_LENGTH)
|
|
28
43
|
else table.text(column)
|
|
29
44
|
}
|
|
30
45
|
|
|
31
|
-
const resolveSlotCounts = (slots = {}) => {
|
|
46
|
+
const resolveSlotCounts = (slots = {}, limits = SLOT_LIMITS, { label = 'slot', minimum = 1 } = {}) => {
|
|
32
47
|
if (!slots || typeof slots !== 'object' || Array.isArray(slots)) {
|
|
33
|
-
throw new Error(
|
|
48
|
+
throw new Error(`AnyAPI ${label}s must be an object of slot counts`)
|
|
34
49
|
}
|
|
35
50
|
for (const [pool, count] of Object.entries(slots)) {
|
|
36
|
-
if (!Object.hasOwn(
|
|
37
|
-
if (!Number.isInteger(count) || count <
|
|
51
|
+
if (!Object.hasOwn(limits, pool)) throw new Error(`Unknown AnyAPI ${label} pool '${pool}'`)
|
|
52
|
+
if (!Number.isInteger(count) || count < minimum) {
|
|
53
|
+
throw new Error(`AnyAPI ${pool} ${label} count must be ${minimum ? 'a positive' : 'a non-negative'} integer`)
|
|
54
|
+
}
|
|
38
55
|
}
|
|
39
|
-
return { ...
|
|
56
|
+
return { ...limits, ...slots }
|
|
40
57
|
}
|
|
41
58
|
|
|
59
|
+
const layoutColumns = (counts, indexedCounts) => [
|
|
60
|
+
...Object.entries(counts).flatMap(([pool, count]) => Array.from({ length: count }, (_, index) => SLOT_COLUMNS[pool](index + 1)).flat()),
|
|
61
|
+
...Object.entries(indexedCounts).flatMap(([pool, count]) => Array.from({ length: count }, (_, index) => indexedSlotColumn(pool, index + 1)))
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
const slotIndexName = column => `any_records_${column}_idx`
|
|
65
|
+
|
|
42
66
|
export const DEFAULT_CANONICAL_CONFIG = {
|
|
43
67
|
scopeName: 'anyapi_records',
|
|
44
68
|
tableName: 'any_records',
|
|
@@ -53,52 +77,121 @@ export const DEFAULT_CANONICAL_CONFIG = {
|
|
|
53
77
|
rightResourceColumn: 'right_resource',
|
|
54
78
|
}
|
|
55
79
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
await
|
|
80
|
+
// Several servers may start at once: DDL another one completed first is success.
|
|
81
|
+
const tolerateConcurrentDdl = async (run, done) => {
|
|
82
|
+
try {
|
|
83
|
+
await run()
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (!await done().catch(() => false)) throw error
|
|
60
86
|
}
|
|
61
87
|
}
|
|
62
88
|
|
|
89
|
+
// Returns whether the table was missing, so its indexes can be built offline.
|
|
90
|
+
const ensureTable = async (knex, tableName, tableBuilder) => {
|
|
91
|
+
if (await knex.schema.hasTable(tableName)) return false
|
|
92
|
+
await tolerateConcurrentDdl(() => knex.schema.createTable(tableName, tableBuilder), () => knex.schema.hasTable(tableName))
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
|
|
63
96
|
const ensureColumn = async (knex, tableName, columnName, columnBuilder) => {
|
|
64
97
|
const exists = await knex.schema.hasTable(tableName)
|
|
65
98
|
if (!exists) return
|
|
66
99
|
|
|
67
|
-
const hasColumn =
|
|
68
|
-
if (!hasColumn) {
|
|
69
|
-
await knex.schema.table(tableName, columnBuilder)
|
|
100
|
+
const hasColumn = () => knex.schema.hasColumn(tableName, columnName)
|
|
101
|
+
if (!await hasColumn()) {
|
|
102
|
+
await tolerateConcurrentDdl(() => knex.schema.table(tableName, columnBuilder), hasColumn)
|
|
70
103
|
}
|
|
71
104
|
}
|
|
72
105
|
|
|
73
106
|
const ensureUniqueIndex = async (knex, tableName, columns, indexName) => {
|
|
74
107
|
const exists = await knex.schema.hasTable(tableName)
|
|
75
108
|
if (!exists) return
|
|
76
|
-
const hasIndex =
|
|
77
|
-
if (!hasIndex) {
|
|
78
|
-
await knex.schema.table(tableName, (table) => {
|
|
109
|
+
const hasIndex = () => hasKnexTableIndex(knex, tableName, indexName)
|
|
110
|
+
if (!await hasIndex()) {
|
|
111
|
+
await tolerateConcurrentDdl(() => knex.schema.table(tableName, (table) => {
|
|
79
112
|
table.unique(columns, indexName)
|
|
80
|
-
})
|
|
113
|
+
}), hasIndex)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Create missing secondary indexes. On an existing table they are built without
|
|
119
|
+
* blocking writes: in place on MySQL, and concurrently on PostgreSQL unless the
|
|
120
|
+
* pool has a single connection. Either way the build reads the whole table, so
|
|
121
|
+
* on a big table run it from a migration before the deploy.
|
|
122
|
+
*/
|
|
123
|
+
const ensureIndexes = async (knex, tableName, indexes, { online }) => {
|
|
124
|
+
const client = knex.client.config.client
|
|
125
|
+
const pg = ['pg', 'postgresql'].includes(client)
|
|
126
|
+
const mysql = ['mysql', 'mysql2'].includes(client)
|
|
127
|
+
// The build lock needs a connection of its own beside the builds.
|
|
128
|
+
const concurrently = online && pg && !knex.isTransaction && (knex.client.pool?.max ?? 0) > 1
|
|
129
|
+
const missing = async () => {
|
|
130
|
+
const existing = await listKnexTableIndexes(knex, tableName)
|
|
131
|
+
return indexes.filter(({ name }) => !existing.get(name)?.valid && !existing.get(name)?.building)
|
|
132
|
+
.map(index => ({ ...index, invalid: existing.has(index.name) }))
|
|
133
|
+
}
|
|
134
|
+
const build = async () => {
|
|
135
|
+
for (const { name, columns, invalid } of await missing()) {
|
|
136
|
+
const list = columns.map(() => '??').join(', ')
|
|
137
|
+
if (invalid) await knex.raw(`drop index ${concurrently ? 'concurrently ' : ''}if exists ??`, [name])
|
|
138
|
+
if (mysql) {
|
|
139
|
+
await tolerateConcurrentDdl(
|
|
140
|
+
() => knex.raw(`alter table ?? add index ?? (${list})${online ? ', algorithm=inplace, lock=none' : ''}`, [tableName, name, ...columns]),
|
|
141
|
+
() => hasKnexTableIndex(knex, tableName, name)
|
|
142
|
+
)
|
|
143
|
+
} else {
|
|
144
|
+
await tolerateConcurrentDdl(
|
|
145
|
+
() => knex.raw(`create index ${concurrently ? 'concurrently ' : ''}if not exists ?? on ?? (${list})`, [name, tableName, ...columns]),
|
|
146
|
+
async () => (await listKnexTableIndexes(knex, tableName)).has(name)
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!(await missing()).length) return
|
|
152
|
+
if (concurrently) await withPgIndexBuildLock(knex, tableName, build)
|
|
153
|
+
else await build()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Concurrent builds on one table deadlock each other, so one server builds at a
|
|
157
|
+
// time. The lock lives on an idle session, which concurrent builds never wait
|
|
158
|
+
// for. A server that finds it taken carries on: the holder builds the same indexes.
|
|
159
|
+
const withPgIndexBuildLock = async (knex, tableName, build) => {
|
|
160
|
+
const connection = await knex.client.acquireConnection()
|
|
161
|
+
const lock = name => knex.select(knex.raw(`${name}(hashtext(?)) as result`, [`json-rest-api:indexes:${tableName}`]))
|
|
162
|
+
.connection(connection).first()
|
|
163
|
+
try {
|
|
164
|
+
if (!(await lock('pg_try_advisory_lock')).result) return
|
|
165
|
+
try {
|
|
166
|
+
await build()
|
|
167
|
+
} finally {
|
|
168
|
+
await lock('pg_advisory_unlock')
|
|
169
|
+
}
|
|
170
|
+
} finally {
|
|
171
|
+
await knex.client.releaseConnection(connection)
|
|
81
172
|
}
|
|
82
173
|
}
|
|
83
174
|
|
|
84
175
|
/**
|
|
85
176
|
* Create or upgrade the shared canonical tables. Run it from a migration, or let
|
|
86
|
-
* the plugin run it at start-up.
|
|
87
|
-
*
|
|
177
|
+
* the plugin run it at start-up. Counts only add missing slot columns, never
|
|
178
|
+
* drop them; any_records always has at least the default layout.
|
|
88
179
|
* @param {import('knex').Knex} knex
|
|
89
|
-
* @param {{
|
|
180
|
+
* @param {{
|
|
181
|
+
* slots?: Partial<Record<keyof typeof SLOT_LIMITS, number>>,
|
|
182
|
+
* indexedSlots?: Partial<Record<keyof typeof INDEXED_SLOT_LIMITS, number>>
|
|
183
|
+
* }} [options]
|
|
90
184
|
*/
|
|
91
|
-
export const ensureAnyApiSchema = async (knex, { slots } = {}) => {
|
|
185
|
+
export const ensureAnyApiSchema = async (knex, { slots, indexedSlots } = {}) => {
|
|
92
186
|
const counts = resolveSlotCounts(slots)
|
|
93
|
-
|
|
187
|
+
const indexedCounts = resolveSlotCounts(indexedSlots, INDEXED_SLOT_LIMITS, { label: 'indexed slot', minimum: 0 })
|
|
188
|
+
const created = await ensureTable(knex, 'any_records', (table) => {
|
|
94
189
|
table.increments('id').primary()
|
|
95
190
|
table.string('logical_id')
|
|
96
191
|
table.string('tenant_id').notNullable()
|
|
97
192
|
table.string('resource').notNullable()
|
|
98
193
|
|
|
99
|
-
|
|
100
|
-
for (let index = 1; index <= count; index += 1) SLOT_COLUMNS[pool](index).forEach(column => addSlotColumn(table, column))
|
|
101
|
-
}
|
|
194
|
+
layoutColumns(counts, indexedCounts).forEach(column => addSlotColumn(table, column))
|
|
102
195
|
|
|
103
196
|
table.dateTime('created_at').defaultTo(knex.fn.now()).notNullable()
|
|
104
197
|
table.dateTime('updated_at').defaultTo(knex.fn.now()).notNullable()
|
|
@@ -109,14 +202,18 @@ export const ensureAnyApiSchema = async (knex, { slots } = {}) => {
|
|
|
109
202
|
table.index(['resource'])
|
|
110
203
|
})
|
|
111
204
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
205
|
+
const readColumns = async () => new Set(Object.keys(await knex('any_records').columnInfo()))
|
|
206
|
+
let recordColumns = await readColumns()
|
|
207
|
+
const missing = layoutColumns(counts, indexedCounts).filter(column => !recordColumns.has(column))
|
|
208
|
+
if (missing.length) {
|
|
209
|
+
await tolerateConcurrentDdl(
|
|
210
|
+
() => knex.schema.alterTable('any_records', (table) => missing.forEach(column => addSlotColumn(table, column))),
|
|
211
|
+
async () => {
|
|
212
|
+
const columns = await readColumns()
|
|
213
|
+
return missing.every(column => columns.has(column))
|
|
214
|
+
}
|
|
215
|
+
)
|
|
216
|
+
recordColumns = await readColumns()
|
|
120
217
|
}
|
|
121
218
|
|
|
122
219
|
const client = knex.client.config.client
|
|
@@ -125,7 +222,7 @@ export const ensureAnyApiSchema = async (knex, { slots } = {}) => {
|
|
|
125
222
|
.select({ column: 'column_name', precision: 'datetime_precision' })
|
|
126
223
|
.where('table_schema', knex.raw(client === 'pg' || client === 'postgresql' ? 'current_schema()' : 'DATABASE()'))
|
|
127
224
|
.where('table_name', 'any_records'))
|
|
128
|
-
.filter(column => /^date_\d+$/.test(column.column))
|
|
225
|
+
.filter(column => /^(indexed_)?date_\d+$/.test(column.column))
|
|
129
226
|
if (!columns.length || columns.some(column => Number(column.precision) !== 6)) {
|
|
130
227
|
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.')
|
|
131
228
|
}
|
|
@@ -151,7 +248,7 @@ export const ensureAnyApiSchema = async (knex, { slots } = {}) => {
|
|
|
151
248
|
.update({ logical_id: String(row.id) })
|
|
152
249
|
}
|
|
153
250
|
|
|
154
|
-
await ensureTable(knex, 'any_links', (table) => {
|
|
251
|
+
const linksCreated = await ensureTable(knex, 'any_links', (table) => {
|
|
155
252
|
table.increments('id').primary()
|
|
156
253
|
table.string('tenant_id').notNullable()
|
|
157
254
|
table.string('relationship').notNullable()
|
|
@@ -172,6 +269,17 @@ export const ensureAnyApiSchema = async (knex, { slots } = {}) => {
|
|
|
172
269
|
table.index(['inverse_relationship'])
|
|
173
270
|
})
|
|
174
271
|
|
|
272
|
+
// Filters on indexed fields, child lookups by parent id and link lookups by
|
|
273
|
+
// record id all stay inside one tenant's rows.
|
|
274
|
+
const recordIndexes = [...recordColumns]
|
|
275
|
+
.filter(column => /^(indexed_[a-z]+_\d+|rel_\d+_id)$/.test(column))
|
|
276
|
+
.map(column => ({ name: slotIndexName(column), columns: ['tenant_id', 'resource', column] }))
|
|
277
|
+
await ensureIndexes(knex, 'any_records', recordIndexes, { online: !created })
|
|
278
|
+
await ensureIndexes(knex, 'any_links', [
|
|
279
|
+
{ name: 'any_links_tenant_left_id_idx', columns: ['tenant_id', 'left_id'] },
|
|
280
|
+
{ name: 'any_links_tenant_right_id_idx', columns: ['tenant_id', 'right_id'] }
|
|
281
|
+
], { online: !linksCreated })
|
|
282
|
+
|
|
175
283
|
await ensureTable(knex, 'any_resource_configs', (table) => {
|
|
176
284
|
table.increments('id').primary()
|
|
177
285
|
table.string('tenant_id').notNullable()
|
|
@@ -249,23 +357,35 @@ export const ensureAnyApiSchema = async (knex, { slots } = {}) => {
|
|
|
249
357
|
})
|
|
250
358
|
}
|
|
251
359
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
360
|
+
// The default unindexed layout, for migrations written against it.
|
|
361
|
+
export const SLOT_POOLS = Object.fromEntries(Object.entries(SLOT_LIMITS).map(([pool, count]) => {
|
|
362
|
+
const indexes = Array.from({ length: count }, (_, index) => index + 1)
|
|
255
363
|
return [pool, pool === 'belongsTo' ? indexes : indexes.map(index => SLOT_COLUMNS[pool](index)[0])]
|
|
256
364
|
}))
|
|
257
365
|
|
|
258
|
-
export const SLOT_POOLS = buildSlotPools((pool, index) => index <= SLOT_LIMITS[pool])
|
|
259
|
-
|
|
260
366
|
/**
|
|
261
367
|
* Read the slot columns any_records actually has. Slots are numbered from 1
|
|
262
|
-
* without gaps; a pool ends at its first missing column.
|
|
368
|
+
* without gaps; a pool ends at its first missing column. `indexed` lists the
|
|
369
|
+
* indexed columns of each pool that has them.
|
|
263
370
|
* @param {import('knex').Knex | import('knex').Knex.Transaction} db
|
|
264
371
|
*/
|
|
265
372
|
export const readSlotPools = async (db) => {
|
|
266
373
|
const columns = new Set(Object.keys(await db('any_records').columnInfo()))
|
|
267
374
|
if (!columns.size) throw new Error('AnyAPI storage tables are missing; run ensureAnyApiSchema in a migration')
|
|
268
|
-
|
|
375
|
+
const run = columnsAt => {
|
|
376
|
+
const indexes = []
|
|
377
|
+
for (let index = 1; columnsAt(index).every(column => columns.has(column)); index += 1) indexes.push(index)
|
|
378
|
+
return indexes
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
...Object.fromEntries(Object.keys(SLOT_LIMITS).map(pool => {
|
|
382
|
+
const indexes = run(SLOT_COLUMNS[pool])
|
|
383
|
+
return [pool, pool === 'belongsTo' ? indexes : indexes.map(index => SLOT_COLUMNS[pool](index)[0])]
|
|
384
|
+
})),
|
|
385
|
+
indexed: Object.fromEntries(Object.keys(INDEXED_SLOT_LIMITS).map(pool => [
|
|
386
|
+
pool, run(index => [indexedSlotColumn(pool, index)]).map(index => indexedSlotColumn(pool, index))
|
|
387
|
+
]))
|
|
388
|
+
}
|
|
269
389
|
}
|
|
270
390
|
|
|
271
391
|
export const TYPE_TO_POOL = new Map([
|
|
@@ -1237,6 +1237,34 @@ export async function hasKnexTableIndex (knex, tableName, indexName) {
|
|
|
1237
1237
|
throw new Error(`Unsupported knex client '${client}' for hasKnexTableIndex`)
|
|
1238
1238
|
}
|
|
1239
1239
|
|
|
1240
|
+
/**
|
|
1241
|
+
* Index names of one table. On PostgreSQL an interrupted concurrent build leaves
|
|
1242
|
+
* an invalid index; `building` tells it from one another session is building.
|
|
1243
|
+
* @returns {Promise<Map<string, { valid: boolean, building: boolean }>>}
|
|
1244
|
+
*/
|
|
1245
|
+
export async function listKnexTableIndexes (knex, tableName) {
|
|
1246
|
+
const client = knex.client.config.client
|
|
1247
|
+
let rows
|
|
1248
|
+
if (['sqlite3', 'better-sqlite3'].includes(client)) {
|
|
1249
|
+
rows = normalizeRows(await knex.raw('PRAGMA index_list(??)', [tableName])).map(row => ({ name: row.name }))
|
|
1250
|
+
} else if (['mysql', 'mysql2'].includes(client)) {
|
|
1251
|
+
rows = (await knex('information_schema.statistics').distinct({ name: 'INDEX_NAME' })
|
|
1252
|
+
.where({ TABLE_SCHEMA: knex.raw('DATABASE()'), TABLE_NAME: tableName }))
|
|
1253
|
+
} else if (['pg', 'postgresql'].includes(client)) {
|
|
1254
|
+
rows = normalizeRows(await knex.raw(`
|
|
1255
|
+
select c.relname as name, i.indisvalid as valid,
|
|
1256
|
+
exists (select 1 from pg_stat_progress_create_index p where p.index_relid = i.indexrelid) as building
|
|
1257
|
+
from pg_index i
|
|
1258
|
+
join pg_class c on c.oid = i.indexrelid
|
|
1259
|
+
join pg_class t on t.oid = i.indrelid
|
|
1260
|
+
join pg_namespace n on n.oid = t.relnamespace
|
|
1261
|
+
where t.relname = ? and n.nspname = current_schema()`, [tableName]))
|
|
1262
|
+
} else {
|
|
1263
|
+
throw new Error(`Unsupported knex client '${client}' for listKnexTableIndexes`)
|
|
1264
|
+
}
|
|
1265
|
+
return new Map(rows.map(row => [row.name, { valid: row.valid !== false, building: row.building === true }]))
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1240
1268
|
// Column-only ALTER must not impose the resource snapshot's ID or index restrictions.
|
|
1241
1269
|
export async function introspectKnexColumnConstraints (knex, tableName) {
|
|
1242
1270
|
requireKnexRaw(knex)
|
|
@@ -7,6 +7,8 @@ import { resolveSortField } from './query-field-sort-helpers.js'
|
|
|
7
7
|
import { databaseIdentityExpression } from '../querying-writing/database-value-normalizers.js'
|
|
8
8
|
|
|
9
9
|
const referenceVisibility = Symbol('referenceVisibility')
|
|
10
|
+
// SQL never matches null against a non-null operand with these.
|
|
11
|
+
const NULL_REJECTING_OPERATORS = new Set(['=', '==', '!=', '<>', '<', '<=', '>', '>=', 'in', 'between', 'like', 'contains', 'startswith', 'endswith'])
|
|
10
12
|
|
|
11
13
|
async function buildSearchJoinQuery ({ scopeName, scopes, context, adapterUtils, db }) {
|
|
12
14
|
const scope = scopes[scopeName]
|
|
@@ -135,12 +137,20 @@ export function applyWhereForOperator ({ builder, columnRef, operator, value, kn
|
|
|
135
137
|
if (selections.length === 0) this.whereRaw('1 = 0')
|
|
136
138
|
for (const selection of selections) this.orWhereExists(selection)
|
|
137
139
|
}
|
|
140
|
+
const visibleMatch = function () {
|
|
141
|
+
this.where(visible)
|
|
142
|
+
applyWhereForOperator({ builder: this, columnRef: columnRef.column, operator, value, knex })
|
|
143
|
+
}
|
|
144
|
+
// A hidden reference reads as null. When null cannot match, leave that branch
|
|
145
|
+
// out: an OR with it keeps planners from using an index on the column.
|
|
146
|
+
const values = Array.isArray(value) ? value : [value]
|
|
147
|
+
if (NULL_REJECTING_OPERATORS.has(String(operator || '=').toLowerCase()) && values.length && values.every(item => item != null)) {
|
|
148
|
+
builder[method](visibleMatch)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
138
151
|
// Compare the column itself to preserve driver affinity and collation.
|
|
139
152
|
builder[method](function () {
|
|
140
|
-
this.where(function () {
|
|
141
|
-
this.where(visible)
|
|
142
|
-
applyWhereForOperator({ builder: this, columnRef: columnRef.column, operator, value, knex })
|
|
143
|
-
}).orWhere(function () {
|
|
153
|
+
this.where(visibleMatch).orWhere(function () {
|
|
144
154
|
this.whereNot(visible)
|
|
145
155
|
applyWhereForOperator({ builder: this, columnRef: knex.raw('null'), operator, value, knex })
|
|
146
156
|
})
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { serializeFieldValueForStorage } from './storage-mapping.js'
|
|
3
3
|
import { getPolymorphicLinkage } from '../querying-writing/relationship-contracts.js'
|
|
4
|
+
import { INDEXED_STRING_LENGTH } from '../anyapi/schema-utils.js'
|
|
5
|
+
import { RestApiValidationError } from '../../../../lib/rest-api-errors.js'
|
|
4
6
|
|
|
5
7
|
/** @import { CanonicalDescriptor, StorageRow, StorageSchemaInfo, StorageWriteOptions } from './storage-types.js' */
|
|
6
8
|
|
|
@@ -34,6 +36,15 @@ export const translateCanonicalAttributesForStorage = (attributes = {}, descript
|
|
|
34
36
|
textStorage: slot.slotType === 'string'
|
|
35
37
|
})
|
|
36
38
|
row[slot.slot] = definition?.type === 'id' && serialized != null ? String(serialized) : serialized
|
|
39
|
+
const stored = row[slot.slot]
|
|
40
|
+
// Databases count code points; one surrogate pair is one character.
|
|
41
|
+
if (slot.indexed && typeof stored === 'string' && stored.length > INDEXED_STRING_LENGTH && [...stored].length > INDEXED_STRING_LENGTH) {
|
|
42
|
+
const field = `data.attributes.${fieldName}`
|
|
43
|
+
throw new RestApiValidationError(`Field '${fieldName}' holds at most ${INDEXED_STRING_LENGTH} characters in its indexed slot.`, {
|
|
44
|
+
fields: [field],
|
|
45
|
+
violations: [{ field, rule: 'storage_range', message: `Indexed string slots hold at most ${INDEXED_STRING_LENGTH} characters.` }]
|
|
46
|
+
})
|
|
47
|
+
}
|
|
37
48
|
}
|
|
38
49
|
|
|
39
50
|
if (slot.slotType === 'belongsTo') {
|
|
@@ -39,7 +39,7 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
39
39
|
const options = pluginOptions || {}
|
|
40
40
|
const knex = options.knex
|
|
41
41
|
const tenantId = options.tenantId || DEFAULT_TENANT
|
|
42
|
-
const { ensureSchema = true, ownsKnex = true, onSchemaChanged, schemaCheckInterval, slots } = options
|
|
42
|
+
const { ensureSchema = true, ownsKnex = true, onSchemaChanged, schemaCheckInterval, slots, indexedSlots } = options
|
|
43
43
|
|
|
44
44
|
if (!knex) {
|
|
45
45
|
throw new Error('AnyapiKnexPlugin requires a knex instance')
|
|
@@ -55,7 +55,7 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
const registry = options.registry || new AnyapiRegistry({ knex, log })
|
|
58
|
-
if (ensureSchema) await registry.ensureSchema({ slots })
|
|
58
|
+
if (ensureSchema) await registry.ensureSchema({ slots, indexedSlots })
|
|
59
59
|
|
|
60
60
|
api.anyapi = api.anyapi || {}
|
|
61
61
|
api.anyapi.registry = registry
|
|
@@ -134,6 +134,15 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
134
134
|
tenant: tenantId, scopeName, fields: hidden, filters: hiddenFilters
|
|
135
135
|
})
|
|
136
136
|
}
|
|
137
|
+
// Fields never move on their own; migrateField copies one into an indexed slot.
|
|
138
|
+
const unindexed = Object.entries(scopeOptions.schema || {})
|
|
139
|
+
.filter(([name, definition]) => definition?.indexed === true && descriptor.fields[name]?.indexed === false)
|
|
140
|
+
.map(([name]) => name)
|
|
141
|
+
if (unindexed.length) {
|
|
142
|
+
await log.warn(`AnyAPI resource '${scopeName}' declares indexed fields stored in unindexed slots`, {
|
|
143
|
+
tenant: tenantId, scopeName, fields: unindexed
|
|
144
|
+
})
|
|
145
|
+
}
|
|
137
146
|
const signature = optionsSignature(scopeOptions)
|
|
138
147
|
let schemaInfo = scope.vars.schemaInfo
|
|
139
148
|
if (compiled?.signature === signature) schemaInfo = compiled.schemaInfo
|
|
@@ -330,7 +339,7 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
330
339
|
})
|
|
331
340
|
|
|
332
341
|
addResourceMethod('createKnexTable', async ({ scopeName }) => {
|
|
333
|
-
if (ensureSchema) await ensureAnyApiSchema(knex, { slots })
|
|
342
|
+
if (ensureSchema) await ensureAnyApiSchema(knex, { slots, indexedSlots })
|
|
334
343
|
const descriptor = await registry.getDescriptor(tenantId, scopeName)
|
|
335
344
|
if (!descriptor) throw new Error(`Descriptor not found for resource '${scopeName}'`)
|
|
336
345
|
await publish(scopeName, descriptor)
|
|
@@ -453,14 +462,21 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
453
462
|
lastId = row.id
|
|
454
463
|
const value = normalizeStoredFieldValue(row[source], fromDefinition, { fieldName: from, resourceType: descriptor.resource })
|
|
455
464
|
const next = transform ? await transform(value, { id: String(row[idColumn]) }) : value
|
|
465
|
+
const rejected = reason => {
|
|
466
|
+
const message = `Record '${row[idColumn]}' cannot migrate '${from}' to '${to}': ${reason}`
|
|
467
|
+
return new RestApiValidationError(message, { fields: [to], violations: [{ field: to, rule: 'field_migration', message }] })
|
|
468
|
+
}
|
|
456
469
|
const { validatedObject, errors } = await schemaInfo.schemaInstance.patch({ [to]: next ?? null })
|
|
457
|
-
if (Object.keys(errors).length)
|
|
458
|
-
|
|
459
|
-
|
|
470
|
+
if (Object.keys(errors).length) throw rejected(Object.values(errors)[0].message)
|
|
471
|
+
let stored
|
|
472
|
+
try {
|
|
473
|
+
stored = translateCanonicalAttributesForStorage({ [to]: validatedObject[to] ?? null }, descriptor, {
|
|
474
|
+
schemaInfo, databaseClient: knex.client.config.client
|
|
475
|
+
})
|
|
476
|
+
} catch (error) {
|
|
477
|
+
if (error?.type === 'rest_api_validation') throw rejected(error.message)
|
|
478
|
+
throw error
|
|
460
479
|
}
|
|
461
|
-
const stored = translateCanonicalAttributesForStorage({ [to]: validatedObject[to] ?? null }, descriptor, {
|
|
462
|
-
schemaInfo, databaseClient: knex.client.config.client
|
|
463
|
-
})
|
|
464
480
|
await transaction(canonical.tableName).where('id', row.id).update({ [target]: stored[target] ?? null })
|
|
465
481
|
}
|
|
466
482
|
}
|
package/types/anyapi.d.ts
CHANGED
|
@@ -2,6 +2,15 @@ import type { Knex } from 'knex'
|
|
|
2
2
|
|
|
3
3
|
export type AnyapiSlotPool = 'string' | 'number' | 'boolean' | 'date' | 'json' | 'belongsTo'
|
|
4
4
|
export type AnyapiSlotCounts = Partial<Record<AnyapiSlotPool, number>>
|
|
5
|
+
// Pools whose slots can carry a (tenant_id, resource, slot) index.
|
|
6
|
+
export type AnyapiIndexedSlotPool = 'string' | 'number' | 'boolean' | 'date'
|
|
7
|
+
export type AnyapiIndexedSlotCounts = Partial<Record<AnyapiIndexedSlotPool, number>>
|
|
8
|
+
|
|
9
|
+
export interface AnyapiSchemaOptions {
|
|
10
|
+
slots?: AnyapiSlotCounts
|
|
11
|
+
// Fields declared `indexed: true` take these slots.
|
|
12
|
+
indexedSlots?: AnyapiIndexedSlotCounts
|
|
13
|
+
}
|
|
5
14
|
|
|
6
15
|
export interface AnyapiSchemaChange {
|
|
7
16
|
tenant: string
|
|
@@ -21,7 +30,7 @@ export class AnyapiRegistry {
|
|
|
21
30
|
constructor(options: AnyapiRegistryOptions)
|
|
22
31
|
readonly knex: Knex
|
|
23
32
|
readonly cacheSize: number
|
|
24
|
-
ensureSchema(options?:
|
|
33
|
+
ensureSchema(options?: AnyapiSchemaOptions): Promise<void>
|
|
25
34
|
getSchemaVersion(tenant: string, resource: string, options?: { transaction?: Knex.Transaction }): Promise<number | null>
|
|
26
35
|
// Retired fields keep their slots and values and reject new writes.
|
|
27
36
|
retireFields(params: { tenant: string; resource: string; fields: string[] }, options?: { transaction?: Knex.Transaction }): Promise<Record<string, unknown>>
|
|
@@ -29,4 +38,4 @@ export class AnyapiRegistry {
|
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
/** Create or upgrade the canonical tables; counts only ever add slot columns. */
|
|
32
|
-
export function ensureAnyApiSchema(knex: Knex, options?:
|
|
41
|
+
export function ensureAnyApiSchema(knex: Knex, options?: AnyapiSchemaOptions): Promise<void>
|
|
@@ -1,7 +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
|
+
import type { AnyapiIndexedSlotCounts, AnyapiRegistry, AnyapiSchemaChange, AnyapiSlotCounts } from './anyapi.js'
|
|
5
5
|
|
|
6
6
|
export type RestApiPluginOptions = RemovedResourceOptions & {
|
|
7
7
|
format?: ResourceFormat
|
|
@@ -50,6 +50,7 @@ export interface RestApiAnyapiKnexPluginOptions extends RestApiKnexPluginOptions
|
|
|
50
50
|
// false leaves a shared Knex instance open when this API is released.
|
|
51
51
|
ownsKnex?: boolean
|
|
52
52
|
slots?: AnyapiSlotCounts
|
|
53
|
+
indexedSlots?: AnyapiIndexedSlotCounts
|
|
53
54
|
// Called after a committed schema change, for example to notify other servers.
|
|
54
55
|
onSchemaChanged?: (change: AnyapiSchemaChange) => void | Promise<void>
|
|
55
56
|
// Milliseconds between schema version checks; 0 checks on every request.
|