json-rest-api 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -307,26 +307,125 @@ separate DDL behavior.
307
307
 
308
308
  Existing rows are not backfilled: unused slots stay null, and defaults apply to
309
309
  subsequent writes. Function defaults do not run during allocation. Backfill old
310
- rows explicitly when needed. Declare the expanded schema and complete slot map
311
- in app source for restart, including callbacks that persisted JSON cannot store.
310
+ rows explicitly when needed.
311
+
312
+ Additions are stored with their search schema, so they survive a restart
313
+ without being declared again. Persisted JSON cannot hold callbacks: getters,
314
+ setters, function defaults, serializers and `compute` functions exist only in the
315
+ process that added them. A server that loads such a field without declaring it
316
+ in code hides the field and logs a warning instead of serving it without its
317
+ callbacks. Declare fields with callbacks in app source on every server; a
318
+ declared field always takes precedence over a stored addition of the same name.
312
319
 
313
320
  Explicit maps now take effect during registration. Repeating `createKnexTable()`
314
321
  ensures canonical tables and refreshes schema information without replacing
315
322
  field metadata or removing additions.
316
323
 
317
- Canonical registration allocates slots from the enriched compiled definitions;
318
- it does not compile a second schema from persisted JSON. Schema/search hooks run
319
- once per registration or `addKnexFields` compilation. Repeated table creation
320
- does not rerun them. Search hook changes therefore survive descriptor refresh.
321
-
322
- Re-registration rejects removing or remapping existing stored fields while the
323
- resource has records. Preserve a complete `canonicalFieldsMap` when declaration
324
- order changes, and perform an explicit data migration for layout changes. See
325
- the [schema enrichment migration steps](33-migrating-to-v2.md#schema-enrichment-and-canonical-registration),
324
+ Canonical registration allocates slots from the enriched compiled definitions.
325
+ Stored additions that the declaration does not repeat are compiled from their
326
+ persisted JSON. Schema/search hooks run once per registration, `addKnexFields`
327
+ compilation or reload that changes the field set. Repeated table creation does
328
+ not rerun them. Search hook changes therefore survive descriptor refresh.
329
+
330
+ Registration never moves a stored field: existing fields keep their slots
331
+ whatever the declaration order, and new fields take the first free slot. A field
332
+ the declaration omits keeps its mapping while the resource has records, because
333
+ servers still running older code may use it; this declaration stops publishing
334
+ it. Unpopulated omitted fields are released. Changing a populated field's storage
335
+ type is still rejected and needs an explicit data migration. See the
336
+ [schema enrichment migration steps](33-migrating-to-v2.md#schema-enrichment-and-canonical-registration),
326
337
  including the consequences for hooks that were previously overwritten.
327
338
 
328
- Deep `AnyapiRegistry.allocateField` calls persist the field definition and its
329
- allocation together. Borrowed transactions own their reads/writes and bypass the
339
+ Registering an unchanged declaration writes nothing. Concurrent schema changes
340
+ to one resource queue on its metadata row, and a unique index stops two fields
341
+ from sharing a slot. Each committed change increments the resource's
342
+ `schema_version`. On MySQL, the registry runs its own transactions at READ
343
+ COMMITTED, because REPEATABLE READ makes concurrent creation of different
344
+ resources or tenants deadlock. A transaction you pass in keeps its own isolation
345
+ level.
346
+
347
+ ### AnyAPI across several servers
348
+
349
+ Each server compiles resources into memory. When one server adds fields, the
350
+ others keep their published schema until they reload it:
351
+
352
+ ```js
353
+ await api.use(RestApiAnyapiKnexPlugin, {
354
+ knex,
355
+ tenantId,
356
+ // Runs after each committed change; publish it to the other servers.
357
+ onSchemaChanged: change => notifyServers(change),
358
+ // Safety net for missed notifications.
359
+ schemaCheckInterval: 5000
360
+ })
361
+
362
+ // On a server that receives a notification:
363
+ await api.anyapi.handleSchemaChange(change)
364
+ ```
365
+
366
+ `handleSchemaChange({ tenant, resource, version })` reloads the resource only when
367
+ the version is newer than the published one, and returns whether it did.
368
+ `api.resources.items.reloadSchema()` reloads unconditionally and resolves to the
369
+ published version. Reloads are read-only: they combine the stored definition
370
+ with this server's declaration and never re-register it.
371
+
372
+ With `schemaCheckInterval`, request methods compare the stored version at most
373
+ once per interval; `0` checks on every request. A request rejected by validation
374
+ also checks the version once and, if the resource changed, reloads and retries.
375
+ A write whose transaction may have committed is never retried. Calls given a
376
+ caller transaction skip both checks, because that transaction may hold
377
+ uncommitted metadata. A rejected `onSchemaChanged` callback is logged; the
378
+ committed change stands.
379
+
380
+ A server can serve resources it never declared. `await api.anyapi.loadResource('items')`
381
+ loads a stored resource, with the resources it relates to, and resolves to it, or to
382
+ `null` when none is stored. `api.anyapi.loadResources()` loads every stored resource
383
+ of the tenant. A stored resource whose non-field options needed callbacks, such as
384
+ `normalizeId`, must be declared in code. To load resources on demand over HTTP,
385
+ call `loadResource` from middleware placed before the API router.
386
+
387
+ ### AnyAPI tenant instances
388
+
389
+ One API instance per tenant stays cheap when the instances share their start-up work:
390
+
391
+ ```js
392
+ // In a migration:
393
+ await ensureAnyApiSchema(knex, { slots: { string: 60, number: 30, boolean: 20, date: 20, json: 10, belongsTo: 15 } })
394
+
395
+ // In the application:
396
+ const registry = new AnyapiRegistry({ knex, cacheSize: 5000 })
397
+ const tenantApi = async tenantId => {
398
+ const api = new JsonRestApi()
399
+ await api.use(RestApiPlugin)
400
+ await api.use(RestApiAnyapiKnexPlugin, { knex, tenantId, registry, ensureSchema: false, ownsKnex: false })
401
+ await api.anyapi.loadResources()
402
+ return api
403
+ }
404
+ ```
405
+
406
+ A shared registry reads database capabilities and the slot layout once, and holds
407
+ one descriptor cache, bounded by `cacheSize` (default 100). `ensureSchema: false`
408
+ skips table checks and the logical ID backfill at start-up and in `createKnexTable()`;
409
+ migrations must then run `ensureAnyApiSchema`. `ownsKnex: false` keeps `api.release()`
410
+ from destroying the shared Knex instance. A dropped instance needs no other cleanup.
411
+
412
+ ### AnyAPI slot limits
413
+
414
+ Slot limits belong to the database: every server reads the slot columns that
415
+ `any_records` actually has. The defaults are 10 string, 10 number, 5 boolean, 5
416
+ date, 5 JSON and 5 belongsTo slots. `ensureAnyApiSchema(knex, { slots })`, or the
417
+ plugin's `slots` option when it ensures the schema, adds missing columns. Lower
418
+ 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.
420
+
421
+ On PostgreSQL, adding a nullable column without a default does not rewrite the
422
+ table. MySQL can add columns instantly from 8.0.29; earlier versions may copy the
423
+ table. MySQL limits a row to 65,535 bytes and each belongsTo slot adds two
424
+ string columns, so check the row size before raising that pool substantially.
425
+
426
+ Deep `AnyapiRegistry.allocateField` and `allocateFields` calls persist the field
427
+ definitions and their allocation together. `allocateFields` adds a batch under one
428
+ version; pass each field's uncompiled `declaration` to store it for other servers. Borrowed transactions own their reads/writes and bypass the
330
429
  global descriptor cache; the registry does not commit, roll back or publish their
331
430
  uncommitted descriptors globally. After commit, call
332
431
  `invalidateDescriptor(tenant, resource)` before an ordinary cached read. Deep
@@ -62,11 +62,14 @@ changes can be omitted with warnings, and destructive `down` migrations are not
62
62
  generated automatically. SQLite check/enum changes can require a reviewed table
63
63
  rebuild. Native SET fields require an explicit MySQL target.
64
64
 
65
- AnyAPI field additions persist slot metadata; `alterKnexFields` is unsupported.
66
- Re-registering populated resources cannot remove or remap stored fields. Preserve
67
- slot maps and migrate stored data explicitly when changing layouts. Old canonical
68
- temporal storage needs the documented data migration, not just a package update.
69
- See [table helpers and migration limits](21-schema-and-migrations.md).
65
+ AnyAPI field additions persist slot metadata and their definitions;
66
+ `alterKnexFields` is unsupported. Registration keeps stored fields in their slots
67
+ and keeps the mapping of populated fields a declaration omits; changing a populated
68
+ field's storage type needs an explicit data migration. Servers sharing a database
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
71
+ the documented data migration, not just a package update. See
72
+ [table helpers and migration limits](21-schema-and-migrations.md#anyapi-across-several-servers).
70
73
 
71
74
  ## Transaction ownership and failures
72
75
 
@@ -1466,6 +1466,16 @@ Missing `fields`, duplicate fields and invalid maps now throw instead of silentl
1466
1466
  succeeding or leaving unusable metadata. See
1467
1467
  [AnyAPI field additions](21-schema-and-migrations.md#anyapi-field-additions).
1468
1468
 
1469
+ `ensureAnyApiSchema` now adds `schema_version` and `definition_json` to
1470
+ `any_resource_configs` and a unique slot index to `any_field_configs`. It refuses
1471
+ to add the index while two fields of one resource share a slot, naming the slot;
1472
+ reassign one of them first. Deployments using `ensureSchema: false` must run
1473
+ `ensureAnyApiSchema` in a migration. Registration no longer rejects a declaration
1474
+ that omits a populated field or reorders fields: stored slots stay where they
1475
+ are, and omitted populated fields keep their data without being published.
1476
+ Fields added before this release that the declaration does not repeat are kept
1477
+ and published from their stored definitions.
1478
+
1469
1479
  Explicit maps take effect during registration, before `createKnexTable()`.
1470
1480
  Repeating that method no longer re-registers the initial schema and discards
1471
1481
  additions. Keep the expanded declaration and complete map in app source for
@@ -2805,13 +2815,14 @@ compilation. Canonical metadata no longer invents an attribute declaration for
2805
2815
  an implicit resource ID; metadata consumers should use `schemaInfo.idProperty`
2806
2816
  to identify the resource ID configuration.
2807
2817
 
2808
- Enrichment runs once at resource registration, and once for each canonical
2809
- `addKnexFields` compilation. Repeated canonical `createKnexTable()` refreshes
2810
- storage metadata without rerunning enrichment or restoring removed search
2811
- fields. Deep registry allocation does not update a running resource's compiled
2812
- schema; use the resource helper and retain its expanded declaration in source.
2813
- Declare callbacks in source again after restart: persisted JSON cannot restore
2814
- functions. Ordinary `addKnexFields` retains its DDL-only behavior.
2818
+ Enrichment runs once at resource registration, once for each canonical
2819
+ `addKnexFields` compilation, and once for a reload that changes the field set.
2820
+ Repeated canonical `createKnexTable()` refreshes storage metadata without
2821
+ rerunning enrichment or restoring removed search fields. Deep registry allocation
2822
+ does not update a running resource's compiled schema; use the resource helper or
2823
+ `reloadSchema()`. Additions survive restarts without being declared again, but
2824
+ persisted JSON cannot restore functions: declare fields with callbacks in source,
2825
+ or servers hide them. Ordinary `addKnexFields` retains its DDL-only behavior.
2815
2826
 
2816
2827
  Fastify now resolves the current cached request contract when validating a
2817
2828
  payload. A belongs-to alias added through canonical `addKnexFields` can be used
package/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export type * from './types/relationship-methods.js'
6
6
  export type * from './types/bulk-methods.js'
7
7
  export type { ManagedTransaction, ManagedTransactionParam, TransactionMethods } from './types/transactions.js'
8
8
  export type * from './types/plugin-options.js'
9
+ export * from './types/anyapi.js'
9
10
  export type * from './types/file-detectors.js'
10
11
  export type * from './types/fastify-options.js'
11
12
  export type * from './types/row-policy.js'
package/index.js CHANGED
@@ -13,6 +13,8 @@ export { SocketIOPlugin } from './plugins/core/socketio-plugin.js'
13
13
  // Database plugins
14
14
  export { RestApiKnexPlugin } from './plugins/core/rest-api-knex-plugin.js'
15
15
  export { RestApiAnyapiKnexPlugin } from './plugins/core/rest-api-anyapi-knex-plugin.js'
16
+ export { AnyapiRegistry } from './plugins/core/lib/anyapi/anyapi-registry.js'
17
+ export { ensureAnyApiSchema } from './plugins/core/lib/anyapi/schema-utils.js'
16
18
 
17
19
  // Connector plugins
18
20
  export { ExpressPlugin } from './plugins/core/connectors/express-plugin.js'
@@ -1,12 +1,16 @@
1
1
  // @ts-check
2
2
  /** @import { OwnedTransaction, TransactionOutcome } from './transaction-types.js' */
3
3
  /** @import { StorageDatabase } from '../plugins/core/lib/storage/storage-types.js' */
4
- /** @typedef {{ cleanupErrors?: Array<{ phase: string, error: unknown }>, [serializableTransaction]?: boolean }} CleanupContext */
4
+ /** @typedef {{ cleanupErrors?: Array<{ phase: string, error: unknown }>, [serializableTransaction]?: boolean, [readCommittedTransaction]?: boolean }} CleanupContext */
5
5
  /** @type {WeakMap<OwnedTransaction, (error?: unknown) => Promise<void>>} */
6
6
  const transactionConnections = new WeakMap()
7
7
 
8
8
  // Internal connector request; HTTP input cannot select transaction isolation.
9
9
  export const serializableTransaction = Symbol('serializableTransaction')
10
+ // MySQL's default REPEATABLE READ gap-locks locking reads of missing rows, so
11
+ // concurrent creators of different rows deadlock on insert. PostgreSQL and
12
+ // SQLite already behave as requested.
13
+ export const readCommittedTransaction = Symbol('readCommittedTransaction')
10
14
 
11
15
  // Own the lease without changing the driver's SQL-completion promise.
12
16
  /**
@@ -30,9 +34,11 @@ export async function createKnexTransaction (knex, context = {}) {
30
34
  return releasePromise
31
35
  }
32
36
  try {
33
- const serializable = context[serializableTransaction] === true
34
- const sqlite = ['sqlite3', 'better-sqlite3'].includes(knex.client.config.client)
35
- transaction = await knex.transaction({ connection, ...(serializable && !sqlite ? { isolationLevel: 'serializable' } : {}) })
37
+ const client = knex.client.config.client
38
+ const isolationLevel = context[serializableTransaction] === true
39
+ ? !['sqlite3', 'better-sqlite3'].includes(client) && 'serializable'
40
+ : context[readCommittedTransaction] === true && ['mysql', 'mysql2'].includes(client) && 'read committed'
41
+ transaction = await knex.transaction({ connection, ...(isolationLevel ? { isolationLevel } : {}) })
36
42
  transactionConnections.set(transaction, release)
37
43
  transaction.executionPromise.then(
38
44
  () => release(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "json-rest-api",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "main": "index.js",
5
5
  "types": "index.d.ts",
6
6
  "type": "module",