@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 +5 -3
- package/package.json +3 -3
- package/src/plugin.js +2 -1
- package/src/schema/initialize.js +177 -7
- package/types/index.d.ts +4 -1
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.
|
|
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.
|
|
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.
|
|
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.
|
|
31
|
-
"@simtlix/simfinity-sql": "3.
|
|
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
|
-
|
|
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
|
package/src/schema/initialize.js
CHANGED
|
@@ -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
|
-
//
|
|
346
|
-
|
|
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
|
-
|
|
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
|
|
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>;
|