@simtlix/simfinity-postgres 3.6.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 +7 -5
- package/package.json +3 -3
- package/src/plugin.js +8 -2
- package/src/query/compiler.js +56 -2
- package/src/schema/initialize.js +177 -7
- package/types/index.d.ts +17 -1
package/README.md
CHANGED
|
@@ -31,9 +31,9 @@ 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
|
-
Both factories permanently bind one pool and schema, and
|
|
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
|
|
|
38
38
|
Operations attempted before successful initialization fail with `DATABASE_NOT_INITIALIZED`. Call `createSchema` before `initializeDatabase`. Configuration and registrations become immutable once schema preparation begins. `preventCreatingCollection(true)` makes instance initialization validate existing storage without issuing creation DDL. A rejected initialization request leaves readiness as it was; a ready instance keeps serving while it is initialized again, and becomes unavailable if that initialization fails. `create` mode reads the catalog without table locks and locks nothing when nothing is missing. When it creates objects, writers of the tables that it changes, of existing tables that a new foreign key references, or of roots whose keys it rebuilds wait until it commits, so use `mode: 'validate'` to check a live instance. `simfinity.compileDatabaseSchema()` returns the instance's DDL for review or export.
|
|
39
39
|
|
|
@@ -55,14 +55,14 @@ await initializeDatabase({ mode: 'validate' });
|
|
|
55
55
|
|
|
56
56
|
Called without arguments after `createSchema()`, the module's `describeDatabase()` and `compileDatabaseSchema()` describe and compile this default instance in its configured schema.
|
|
57
57
|
|
|
58
|
-
Transactions use one borrowed `pg` client and repeatable-read isolation. Reads without a session of types without owned embedded tables run as a single statement on a pooled connection instead. A supplied Simfinity PostgreSQL session joins the active transaction. Serialization failures and deadlocks are retried up to five times, each after a short random delay that starts once the client is released; mutation input is reset for each attempt. Unknown commit outcomes are not replayed. The caller owns the pool lifecycle.
|
|
58
|
+
Transactions use one borrowed `pg` client and repeatable-read isolation. Reads without a session of types without owned embedded tables run as a single statement on a pooled connection instead. With `batchedCollections: true`, the runtime reads the generated collection fields of up to 100 parents that share their arguments in one batch, with one statement, a `UNION ALL` of each parent's own statement with its parent ID as a parameter of its own, so PostgreSQL plans each parent's rows as it plans that parent's own statement; more parents run as several batches, each in its own transaction. Each batch runs in one repeatable-read transaction: `BEGIN`, `SET LOCAL jit = off`, the statement, the owned-row reads, then `COMMIT`. Only a direct `findChildrenBatch` call on the SQL adapter with more than 100 IDs repeats `SET LOCAL` and the statement for each further 100 within its transaction. The batch's plan can cost more than `jit_above_cost` when no parent's own plan does, and JIT compilation of its branches can take longer than the statement itself; `SET LOCAL` ends with the transaction, so pooled connections keep the server's setting. A batch takes these 4 round trips, or more with owned rows, one after another on one connection and runs its parents one after another, where per-parent statements take one round trip each, concurrently on the pool's free connections. It pays off when the pool is saturated or much smaller than the number of parents per level, as with many parents with small child lists; with free connections it can be slower, even on a remote database, so measure before enabling it. Transaction-mode PgBouncer keeps each batch on one server connection, while Amazon RDS Proxy pins the pooled connections that run batches, for their `SET LOCAL` and their statements over 16 KB. See [batched collection reads](https://simtlix.github.io/simfinity.js/guide/relationships.html#batched-collection-reads). A supplied Simfinity PostgreSQL session joins the active transaction. Serialization failures and deadlocks are retried up to five times, each after a short random delay that starts once the client is released; mutation input is reset for each attempt. Unknown commit outcomes are not replayed. The caller owns the pool lifecycle.
|
|
59
59
|
|
|
60
60
|
The module and every `createPostgres` instance expose the shared `auth`, `validators`, `scalars`, and `plugins` helpers. These are the same helper objects exported by the MongoDB package, so rules, scalar identities, and errors can be shared safely between backends.
|
|
61
61
|
|
|
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
|
@@ -4,7 +4,7 @@ import { identifier, generatedName } from './schema/sql.js';
|
|
|
4
4
|
import { describeRelationalSchema } from './schema/describe.js';
|
|
5
5
|
import { initializeDatabase } from './schema/initialize.js';
|
|
6
6
|
import { compileDatabaseSchema } from './schema/ddl.js';
|
|
7
|
-
import { compileQuery } from './query/compiler.js';
|
|
7
|
+
import { compileChildrenQuery, compileQuery } from './query/compiler.js';
|
|
8
8
|
import { compileRecord, compileRecordBatch, MAX_RECORD_BATCH_VALUES } from './record-statements.js';
|
|
9
9
|
import { castId, encodeScalar, decodeScalar, encodeParameter, isRetryableDatabaseError, normalizeDatabaseError } from './codecs.js';
|
|
10
10
|
|
|
@@ -20,9 +20,15 @@ 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,
|
|
27
|
+
// Optional member: the find statements of many parents in one statement, for findChildrenBatch, which the SQL
|
|
28
|
+
// adapter defines only with `batchedCollections: true`. Each statement carries setup that turns JIT off for its
|
|
29
|
+
// transaction, and the compileQuery it reproduces, so a copy of this plugin with another compileQuery reads each
|
|
30
|
+
// parent alone.
|
|
31
|
+
compileChildrenQuery,
|
|
26
32
|
compileRecord,
|
|
27
33
|
compileRecordBatch,
|
|
28
34
|
maxRecordBatchValues: MAX_RECORD_BATCH_VALUES,
|
package/src/query/compiler.js
CHANGED
|
@@ -4,11 +4,65 @@ import { encodeScalar, storageTypes, invalidValue } from '../codecs.js';
|
|
|
4
4
|
|
|
5
5
|
const unsupported = (message) => { throw new SimfinityError(message, 'UNSUPPORTED_QUERY', 400); };
|
|
6
6
|
|
|
7
|
+
// Alias of each per-parent statement in the batched children statement. The per-parent statement only uses t<n>,
|
|
8
|
+
// f<n> and short subquery aliases, so it never shadows this one.
|
|
9
|
+
const CHILDREN = 'simfinity_children';
|
|
10
|
+
// Stands for the parent's parameter in the per-parent statement that compileChildrenQuery repeats once per parent.
|
|
11
|
+
// Compiled statements carry no NUL character, which PostgreSQL rejects in statement text: caller values are
|
|
12
|
+
// parameters and identifiers and literals come from GraphQL names.
|
|
13
|
+
const PARENT = '\u0000';
|
|
14
|
+
// Runs before each batched children statement, in its transaction; SET LOCAL ends with that transaction, so the
|
|
15
|
+
// connection goes back to the pool with the server's JIT setting.
|
|
16
|
+
const CHILDREN_SETUP = 'SET LOCAL jit = off';
|
|
17
|
+
|
|
7
18
|
/**
|
|
8
19
|
* Compiles validated paths; all caller values are parameters, identifiers come from metadata.
|
|
9
20
|
* `configuration.explicitNullsOrder: true` keeps the NULLS clause on every sort term, as 3.5.9 did.
|
|
10
21
|
*/
|
|
11
|
-
export const compileQuery = (models, database, plan, extra = null, configuration = null) =>
|
|
22
|
+
export const compileQuery = (models, database, plan, extra = null, configuration = null) => compileSelect(models, database, plan, extra, configuration, false);
|
|
23
|
+
|
|
24
|
+
// A result column for the batched statement that no column of the child table has. Planning rejects storage names
|
|
25
|
+
// that start with `__`, and generated root columns end in `_state` or `_present`, so the loop only guards a
|
|
26
|
+
// description built by hand.
|
|
27
|
+
const freeColumn = (table, name, taken = []) => {
|
|
28
|
+
let column = name;
|
|
29
|
+
while (taken.includes(column) || table.columns.some((item) => item.name === column)) column = `_${column}`;
|
|
30
|
+
return column;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The find statement of each parent in `extra.ids`, in one statement: a UNION ALL with one branch per ID, in
|
|
35
|
+
* order. Each branch is the statement compileQuery returns for `{ column, id }`, with the same filters, joins,
|
|
36
|
+
* ORDER BY, LIMIT and OFFSET, and with that ID as a parameter of its own, so PostgreSQL plans every branch for its
|
|
37
|
+
* own parent, with the statistics of that value, as it plans the per-parent statement. The other parameters, such
|
|
38
|
+
* as filter values and the page, are shared by every branch. Each branch tags its rows with the 1-based position of
|
|
39
|
+
* its parent ID, in `parentColumn`, and with their row number in the order its statement returns them: the
|
|
40
|
+
* statement always has a LIMIT, so PostgreSQL keeps it a subquery and never merges it into the branch. The outer
|
|
41
|
+
* ORDER BY on both returns each parent's rows together, in position order, and in its statement's order, which a
|
|
42
|
+
* Parallel Append would not keep.
|
|
43
|
+
* Values are the per-parent statement's values without the parent ID, then one uuid per parent ID.
|
|
44
|
+
* Its `setup` turns JIT compilation off for the transaction the SQL adapter runs it in: the plan of up to 100
|
|
45
|
+
* branches costs about as much as the per-parent plans together, so it can cross jit_above_cost when none of them
|
|
46
|
+
* does, and JIT then compiles every branch's expressions, which can take longer than running the statement.
|
|
47
|
+
*/
|
|
48
|
+
export const compileChildrenQuery = (models, database, plan, extra, configuration = null) => {
|
|
49
|
+
if (plan?.mode !== 'find' || !plan.pagination) unsupported('Only paginated find queries read children in batches');
|
|
50
|
+
if (!Array.isArray(extra?.ids) || !extra.ids.length) unsupported('Batched children reads require parent IDs');
|
|
51
|
+
const { text, values } = compileSelect(models, database, plan, { column: extra.column, id: null }, configuration, true);
|
|
52
|
+
const [before, after, ...rest] = text.split(PARENT);
|
|
53
|
+
if (after === undefined || rest.length) unsupported('Invalid batched children statement');
|
|
54
|
+
const root = database.tables.find((item) => item.name === plan.entity);
|
|
55
|
+
const parentColumn = freeColumn(root, '__simfinity_parent');
|
|
56
|
+
const ordinalColumn = freeColumn(root, '__simfinity_ordinal', [parentColumn]);
|
|
57
|
+
const branches = extra.ids.map((id, index) => `SELECT ${index + 1}::integer AS ${q(parentColumn)}, row_number() OVER () AS ${q(ordinalColumn)}, ${CHILDREN}.* FROM (${before}$${values.length + index + 1}::uuid${after}) ${CHILDREN}`);
|
|
58
|
+
return { text: `${branches.join(' UNION ALL ')} ORDER BY ${q(parentColumn)}, ${q(ordinalColumn)}`, values: [...values, ...extra.ids], parentColumn, setup: [{ text: CHILDREN_SETUP, values: [] }] };
|
|
59
|
+
};
|
|
60
|
+
// The compileQuery whose per-parent statements compileChildrenQuery reproduces. bindPlugin binds the children
|
|
61
|
+
// compiler only for a plugin whose compileQuery is this one, so a plugin copy that overrides compileQuery reads
|
|
62
|
+
// each parent with its own compileQuery.
|
|
63
|
+
Object.defineProperty(compileChildrenQuery, 'compileQuery', { value: compileQuery });
|
|
64
|
+
|
|
65
|
+
const compileSelect = (models, database, plan, extra, configuration, batched) => {
|
|
12
66
|
const explicitNullsOrder = configuration?.explicitNullsOrder === true;
|
|
13
67
|
const values = [];
|
|
14
68
|
const joins = new Map();
|
|
@@ -232,7 +286,7 @@ export const compileQuery = (models, database, plan, extra = null, configuration
|
|
|
232
286
|
if (extra) {
|
|
233
287
|
const column = root.columns.find((item) => item.name === extra.column);
|
|
234
288
|
if (!column || column.type !== 'uuid') unsupported('Invalid inverse connection field');
|
|
235
|
-
condition += ` AND ${col('t0', column.name)} = ${bind(extra.id, 'uuid')}`;
|
|
289
|
+
condition += ` AND ${col('t0', column.name)} = ${batched ? PARENT : bind(extra.id, 'uuid')}`;
|
|
236
290
|
}
|
|
237
291
|
let select = 't0.*';
|
|
238
292
|
// List values that MIN and MAX facts read, each evaluated once per row in a lateral subquery.
|
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
|
@@ -16,6 +16,7 @@ export type {
|
|
|
16
16
|
AuthPluginOptions,
|
|
17
17
|
AuthRule,
|
|
18
18
|
AuthRuleFunction,
|
|
19
|
+
AuthSchemaPlugin,
|
|
19
20
|
EntityController,
|
|
20
21
|
EnvelopSchemaPlugin,
|
|
21
22
|
FieldValidations,
|
|
@@ -31,6 +32,8 @@ export type {
|
|
|
31
32
|
StateMachine,
|
|
32
33
|
TypePermissions,
|
|
33
34
|
TypeScopes,
|
|
35
|
+
VirtualFieldDeclaration,
|
|
36
|
+
VirtualFieldOptions,
|
|
34
37
|
} from '@simtlix/simfinity-core';
|
|
35
38
|
|
|
36
39
|
export interface DatabaseColumn {
|
|
@@ -178,6 +181,18 @@ export interface PostgresConfiguration {
|
|
|
178
181
|
* indexes can return them in order. `true` keeps the explicit NULLS clause on every sort term, as 3.5.9 did.
|
|
179
182
|
*/
|
|
180
183
|
explicitNullsOrder?: boolean;
|
|
184
|
+
/**
|
|
185
|
+
* Defaults to false: each generated collection field is read with its own statement, as 3.6.0 did. `true` reads the
|
|
186
|
+
* collection fields of many parents that share their arguments with one statement per 100 parents, a UNION ALL of
|
|
187
|
+
* each parent's own statement with its parent ID as a parameter of its own, so PostgreSQL plans each parent's rows
|
|
188
|
+
* for that parent's ID. A batch runs in one repeatable-read transaction, each of its statements right after
|
|
189
|
+
* `SET LOCAL jit = off`, because the batch's plan can cross jit_above_cost when no per-parent plan does.
|
|
190
|
+
* Each parent gets the same rows as before when its order is total, such as a sort that ends in `id`; PostgreSQL
|
|
191
|
+
* does not promise an order for unsorted rows and rows with equal sort keys, which can then come in another order,
|
|
192
|
+
* and on a page that does not hold all of them, other such rows can fill it. Any value other than `true` keeps the
|
|
193
|
+
* default.
|
|
194
|
+
*/
|
|
195
|
+
batchedCollections?: boolean | undefined;
|
|
181
196
|
/**
|
|
182
197
|
* Defaults to 'perRow', as 3.5.9: the deferred triggers of a root with owned tables or unique lists refresh its owned
|
|
183
198
|
* shape and unique keys once per changed row. 'perRoot' refreshes each changed root once per transaction while its
|
|
@@ -209,7 +224,8 @@ export interface PostgresRuntime extends Runtime<PostgresModel, PostgresSession>
|
|
|
209
224
|
* Must be awaited before operations execute. Does not synchronize incompatible schemas. A request rejected before
|
|
210
225
|
* storage is touched, such as an unknown mode (INVALID_INITIALIZATION_MODE, 400) or, on a validation-only runtime,
|
|
211
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
|
|
212
|
-
* 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.
|
|
213
229
|
*/
|
|
214
230
|
initializeDatabase(options?: InitializationOptions): Promise<InitializationResult>;
|
|
215
231
|
withTransaction<T>(session: PostgresSession | null | undefined, callback: (session: PostgresSession) => Promise<T> | T): Promise<T>;
|