json-rest-api 2.1.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/API.md +80 -0
- package/docs/GUIDE/17-row-policies.md +1 -0
- package/docs/GUIDE/21-schema-and-migrations.md +99 -2
- package/docs/GUIDE/30-backend-capabilities.md +3 -1
- package/docs/GUIDE/33-migrating-to-v2.md +20 -0
- package/package.json +1 -1
- package/plugins/core/lib/anyapi/anyapi-registry.js +97 -42
- package/plugins/core/lib/anyapi/schema-utils.js +168 -53
- package/plugins/core/lib/anyapi/stored-definition.js +3 -0
- package/plugins/core/lib/dbIntrospection.js +28 -0
- package/plugins/core/lib/querying/aggregate-query.js +170 -0
- package/plugins/core/lib/querying/knex-query-helpers.js +14 -4
- package/plugins/core/lib/querying-writing/compile-schemas.js +17 -0
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +16 -0
- package/plugins/core/lib/querying-writing/default-data-helpers.js +4 -0
- package/plugins/core/lib/querying-writing/field-types.js +43 -0
- package/plugins/core/lib/querying-writing/request-contracts.js +1 -1
- package/plugins/core/lib/storage/canonical-data-helpers.js +29 -0
- package/plugins/core/lib/storage/canonical-storage-mapping.js +11 -0
- package/plugins/core/lib/storage/ordinary-data-helpers.js +25 -0
- package/plugins/core/lib/storage/storage-types.d.ts +23 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +140 -31
- package/plugins/core/rest-api-plugin-methods/aggregate.js +41 -0
- package/plugins/core/rest-api-plugin-methods/common.js +16 -1
- package/plugins/core/rest-api-plugin.js +2 -0
- package/types/anyapi.d.ts +13 -2
- package/types/plugin-options.d.ts +2 -1
- package/types/resource-methods.d.ts +19 -0
- package/types/resource-schema.d.ts +5 -3
package/docs/API.md
CHANGED
|
@@ -78,6 +78,7 @@ a separate context for each enlisted operation. See [transaction support](#trans
|
|
|
78
78
|
- [PUT - Replace Resource](#put---replace-resource)
|
|
79
79
|
- [PATCH - Update Resource](#patch---update-resource)
|
|
80
80
|
- [DELETE - Remove Resource](#delete---remove-resource)
|
|
81
|
+
- [AGGREGATE - Grouped Totals](#aggregate---grouped-totals)
|
|
81
82
|
3. [Relationship Methods](#relationship-methods)
|
|
82
83
|
- [getRelated - Retrieve Related Resources](#getrelated---retrieve-related-resources)
|
|
83
84
|
- [getRelationship - Retrieve Relationship Identifiers](#getrelationship---retrieve-relationship-identifiers)
|
|
@@ -1136,6 +1137,68 @@ savepoints cannot own library writes.
|
|
|
1136
1137
|
|
|
1137
1138
|
---
|
|
1138
1139
|
|
|
1140
|
+
### AGGREGATE - Grouped Totals
|
|
1141
|
+
|
|
1142
|
+
Groups the records a collection query would select and computes measures for
|
|
1143
|
+
each group, on ordinary and AnyAPI storage alike.
|
|
1144
|
+
|
|
1145
|
+
#### Method Signature
|
|
1146
|
+
|
|
1147
|
+
```javascript
|
|
1148
|
+
await api.resources.sales.aggregate(params, context)
|
|
1149
|
+
```
|
|
1150
|
+
|
|
1151
|
+
#### Parameters
|
|
1152
|
+
|
|
1153
|
+
| Parameter | Type | Description |
|
|
1154
|
+
|-----------|------|-------------|
|
|
1155
|
+
| `groupBy` | string[] | Fields to group by. Omit for one row over all selected records. |
|
|
1156
|
+
| `measures` | object | Output names mapped to `{ count \| countDistinct \| sum \| avg \| min \| max: field }`. Only `count` accepts `'*'`. |
|
|
1157
|
+
| `queryParams.filters` | object | Search schema filters, validated as for `query`. |
|
|
1158
|
+
| `queryParams.sort` | string[] | Grouped field or measure names, `-` for descending. Grouped fields break ties. |
|
|
1159
|
+
| `queryParams.page` | object | `size` and `number`, for grouped results only. `size` defaults to `queryDefaultLimit` and cannot exceed `queryMaxLimit`. |
|
|
1160
|
+
| `transaction` | Knex transaction | Optional transaction to read in. |
|
|
1161
|
+
|
|
1162
|
+
`sum` and `avg` take numeric fields; `min` and `max` also take strings, dates and
|
|
1163
|
+
times. Grouping and counting accept any scalar stored field, including belongs-to
|
|
1164
|
+
foreign keys. Fields that are `hidden`, virtual, computed, or that have a getter or
|
|
1165
|
+
`storage.serialize` are rejected, because SQL would group or sum values other than
|
|
1166
|
+
the ones clients read. `normallyHidden` fields are allowed.
|
|
1167
|
+
|
|
1168
|
+
#### Return Value
|
|
1169
|
+
|
|
1170
|
+
```javascript
|
|
1171
|
+
{
|
|
1172
|
+
data: [{ salesperson: 'Ann', total: 150.5, count: 2 }],
|
|
1173
|
+
meta: { page: { size: 20, number: 1, hasMore: false } } // grouped results only
|
|
1174
|
+
}
|
|
1175
|
+
```
|
|
1176
|
+
|
|
1177
|
+
Both formats return these plain rows. Group values and `min`/`max` use the
|
|
1178
|
+
field's public representation; counts are numbers, and `sum`/`avg` are numbers
|
|
1179
|
+
except on decimal fields (`precision` and `scale`), which keep the driver's value.
|
|
1180
|
+
NULL sorts as the lowest value on every database.
|
|
1181
|
+
|
|
1182
|
+
#### Security
|
|
1183
|
+
|
|
1184
|
+
`aggregate` checks permissions as a `query` (`method: 'query'`, with
|
|
1185
|
+
`originalContext.method === 'aggregate'`). Row policies, autofilters and other
|
|
1186
|
+
`knexQueryFiltering` hooks run with `queryPurpose: 'aggregate'`. The aggregate reads
|
|
1187
|
+
only the IDs the filtered query selects, so joins added by filters or policies
|
|
1188
|
+
never count a record twice.
|
|
1189
|
+
|
|
1190
|
+
#### Examples
|
|
1191
|
+
|
|
1192
|
+
```javascript
|
|
1193
|
+
const totals = await api.resources.sales.aggregate({
|
|
1194
|
+
groupBy: ['salesperson'],
|
|
1195
|
+
measures: { total: { sum: 'amount' }, count: { count: '*' }, avg: { avg: 'amount' } },
|
|
1196
|
+
queryParams: { filters: { closed: true }, sort: ['-total'], page: { size: 10 } }
|
|
1197
|
+
}, { subject })
|
|
1198
|
+
```
|
|
1199
|
+
|
|
1200
|
+
---
|
|
1201
|
+
|
|
1139
1202
|
## Relationship Methods
|
|
1140
1203
|
|
|
1141
1204
|
### getRelated - Retrieve Related Resources
|
|
@@ -2039,6 +2102,23 @@ database writes; it is not a persisted UI-state field. Use a setter when a
|
|
|
2039
2102
|
virtual input needs to prepare other stored attributes. A `virtualFields` array
|
|
2040
2103
|
is not the resource declaration contract. See [virtual-field behavior](GUIDE/12-field-transformations.md).
|
|
2041
2104
|
|
|
2105
|
+
#### Email and UUID Fields
|
|
2106
|
+
|
|
2107
|
+
`type: 'email'` and `type: 'uuid'` store trimmed strings and reject values that
|
|
2108
|
+
are not an email address or a UUID. Filters with a text operator (`like`,
|
|
2109
|
+
`contains`, `startsWith`, `endsWith`) accept fragments. Registration rejects any
|
|
2110
|
+
type json-rest-schema cannot cast, so a misspelt type fails at `addResource`
|
|
2111
|
+
rather than on the first write.
|
|
2112
|
+
|
|
2113
|
+
#### Retired Fields
|
|
2114
|
+
|
|
2115
|
+
`retired: true` keeps a stored field readable, filterable and sortable while
|
|
2116
|
+
rejecting every value written to it, including `null`, with the `retired_field`
|
|
2117
|
+
rule. `required` and `defaultTo` no longer apply, and PUT leaves its stored value
|
|
2118
|
+
alone. Only stored attributes can be retired, not belongs-to fields. AnyAPI
|
|
2119
|
+
resources can retire fields at run time with
|
|
2120
|
+
[`retireKnexFields`](GUIDE/21-schema-and-migrations.md#retiring-and-migrating-anyapi-fields).
|
|
2121
|
+
|
|
2042
2122
|
#### Field Transformations
|
|
2043
2123
|
|
|
2044
2124
|
Getters and setters receive a value and an injected context. Computed fields
|
|
@@ -286,6 +286,7 @@ Policies are reused for the resource selections that define externally visible d
|
|
|
286
286
|
| --- | --- |
|
|
287
287
|
| `collection` | Top-level collection query, before sorting and pagination. On AnyAPI this filtered builder is also cloned for the matching count. |
|
|
288
288
|
| `count` | A separate count query, including native offset-pagination counts and standalone count helpers. |
|
|
289
|
+
| `aggregate` | The records a resource `aggregate()` call groups and measures. |
|
|
289
290
|
| `single` | Minimal lookup used by `get`, and by existing-record preflights for `put`, `patch`, and `delete`. |
|
|
290
291
|
| `include` | A target resource loaded into JSON:API `included` data. |
|
|
291
292
|
| `search-join` | A related resource used by a cross-resource or polymorphic search, reference filter, or reference sort, including intermediate search path segments. |
|
|
@@ -297,7 +297,8 @@ This optional map may cover a subset of the new stored fields; remaining fields
|
|
|
297
297
|
use the existing allocator. An initial resource map must still cover all stored
|
|
298
298
|
fields. Invalid, occupied or unknown slots and existing field names reject the
|
|
299
299
|
addition. Computed and virtual fields need no slots and cannot have overrides.
|
|
300
|
-
AnyAPI `alterKnexFields` remains explicitly unsupported
|
|
300
|
+
AnyAPI `alterKnexFields` remains explicitly unsupported; change a field's type with
|
|
301
|
+
[`migrateField`](#retiring-and-migrating-anyapi-fields).
|
|
301
302
|
|
|
302
303
|
One call uses one owned metadata transaction. Allocation failure rolls back the
|
|
303
304
|
batch and leaves the runtime schema unchanged. Added getters, setters, defaults,
|
|
@@ -365,6 +366,7 @@ await api.anyapi.handleSchemaChange(change)
|
|
|
365
366
|
|
|
366
367
|
`handleSchemaChange({ tenant, resource, version })` reloads the resource only when
|
|
367
368
|
the version is newer than the published one, and returns whether it did.
|
|
369
|
+
`api.anyapi.getSchemaVersion('items')` reads the stored version with one query.
|
|
368
370
|
`api.resources.items.reloadSchema()` reloads unconditionally and resolves to the
|
|
369
371
|
published version. Reloads are read-only: they combine the stored definition
|
|
370
372
|
with this server's declaration and never re-register it.
|
|
@@ -384,6 +386,45 @@ of the tenant. A stored resource whose non-field options needed callbacks, such
|
|
|
384
386
|
`normalizeId`, must be declared in code. To load resources on demand over HTTP,
|
|
385
387
|
call `loadResource` from middleware placed before the API router.
|
|
386
388
|
|
|
389
|
+
### Retiring and migrating AnyAPI fields
|
|
390
|
+
|
|
391
|
+
Slots are never released while records exist, so AnyAPI retires fields instead of
|
|
392
|
+
dropping them:
|
|
393
|
+
|
|
394
|
+
```js
|
|
395
|
+
await api.resources.contacts.retireKnexFields(['phone'])
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
A retired field keeps its slot and its values, stays readable, filterable and
|
|
399
|
+
sortable, and rejects writes as a [`retired: true`](../API.md#retired-fields) field
|
|
400
|
+
does. The mark is stored with the resource definition, so a server that registers
|
|
401
|
+
an older declaration still listing the field keeps it retired. Its name stays taken.
|
|
402
|
+
|
|
403
|
+
`migrateField` changes a field's type by copying its values into a new field:
|
|
404
|
+
|
|
405
|
+
```js
|
|
406
|
+
await api.resources.contacts.migrateField({
|
|
407
|
+
from: 'phone',
|
|
408
|
+
to: { name: 'phoneNumber', type: 'number', nullable: true, search: true },
|
|
409
|
+
transform: (value, { id }) => value === null ? null : Number(value.replace(/\D/g, ''))
|
|
410
|
+
})
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
In one transaction it allocates a slot for the new field, converts every record's
|
|
414
|
+
value, and retires the old field. Each converted value must pass the new field's
|
|
415
|
+
validation; without `transform`, that validation casts the old value directly. A
|
|
416
|
+
value it rejects names the record and rolls everything back. The new field must be
|
|
417
|
+
a plain stored attribute: add fields with callbacks with `addKnexFields` and
|
|
418
|
+
backfill them. Values are written directly, so setters, hooks and record versions
|
|
419
|
+
do not run. Records are converted one by one, which suits per-tenant tables
|
|
420
|
+
better than very large ones. Other servers keep accepting writes to the old field
|
|
421
|
+
until the migration commits, and those writes do not reach the new field, so run
|
|
422
|
+
it while nothing writes the field.
|
|
423
|
+
|
|
424
|
+
Both methods bump `schema_version` and call `onSchemaChanged` once after commit.
|
|
425
|
+
`AnyapiRegistry.retireFields({ tenant, resource, fields }, { transaction })` is the
|
|
426
|
+
deep form of retirement.
|
|
427
|
+
|
|
387
428
|
### AnyAPI tenant instances
|
|
388
429
|
|
|
389
430
|
One API instance per tenant stays cheap when the instances share their start-up work:
|
|
@@ -416,7 +457,8 @@ Slot limits belong to the database: every server reads the slot columns that
|
|
|
416
457
|
date, 5 JSON and 5 belongsTo slots. `ensureAnyApiSchema(knex, { slots })`, or the
|
|
417
458
|
plugin's `slots` option when it ensures the schema, adds missing columns. Lower
|
|
418
459
|
counts never drop columns. A server whose pool is exhausted rereads the layout
|
|
419
|
-
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).
|
|
420
462
|
|
|
421
463
|
On PostgreSQL, adding a nullable column without a default does not rewrite the
|
|
422
464
|
table. MySQL can add columns instantly from 8.0.29; earlier versions may copy the
|
|
@@ -457,6 +499,61 @@ lookups return null for absent metadata; operations requiring that relationship
|
|
|
457
499
|
descriptor reject a missing descriptor. Schema errors and database failures
|
|
458
500
|
are distinct from a valid relationship with no matching records.
|
|
459
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
|
+
|
|
460
557
|
## Live Table Snapshots
|
|
461
558
|
|
|
462
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
|
|
|
@@ -1038,6 +1038,15 @@ Use `HookHandler<Event, AppContext>` for known hooks and
|
|
|
1038
1038
|
`RuntimeHook<PluginContext>` for arbitrary plugin events; see the
|
|
1039
1039
|
[hook guide](13-hooks-and-lifecycle.md).
|
|
1040
1040
|
|
|
1041
|
+
## Field type names
|
|
1042
|
+
|
|
1043
|
+
`addResource` now rejects a field whose type json-rest-schema cannot cast, naming
|
|
1044
|
+
the field and type. Such fields used to register and then fail on their first
|
|
1045
|
+
write with `No casting function for type`. AnyAPI no longer lists `text`, `float`,
|
|
1046
|
+
`decimal`, `datetime` or `json`, which no storage could write; use `string`,
|
|
1047
|
+
`number`, `dateTime` and `object` or `array`. `email` and `uuid` are now real
|
|
1048
|
+
string types with format checks.
|
|
1049
|
+
|
|
1041
1050
|
## Object and array attributes
|
|
1042
1051
|
|
|
1043
1052
|
Declare structured fields using their public JSON type:
|
|
@@ -1476,6 +1485,17 @@ are, and omitted populated fields keep their data without being published.
|
|
|
1476
1485
|
Fields added before this release that the declaration does not repeat are kept
|
|
1477
1486
|
and published from their stored definitions.
|
|
1478
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
|
+
|
|
1479
1499
|
Explicit maps take effect during registration, before `createKnexTable()`.
|
|
1480
1500
|
Repeating that method no longer re-registers the initial schema and discards
|
|
1481
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
|
}
|
|
@@ -243,22 +240,16 @@ export class AnyapiRegistry {
|
|
|
243
240
|
})
|
|
244
241
|
}
|
|
245
242
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const { tenant, resource, fields, searchSchema } = readParams()
|
|
251
|
-
Object.assign(context, { tenant, resource })
|
|
243
|
+
#allocate (options, method, readParams) {
|
|
244
|
+
return this.#changeResource(options, method, () => {
|
|
245
|
+
const params = readParams()
|
|
246
|
+
const { resource, fields, searchSchema } = params
|
|
252
247
|
for (const [fieldName, { definition }] of Object.entries(fields)) {
|
|
253
248
|
validateSchemaFieldNames({ [fieldName]: definition }, resource)
|
|
254
249
|
}
|
|
255
250
|
assertFieldNameMap(searchSchema, `added search schema in '${resource}'`)
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
trx = await beginWriteTransaction(context, options.transaction, ownerContext => createKnexTransaction(this.knex, ownerContext))
|
|
259
|
-
const resourceRow = await this.#lockResourceRow(trx, tenant, resource)
|
|
260
|
-
const descriptor = resourceRow && await this.#loadDescriptor(tenant, resource, trx)
|
|
261
|
-
if (!descriptor) throw new Error(`Resource '${resource}' not registered for tenant '${tenant}'`)
|
|
251
|
+
return params
|
|
252
|
+
}, async ({ trx, resourceRow, descriptor }, { fields, searchSchema }) => {
|
|
262
253
|
const plan = await this.#withSlotPools(trx, pools => this.#planAllocation(descriptor, fields, pools))
|
|
263
254
|
|
|
264
255
|
for (const row of plan.fieldRows) {
|
|
@@ -280,11 +271,53 @@ export class AnyapiRegistry {
|
|
|
280
271
|
runtime.callbacks.push(...callbacks.map(path => [kind, name, ...path]))
|
|
281
272
|
}
|
|
282
273
|
}
|
|
283
|
-
|
|
274
|
+
return {
|
|
284
275
|
schema_json: JSON.stringify({ ...descriptor.schema, ...plan.definitions }),
|
|
285
|
-
definition_json: JSON.stringify({ ...stored, runtime })
|
|
286
|
-
|
|
287
|
-
|
|
276
|
+
definition_json: JSON.stringify({ ...stored, runtime })
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Mark stored fields retired: they keep their slots and values, and the
|
|
283
|
+
* published schema stops accepting writes to them. Registration keeps the mark.
|
|
284
|
+
*/
|
|
285
|
+
async retireFields (params, options = {}) {
|
|
286
|
+
return this.#changeResource(options, 'retireFields', () => {
|
|
287
|
+
const { tenant, resource, fields } = params || {}
|
|
288
|
+
if (!tenant || !resource || !Array.isArray(fields) || !fields.length || fields.some(field => typeof field !== 'string')) {
|
|
289
|
+
throw new Error('retireFields requires tenant, resource, and an array of field names')
|
|
290
|
+
}
|
|
291
|
+
return { tenant, resource, fields }
|
|
292
|
+
}, ({ descriptor }, { fields }) => {
|
|
293
|
+
for (const fieldName of fields) {
|
|
294
|
+
const field = descriptor.fields[fieldName]
|
|
295
|
+
if (!field) throw new Error(`Field '${descriptor.resource}.${fieldName}' has no stored slot to retire`)
|
|
296
|
+
if (field.slotType === 'belongsTo') throw new Error(`Field '${descriptor.resource}.${fieldName}' is a relationship and cannot be retired`)
|
|
297
|
+
}
|
|
298
|
+
const stored = descriptor.definition || { options: null, callbacks: [] }
|
|
299
|
+
const retired = [...new Set([...(stored.retired || []), ...fields])]
|
|
300
|
+
return { definition_json: JSON.stringify({ ...stored, retired }) }
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Run one schema change under the resource's row lock and bump its version once.
|
|
305
|
+
// `change` returns the any_resource_configs values to write.
|
|
306
|
+
async #changeResource (options, method, readParams, change) {
|
|
307
|
+
const context = { method, [readCommittedTransaction]: true }
|
|
308
|
+
let trx; let managed = false
|
|
309
|
+
try {
|
|
310
|
+
const params = readParams()
|
|
311
|
+
const { tenant, resource } = params
|
|
312
|
+
Object.assign(context, { tenant, resource })
|
|
313
|
+
|
|
314
|
+
managed = !options.transaction
|
|
315
|
+
trx = await beginWriteTransaction(context, options.transaction, ownerContext => createKnexTransaction(this.knex, ownerContext))
|
|
316
|
+
const resourceRow = await this.#lockResourceRow(trx, tenant, resource)
|
|
317
|
+
const descriptor = resourceRow && await this.#loadDescriptor(tenant, resource, trx)
|
|
318
|
+
if (!descriptor) throw new Error(`Resource '${resource}' not registered for tenant '${tenant}'`)
|
|
319
|
+
const values = await change({ trx, resourceRow, descriptor }, params)
|
|
320
|
+
await this.#bumpVersion(trx, resourceRow, { ...values, updated_at: this.knex.fn.now() })
|
|
288
321
|
const updated = await this.#loadDescriptor(tenant, resource, trx)
|
|
289
322
|
if (managed) {
|
|
290
323
|
await commitTransaction(trx, context)
|
|
@@ -551,7 +584,13 @@ export class AnyapiRegistry {
|
|
|
551
584
|
const row = {
|
|
552
585
|
schema_json: JSON.stringify(storageSchema),
|
|
553
586
|
relationships_json: JSON.stringify(relationships || {}),
|
|
554
|
-
definition_json: JSON.stringify({
|
|
587
|
+
definition_json: JSON.stringify({
|
|
588
|
+
options: declaration.json,
|
|
589
|
+
callbacks: declaration.callbacks,
|
|
590
|
+
runtime,
|
|
591
|
+
// Retirement outlives declarations that still list the field.
|
|
592
|
+
...(stored?.retired?.length ? { retired: stored.retired } : {})
|
|
593
|
+
}),
|
|
555
594
|
}
|
|
556
595
|
const unchanged = previous &&
|
|
557
596
|
Object.entries(row).every(([column, value]) => previous.resourceRow[column] === value) &&
|
|
@@ -715,9 +754,10 @@ export class AnyapiRegistry {
|
|
|
715
754
|
const previous = previousDescriptor?.fields?.[entry.fieldName]
|
|
716
755
|
if (entry.override || !previous || previous.slotType !== slotPoolFor(entry.fieldDef)) continue
|
|
717
756
|
const columns = previous.slotType === 'belongsTo' ? belongsToColumns(previous.slotIndex) : [previous.slot]
|
|
757
|
+
// A field keeps its slot even when its `indexed` declaration changed.
|
|
718
758
|
const available = previous.slotType === 'belongsTo'
|
|
719
759
|
? pools.belongsTo.includes(previous.slotIndex)
|
|
720
|
-
: pools
|
|
760
|
+
: slotColumnsOf(pools, previous.slotType, isIndexedSlot(previous.slot)).includes(previous.slot)
|
|
721
761
|
if (!available || columns.some(column => used.has(column))) continue
|
|
722
762
|
columns.forEach(column => used.add(column))
|
|
723
763
|
entry.override = previous.slotType === 'belongsTo' ? { idSlot: columns[0], typeSlot: columns[1] } : { slotColumn: previous.slot }
|
|
@@ -827,6 +867,8 @@ export class AnyapiRegistry {
|
|
|
827
867
|
slot: row.slot_column,
|
|
828
868
|
slotType: row.slot_type,
|
|
829
869
|
slotIndex: row.slot_index,
|
|
870
|
+
// belongsTo slots are always indexed by parent id.
|
|
871
|
+
indexed: row.slot_type === 'belongsTo' || isIndexedSlot(row.slot_column),
|
|
830
872
|
nullable: row.nullable === 1 || row.nullable === true,
|
|
831
873
|
required: row.required === 1 || row.required === true,
|
|
832
874
|
target: row.target_resource,
|
|
@@ -956,16 +998,29 @@ export class AnyapiRegistry {
|
|
|
956
998
|
`Canonical slot '${slotColumn}' does not match expected type '${pool}' for field '${fieldDef.fieldName}'`
|
|
957
999
|
)
|
|
958
1000
|
}
|
|
1001
|
+
this.#assertFitsSlot(fieldDef, pool, slotColumn)
|
|
959
1002
|
used.add(slotColumn)
|
|
960
1003
|
return { slotType: pool, slotIndex, slotColumn }
|
|
961
1004
|
}
|
|
962
1005
|
|
|
963
|
-
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))
|
|
964
1012
|
if (!slotColumn) {
|
|
965
|
-
throw slotsExhausted(pool)
|
|
1013
|
+
throw slotsExhausted(pool, indexed)
|
|
966
1014
|
}
|
|
1015
|
+
this.#assertFitsSlot(fieldDef, pool, slotColumn)
|
|
967
1016
|
used.add(slotColumn)
|
|
968
|
-
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
|
+
}
|
|
969
1024
|
}
|
|
970
1025
|
|
|
971
1026
|
#assignBelongsTo (fieldDef, used, canonicalOverride, pools) {
|