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.
@@ -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. Old canonical temporal storage needs
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
  {
2
2
  "name": "json-rest-api",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "main": "index.js",
5
5
  "types": "index.d.ts",
6
6
  "type": "module",
@@ -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 SLOT_PATTERNS = {
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(`No available ${pool} slots remaining`), { slotsExhausted: true })
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
- for (const [type, pattern] of Object.entries(SLOT_PATTERNS)) {
70
- const match = pattern.exec(column)
71
- if (match) {
72
- if (!pools[type].includes(column)) {
73
- throw new Error(`Canonical slot '${column}' is out of range`)
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
- throw new Error(`Invalid canonical slot column '${column}'`)
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[previous.slotType]?.includes(previous.slot)
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 slotColumn = pools[pool].find(column => !used.has(column))
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: pools[pool].indexOf(slotColumn) + 1, slotColumn }
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 a new any_records table. The registry reads the actual
4
- // columns, so a migration that adds slots raises the limits for every server.
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
- 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)
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('AnyAPI slots must be an object of slot counts')
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(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`)
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 { ...SLOT_LIMITS, ...slots }
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
- const ensureTable = async (knex, tableName, tableBuilder) => {
57
- const exists = await knex.schema.hasTable(tableName)
58
- if (!exists) {
59
- await knex.schema.createTable(tableName, tableBuilder)
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 = await knex.schema.hasColumn(tableName, columnName)
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 = await hasKnexTableIndex(knex, tableName, indexName)
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. `slots` only adds missing slot columns; lowering
87
- * a count never drops columns that may hold data.
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 {{ slots?: Partial<Record<keyof typeof SLOT_LIMITS, number>> }} [options]
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
- await ensureTable(knex, 'any_records', (table) => {
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
- 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))
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
- 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
- }
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
- 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)
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
- return buildSlotPools((pool, index) => SLOT_COLUMNS[pool](index).every(column => columns.has(column)))
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') {
@@ -79,6 +79,8 @@ export interface StorageInfo {
79
79
  export interface CanonicalFieldSlot {
80
80
  slot: string
81
81
  slotType: string
82
+ // The slot has a (tenant_id, resource, slot) index; belongsTo slots always do.
83
+ indexed?: boolean
82
84
  alias?: string
83
85
  }
84
86
 
@@ -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
- const message = `Record '${row[idColumn]}' cannot migrate '${from}' to '${to}': ${Object.values(errors)[0].message}`
459
- throw new RestApiValidationError(message, { fields: [to], violations: [{ field: to, rule: 'field_migration', message }] })
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?: { slots?: AnyapiSlotCounts }): Promise<void>
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?: { slots?: AnyapiSlotCounts }): Promise<void>
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.