@simtlix/simfinity-postgres 3.5.8 → 3.6.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 +6 -6
- package/package.json +3 -3
- package/src/plugin.js +8 -3
- package/src/query/compiler.js +27 -6
- package/src/record-statements.js +11 -0
- package/src/schema/constraints.js +135 -25
- package/src/schema/ddl.js +4 -2
- package/src/schema/describe.js +36 -6
- package/src/schema/initialize.js +288 -95
- package/types/index.d.ts +30 -2
package/README.md
CHANGED
|
@@ -31,11 +31,11 @@ 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.6.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. 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.
|
|
36
|
+
Both factories permanently bind one pool and schema, and four 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; 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
|
-
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.
|
|
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
|
|
|
40
40
|
A default module instance is also available when a singleton facade is preferable:
|
|
41
41
|
|
|
@@ -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. 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. 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.6.0 @modelcontextprotocol/sdk@^1.31.0
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
```javascript
|
|
@@ -101,4 +101,4 @@ const statements = compileDatabaseSchema(description);
|
|
|
101
101
|
await initializeDatabase(pool, description, { mode: 'validate' });
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
-
Low-level `describeDatabase(registrations, { schema })` uses the `public` schema when `schema` is omitted, not the schema of a configured runtime; pass the schema given to `configure()` or `createPostgres()`, or call `describeDatabase()` and `compileDatabaseSchema()` without arguments for the default instance. `compileDatabaseSchema` returns reviewable SQL. Low-level `initializeDatabase(pool, description, { mode })` creates or validates storage transactionally and never closes the pool. Create mode adds missing objects and rejects incompatible definitions; validate mode performs no DDL. Neither mode performs destructive schema synchronization or data migration.
|
|
104
|
+
Low-level `describeDatabase(registrations, { schema, refreshTriggers, ownerIndexes })` uses the `public` schema when `schema` is omitted, not the schema of a configured runtime; pass the schema given to `configure()` or `createPostgres()`, or call `describeDatabase()` and `compileDatabaseSchema()` without arguments for the default instance. `compileDatabaseSchema` returns reviewable SQL. Low-level `initializeDatabase(pool, description, { mode })` creates or validates storage transactionally and never closes the pool. Create mode adds missing objects and rejects incompatible definitions; validate mode performs no DDL. Neither mode performs destructive schema synchronization or data migration.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simtlix/simfinity-postgres",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.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.6.0",
|
|
31
|
+
"@simtlix/simfinity-sql": "3.6.0",
|
|
32
32
|
"pg": "^8.16.3"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
package/src/plugin.js
CHANGED
|
@@ -5,7 +5,7 @@ import { describeRelationalSchema } from './schema/describe.js';
|
|
|
5
5
|
import { initializeDatabase } from './schema/initialize.js';
|
|
6
6
|
import { compileDatabaseSchema } from './schema/ddl.js';
|
|
7
7
|
import { compileQuery } from './query/compiler.js';
|
|
8
|
-
import { compileRecord } from './record-statements.js';
|
|
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
|
|
|
11
11
|
export const postgresPlugin = (options) => ({
|
|
@@ -14,13 +14,18 @@ export const postgresPlugin = (options) => ({
|
|
|
14
14
|
displayName: 'PostgreSQL',
|
|
15
15
|
defaultSchema: 'public',
|
|
16
16
|
options: options || null,
|
|
17
|
-
|
|
17
|
+
// singleStatementReads: one statement reads one snapshot at any isolation level, so a read without owned
|
|
18
|
+
// tables needs no repeatable-read transaction.
|
|
19
|
+
capabilities: ['transactions', 'foreignKeys', 'deferredForeignKeys', 'embeddedValues', 'ownedRecords', 'scalarLists', 'uniqueValues', 'nullableUnique', 'singleStatementReads'],
|
|
18
20
|
naming: { validateIdentifier: identifier, generatedName },
|
|
19
|
-
|
|
21
|
+
// The runtime passes its bound configuration: createPostgres(options) or configure(value).
|
|
22
|
+
describeSchema: (plan, configuration) => describeRelationalSchema(plan, { refreshTriggers: configuration?.refreshTriggers, ownerIndexes: configuration?.ownerIndexes }),
|
|
20
23
|
initialize: (configuration, description, initialization) => initializeDatabase(configuration.pool, description, initialization),
|
|
21
24
|
compileSchema: compileDatabaseSchema,
|
|
22
25
|
compileQuery,
|
|
23
26
|
compileRecord,
|
|
27
|
+
compileRecordBatch,
|
|
28
|
+
maxRecordBatchValues: MAX_RECORD_BATCH_VALUES,
|
|
24
29
|
values: { createId: randomUUID, castId, encodeScalar, decodeScalar, encodeEmbedded: JSON.stringify },
|
|
25
30
|
driver: {
|
|
26
31
|
assertConfiguration(configuration) {
|
package/src/query/compiler.js
CHANGED
|
@@ -4,8 +4,12 @@ import { encodeScalar, storageTypes, invalidValue } from '../codecs.js';
|
|
|
4
4
|
|
|
5
5
|
const unsupported = (message) => { throw new SimfinityError(message, 'UNSUPPORTED_QUERY', 400); };
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Compiles validated paths; all caller values are parameters, identifiers come from metadata.
|
|
9
|
+
* `configuration.explicitNullsOrder: true` keeps the NULLS clause on every sort term, as 3.5.9 did.
|
|
10
|
+
*/
|
|
11
|
+
export const compileQuery = (models, database, plan, extra = null, configuration = null) => {
|
|
12
|
+
const explicitNullsOrder = configuration?.explicitNullsOrder === true;
|
|
9
13
|
const values = [];
|
|
10
14
|
const joins = new Map();
|
|
11
15
|
let serial = 0;
|
|
@@ -151,7 +155,12 @@ export const compileQuery = (models, database, plan, extra = null) => {
|
|
|
151
155
|
}
|
|
152
156
|
let sql = context.vector ? arrayFrom(context.vector, expression) : expression;
|
|
153
157
|
if (type === 'text') sql = `(${sql}) COLLATE "pg_catalog"."C"`;
|
|
154
|
-
|
|
158
|
+
// A NOT NULL column of the root row has no nulls to place, so its sort needs no NULLS clause and a
|
|
159
|
+
// default-order btree (the primary key or a declared index) can return it in order. Joined and
|
|
160
|
+
// embedded paths can be NULL even for NOT NULL columns, so they keep the clause.
|
|
161
|
+
const notNull = !context.json && !context.vector && !field.list && context.alias === 't0'
|
|
162
|
+
&& context.table.columns.find((item) => item.name === (identity ? 'id' : field.storageName))?.nullable === false;
|
|
163
|
+
return { sql, field: identity ? { ...field, scalar: 'ID' } : field, type: dateNumeric ? 'double precision' : type, list: field.list || !!context.vector, vector: !!context.vector, dateNumeric, sortKey, notNull };
|
|
155
164
|
}
|
|
156
165
|
}
|
|
157
166
|
unsupported('Invalid query path');
|
|
@@ -226,6 +235,8 @@ export const compileQuery = (models, database, plan, extra = null) => {
|
|
|
226
235
|
condition += ` AND ${col('t0', column.name)} = ${bind(extra.id, 'uuid')}`;
|
|
227
236
|
}
|
|
228
237
|
let select = 't0.*';
|
|
238
|
+
// List values that MIN and MAX facts read, each evaluated once per row in a lateral subquery.
|
|
239
|
+
const laterals = new Map();
|
|
229
240
|
let group = '';
|
|
230
241
|
const order = [];
|
|
231
242
|
if (plan.mode === 'count') select = 'count(*) AS size';
|
|
@@ -243,7 +254,15 @@ export const compileQuery = (models, database, plan, extra = null) => {
|
|
|
243
254
|
if (fact.operation === 'COUNT') operation = 'count(*)';
|
|
244
255
|
else if (expression.list) {
|
|
245
256
|
if (fact.operation === 'SUM' || fact.operation === 'AVG') operation = fact.operation === 'SUM' ? '0' : 'NULL';
|
|
246
|
-
else
|
|
257
|
+
else {
|
|
258
|
+
// The list value is a correlated subquery that the aggregate reads four times; MIN and MAX of the
|
|
259
|
+
// same path share one evaluation per row. OFFSET 0 keeps the planner from pulling the subquery up
|
|
260
|
+
// and copying the expression back, and a subquery without FROM keeps one row per root row.
|
|
261
|
+
const key = fact.path.join('.');
|
|
262
|
+
if (!laterals.has(key)) laterals.set(key, { alias: `f${laterals.size}`, sql: expression.sql });
|
|
263
|
+
const value = `${laterals.get(key).alias}.value`;
|
|
264
|
+
operation = `(array_agg(${value} ORDER BY ${helper('value_key', value, literal(expression.field.scalar))} ${fact.operation === 'MIN' ? 'ASC' : 'DESC'}) FILTER (WHERE ${value} IS NOT NULL AND ${value} <> 'null'::jsonb))[1]`;
|
|
265
|
+
}
|
|
247
266
|
}
|
|
248
267
|
else if (['SUM', 'AVG'].includes(fact.operation) && !['Int', 'Float'].includes(expression.field.scalar)) operation = fact.operation === 'SUM' ? '0' : 'NULL';
|
|
249
268
|
else if (['MIN', 'MAX'].includes(fact.operation) && expression.type === 'boolean') operation = `${fact.operation === 'MIN' ? 'bool_and' : 'bool_or'}(${expression.sql})`;
|
|
@@ -263,9 +282,11 @@ export const compileQuery = (models, database, plan, extra = null) => {
|
|
|
263
282
|
const expression = path(term.path, 'sort');
|
|
264
283
|
const key = expression.sql;
|
|
265
284
|
if (expression.sortKey) { order.push(`${expression.sortKey(term.order)} ${term.order} NULLS ${term.order === 'ASC' ? 'FIRST' : 'LAST'}`); continue; }
|
|
266
|
-
order.push(`${key} ${term.order} NULLS ${term.order === 'ASC' ? 'FIRST' : 'LAST'}`);
|
|
285
|
+
order.push(expression.notNull && !explicitNullsOrder ? `${key} ${term.order}` : `${key} ${term.order} NULLS ${term.order === 'ASC' ? 'FIRST' : 'LAST'}`);
|
|
267
286
|
}
|
|
268
|
-
|
|
287
|
+
// Laterals follow every join, so their expressions can read the root row and any joined alias.
|
|
288
|
+
const lateralSQL = [...laterals.values()].map((item) => ` CROSS JOIN LATERAL (SELECT ${item.sql} AS value OFFSET 0) ${item.alias}`).join('');
|
|
289
|
+
let text = `SELECT ${select} FROM ${qualified(database.schema, root.name)} t0 ${[...joins.values()].map((item) => item.sql).join(' ')}${lateralSQL} WHERE ${condition}${group}`;
|
|
269
290
|
if (plan.mode === 'aggregate') text = `SELECT * FROM (${text}) aggregated`;
|
|
270
291
|
if (order.length) text += ` ORDER BY ${order.join(', ')}`;
|
|
271
292
|
if (plan.pagination) text += ` LIMIT ${bind(plan.pagination.limit, 'bigint')} OFFSET ${bind(plan.pagination.offset, 'bigint')}`;
|
package/src/record-statements.js
CHANGED
|
@@ -20,3 +20,14 @@ export const compileRecord = (database, operation) => {
|
|
|
20
20
|
}
|
|
21
21
|
throw new SimfinityError(`Unknown record operation ${kind}`, 'INVALID_RECORD_OPERATION', 400);
|
|
22
22
|
};
|
|
23
|
+
|
|
24
|
+
// Values (rows times columns) per multi-row INSERT, well below PostgreSQL's 65,535 bind parameters.
|
|
25
|
+
export const MAX_RECORD_BATCH_VALUES = 32767;
|
|
26
|
+
/** One multi-row INSERT of owned rows that share their columns in the same order; no rows are returned. */
|
|
27
|
+
export const compileRecordBatch = (database, { table: storage, rows }) => {
|
|
28
|
+
const names = Object.keys(rows[0]);
|
|
29
|
+
const types = names.map((name) => storage.columns.find((column) => column.name === name).type);
|
|
30
|
+
const values = [];
|
|
31
|
+
const tuples = rows.map((row) => `(${names.map((name, index) => { values.push(row[name]); return `$${values.length}::${types[index]}`; }).join(', ')})`);
|
|
32
|
+
return { text: `INSERT INTO ${qualified(database.schema, storage.name)} (${names.map((name) => q(name)).join(', ')}) VALUES ${tuples.join(', ')}`, values };
|
|
33
|
+
};
|
|
@@ -12,13 +12,138 @@ const dateTimeValidation = `DECLARE parts integer[]; BEGIN
|
|
|
12
12
|
AND parts >= ARRAY[-271821, 4, 20, 0, 0, 0, 0] AND parts <= ARRAY[275760, 9, 13, 0, 0, 0, 0];
|
|
13
13
|
END;`;
|
|
14
14
|
|
|
15
|
+
const functionObject = (name, args, returns, body, immutable = false) => ({ name, arguments: args, returns, language: 'plpgsql', volatility: immutable ? 'IMMUTABLE' : 'VOLATILE', configuration: ['search_path=pg_catalog', 'TimeZone=UTC', 'DateStyle=ISO, YMD'], body });
|
|
16
|
+
// The ownership chain from its root (t0) to its last table.
|
|
17
|
+
const joinChain = (schema, path, left = false) => path.map((table, index) => {
|
|
18
|
+
if (!index) return `${qualified(schema, table.name)} t0`;
|
|
19
|
+
const parent = path[index - 1];
|
|
20
|
+
return `${left ? 'LEFT ' : ''}JOIN ${qualified(schema, table.name)} t${index} ON t${index}.__owner_id = t${index - 1}.${q(parent.primaryKey.columns[0])}${left ? ` AND t${index - 1}.${q(table.ownership.stateColumn)} = 'present'${parent.ownership?.nullableItems ? ` AND t${index - 1}.__item_present` : ''}` : ''}`;
|
|
21
|
+
}).join(' ');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The refresh-trigger objects of one table of a maintained root; `path` runs from the root to the table.
|
|
25
|
+
* 'perRow' (3.5.9): a touch trigger bumps the root's guard row and a deferred trigger refreshes the root
|
|
26
|
+
* once per changed row. 'perRoot': while the guard row's trigger stays deferred, the touch trigger marks
|
|
27
|
+
* the guard row dirty (a negative version) once per transaction and that trigger refreshes the root once
|
|
28
|
+
* at commit; under IMMEDIATE both happen once per changed row (guardTriggerObjects).
|
|
29
|
+
*/
|
|
30
|
+
const refreshTriggerObjects = (schema, path, item, layout) => {
|
|
31
|
+
const table = path.at(-1);
|
|
32
|
+
const owner = (record) => path.length === 1 ? `${record}.id` : `(SELECT t0.id FROM ${joinChain(schema, path.slice(0, -1))} WHERE t${path.length - 2}.${q(path.at(-2).primaryKey.columns[0])} = ${record}.__owner_id)`;
|
|
33
|
+
const guard = q(item.guardTable);
|
|
34
|
+
const target = `${qualified(schema, item.guardTable)} (__owner_id${layout === 'perRoot' ? ', version' : ''}) SELECT __root_id${layout === 'perRoot' ? ', -1' : ''} WHERE EXISTS (SELECT 1 FROM ${qualified(schema, item.rootTable)} WHERE id = __root_id) ON CONFLICT (__owner_id) DO UPDATE SET`;
|
|
35
|
+
// A dirty row written by an earlier transaction (its guard trigger was disabled) is marked again.
|
|
36
|
+
const touch = layout === 'perRoot'
|
|
37
|
+
? `INSERT INTO ${target} version = -(abs(${guard}.version) + 1) WHERE ${guard}.version >= 0 OR age(${guard}.xmin) <> 0;`
|
|
38
|
+
: `INSERT INTO ${target} version = ${guard}.version + 1;`;
|
|
39
|
+
const objects = { functions: [], triggers: [] };
|
|
40
|
+
for (const deferred of layout === 'perRow' ? [false, true] : [false]) {
|
|
41
|
+
const name = generatedName(table.name, deferred ? 'deferred' : 'touch');
|
|
42
|
+
const action = deferred ? `PERFORM ${qualified(schema, item.refreshFunction)}(__root_id);` : touch;
|
|
43
|
+
objects.functions.push(functionObject(name, [], 'trigger', `DECLARE __root_id uuid; __old_root_id uuid; BEGIN
|
|
44
|
+
IF TG_OP <> 'INSERT' THEN __old_root_id := ${owner('OLD')}; END IF;
|
|
45
|
+
IF TG_OP <> 'DELETE' THEN __root_id := ${owner('NEW')}; END IF;
|
|
46
|
+
IF __old_root_id IS NOT NULL AND __old_root_id IS DISTINCT FROM __root_id THEN
|
|
47
|
+
DECLARE __new_root_id uuid := __root_id; BEGIN __root_id := __old_root_id; ${action} __root_id := __new_root_id; END;
|
|
48
|
+
END IF;
|
|
49
|
+
IF __root_id IS NOT NULL THEN ${action} END IF;
|
|
50
|
+
RETURN NULL;
|
|
51
|
+
END`));
|
|
52
|
+
objects.triggers.push({ name, table: table.name, function: name, constraint: deferred, deferrable: deferred, initiallyDeferred: deferred });
|
|
53
|
+
}
|
|
54
|
+
return objects;
|
|
55
|
+
};
|
|
56
|
+
// 'perRoot': at commit, each dirty guard row is marked clean and its root refreshed once. The event is
|
|
57
|
+
// queued by the touch write itself, so it always follows the touch, whatever the trigger names; committed
|
|
58
|
+
// guard rows stay clean, as earlier versions wrote them. The once-per-transaction saving holds only while
|
|
59
|
+
// this trigger stays deferred: under IMMEDIATE its event fires at the end of the touch trigger's guard
|
|
60
|
+
// upsert, so the root is refreshed once per changed row, as with 'perRow', and the guard row is written
|
|
61
|
+
// twice per row (marked dirty, then clean).
|
|
62
|
+
const guardTriggerObjects = (schema, item) => {
|
|
63
|
+
const name = generatedName(item.guardTable, 'deferred');
|
|
64
|
+
return {
|
|
65
|
+
functions: [functionObject(name, [], 'trigger', `BEGIN
|
|
66
|
+
IF NEW.version < 0 THEN
|
|
67
|
+
UPDATE ${qualified(schema, item.guardTable)} SET version = -version WHERE __owner_id = NEW.__owner_id AND version < 0;
|
|
68
|
+
IF FOUND THEN PERFORM ${qualified(schema, item.refreshFunction)}(NEW.__owner_id); END IF;
|
|
69
|
+
END IF;
|
|
70
|
+
RETURN NULL;
|
|
71
|
+
END`)],
|
|
72
|
+
// Only dirty rows queue an event: not the clean-up update, nor a backfill's guard inserts.
|
|
73
|
+
triggers: [{ name, table: item.guardTable, function: name, constraint: true, deferrable: true, initiallyDeferred: true, events: ['INSERT', 'UPDATE'], when: 'new.version < 0' }],
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* For each maintained root of a description, the refresh-trigger functions and triggers of both layouts,
|
|
79
|
+
* built from the description alone. Tables follow the order of the description's touch triggers.
|
|
80
|
+
*/
|
|
81
|
+
export const maintenanceLayouts = (description) => {
|
|
82
|
+
const { schema } = description;
|
|
83
|
+
const byName = new Map(description.tables.map((table) => [table.name, table]));
|
|
84
|
+
const chainOf = (table) => {
|
|
85
|
+
const path = [table];
|
|
86
|
+
while (path[0].ownership && byName.has(path[0].ownership.ownerTable)) path.unshift(byName.get(path[0].ownership.ownerTable));
|
|
87
|
+
return path;
|
|
88
|
+
};
|
|
89
|
+
const touches = (description.triggers || []).filter((trigger) => !trigger.constraint && byName.has(trigger.table) && trigger.name === generatedName(trigger.table, 'touch'));
|
|
90
|
+
const layouts = new Map();
|
|
91
|
+
for (const item of description.maintenance || []) {
|
|
92
|
+
const layout = { perRow: { functions: [], triggers: [] }, perRoot: { functions: [], triggers: [] } };
|
|
93
|
+
for (const touch of touches) {
|
|
94
|
+
const path = chainOf(byName.get(touch.table));
|
|
95
|
+
if (path[0].name !== item.rootTable || path[0].ownership) continue;
|
|
96
|
+
for (const name of ['perRow', 'perRoot']) {
|
|
97
|
+
const objects = refreshTriggerObjects(schema, path, item, name);
|
|
98
|
+
layout[name].functions.push(...objects.functions);
|
|
99
|
+
layout[name].triggers.push(...objects.triggers);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const guard = guardTriggerObjects(schema, item);
|
|
103
|
+
layout.perRoot.functions.push(...guard.functions);
|
|
104
|
+
layout.perRoot.triggers.push(...guard.triggers);
|
|
105
|
+
layouts.set(item.rootTable, layout);
|
|
106
|
+
}
|
|
107
|
+
return layouts;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** 'perRoot' when the root's guard table carries the deferred trigger, otherwise 'perRow'. */
|
|
111
|
+
export const layoutOf = (description, item) => ((description.triggers || []).some((trigger) => trigger.constraint && trigger.table === item.guardTable) ? 'perRoot' : 'perRow');
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Replace the refresh-trigger objects of the chosen roots (a Map of root table to layout) with that layout's,
|
|
115
|
+
* at the position of the root's first object. Every other object and the list order are kept.
|
|
116
|
+
*/
|
|
117
|
+
export const withLayouts = (description, choices, layouts = maintenanceLayouts(description)) => {
|
|
118
|
+
const owners = { functions: new Map(), triggers: new Map() };
|
|
119
|
+
const keys = { functions: (object) => object.name, triggers: (object) => JSON.stringify([object.table, object.name]) };
|
|
120
|
+
for (const [root, layout] of layouts) {
|
|
121
|
+
for (const kind of ['functions', 'triggers']) {
|
|
122
|
+
for (const object of [...layout.perRow[kind], ...layout.perRoot[kind]]) owners[kind].set(keys[kind](object), root);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const replace = (kind) => {
|
|
126
|
+
const output = [];
|
|
127
|
+
const done = new Set();
|
|
128
|
+
for (const object of description[kind] || []) {
|
|
129
|
+
const root = owners[kind].get(keys[kind](object));
|
|
130
|
+
if (!choices.has(root)) output.push(object);
|
|
131
|
+
else if (!done.has(root)) {
|
|
132
|
+
done.add(root);
|
|
133
|
+
output.push(...layouts.get(root)[choices.get(root)][kind]);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return output;
|
|
137
|
+
};
|
|
138
|
+
return { ...description, functions: replace('functions'), triggers: replace('triggers') };
|
|
139
|
+
};
|
|
140
|
+
|
|
15
141
|
/** Database-side validation and multikey maintenance. All bodies resolve names in pg_catalog. */
|
|
16
142
|
export const constraintBuilder = (schema, tables, { addTable, addColumn, addReference, addIndex, addCheck }) => {
|
|
17
143
|
const functions = []; const triggers = []; const uniquePaths = [];
|
|
18
144
|
const fn = (name, args, returns, body, immutable = false) => {
|
|
19
145
|
if (functions.some((item) => item.name === name)) invalid(`Function name collision: ${name}`);
|
|
20
|
-
|
|
21
|
-
functions.push(result);
|
|
146
|
+
functions.push(functionObject(name, args, returns, body, immutable));
|
|
22
147
|
return qualified(schema, name);
|
|
23
148
|
};
|
|
24
149
|
const jsonValidator = (table, field, path = []) => {
|
|
@@ -55,11 +180,7 @@ END`, true);
|
|
|
55
180
|
while (result[0].ownership) result.unshift(tables.find((item) => item.name === result[0].ownership.ownerTable));
|
|
56
181
|
return result;
|
|
57
182
|
};
|
|
58
|
-
const joins = (path, left = false) =>
|
|
59
|
-
if (!index) return `${qualified(schema, table.name)} t0`;
|
|
60
|
-
const parent = path[index - 1];
|
|
61
|
-
return `${left ? 'LEFT ' : ''}JOIN ${qualified(schema, table.name)} t${index} ON t${index}.__owner_id = t${index - 1}.${q(parent.primaryKey.columns[0])}${left ? ` AND t${index - 1}.${q(table.ownership.stateColumn)} = 'present'${parent.ownership?.nullableItems ? ` AND t${index - 1}.__item_present` : ''}` : ''}`;
|
|
62
|
-
}).join(' ');
|
|
183
|
+
const joins = (path, left = false) => joinChain(schema, path, left);
|
|
63
184
|
const finish = () => {
|
|
64
185
|
const sources = [...tables];
|
|
65
186
|
const roots = sources.filter((table) => !table.ownership && (sources.some((owned) => owned.ownership && chain(owned)[0] === table) || uniquePaths.some((key) => chain(key.table)[0] === table)));
|
|
@@ -112,28 +233,17 @@ END`, true);
|
|
|
112
233
|
rebuild += `\n DELETE FROM ${qualified(schema, storage.name)} WHERE __owner_id = $1;\n INSERT INTO ${qualified(schema, storage.name)} (__owner_id, kind, value) ${query};`;
|
|
113
234
|
}
|
|
114
235
|
const refreshName = generatedName(root.name, 'refresh');
|
|
115
|
-
|
|
116
|
-
|
|
236
|
+
fn(refreshName, ['uuid'], 'void', `BEGIN\n PERFORM ${validate}($1);${rebuild}\nEND`);
|
|
237
|
+
const item = { rootTable: root.name, guardTable: guard.name, validateFunction: validateName, refreshFunction: refreshName };
|
|
238
|
+
maintenance.push(item);
|
|
117
239
|
// Touch a real shared tuple at write time, not only at deferred-check time.
|
|
118
240
|
// RR writers must conflict on its version; RC writers then refresh using a new
|
|
119
241
|
// command snapshot after acquiring it. Lock-only reads permit stale RR keys.
|
|
242
|
+
// describeRelationalSchema swaps these 'perRow' objects for 'perRoot' ones when asked.
|
|
120
243
|
for (const table of [root, ...ownedTables]) {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const name = generatedName(table.name, deferred ? 'deferred' : 'touch');
|
|
125
|
-
const action = deferred ? `PERFORM ${refresh}(__root_id);` : `INSERT INTO ${qualified(schema, guard.name)} (__owner_id) SELECT __root_id WHERE EXISTS (SELECT 1 FROM ${qualified(schema, root.name)} WHERE id = __root_id) ON CONFLICT (__owner_id) DO UPDATE SET version = ${q(guard.name)}.version + 1;`;
|
|
126
|
-
fn(name, [], 'trigger', `DECLARE __root_id uuid; __old_root_id uuid; BEGIN
|
|
127
|
-
IF TG_OP <> 'INSERT' THEN __old_root_id := ${owner('OLD')}; END IF;
|
|
128
|
-
IF TG_OP <> 'DELETE' THEN __root_id := ${owner('NEW')}; END IF;
|
|
129
|
-
IF __old_root_id IS NOT NULL AND __old_root_id IS DISTINCT FROM __root_id THEN
|
|
130
|
-
DECLARE __new_root_id uuid := __root_id; BEGIN __root_id := __old_root_id; ${action} __root_id := __new_root_id; END;
|
|
131
|
-
END IF;
|
|
132
|
-
IF __root_id IS NOT NULL THEN ${action} END IF;
|
|
133
|
-
RETURN NULL;
|
|
134
|
-
END`);
|
|
135
|
-
triggers.push({ name, table: table.name, function: name, constraint: deferred, deferrable: deferred, initiallyDeferred: deferred });
|
|
136
|
-
}
|
|
244
|
+
const objects = refreshTriggerObjects(schema, chain(table), item, 'perRow');
|
|
245
|
+
for (const object of objects.functions) fn(object.name, object.arguments, object.returns, object.body);
|
|
246
|
+
triggers.push(...objects.triggers);
|
|
137
247
|
}
|
|
138
248
|
}
|
|
139
249
|
return { functions, triggers, maintenance };
|
package/src/schema/ddl.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { identifier, qualified, columnsSQL, literal } from './sql.js';
|
|
1
|
+
import { identifier, qualified, columnsSQL, literal, invalid } from './sql.js';
|
|
2
2
|
|
|
3
3
|
export const createTableSQL = (schema, table) => {
|
|
4
4
|
const columns = table.columns.map((column) => `${identifier(column.name)} ${column.type}${column.collation ? ` COLLATE ${qualified('pg_catalog', column.collation)}` : ''}${column.nullable ? '' : ' NOT NULL'}${column.default ? ` DEFAULT ${column.default}` : ''}`);
|
|
@@ -16,7 +16,9 @@ export const createFunctionSQL = (schema, fn) => {
|
|
|
16
16
|
}).join(' ');
|
|
17
17
|
return `CREATE FUNCTION ${qualified(schema, fn.name)}(${fn.arguments.join(', ')}) RETURNS ${fn.returns} LANGUAGE ${fn.language} ${fn.volatility} CALLED ON NULL INPUT SECURITY INVOKER PARALLEL UNSAFE ${settings} AS ${literal(fn.body)}`;
|
|
18
18
|
};
|
|
19
|
-
|
|
19
|
+
/** Row events a trigger fires on; a trigger without `events` fires on all three. */
|
|
20
|
+
export const triggerEvents = (trigger) => (trigger.events || ['INSERT', 'UPDATE', 'DELETE']).map((event) => (['INSERT', 'UPDATE', 'DELETE'].includes(event) ? event : invalid(`Unsupported trigger event: ${String(event)}`)));
|
|
21
|
+
export const createTriggerSQL = (schema, trigger) => `CREATE ${trigger.constraint ? 'CONSTRAINT ' : ''}TRIGGER ${identifier(trigger.name)} AFTER ${triggerEvents(trigger).join(' OR ')} ON ${qualified(schema, trigger.table)} ${trigger.constraint ? 'DEFERRABLE INITIALLY DEFERRED ' : ''}FOR EACH ROW ${trigger.when ? `WHEN (${trigger.when}) ` : ''}EXECUTE FUNCTION ${qualified(schema, trigger.function)}()`;
|
|
20
22
|
|
|
21
23
|
/** Tables/primary keys precede all FKs so forward and cyclic references work. */
|
|
22
24
|
export const compileDatabaseSchema = (description) => [
|
package/src/schema/describe.js
CHANGED
|
@@ -1,15 +1,31 @@
|
|
|
1
1
|
import { describeModels } from '@simtlix/simfinity-core';
|
|
2
2
|
import { planRelationalSchema } from '@simtlix/simfinity-sql';
|
|
3
3
|
import { queryFunctions } from '../query/functions.js';
|
|
4
|
-
import { constraintBuilder } from './constraints.js';
|
|
4
|
+
import { constraintBuilder, withLayouts } from './constraints.js';
|
|
5
5
|
import { identifier, literal, generatedName, invalid } from './sql.js';
|
|
6
6
|
|
|
7
7
|
const scalarTypes = { ID: 'uuid', String: 'text', Enum: 'text', Int: 'integer', Float: 'double precision', Boolean: 'boolean', DateTime: 'timestamp with time zone', Embedded: 'jsonb' };
|
|
8
8
|
const physicalType = (column) => `${scalarTypes[column.scalar]}${column.list ? '[]' : ''}`;
|
|
9
9
|
const physicalDefault = (value) => value.kind === 'identity' ? 'gen_random_uuid()' : typeof value.value === 'string' ? `${literal(value.value)}::text` : String(value.value);
|
|
10
|
+
// The first value is the default, which keeps the 3.5.9 description.
|
|
11
|
+
const option = (name, value, values) => {
|
|
12
|
+
if (value !== undefined && !values.includes(value)) invalid(`Unknown PostgreSQL ${name} option: ${String(value)}. Use ${values.map((item) => `'${item}'`).join(' or ')}`);
|
|
13
|
+
return value ?? values[0];
|
|
14
|
+
};
|
|
10
15
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Lower engine-independent relational guarantees into PostgreSQL storage and enforcement. Two opt-in
|
|
18
|
+
* options change what new databases get:
|
|
19
|
+
* - `refreshTriggers: 'perRoot'` refreshes each maintained root once per transaction, while its guard trigger
|
|
20
|
+
* stays deferred, instead of once per changed row ('perRow'). Initialization keeps each existing root on
|
|
21
|
+
* the layout its database holds;
|
|
22
|
+
* - `ownerIndexes: 'minimal'` omits the (__owner_id) index of owned and guard tables, which their primary
|
|
23
|
+
* key or a unique index already covers ('all' keeps it). 'minimal' accepts existing owner indexes; a
|
|
24
|
+
* database created with 'minimal' fails validation under 'all', and create mode adds the indexes back.
|
|
25
|
+
*/
|
|
26
|
+
export const describeRelationalSchema = (plan, options) => {
|
|
27
|
+
const refreshTriggers = option('refreshTriggers', options?.refreshTriggers, ['perRow', 'perRoot']);
|
|
28
|
+
const ownerIndexes = option('ownerIndexes', options?.ownerIndexes, ['all', 'minimal']);
|
|
13
29
|
const { schema } = plan;
|
|
14
30
|
identifier(schema);
|
|
15
31
|
const tables = [];
|
|
@@ -79,6 +95,12 @@ export const describeRelationalSchema = (plan) => {
|
|
|
79
95
|
}
|
|
80
96
|
const generated = constraints.finish();
|
|
81
97
|
generated.functions.push(...queryFunctions(schema));
|
|
98
|
+
if (ownerIndexes === 'minimal') {
|
|
99
|
+
for (const table of tables.filter((item) => item.ownership || item.auxiliary?.kind === 'guard')) {
|
|
100
|
+
const covered = table.primaryKey.columns[0] === '__owner_id' || table.indexes.some((index) => index.unique && index.columns[0] === '__owner_id');
|
|
101
|
+
if (covered) table.indexes = table.indexes.filter((index) => index.unique || index.columns.length !== 1 || index.columns[0] !== '__owner_id');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
82
104
|
// PostgreSQL shares one namespace for table and index names.
|
|
83
105
|
const relationNames = new Set(tables.map((table) => table.name));
|
|
84
106
|
for (const table of tables) {
|
|
@@ -92,10 +114,18 @@ export const describeRelationalSchema = (plan) => {
|
|
|
92
114
|
relationNames.add(index.name);
|
|
93
115
|
}
|
|
94
116
|
}
|
|
95
|
-
|
|
117
|
+
const description = { schema, tables: tables.sort((a, b) => a.name.localeCompare(b.name)), ...generated };
|
|
118
|
+
if (refreshTriggers === 'perRow') return description;
|
|
119
|
+
const result = withLayouts(description, new Map(description.maintenance.map((item) => [item.rootTable, 'perRoot'])));
|
|
120
|
+
const functionNames = new Set();
|
|
121
|
+
for (const fn of result.functions) {
|
|
122
|
+
if (functionNames.has(fn.name)) invalid(`Function name collision: ${fn.name}`);
|
|
123
|
+
functionNames.add(fn.name);
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
96
126
|
};
|
|
97
127
|
|
|
98
128
|
/** Compile GraphQL storage metadata into the compatible PostgreSQL description. */
|
|
99
|
-
export const describeDatabase = (registrations, { schema = 'public' } = {}) => describeRelationalSchema(planRelationalSchema(describeModels(registrations), {
|
|
129
|
+
export const describeDatabase = (registrations, { schema = 'public', refreshTriggers, ownerIndexes } = {}) => describeRelationalSchema(planRelationalSchema(describeModels(registrations), {
|
|
100
130
|
schema, naming: { validateIdentifier: identifier, generatedName },
|
|
101
|
-
}));
|
|
131
|
+
}), { refreshTriggers, ownerIndexes });
|
package/src/schema/initialize.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SimfinityError } from '@simtlix/simfinity-core';
|
|
2
2
|
import { identifier, qualified, generatedName } from './sql.js';
|
|
3
|
-
import { createTableSQL, createForeignKeySQL, createIndexSQL, createFunctionSQL, createTriggerSQL } from './ddl.js';
|
|
3
|
+
import { createTableSQL, createForeignKeySQL, createIndexSQL, createFunctionSQL, createTriggerSQL, triggerEvents } from './ddl.js';
|
|
4
|
+
import { maintenanceLayouts, layoutOf, withLayouts } from './constraints.js';
|
|
4
5
|
|
|
5
6
|
const mismatch = (object, reason) => {
|
|
6
7
|
throw new SimfinityError(`Schema mismatch at ${object}: ${reason}. Apply an explicit migration before startup.`, 'SCHEMA_MISMATCH', 409);
|
|
@@ -43,19 +44,14 @@ export const normalizeExpression = (expression) => {
|
|
|
43
44
|
return value;
|
|
44
45
|
};
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (!relations.length) return null;
|
|
50
|
-
const relation = relations[0];
|
|
51
|
-
if (relation.relkind !== 'r' || relation.relpersistence !== 'p' || relation.relrowsecurity || relation.relforcerowsecurity) mismatch(`${schema}.${name}`, 'expected a persistent ordinary table without row security');
|
|
52
|
-
const { rows: columns } = await client.query(`SELECT a.attname AS name, format_type(a.atttypid, a.atttypmod) AS type,
|
|
47
|
+
// Catalog columns read for each managed relation, function and trigger.
|
|
48
|
+
const relationColumns = 'c.oid, c.relkind, c.relpersistence, c.relrowsecurity, c.relforcerowsecurity';
|
|
49
|
+
const columnRows = `a.attname AS name, format_type(a.atttypid, a.atttypmod) AS type,
|
|
53
50
|
NOT a.attnotnull AS nullable, pg_get_expr(d.adbin, d.adrelid) AS "default", a.attidentity, a.attgenerated,
|
|
54
51
|
col.collname AS collation, ns.nspname AS collation_schema
|
|
55
52
|
FROM pg_attribute a LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
|
|
56
|
-
LEFT JOIN pg_collation col ON col.oid = a.attcollation LEFT JOIN pg_namespace ns ON ns.oid = col.collnamespace
|
|
57
|
-
|
|
58
|
-
const { rows: constraints } = await client.query(`SELECT c.conname AS name, c.contype AS type,
|
|
53
|
+
LEFT JOIN pg_collation col ON col.oid = a.attcollation LEFT JOIN pg_namespace ns ON ns.oid = col.collnamespace`;
|
|
54
|
+
const constraintRows = `c.conname AS name, c.contype AS type,
|
|
59
55
|
c.condeferrable AS deferrable, c.condeferred AS deferred, c.convalidated AS validated,
|
|
60
56
|
COALESCE((to_jsonb(c)->>'conenforced')::boolean, true) AS enforced,
|
|
61
57
|
c.confdeltype AS delete_action, c.confupdtype AS update_action, c.confmatchtype AS match_type, c.connoinherit AS no_inherit,
|
|
@@ -66,9 +62,8 @@ const readTable = async (client, schema, name) => {
|
|
|
66
62
|
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.num ORDER BY k.pos) AS columns,
|
|
67
63
|
ARRAY(SELECT a.attname::text FROM unnest(c.confkey) WITH ORDINALITY k(num, pos)
|
|
68
64
|
JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = k.num ORDER BY k.pos) AS target_columns
|
|
69
|
-
FROM pg_constraint c LEFT JOIN pg_class t ON t.oid = c.confrelid LEFT JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
70
|
-
|
|
71
|
-
const { rows: indexes } = await client.query(`SELECT ic.relname AS name, i.indisprimary AS primary,
|
|
65
|
+
FROM pg_constraint c LEFT JOIN pg_class t ON t.oid = c.confrelid LEFT JOIN pg_namespace n ON n.oid = t.relnamespace`;
|
|
66
|
+
const indexRows = `ic.relname AS name, i.indisprimary AS primary,
|
|
72
67
|
i.indisunique AS unique, i.indnullsnotdistinct AS nulls_not_distinct, i.indisvalid AS valid, i.indisready AS ready,
|
|
73
68
|
i.indimmediate AS immediate, am.amname AS method, i.indnatts = i.indnkeyatts AS no_include,
|
|
74
69
|
i.indpred IS NULL AS no_predicate, i.indexprs IS NULL AS no_expression,
|
|
@@ -79,10 +74,88 @@ const readTable = async (client, schema, name) => {
|
|
|
79
74
|
NOT EXISTS (SELECT 1 FROM unnest(i.indcollation) WITH ORDINALITY col(oid, pos)
|
|
80
75
|
JOIN unnest(i.indkey) WITH ORDINALITY k(num, pos) USING(pos)
|
|
81
76
|
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.num WHERE col.oid <> a.attcollation) AS default_collation
|
|
82
|
-
FROM pg_index i JOIN pg_class ic ON ic.oid = i.indexrelid JOIN pg_am am ON am.oid = ic.relam
|
|
77
|
+
FROM pg_index i JOIN pg_class ic ON ic.oid = i.indexrelid JOIN pg_am am ON am.oid = ic.relam`;
|
|
78
|
+
const functionRows = `p.oid, pg_get_function_identity_arguments(p.oid) AS arguments,
|
|
79
|
+
pg_get_function_result(p.oid) AS returns, p.prosrc AS body, l.lanname AS language, p.provolatile AS volatility,
|
|
80
|
+
p.proconfig AS configuration, p.prosecdef, p.proisstrict, p.proleakproof, p.proparallel, p.prokind,
|
|
81
|
+
p.proretset, p.pronargdefaults, p.procost, p.prorows, p.prosupport::oid AS support
|
|
82
|
+
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace JOIN pg_language l ON l.oid = p.prolang`;
|
|
83
|
+
// `definition` and `function_text` locate a WHEN condition in pg_get_triggerdef (whenClause).
|
|
84
|
+
const triggerRows = `t.tgtype, t.tgenabled, t.tgisinternal, t.tgdeferrable, t.tginitdeferred,
|
|
85
|
+
t.tgnargs, t.tgqual IS NULL AS no_when, t.tgattr::text AS columns, p.proname AS function, n.nspname AS function_schema,
|
|
86
|
+
p.pronargs, t.tgconstraint <> 0 AS constraint, t.tgconstrrelid::oid AS constraint_relation,
|
|
87
|
+
t.tgparentid::oid AS parent, c.condeferrable, c.condeferred, c.convalidated,
|
|
88
|
+
COALESCE((to_jsonb(c)->>'conenforced')::boolean, true) AS enforced,
|
|
89
|
+
t.tgoldtable, t.tgnewtable,
|
|
90
|
+
CASE WHEN t.tgqual IS NOT NULL THEN pg_get_triggerdef(t.oid) END AS definition, quote_ident(n.nspname) || '.' || quote_ident(p.proname) AS function_text
|
|
91
|
+
FROM pg_trigger t JOIN pg_proc p ON p.oid = t.tgfoid JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
92
|
+
LEFT JOIN pg_constraint c ON c.oid = t.tgconstraint`;
|
|
93
|
+
const ordinaryTable = (schema, name, relation) => {
|
|
94
|
+
if (relation.relkind !== 'r' || relation.relpersistence !== 'p' || relation.relrowsecurity || relation.relforcerowsecurity) mismatch(`${schema}.${name}`, 'expected a persistent ordinary table without row security');
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Re-reads one table, such as a table this run has just created. */
|
|
98
|
+
const readTable = async (client, schema, name) => {
|
|
99
|
+
const { rows: relations } = await client.query(`SELECT ${relationColumns}
|
|
100
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = $1 AND c.relname = $2`, [schema, name]);
|
|
101
|
+
if (!relations.length) return null;
|
|
102
|
+
const relation = relations[0];
|
|
103
|
+
ordinaryTable(schema, name, relation);
|
|
104
|
+
const { rows: columns } = await client.query(`SELECT ${columnRows}
|
|
105
|
+
WHERE a.attrelid = $1 AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum`, [relation.oid]);
|
|
106
|
+
const { rows: constraints } = await client.query(`SELECT ${constraintRows}
|
|
107
|
+
WHERE c.conrelid = $1`, [relation.oid]);
|
|
108
|
+
const { rows: indexes } = await client.query(`SELECT ${indexRows} WHERE i.indrelid = $1`, [relation.oid]);
|
|
83
109
|
return { columns, constraints, indexes };
|
|
84
110
|
};
|
|
85
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Reads every managed relation, function and trigger in six statements, whatever the schema size. Names
|
|
114
|
+
* include both refresh-trigger layouts. Takes no table lock.
|
|
115
|
+
*/
|
|
116
|
+
const loadCatalog = async (client, schema, names) => {
|
|
117
|
+
const { rows: relations } = await client.query(`SELECT c.relname AS __name, ${relationColumns}
|
|
118
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = $1 AND c.relname = ANY ($2::text[])`, [schema, names.tables]);
|
|
119
|
+
const oids = relations.map((relation) => relation.oid);
|
|
120
|
+
const { rows: columns } = await client.query(`SELECT a.attrelid AS __relation, ${columnRows}
|
|
121
|
+
WHERE a.attrelid = ANY ($1::oid[]) AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attrelid, a.attnum`, [oids]);
|
|
122
|
+
const { rows: constraints } = await client.query(`SELECT c.conrelid AS __relation, ${constraintRows}
|
|
123
|
+
WHERE c.conrelid = ANY ($1::oid[])`, [oids]);
|
|
124
|
+
const { rows: indexes } = await client.query(`SELECT i.indrelid AS __relation, ${indexRows} WHERE i.indrelid = ANY ($1::oid[])`, [oids]);
|
|
125
|
+
const { rows: functions } = await client.query(`SELECT p.proname AS __name, ${functionRows}
|
|
126
|
+
WHERE n.nspname = $1 AND p.proname = ANY ($2::text[])`, [schema, names.functions]);
|
|
127
|
+
const { rows: triggers } = await client.query(`SELECT t.tgrelid AS __relation, t.tgname AS __name, ${triggerRows}
|
|
128
|
+
WHERE t.tgrelid = ANY ($1::oid[]) AND t.tgname = ANY ($2::text[])`, [oids, names.triggers]);
|
|
129
|
+
const group = (rows, key) => {
|
|
130
|
+
const map = new Map();
|
|
131
|
+
for (const { __relation: relation, __name: name, ...row } of rows) {
|
|
132
|
+
const id = key(relation, name);
|
|
133
|
+
if (!map.has(id)) map.set(id, []);
|
|
134
|
+
map.get(id).push(row);
|
|
135
|
+
}
|
|
136
|
+
return map;
|
|
137
|
+
};
|
|
138
|
+
const relationNames = new Map(relations.map((relation) => [relation.oid, relation.__name]));
|
|
139
|
+
const byRelation = (rows) => group(rows, (relation) => relation);
|
|
140
|
+
const tableColumns = byRelation(columns); const tableConstraints = byRelation(constraints); const tableIndexes = byRelation(indexes);
|
|
141
|
+
const functionsByName = group(functions, (relation, name) => name);
|
|
142
|
+
const triggersByName = group(triggers, (relation, name) => JSON.stringify([relationNames.get(relation), name]));
|
|
143
|
+
return {
|
|
144
|
+
tables: new Map(relations.map(({ __name: name, ...relation }) => [name, {
|
|
145
|
+
relation, columns: tableColumns.get(relation.oid) || [], constraints: tableConstraints.get(relation.oid) || [], indexes: tableIndexes.get(relation.oid) || [],
|
|
146
|
+
}])),
|
|
147
|
+
functions: (name) => functionsByName.get(name) || [],
|
|
148
|
+
triggers: (table, name) => triggersByName.get(JSON.stringify([table, name])) || [],
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
// The stored table, as readTable returns it, or null when it does not exist.
|
|
152
|
+
const fromCatalog = (schema, name, catalog) => {
|
|
153
|
+
const entry = catalog.tables.get(name);
|
|
154
|
+
if (!entry) return null;
|
|
155
|
+
ordinaryTable(schema, name, entry.relation);
|
|
156
|
+
return { columns: entry.columns, constraints: entry.constraints, indexes: entry.indexes };
|
|
157
|
+
};
|
|
158
|
+
|
|
86
159
|
const validateBase = (schema, table, actual, triggers = []) => {
|
|
87
160
|
const name = `${schema}.${table.name}`;
|
|
88
161
|
if (!actual) mismatch(name, 'table is missing');
|
|
@@ -144,16 +217,16 @@ const legacyListIndexes = (schema, table, actual) => table.columns
|
|
|
144
217
|
&& !index.unique && !index.primary && index.method === 'btree' && same(index.columns, [column]) && index.no_predicate && index.no_expression))
|
|
145
218
|
.map(({ name }) => `${identifier(schema)}.${identifier(name)}`);
|
|
146
219
|
|
|
147
|
-
|
|
220
|
+
// Each pass walks the description in the same order and rejects drift of an existing object at the same
|
|
221
|
+
// point: 'validate' also rejects a missing object, 'plan' records it without DDL, and 'create' creates it
|
|
222
|
+
// and records it.
|
|
223
|
+
const ensureFunctions = async (client, description, catalog, pass, missing, created) => {
|
|
148
224
|
for (const fn of description.functions || []) {
|
|
149
|
-
const
|
|
150
|
-
pg_get_function_result(p.oid) AS returns, p.prosrc AS body, l.lanname AS language, p.provolatile AS volatility,
|
|
151
|
-
p.proconfig AS configuration, p.prosecdef, p.proisstrict, p.proleakproof, p.proparallel, p.prokind,
|
|
152
|
-
p.proretset, p.pronargdefaults, p.procost, p.prorows, p.prosupport::oid AS support
|
|
153
|
-
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace JOIN pg_language l ON l.oid = p.prolang
|
|
154
|
-
WHERE n.nspname = $1 AND p.proname = $2`, [description.schema, fn.name]);
|
|
225
|
+
const rows = catalog.functions(fn.name);
|
|
155
226
|
if (!rows.length) {
|
|
156
|
-
if (
|
|
227
|
+
if (pass === 'validate') mismatch(`${description.schema}.${fn.name}`, 'function is missing');
|
|
228
|
+
missing.push({ kind: 'function', name: fn.name });
|
|
229
|
+
if (pass === 'plan') continue;
|
|
157
230
|
await client.query(createFunctionSQL(description.schema, fn));
|
|
158
231
|
created.push(`function:${description.schema}.${fn.name}`);
|
|
159
232
|
continue;
|
|
@@ -169,19 +242,25 @@ const ensureFunctions = async (client, description, mode, created) => {
|
|
|
169
242
|
}
|
|
170
243
|
};
|
|
171
244
|
|
|
172
|
-
|
|
245
|
+
// pg_trigger.tgtype of an AFTER ... FOR EACH ROW trigger: the row bit plus one bit per event.
|
|
246
|
+
const eventBits = { INSERT: 4, DELETE: 8, UPDATE: 16 };
|
|
247
|
+
const triggerType = (trigger) => triggerEvents(trigger).reduce((type, event) => type + eventBits[event], 1);
|
|
248
|
+
// pg_get_triggerdef renders `... FOR EACH ROW WHEN (<condition>) EXECUTE FUNCTION <function>()`.
|
|
249
|
+
const whenClause = ({ definition, function_text: target }) => {
|
|
250
|
+
const head = ' FOR EACH ROW WHEN (';
|
|
251
|
+
const tail = `) EXECUTE FUNCTION ${target}()`;
|
|
252
|
+
if (!definition?.endsWith(tail)) return null;
|
|
253
|
+
const start = definition.lastIndexOf(head, definition.length - tail.length);
|
|
254
|
+
return start < 0 ? null : definition.slice(start + head.length, definition.length - tail.length);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const ensureTriggers = async (client, description, catalog, pass, missing, created) => {
|
|
173
258
|
for (const trigger of description.triggers || []) {
|
|
174
|
-
const
|
|
175
|
-
t.tgnargs, t.tgqual IS NULL AS no_when, t.tgattr::text AS columns, p.proname AS function, n.nspname AS function_schema,
|
|
176
|
-
p.pronargs, t.tgconstraint <> 0 AS constraint, t.tgconstrrelid::oid AS constraint_relation,
|
|
177
|
-
t.tgparentid::oid AS parent, c.condeferrable, c.condeferred, c.convalidated,
|
|
178
|
-
COALESCE((to_jsonb(c)->>'conenforced')::boolean, true) AS enforced,
|
|
179
|
-
t.tgoldtable, t.tgnewtable
|
|
180
|
-
FROM pg_trigger t JOIN pg_proc p ON p.oid = t.tgfoid JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
181
|
-
LEFT JOIN pg_constraint c ON c.oid = t.tgconstraint
|
|
182
|
-
WHERE t.tgrelid = to_regclass($1) AND t.tgname = $2`, [qualified(description.schema, trigger.table), trigger.name]);
|
|
259
|
+
const rows = catalog.triggers(trigger.table, trigger.name);
|
|
183
260
|
if (!rows.length) {
|
|
184
|
-
if (
|
|
261
|
+
if (pass === 'validate') mismatch(`${description.schema}.${trigger.name}`, 'trigger is missing');
|
|
262
|
+
missing.push({ kind: 'trigger', table: trigger.table, name: trigger.name });
|
|
263
|
+
if (pass === 'plan') continue;
|
|
185
264
|
const collision = await client.query('SELECT 1 FROM pg_constraint WHERE conrelid = to_regclass($1) AND conname = $2', [qualified(description.schema, trigger.table), trigger.name]);
|
|
186
265
|
if (collision.rowCount) mismatch(`${description.schema}.${trigger.table}.${trigger.name}`, 'constraint name is already in use');
|
|
187
266
|
await client.query(createTriggerSQL(description.schema, trigger));
|
|
@@ -189,8 +268,9 @@ const ensureTriggers = async (client, description, mode, created) => {
|
|
|
189
268
|
continue;
|
|
190
269
|
}
|
|
191
270
|
const stored = rows[0];
|
|
192
|
-
if (stored.tgtype !==
|
|
193
|
-
|| stored.tginitdeferred !== trigger.initiallyDeferred || stored.tgnargs ||
|
|
271
|
+
if (stored.tgtype !== triggerType(trigger) || stored.tgenabled !== 'O' || stored.tgisinternal || stored.tgdeferrable !== trigger.deferrable
|
|
272
|
+
|| stored.tginitdeferred !== trigger.initiallyDeferred || stored.tgnargs || stored.no_when !== !trigger.when
|
|
273
|
+
|| (trigger.when && normalizeExpression(whenClause(stored)) !== normalizeExpression(trigger.when)) || stored.columns !== ''
|
|
194
274
|
|| stored.function !== trigger.function || stored.function_schema !== description.schema || stored.pronargs
|
|
195
275
|
|| stored.constraint !== trigger.constraint || stored.constraint_relation || stored.parent || stored.tgoldtable || stored.tgnewtable
|
|
196
276
|
|| (trigger.constraint && (!stored.condeferrable || !stored.condeferred || !stored.convalidated || !stored.enforced))) {
|
|
@@ -199,35 +279,168 @@ const ensureTriggers = async (client, description, mode, created) => {
|
|
|
199
279
|
}
|
|
200
280
|
};
|
|
201
281
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
282
|
+
/** One pass over the description: schema, functions, tables, foreign keys and indexes, then triggers. */
|
|
283
|
+
const traverse = async (client, description, catalog, pass, missing, created) => {
|
|
284
|
+
const { schema, tables } = description;
|
|
285
|
+
const legacy = [];
|
|
286
|
+
const namespace = await client.query('SELECT 1 FROM pg_namespace WHERE nspname = $1', [schema]);
|
|
287
|
+
if (!namespace.rowCount) {
|
|
288
|
+
if (pass === 'validate') mismatch(schema, 'schema is missing');
|
|
289
|
+
missing.push({ kind: 'schema' });
|
|
290
|
+
if (pass === 'plan') return legacy;
|
|
291
|
+
await client.query(`CREATE SCHEMA ${identifier(schema)}`);
|
|
292
|
+
created.push(`schema:${schema}`);
|
|
293
|
+
}
|
|
294
|
+
await ensureFunctions(client, description, catalog, pass, missing, created);
|
|
295
|
+
const actuals = new Map();
|
|
296
|
+
for (const table of tables) {
|
|
297
|
+
let actual = fromCatalog(schema, table.name, catalog);
|
|
298
|
+
if (!actual) {
|
|
299
|
+
if (pass === 'validate') mismatch(`${schema}.${table.name}`, 'table is missing');
|
|
300
|
+
missing.push({ kind: 'table', table: table.name });
|
|
301
|
+
// A table that does not exist has none of its foreign keys, indexes or triggers either.
|
|
302
|
+
if (pass === 'plan') continue;
|
|
303
|
+
await client.query(createTableSQL(schema, table));
|
|
304
|
+
created.push(`table:${schema}.${table.name}`);
|
|
305
|
+
actual = await readTable(client, schema, table.name);
|
|
207
306
|
}
|
|
307
|
+
validateBase(schema, table, actual, description.triggers);
|
|
308
|
+
actuals.set(table.name, actual);
|
|
208
309
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
310
|
+
for (const table of tables.filter((item) => actuals.has(item.name))) {
|
|
311
|
+
const actual = actuals.get(table.name);
|
|
312
|
+
for (const key of table.foreignKeys) {
|
|
313
|
+
const stored = actual.constraints.find((item) => item.name === key.name);
|
|
314
|
+
if (stored) validateForeignKey(schema, table, key, stored);
|
|
315
|
+
else {
|
|
316
|
+
if (pass === 'validate') mismatch(`${schema}.${table.name}.${key.name}`, 'foreign key is missing');
|
|
317
|
+
missing.push({ kind: 'foreignKey', table: table.name, name: key.name });
|
|
318
|
+
if (pass === 'plan') continue;
|
|
319
|
+
await client.query(createForeignKeySQL(schema, table, key));
|
|
320
|
+
created.push(`foreignKey:${schema}.${table.name}.${key.name}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
for (const index of table.indexes) {
|
|
324
|
+
const stored = actual.indexes.find((item) => item.name === index.name);
|
|
325
|
+
if (stored) validateIndex(schema, table, index, stored);
|
|
326
|
+
else {
|
|
327
|
+
if (pass === 'validate') mismatch(`${schema}.${table.name}.${index.name}`, 'index is missing');
|
|
328
|
+
missing.push({ kind: 'index', table: table.name, name: index.name });
|
|
329
|
+
if (pass === 'plan') continue;
|
|
330
|
+
const collision = await client.query('SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = $1 AND c.relname = $2', [schema, index.name]);
|
|
331
|
+
if (collision.rowCount) mismatch(`${schema}.${index.name}`, 'relation name is already in use');
|
|
332
|
+
await client.query(createIndexSQL(schema, table, index));
|
|
333
|
+
created.push(`index:${schema}.${index.name}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
for (const index of actual.indexes) {
|
|
337
|
+
if (index.unique && !index.primary && !table.indexes.some((item) => item.name === index.name)) mismatch(`${schema}.${table.name}.${index.name}`, 'unexpected unique index');
|
|
338
|
+
}
|
|
339
|
+
legacy.push(...legacyListIndexes(schema, table, actual));
|
|
214
340
|
}
|
|
341
|
+
await ensureTriggers(client, description, catalog, pass, missing, created);
|
|
342
|
+
return legacy;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// Names to read from the catalog: the description's, plus the refresh-trigger objects of both layouts.
|
|
346
|
+
const catalogNames = (description, layouts) => {
|
|
347
|
+
const functions = new Set((description.functions || []).map((fn) => fn.name));
|
|
348
|
+
const triggers = new Set((description.triggers || []).map((trigger) => trigger.name));
|
|
349
|
+
for (const layout of layouts.values()) {
|
|
350
|
+
for (const objects of [layout.perRow, layout.perRoot]) {
|
|
351
|
+
for (const fn of objects.functions) functions.add(fn.name);
|
|
352
|
+
for (const trigger of objects.triggers) triggers.add(trigger.name);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return { tables: description.tables.map((table) => table.name), functions: [...functions], triggers: [...triggers] };
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
// Each maintained root keeps the refresh-trigger layout its database already has: a function or trigger
|
|
359
|
+
// that only one layout has, or a touch function with one layout's body. A root with neither, such as a
|
|
360
|
+
// new one, gets the description's layout. Objects of both layouts in one root are drift.
|
|
361
|
+
const resolveLayouts = (description, catalog, layouts) => {
|
|
362
|
+
const holds = (own, other) => own.functions.some((fn) => {
|
|
363
|
+
const twin = other.functions.find((item) => item.name === fn.name);
|
|
364
|
+
const stored = catalog.functions(fn.name);
|
|
365
|
+
return twin ? twin.body !== fn.body && stored.some((row) => row.body === fn.body) : stored.length > 0;
|
|
366
|
+
}) || own.triggers.some((trigger) => !other.triggers.some((item) => item.name === trigger.name && item.table === trigger.table)
|
|
367
|
+
&& catalog.triggers(trigger.table, trigger.name).length > 0);
|
|
368
|
+
const choices = new Map();
|
|
215
369
|
for (const item of description.maintenance || []) {
|
|
370
|
+
const { perRow, perRoot } = layouts.get(item.rootTable);
|
|
371
|
+
const row = holds(perRow, perRoot);
|
|
372
|
+
const root = holds(perRoot, perRow);
|
|
373
|
+
if (row && root) mismatch(`${description.schema}.${item.rootTable}`, 'refresh triggers of two layouts are present');
|
|
374
|
+
const described = layoutOf(description, item);
|
|
375
|
+
const layout = row ? 'perRow' : root ? 'perRoot' : described;
|
|
376
|
+
if (layout !== described) choices.set(item.rootTable, layout);
|
|
377
|
+
}
|
|
378
|
+
return choices.size ? withLayouts(description, choices, layouts) : description;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// The maintained root a table belongs to: itself, the root of its ownership chain, or the root an
|
|
382
|
+
// auxiliary (guard or keys) table serves.
|
|
383
|
+
const rootOf = (description, name) => {
|
|
384
|
+
let table = description.tables.find((item) => item.name === name);
|
|
385
|
+
if (table?.auxiliary) return table.auxiliary.rootTable;
|
|
386
|
+
while (table?.ownership) table = description.tables.find((item) => item.name === table.ownership.ownerTable);
|
|
387
|
+
return table?.name;
|
|
388
|
+
};
|
|
389
|
+
// Keys can be stale only for roots whose maintenance objects are missing: guard and keys tables, the
|
|
390
|
+
// validate and refresh functions, and the refresh triggers and their functions. A missing foreign key or
|
|
391
|
+
// index validates existing rows itself when it is created, and checks only arrive with new tables.
|
|
392
|
+
const staleRoots = (description, objects) => {
|
|
393
|
+
const maintenance = description.maintenance || [];
|
|
394
|
+
const maintained = new Set(maintenance.map((item) => item.rootTable));
|
|
395
|
+
const roots = new Set();
|
|
396
|
+
for (const object of objects) {
|
|
397
|
+
let root;
|
|
398
|
+
if (object.kind === 'table' || object.kind === 'trigger') root = rootOf(description, object.table);
|
|
399
|
+
else if (object.kind === 'function') {
|
|
400
|
+
const item = maintenance.find((entry) => entry.validateFunction === object.name || entry.refreshFunction === object.name);
|
|
401
|
+
const trigger = (description.triggers || []).find((entry) => entry.function === object.name);
|
|
402
|
+
root = item ? item.rootTable : trigger && rootOf(description, trigger.table);
|
|
403
|
+
}
|
|
404
|
+
if (maintained.has(root)) roots.add(root);
|
|
405
|
+
}
|
|
406
|
+
return roots;
|
|
407
|
+
};
|
|
408
|
+
// Rebuilding keys needs the rows of their root to stay still until commit: lock the existing tables of
|
|
409
|
+
// those roots (root, owned, guard and keys tables) in one statement. Other DDL takes its own locks.
|
|
410
|
+
const lockRoots = async (client, description, catalog, roots) => {
|
|
411
|
+
const names = description.tables.filter((table) => catalog.tables.has(table.name) && roots.has(rootOf(description, table.name)));
|
|
412
|
+
if (names.length) await client.query(`LOCK TABLE ${names.map((table) => qualified(description.schema, table.name)).join(', ')} IN SHARE ROW EXCLUSIVE MODE`);
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const backfill = async (client, description, roots) => {
|
|
416
|
+
// The keys of these roots can be stale because maintenance objects were recreated. Their tables are
|
|
417
|
+
// locked; rebuild their complete derived key set before testing owners against one another. Rollback
|
|
418
|
+
// restores the old set.
|
|
419
|
+
for (const table of description.tables.filter((item) => item.uniqueKeys && roots.has(item.uniqueKeys.rootTable))) {
|
|
420
|
+
await client.query(`DELETE FROM ${qualified(description.schema, table.name)}`);
|
|
421
|
+
}
|
|
422
|
+
for (const item of (description.maintenance || []).filter((entry) => roots.has(entry.rootTable))) {
|
|
216
423
|
await client.query(`INSERT INTO ${qualified(description.schema, item.guardTable)} (__owner_id)
|
|
217
424
|
SELECT id FROM ${qualified(description.schema, item.rootTable)} ON CONFLICT (__owner_id) DO NOTHING`);
|
|
218
425
|
await client.query(`SELECT ${qualified(description.schema, item.refreshFunction)}(id) FROM ${qualified(description.schema, item.rootTable)} ORDER BY id`);
|
|
219
426
|
}
|
|
220
427
|
};
|
|
221
428
|
|
|
222
|
-
/**
|
|
429
|
+
/**
|
|
430
|
+
* Create missing objects or validate an existing schema; never alter/drop existing storage. The catalog is
|
|
431
|
+
* read in a fixed number of statements without table locks. Create mode changes nothing, and locks nothing,
|
|
432
|
+
* when nothing is missing; otherwise it locks only the tables of roots whose keys it must rebuild.
|
|
433
|
+
*/
|
|
223
434
|
export const initializeDatabase = async (pool, description, { mode = 'create' } = {}) => {
|
|
224
435
|
if (!['create', 'validate'].includes(mode)) throw new SimfinityError(`Unknown initialization mode: ${mode}`, 'INVALID_INITIALIZATION_MODE', 400);
|
|
225
|
-
const { schema
|
|
436
|
+
const { schema } = description;
|
|
226
437
|
identifier(schema);
|
|
227
438
|
if (schema.startsWith('pg_') || schema === 'information_schema') throw new SimfinityError('System schemas cannot be managed', 'INVALID_DATABASE_SCHEMA', 400);
|
|
439
|
+
const layouts = maintenanceLayouts(description);
|
|
440
|
+
const names = catalogNames(description, layouts);
|
|
228
441
|
const client = await pool.connect();
|
|
229
442
|
const created = [];
|
|
230
|
-
|
|
443
|
+
let legacy = [];
|
|
231
444
|
let rollbackError;
|
|
232
445
|
try {
|
|
233
446
|
await client.query(mode === 'validate' ? 'BEGIN READ ONLY' : 'BEGIN');
|
|
@@ -235,56 +448,36 @@ export const initializeDatabase = async (pool, description, { mode = 'create' }
|
|
|
235
448
|
await client.query('SET LOCAL standard_conforming_strings = \'on\'');
|
|
236
449
|
const version = await client.query('SELECT current_setting(\'server_version_num\')::integer AS version');
|
|
237
450
|
if (version.rows[0].version < 150000) throw new SimfinityError('PostgreSQL 15 or later is required', 'UNSUPPORTED_DATABASE_VERSION', 400);
|
|
451
|
+
// Serializes initializers of this schema before anything is read.
|
|
238
452
|
await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [`simfinity:${schema}`]);
|
|
239
|
-
const
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
created.push(`table:${schema}.${table.name}`);
|
|
253
|
-
actual = await readTable(client, schema, table.name);
|
|
254
|
-
}
|
|
255
|
-
if (mode === 'create') await client.query(`LOCK TABLE ${qualified(schema, table.name)} IN SHARE ROW EXCLUSIVE MODE`);
|
|
256
|
-
validateBase(schema, table, actual, description.triggers);
|
|
257
|
-
catalog.set(table.name, actual);
|
|
258
|
-
}
|
|
259
|
-
for (const table of tables) {
|
|
260
|
-
const actual = catalog.get(table.name);
|
|
261
|
-
for (const key of table.foreignKeys) {
|
|
262
|
-
const stored = actual.constraints.find((item) => item.name === key.name);
|
|
263
|
-
if (stored) validateForeignKey(schema, table, key, stored);
|
|
264
|
-
else {
|
|
265
|
-
if (mode === 'validate') mismatch(`${schema}.${table.name}.${key.name}`, 'foreign key is missing');
|
|
266
|
-
await client.query(createForeignKeySQL(schema, table, key));
|
|
267
|
-
created.push(`foreignKey:${schema}.${table.name}.${key.name}`);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
for (const index of table.indexes) {
|
|
271
|
-
const stored = actual.indexes.find((item) => item.name === index.name);
|
|
272
|
-
if (stored) validateIndex(schema, table, index, stored);
|
|
273
|
-
else {
|
|
274
|
-
if (mode === 'validate') mismatch(`${schema}.${table.name}.${index.name}`, 'index is missing');
|
|
275
|
-
const collision = await client.query('SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = $1 AND c.relname = $2', [schema, index.name]);
|
|
276
|
-
if (collision.rowCount) mismatch(`${schema}.${index.name}`, 'relation name is already in use');
|
|
277
|
-
await client.query(createIndexSQL(schema, table, index));
|
|
278
|
-
created.push(`index:${schema}.${index.name}`);
|
|
279
|
-
}
|
|
453
|
+
const inspected = await loadCatalog(client, schema, names);
|
|
454
|
+
if (mode === 'validate') legacy = await traverse(client, resolveLayouts(description, inspected, layouts), inspected, 'validate', [], created);
|
|
455
|
+
else {
|
|
456
|
+
// Inspect first, without table locks or DDL. Drift before the first missing object is reported as
|
|
457
|
+
// before; drift after one is left to the create pass, which reports what earlier versions did, such
|
|
458
|
+
// as a foreign key that existing rows violate.
|
|
459
|
+
const missing = [];
|
|
460
|
+
let planned;
|
|
461
|
+
try {
|
|
462
|
+
planned = resolveLayouts(description, inspected, layouts);
|
|
463
|
+
legacy = await traverse(client, planned, inspected, 'plan', missing, []);
|
|
464
|
+
} catch (error) {
|
|
465
|
+
if (!(error instanceof SimfinityError) || !missing.length) throw error;
|
|
280
466
|
}
|
|
281
|
-
|
|
282
|
-
|
|
467
|
+
if (missing.length) {
|
|
468
|
+
const roots = staleRoots(planned, missing);
|
|
469
|
+
await lockRoots(client, planned, inspected, roots);
|
|
470
|
+
const catalog = await loadCatalog(client, schema, names);
|
|
471
|
+
const effective = resolveLayouts(description, catalog, layouts);
|
|
472
|
+
const objects = [];
|
|
473
|
+
legacy = await traverse(client, effective, catalog, 'create', objects, created);
|
|
474
|
+
// A missing schema stops the plan early; its new tables need no lock. Lock any other root the
|
|
475
|
+
// plan did not reach, such as one whose objects an operator dropped meanwhile, before rebuilding.
|
|
476
|
+
const late = new Set([...staleRoots(effective, objects)].filter((root) => !roots.has(root)));
|
|
477
|
+
await lockRoots(client, effective, catalog, late);
|
|
478
|
+
if (roots.size || late.size) await backfill(client, effective, new Set([...roots, ...late]));
|
|
283
479
|
}
|
|
284
|
-
legacy.push(...legacyListIndexes(schema, table, actual));
|
|
285
480
|
}
|
|
286
|
-
await ensureTriggers(client, description, mode, created);
|
|
287
|
-
if (mode === 'create' && created.length) await backfill(client, description);
|
|
288
481
|
await client.query('COMMIT');
|
|
289
482
|
if (legacy.length) {
|
|
290
483
|
console.warn(`Simfinity: PostgreSQL schema ${identifier(schema)} has btree indexes that earlier versions generated on [ID] list columns: ${legacy.join(', ')}. `
|
package/types/index.d.ts
CHANGED
|
@@ -94,6 +94,10 @@ export interface DatabaseTrigger {
|
|
|
94
94
|
constraint: boolean;
|
|
95
95
|
deferrable: boolean;
|
|
96
96
|
initiallyDeferred: boolean;
|
|
97
|
+
/** Row events the trigger fires on; omitted means INSERT, UPDATE and DELETE. Only the `refreshTriggers: 'perRoot'` guard trigger sets it. */
|
|
98
|
+
events?: Array<'INSERT' | 'UPDATE' | 'DELETE'>;
|
|
99
|
+
/** A WHEN condition on the row; only the `refreshTriggers: 'perRoot'` guard trigger sets it. */
|
|
100
|
+
when?: string;
|
|
97
101
|
}
|
|
98
102
|
export interface DatabaseMaintenance {
|
|
99
103
|
rootTable: string;
|
|
@@ -124,7 +128,7 @@ export interface DatabasePool {
|
|
|
124
128
|
* Low-level describer for any registrations. `options.schema` defaults to `public`, not to the schema
|
|
125
129
|
* of a configured runtime; pass the schema given to configure() or createPostgres().
|
|
126
130
|
*/
|
|
127
|
-
export function describeDatabase(registrations: ModelRegistration[], options?: { schema?: string }): DatabaseDescription;
|
|
131
|
+
export function describeDatabase(registrations: ModelRegistration[], options?: { schema?: string } & Pick<PostgresConfiguration, 'refreshTriggers' | 'ownerIndexes'>): DatabaseDescription;
|
|
128
132
|
/** Describes the default module instance in its configured schema, after configure/connect/createSchema. */
|
|
129
133
|
export function describeDatabase(): DatabaseDescription;
|
|
130
134
|
/** Accepts a trusted description produced by describeDatabase. */
|
|
@@ -164,7 +168,31 @@ export interface PostgresModel {
|
|
|
164
168
|
update(id: string, changes: Record<string, any>, options?: { session?: PostgresSession }): Promise<PostgresRecord | null>;
|
|
165
169
|
delete(id: string, options?: { session?: PostgresSession }): Promise<PostgresRecord | null>;
|
|
166
170
|
}
|
|
167
|
-
export interface PostgresConfiguration {
|
|
171
|
+
export interface PostgresConfiguration {
|
|
172
|
+
pool: PostgresPool;
|
|
173
|
+
schema?: string;
|
|
174
|
+
/** Defaults to true: the owned rows of an embedded list are inserted with multi-row INSERT statements. `false` keeps one INSERT per row. */
|
|
175
|
+
multiRowInserts?: boolean;
|
|
176
|
+
/**
|
|
177
|
+
* Defaults to false: sorts on `id` and required root fields omit the NULLS clause, so the primary key and declared
|
|
178
|
+
* indexes can return them in order. `true` keeps the explicit NULLS clause on every sort term, as 3.5.9 did.
|
|
179
|
+
*/
|
|
180
|
+
explicitNullsOrder?: boolean;
|
|
181
|
+
/**
|
|
182
|
+
* Defaults to 'perRow', as 3.5.9: the deferred triggers of a root with owned tables or unique lists refresh its owned
|
|
183
|
+
* shape and unique keys once per changed row. 'perRoot' refreshes each changed root once per transaction while its
|
|
184
|
+
* guard trigger stays deferred, the default (under SET CONSTRAINTS ALL IMMEDIATE, once per changed row). It applies
|
|
185
|
+
* to roots created while it is set: initialization keeps each existing root on the layout its database has, and
|
|
186
|
+
* accepts both. An unknown value throws INVALID_DATABASE_SCHEMA (400) from createSchema or describeDatabase.
|
|
187
|
+
*/
|
|
188
|
+
refreshTriggers?: 'perRow' | 'perRoot';
|
|
189
|
+
/**
|
|
190
|
+
* Defaults to 'all', as 3.5.9. 'minimal' does not create the (__owner_id) index of owned and guard tables, which their
|
|
191
|
+
* primary key or a unique index covers; keys tables keep theirs. Existing indexes are kept and accepted. An unknown
|
|
192
|
+
* value throws INVALID_DATABASE_SCHEMA (400) from createSchema or describeDatabase.
|
|
193
|
+
*/
|
|
194
|
+
ownerIndexes?: 'all' | 'minimal';
|
|
195
|
+
}
|
|
168
196
|
export interface InitializationOptions { mode?: 'create' | 'validate' }
|
|
169
197
|
export interface InitializationResult { mode: 'create' | 'validate'; created: string[] }
|
|
170
198
|
export interface PostgresRuntime extends Runtime<PostgresModel, PostgresSession> {
|