@simtlix/simfinity-postgres 3.7.0 → 3.8.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/README.md CHANGED
@@ -31,7 +31,7 @@ import { postgresPlugin } from '@simtlix/simfinity-postgres';
31
31
  const simfinity = createSQL({ plugin: postgresPlugin({ pool, schema: 'library' }) });
32
32
  ```
33
33
 
34
- Install `@simtlix/simfinity-sql@3.7.0` as a direct dependency when importing its factory. PostgreSQL 15 or later is the only supported SQL plugin. The [SQL package guide](https://github.com/simtlix/simfinity.js/tree/master/packages/sql) documents relational planning and the versioned plugin contract.
34
+ Install `@simtlix/simfinity-sql@3.8.0` as a direct dependency when importing its factory. PostgreSQL 15 or later is the only supported SQL plugin. The [SQL package guide](https://github.com/simtlix/simfinity.js/tree/master/packages/sql) documents relational planning and the versioned plugin contract.
35
35
 
36
36
  Both factories permanently bind one pool and schema, and five optional settings: `multiRowInserts: false` keeps one `INSERT` per owned row instead of multi-row statements; `explicitNullsOrder: true` keeps the `NULLS` clause on sorts of `id` and required fields, as 3.5.9 did, instead of letting indexes serve them; `batchedCollections: true` reads the generated collection fields of the parents that share their arguments with one statement per 100 parents, in a transaction that turns JIT off for that transaction only, instead of one statement per field as 3.6.0 did and as the default still does, and can return unsorted and tied rows in another order; the opt-in `refreshTriggers: 'perRoot'` refreshes each changed root's owned shape and unique keys once per transaction, while its guard trigger stays deferred, instead of once per changed row; and the opt-in `ownerIndexes: 'minimal'` leaves out the `(__owner_id)` indexes that the primary key or a unique index covers on new owned and guard tables. An unknown `refreshTriggers` or `ownerIndexes` value throws `INVALID_DATABASE_SCHEMA` (400). Read the [upgrade notes](https://simtlix.github.io/simfinity.js/resources/compatibility.html#upgrade-to-3-6-0) before enabling the opt-ins: versions up to 3.5.9 cannot validate databases created with them. The returned runtime supports registration, generated queries and mutations, middleware and scopes, controllers and validators, custom mutations, state machines, nested relationship writes, embedded reconstruction, and native PostgreSQL model handles. Native handles expose `findById`, `find`, `create`, `update`, and `delete`; they are not Mongoose models. `find(args, { session })` accepts the generated GraphQL list query arguments. Existing `pg.Pool` configuration and `pg.PoolClient` query interfaces remain compatible across both factories. A custom pool or client wrapper should pass `pg`'s `command` through in query results: the plugin reads it from the `COMMIT` result to detect a transaction that PostgreSQL rolled back because one of its statements failed, and without it such a transaction reports success with nothing stored.
37
37
 
@@ -62,7 +62,7 @@ The module and every `createPostgres` instance expose the shared `auth`, `valida
62
62
  MCP integration is an independent opt-in and does not add MCP dependencies to PostgreSQL applications:
63
63
 
64
64
  ```sh
65
- npm install @simtlix/simfinity-mcp@3.7.0 @modelcontextprotocol/sdk@^1.31.0
65
+ npm install @simtlix/simfinity-mcp@3.8.0 @modelcontextprotocol/sdk@^1.31.0
66
66
  ```
67
67
 
68
68
  ```javascript
@@ -81,9 +81,11 @@ Errors that PostgreSQL reports become `SimfinityError`s without SQL details, wit
81
81
 
82
82
  PostgreSQL enforces required values, real FKs, JSONB/owned embedded shapes, root and embedded scalar/reference uniqueness, and scalar-list multikey uniqueness. Root unique indexes use `NULLS NOT DISTINCT`; private typed owner-key tables allow repeated keys within one owner but reject the same key across owners. Presence markers preserve missing versus explicit null fields. This is stronger and broader than generated Mongoose storage.
83
83
 
84
+ Virtual fields (`extensions.virtual`), on entities and on members of embedded values, have no storage: no column, presence marker, check, JSON validation function, owned table, index or foreign key, and any output type works as their result. Adding one to a deployed type is therefore not a schema change. A field that a database already stores and that becomes virtual leaves its storage behind, so initialization keeps failing with `SCHEMA_MISMATCH` until the database is migrated. When the leftover is a column named after that field, its presence marker or owned-records state, its owned table, a validation or maintenance function of its own column or root, or the owned table of an embedded value whose last member with its own table it was, the error names the field and lists the migration. Storage named otherwise is not attributed: the `connectionField` column of a former reference, and the private back-reference column of a former collection in its child's table.
85
+
84
86
  Scalar-list leaves inside nested embedded lists support filters, sorts, ragged group projections, and array-valued facts. Array `MIN`/`MAX` compares complete arrays; result sorting uses immediate extrema. Date parameters use per-query UTC encoding without changing global `pg` behavior.
85
87
 
86
- The following mappings remain unsupported and fail explicitly: implicit many-to-many reciprocal lists, non-embedded collections inside embedded objects, embedded cycles, nested list wrappers, whole embedded-object uniqueness, conflicting relationship metadata, enum values that collide after text conversion, and custom scalars without a recognized base scalar. Query paths cannot sort or group whole embedded objects. Arbitrary MongoDB pipelines and Mongoose-native methods are outside the PostgreSQL contract.
88
+ The following mappings remain unsupported and fail explicitly: implicit many-to-many reciprocal lists, non-embedded collections inside embedded objects, embedded cycles, nested list wrappers, whole embedded-object uniqueness, conflicting relationship metadata, enum values that collide after text conversion, and stored custom scalars without a recognized base scalar. Query paths cannot sort or group whole embedded objects. Arbitrary MongoDB pipelines and Mongoose-native methods are outside the PostgreSQL contract.
87
89
 
88
90
  ## Low-level schema API
89
91
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-postgres",
3
- "version": "3.7.0",
3
+ "version": "3.8.0",
4
4
  "description": "PostgreSQL GraphQL runtime and schema generation with foreign keys for Simfinity",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -27,8 +27,8 @@
27
27
  "directory": "packages/postgres"
28
28
  },
29
29
  "dependencies": {
30
- "@simtlix/simfinity-core": "3.7.0",
31
- "@simtlix/simfinity-sql": "3.7.0",
30
+ "@simtlix/simfinity-core": "3.8.0",
31
+ "@simtlix/simfinity-sql": "3.8.0",
32
32
  "pg": "^8.16.3"
33
33
  },
34
34
  "peerDependencies": {
package/src/plugin.js CHANGED
@@ -20,7 +20,8 @@ export const postgresPlugin = (options) => ({
20
20
  naming: { validateIdentifier: identifier, generatedName },
21
21
  // The runtime passes its bound configuration: createPostgres(options) or configure(value).
22
22
  describeSchema: (plan, configuration) => describeRelationalSchema(plan, { refreshTriggers: configuration?.refreshTriggers, ownerIndexes: configuration?.ownerIndexes }),
23
- initialize: (configuration, description, initialization) => initializeDatabase(configuration.pool, description, initialization),
23
+ // `context.virtualFields` locates virtual fields, so that SCHEMA_MISMATCH can name one whose earlier storage is left.
24
+ initialize: (configuration, description, initialization, context) => initializeDatabase(configuration.pool, description, initialization, context),
24
25
  compileSchema: compileDatabaseSchema,
25
26
  compileQuery,
26
27
  // Optional member: the find statements of many parents in one statement, for findChildrenBatch, which the SQL
@@ -1,5 +1,5 @@
1
1
  import { SimfinityError } from '@simtlix/simfinity-core';
2
- import { identifier, qualified, generatedName } from './sql.js';
2
+ import { identifier, qualified, generatedName, literal } from './sql.js';
3
3
  import { createTableSQL, createForeignKeySQL, createIndexSQL, createFunctionSQL, createTriggerSQL, triggerEvents } from './ddl.js';
4
4
  import { maintenanceLayouts, layoutOf, withLayouts } from './constraints.js';
5
5
 
@@ -342,8 +342,18 @@ const traverse = async (client, description, catalog, pass, missing, created) =>
342
342
  return legacy;
343
343
  };
344
344
 
345
- // Names to read from the catalog: the description's, plus the refresh-trigger objects of both layouts.
346
- const catalogNames = (description, layouts) => {
345
+ // The table that an embedded value holding a virtual member owned while that member was stored, when the description
346
+ // no longer has it: the value then has no member with its own table, so it is stored as JSON.
347
+ const formerOwnedTable = (description, location) => {
348
+ if (!location.path.length) return null;
349
+ const name = generatedName(location.table, location.path[0]);
350
+ return description.tables.some((table) => table.name === name) ? null : name;
351
+ };
352
+
353
+ // Names to read from the catalog: the description's, plus the refresh-trigger objects of both layouts. With virtual
354
+ // fields, also the touch trigger of each root that owns nothing, and the former owned table of each embedded value
355
+ // that holds one (see formerOwnedTable), which only storage no longer described can leave.
356
+ const catalogNames = (description, layouts, virtualFields) => {
347
357
  const functions = new Set((description.functions || []).map((fn) => fn.name));
348
358
  const triggers = new Set((description.triggers || []).map((trigger) => trigger.name));
349
359
  for (const layout of layouts.values()) {
@@ -352,7 +362,18 @@ const catalogNames = (description, layouts) => {
352
362
  for (const trigger of objects.triggers) triggers.add(trigger.name);
353
363
  }
354
364
  }
355
- return { tables: description.tables.map((table) => table.name), functions: [...functions], triggers: [...triggers] };
365
+ const tables = new Set(description.tables.map((table) => table.name));
366
+ if (virtualFields.length) {
367
+ const maintained = new Set((description.maintenance || []).map((item) => item.rootTable));
368
+ for (const table of description.tables) {
369
+ if (!table.ownership && !table.auxiliary && !maintained.has(table.name)) triggers.add(generatedName(table.name, 'touch'));
370
+ }
371
+ for (const location of virtualFields) {
372
+ const former = formerOwnedTable(description, location);
373
+ if (former) tables.add(former);
374
+ }
375
+ }
376
+ return { tables: [...tables], functions: [...functions], triggers: [...triggers] };
356
377
  };
357
378
 
358
379
  // Each maintained root keeps the refresh-trigger layout its database already has: a function or trigger
@@ -412,6 +433,148 @@ const lockRoots = async (client, description, catalog, roots) => {
412
433
  if (names.length) await client.query(`LOCK TABLE ${names.map((table) => qualified(description.schema, table.name)).join(', ')} IN SHARE ROW EXCLUSIVE MODE`);
413
434
  };
414
435
 
436
+ const UPGRADE_NOTES = 'https://simtlix.github.io/simfinity.js/resources/compatibility.html';
437
+ const series = (items) => (items.length < 3 ? items.join(' and ') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`);
438
+ // A name longer than PostgreSQL identifiers was never stored, so nothing can refer to it.
439
+ const quotedName = (name) => (Buffer.byteLength(name) <= 63 ? identifier(name) : null);
440
+
441
+ // The described functions whose bodies could refer to the storage of a virtual field's location: for a member of a
442
+ // JSON value, the validation functions of its column, which the column's shape check calls; for a column, the
443
+ // maintenance functions of its table's root and the functions of that root's triggers. Each set includes the
444
+ // described functions that its functions call.
445
+ const functionOwners = (description) => {
446
+ const functions = description.functions || [];
447
+ const called = (text) => functions.filter((fn) => text.includes(qualified(description.schema, fn.name)));
448
+ const closure = (seeds) => {
449
+ const names = new Set();
450
+ const queue = [...seeds];
451
+ while (queue.length) {
452
+ const fn = queue.pop();
453
+ if (names.has(fn.name)) continue;
454
+ names.add(fn.name);
455
+ queue.push(...called(fn.body));
456
+ }
457
+ return names;
458
+ };
459
+ const ofRoot = (table) => {
460
+ const root = rootOf(description, table);
461
+ const item = (description.maintenance || []).find((entry) => entry.rootTable === root);
462
+ const triggered = new Set((description.triggers || []).filter((trigger) => rootOf(description, trigger.table) === root).map((trigger) => trigger.function));
463
+ return closure(functions.filter((fn) => triggered.has(fn.name) || (item && [item.validateFunction, item.refreshFunction].includes(fn.name))));
464
+ };
465
+ const ofColumn = (tableName, column) => {
466
+ const table = description.tables.find((item) => item.name === tableName);
467
+ const check = table?.checks.find((item) => item.name === generatedName(tableName, column, 'shape'));
468
+ return closure(check ? called(check.expression) : []);
469
+ };
470
+ return { ofRoot, ofColumn };
471
+ };
472
+
473
+ // Storage that virtual fields (`extensions.virtual`) left in a database created while they were stored, found in
474
+ // the catalog this run has read, with no further statement: a column of a described table named as such a field's
475
+ // column, presence flag or owned-records state, a described function whose stored body still refers to such a
476
+ // field while its description does not, and the owned table of an embedded value that such a field, as its last
477
+ // member with its own table, made the value own. A state column means the field owned tables, which are dropped. An
478
+ // embedded value that no longer owns a table is stored as JSON instead, which needs a data migration. A root that
479
+ // now owns nothing but still has its refresh triggers loses them and its guard table, since the functions they call
480
+ // refer to the storage being dropped. Only the functions of a field's own column or root are compared (see
481
+ // functionOwners). Returns the explanation, with the migration, that a SCHEMA_MISMATCH appends, or null when
482
+ // nothing is left.
483
+ const virtualLeftovers = (description, catalog, virtualFields) => {
484
+ const { schema } = description;
485
+ const maintained = new Set((description.maintenance || []).map((item) => item.rootTable));
486
+ const owners = functionOwners(description);
487
+ const left = new Set();
488
+ const columns = new Map();
489
+ const owned = [];
490
+ const moved = new Map();
491
+ const has = (actual, name) => actual.columns.some((column) => column.name === name);
492
+ for (const location of virtualFields) {
493
+ const former = formerOwnedTable(description, location);
494
+ const table = former && description.tables.find((item) => item.name === location.table);
495
+ const actual = table && catalog.tables.get(table.name);
496
+ const ownedActual = former && catalog.tables.get(former);
497
+ const [value, member = location.name] = location.path;
498
+ const state = `__${value}_state`;
499
+ if (!actual || !ownedActual || table.columns.some((column) => column.name === state) || !has(actual, state)) continue;
500
+ // The member, or the value inside the owned table that holds it, had storage there.
501
+ if (![member, generatedName('__field', member, 'present'), `__${member}_state`].some((name) => has(ownedActual, name))) continue;
502
+ left.add(location.field);
503
+ moved.set(former, { table: table.name, value, state, location });
504
+ }
505
+ for (const location of virtualFields.filter((item) => !item.path.length)) {
506
+ const table = description.tables.find((item) => item.name === location.table);
507
+ const actual = table && catalog.tables.get(table.name);
508
+ if (!actual) continue;
509
+ const state = `__${location.name}_state`;
510
+ const names = [location.name, generatedName('__field', location.name, 'present'), state].filter((name) => !table.columns.some((column) => column.name === name)
511
+ && actual.columns.some((column) => column.name === name));
512
+ if (!names.length) continue;
513
+ left.add(location.field);
514
+ columns.set(table.name, [...(columns.get(table.name) || []), ...names]);
515
+ if (names.includes(state)) owned.push(location);
516
+ }
517
+ const statements = [...columns].map(([table, names]) => `ALTER TABLE ${qualified(schema, table)} ${names.map((name) => `DROP COLUMN ${identifier(name)}`).join(', ')};`);
518
+ const add = (statement) => { if (!statements.includes(statement)) statements.push(statement); };
519
+ for (const location of owned) add(`DROP TABLE IF EXISTS ${qualified(schema, generatedName(location.table, location.name))} CASCADE;`);
520
+ for (const [former, { table, value, state }] of moved) {
521
+ const json = [value, generatedName('__field', value, 'present')].map(identifier).join(' and ');
522
+ add(`The embedded value ${value} of ${qualified(schema, table)} now has no member with its own table, so it is stored as JSON in the columns ${json} `
523
+ + `of that table, with the checks and functions that compileDatabaseSchema() gives them, instead of in the table ${qualified(schema, former)}: `
524
+ + `add them, fill them from the rows of ${qualified(schema, former)}, then drop the column ${identifier(state)} and run DROP TABLE IF EXISTS ${qualified(schema, former)} CASCADE;`);
525
+ }
526
+ for (const root of new Set([...columns.keys(), ...[...moved.values()].map((item) => item.table)].map((table) => rootOf(description, table)))) {
527
+ if (maintained.has(root) || !catalog.triggers(root, generatedName(root, 'touch')).length) continue;
528
+ for (const suffix of ['touch', 'deferred']) add(`DROP TRIGGER IF EXISTS ${identifier(generatedName(root, suffix))} ON ${qualified(schema, root)};`);
529
+ add(`DROP TABLE IF EXISTS ${qualified(schema, generatedName(root, 'guard'))};`);
530
+ }
531
+ // A column is referred to as `alias."name"`, a member of a JSON value as `($1 -> 'name')`, and a former owned table
532
+ // by its qualified name.
533
+ const references = [
534
+ ...virtualFields.map((location) => ({
535
+ field: location.field,
536
+ functions: location.path.length ? owners.ofColumn(location.table, location.path[0]) : owners.ofRoot(location.table),
537
+ tokens: location.path.length
538
+ ? [`($1 -> ${literal(location.name)})`]
539
+ : [location.name, generatedName('__field', location.name, 'present'), `__${location.name}_state`].map(quotedName).filter(Boolean).map((name) => `.${name}`),
540
+ })),
541
+ ...[...moved].map(([former, { table, location }]) => ({ field: location.field, functions: owners.ofRoot(table), tokens: [qualified(schema, former)] })),
542
+ ];
543
+ const functions = [];
544
+ for (const fn of description.functions || []) {
545
+ const rows = catalog.functions(fn.name);
546
+ if (rows.length !== 1 || rows[0].body === fn.body) continue;
547
+ const matched = references.filter(({ functions: owned, tokens }) => owned.has(fn.name)
548
+ && tokens.some((token) => rows[0].body.includes(token) && !fn.body.includes(token)));
549
+ if (!matched.length) continue;
550
+ functions.push(qualified(schema, fn.name));
551
+ for (const { field } of matched) left.add(field);
552
+ }
553
+ if (!left.size) return null;
554
+ const fields = [...new Set(virtualFields.map((item) => item.field))].filter((field) => left.has(field));
555
+ const many = fields.length > 1;
556
+ if (functions.length) {
557
+ statements.push(`Replace ${functions.length > 1 ? 'the functions' : 'the function'} ${series(functions)} with ${functions.length > 1 ? 'their CREATE FUNCTION statements' : 'its CREATE FUNCTION statement'} `
558
+ + 'from compileDatabaseSchema(), run as CREATE OR REPLACE FUNCTION.');
559
+ }
560
+ return `${series(fields)} ${many ? 'are' : 'is'} virtual (extensions.virtual), so ${many ? 'they are' : 'it is'} never stored, but this database still holds ${many ? 'their' : 'its'} `
561
+ + `earlier storage. Once nothing reads that storage, migrate it: ${statements.join(' ')} See the upgrade notes at ${UPGRADE_NOTES}`;
562
+ };
563
+ // A SCHEMA_MISMATCH of a database that still holds storage of fields that are now virtual also names them.
564
+ const explainMismatch = (error, description, catalog, layouts, virtualFields) => {
565
+ if (!virtualFields.length || !catalog || !(error instanceof SimfinityError) || error.extensions?.code !== 'SCHEMA_MISMATCH') return error;
566
+ let resolved = description;
567
+ // Refresh-trigger objects of two layouts in one root are themselves the mismatch; compare with the description.
568
+ try { resolved = resolveLayouts(description, catalog, layouts); } catch { /* keep the description */ }
569
+ try {
570
+ const explanation = virtualLeftovers(resolved, catalog, virtualFields);
571
+ return explanation ? new SimfinityError(`${error.message} ${explanation}`, 'SCHEMA_MISMATCH', 409) : error;
572
+ } catch {
573
+ // An explanation never replaces the mismatch it explains.
574
+ return error;
575
+ }
576
+ };
577
+
415
578
  const backfill = async (client, description, roots) => {
416
579
  // The keys of these roots can be stale because maintenance objects were recreated. Their tables are
417
580
  // locked; rebuild their complete derived key set before testing owners against one another. Rollback
@@ -430,18 +593,23 @@ const backfill = async (client, description, roots) => {
430
593
  * Create missing objects or validate an existing schema; never alter/drop existing storage. The catalog is
431
594
  * read in a fixed number of statements without table locks. Create mode changes nothing, and locks nothing,
432
595
  * when nothing is missing; otherwise it locks only the tables of roots whose keys it must rebuild.
596
+ * `context.virtualFields`, which the SQL runtime passes when its types declare virtual fields, lets a
597
+ * SCHEMA_MISMATCH name such a field whose earlier storage the database still holds, with its migration.
433
598
  */
434
- export const initializeDatabase = async (pool, description, { mode = 'create' } = {}) => {
599
+ export const initializeDatabase = async (pool, description, { mode = 'create' } = {}, context) => {
435
600
  if (!['create', 'validate'].includes(mode)) throw new SimfinityError(`Unknown initialization mode: ${mode}`, 'INVALID_INITIALIZATION_MODE', 400);
436
601
  const { schema } = description;
437
602
  identifier(schema);
438
603
  if (schema.startsWith('pg_') || schema === 'information_schema') throw new SimfinityError('System schemas cannot be managed', 'INVALID_DATABASE_SCHEMA', 400);
439
604
  const layouts = maintenanceLayouts(description);
440
- const names = catalogNames(description, layouts);
605
+ const virtualFields = Array.isArray(context?.virtualFields) ? context.virtualFields : [];
606
+ const names = catalogNames(description, layouts, virtualFields);
441
607
  const client = await pool.connect();
442
608
  const created = [];
443
609
  let legacy = [];
444
610
  let rollbackError;
611
+ // The catalog most recently read, which explains a mismatch.
612
+ let latest = null;
445
613
  try {
446
614
  await client.query(mode === 'validate' ? 'BEGIN READ ONLY' : 'BEGIN');
447
615
  await client.query('SET LOCAL search_path = pg_catalog');
@@ -451,6 +619,7 @@ export const initializeDatabase = async (pool, description, { mode = 'create' }
451
619
  // Serializes initializers of this schema before anything is read.
452
620
  await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [`simfinity:${schema}`]);
453
621
  const inspected = await loadCatalog(client, schema, names);
622
+ latest = inspected;
454
623
  if (mode === 'validate') legacy = await traverse(client, resolveLayouts(description, inspected, layouts), inspected, 'validate', [], created);
455
624
  else {
456
625
  // Inspect first, without table locks or DDL. Drift before the first missing object is reported as
@@ -468,6 +637,7 @@ export const initializeDatabase = async (pool, description, { mode = 'create' }
468
637
  const roots = staleRoots(planned, missing);
469
638
  await lockRoots(client, planned, inspected, roots);
470
639
  const catalog = await loadCatalog(client, schema, names);
640
+ latest = catalog;
471
641
  const effective = resolveLayouts(description, catalog, layouts);
472
642
  const objects = [];
473
643
  legacy = await traverse(client, effective, catalog, 'create', objects, created);
@@ -487,7 +657,7 @@ export const initializeDatabase = async (pool, description, { mode = 'create' }
487
657
  return { mode, created };
488
658
  } catch (error) {
489
659
  try { await client.query('ROLLBACK'); } catch (failure) { rollbackError = failure; }
490
- throw error;
660
+ throw explainMismatch(error, description, latest, layouts, virtualFields);
491
661
  } finally {
492
662
  // A failed ROLLBACK can leave the transaction open; the error makes pg-pool destroy the connection.
493
663
  client.release(rollbackError);
package/types/index.d.ts CHANGED
@@ -32,6 +32,8 @@ export type {
32
32
  StateMachine,
33
33
  TypePermissions,
34
34
  TypeScopes,
35
+ VirtualFieldDeclaration,
36
+ VirtualFieldOptions,
35
37
  } from '@simtlix/simfinity-core';
36
38
 
37
39
  export interface DatabaseColumn {
@@ -222,7 +224,8 @@ export interface PostgresRuntime extends Runtime<PostgresModel, PostgresSession>
222
224
  * Must be awaited before operations execute. Does not synchronize incompatible schemas. A request rejected before
223
225
  * storage is touched, such as an unknown mode (INVALID_INITIALIZATION_MODE, 400) or, on a validation-only runtime,
224
226
  * a non-empty mode other than validate (DATABASE_CREATION_DISABLED, 409), keeps the current readiness; a ready runtime keeps serving while it is initialized again, and any failure of the latest
225
- * initialization makes it unavailable.
227
+ * initialization makes it unavailable. When the database still holds storage of a field that is now virtual (`extensions.virtual`), such as its column,
228
+ * the SCHEMA_MISMATCH (409) message also names the field and gives the migration.
226
229
  */
227
230
  initializeDatabase(options?: InitializationOptions): Promise<InitializationResult>;
228
231
  withTransaction<T>(session: PostgresSession | null | undefined, callback: (session: PostgresSession) => Promise<T> | T): Promise<T>;