@simtlix/simfinity-postgres 3.5.9 → 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 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.5.9` as a direct dependency when importing its factory. PostgreSQL 15 or later is the only supported SQL plugin. The [SQL package guide](https://github.com/simtlix/simfinity.js/tree/master/packages/sql) documents relational planning and the versioned plugin contract.
34
+ Install `@simtlix/simfinity-sql@3.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. Use `mode: 'validate'` to check a live instance, because `create` mode locks the managed tables against writes until it commits. `simfinity.compileDatabaseSchema()` returns the instance's DDL for review or export.
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.5.9 @modelcontextprotocol/sdk@^1.31.0
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.5.9",
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.5.9",
31
- "@simtlix/simfinity-sql": "3.5.9",
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
- capabilities: ['transactions', 'foreignKeys', 'deferredForeignKeys', 'embeddedValues', 'ownedRecords', 'scalarLists', 'uniqueValues', 'nullableUnique'],
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
- describeSchema: describeRelationalSchema,
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) {
@@ -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
- /** Compiles validated paths; all caller values are parameters, identifiers come from metadata. */
8
- export const compileQuery = (models, database, plan, extra = null) => {
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
- return { sql, field: identity ? { ...field, scalar: 'ID' } : field, type: dateNumeric ? 'double precision' : type, list: field.list || !!context.vector, vector: !!context.vector, dateNumeric, sortKey };
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 operation = `(array_agg(${expression.sql} ORDER BY ${helper('value_key', expression.sql, literal(expression.field.scalar))} ${fact.operation === 'MIN' ? 'ASC' : 'DESC'}) FILTER (WHERE ${expression.sql} IS NOT NULL AND ${expression.sql} <> 'null'::jsonb))[1]`;
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
- let text = `SELECT ${select} FROM ${qualified(database.schema, root.name)} t0 ${[...joins.values()].map((item) => item.sql).join(' ')} WHERE ${condition}${group}`;
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')}`;
@@ -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
- const result = { name, arguments: args, returns, language: 'plpgsql', volatility: immutable ? 'IMMUTABLE' : 'VOLATILE', configuration: ['search_path=pg_catalog', 'TimeZone=UTC', 'DateStyle=ISO, YMD'], body };
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) => path.map((table, index) => {
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
- const refresh = fn(refreshName, ['uuid'], 'void', `BEGIN\n PERFORM ${validate}($1);${rebuild}\nEND`);
116
- maintenance.push({ rootTable: root.name, guardTable: guard.name, validateFunction: validateName, refreshFunction: refreshName });
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 path = chain(table);
122
- const owner = (record) => table === root ? `${record}.id` : `(SELECT t0.id FROM ${joins(path.slice(0, -1))} WHERE t${path.length - 2}.${q(path.at(-2).primaryKey.columns[0])} = ${record}.__owner_id)`;
123
- for (const deferred of [false, true]) {
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
- export const createTriggerSQL = (schema, trigger) => `CREATE ${trigger.constraint ? 'CONSTRAINT ' : ''}TRIGGER ${identifier(trigger.name)} AFTER INSERT OR UPDATE OR DELETE ON ${qualified(schema, trigger.table)} ${trigger.constraint ? 'DEFERRABLE INITIALLY DEFERRED ' : ''}FOR EACH ROW EXECUTE FUNCTION ${qualified(schema, trigger.function)}()`;
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) => [
@@ -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
- /** Lower engine-independent relational guarantees into PostgreSQL storage and enforcement. */
12
- export const describeRelationalSchema = (plan) => {
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
- return { schema, tables: tables.sort((a, b) => a.name.localeCompare(b.name)), ...generated };
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 });
@@ -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
- const readTable = async (client, schema, name) => {
47
- const { rows: relations } = await client.query(`SELECT c.oid, c.relkind, c.relpersistence, c.relrowsecurity, c.relforcerowsecurity
48
- FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = $1 AND c.relname = $2`, [schema, name]);
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
- WHERE a.attrelid = $1 AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum`, [relation.oid]);
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
- WHERE c.conrelid = $1`, [relation.oid]);
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 WHERE i.indrelid = $1`, [relation.oid]);
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
- const ensureFunctions = async (client, description, mode, created) => {
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 { rows } = await client.query(`SELECT p.oid, pg_get_function_identity_arguments(p.oid) AS arguments,
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 (mode === 'validate') mismatch(`${description.schema}.${fn.name}`, 'function is missing');
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
- const ensureTriggers = async (client, description, mode, created) => {
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 { rows } = await client.query(`SELECT t.tgtype, t.tgenabled, t.tgisinternal, t.tgdeferrable, t.tginitdeferred,
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 (mode === 'validate') mismatch(`${description.schema}.${trigger.name}`, 'trigger is missing');
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 !== 29 || stored.tgenabled !== 'O' || stored.tgisinternal || stored.tgdeferrable !== trigger.deferrable
193
- || stored.tginitdeferred !== trigger.initiallyDeferred || stored.tgnargs || !stored.no_when || stored.columns !== ''
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
- const backfill = async (client, description) => {
203
- for (const table of description.tables) {
204
- for (const check of table.checks) {
205
- const invalid = await client.query(`SELECT 1 FROM ${qualified(description.schema, table.name)} WHERE NOT (${check.expression}) LIMIT 1`);
206
- if (invalid.rowCount) mismatch(`${description.schema}.${table.name}.${check.name}`, 'existing data violates generated check');
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
- // Existing keys can be stale when missing maintenance triggers are recreated.
210
- // All managed tables are already locked; rebuild the complete derived key set
211
- // before testing owners against one another. Rollback restores the old set.
212
- for (const table of description.tables.filter((item) => item.uniqueKeys)) {
213
- await client.query(`DELETE FROM ${qualified(description.schema, table.name)}`);
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
- /** Create missing objects or validate an existing schema; never alter/drop existing storage. */
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, tables } = description;
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
- const legacy = [];
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 namespace = await client.query('SELECT 1 FROM pg_namespace WHERE nspname = $1', [schema]);
240
- if (!namespace.rowCount) {
241
- if (mode === 'validate') mismatch(schema, 'schema is missing');
242
- await client.query(`CREATE SCHEMA ${identifier(schema)}`);
243
- created.push(`schema:${schema}`);
244
- }
245
- await ensureFunctions(client, description, mode, created);
246
- const catalog = new Map();
247
- for (const table of tables) {
248
- let actual = await readTable(client, schema, table.name);
249
- if (!actual) {
250
- if (mode === 'validate') mismatch(`${schema}.${table.name}`, 'table is missing');
251
- await client.query(createTableSQL(schema, table));
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
- for (const index of actual.indexes) {
282
- if (index.unique && !index.primary && !table.indexes.some((item) => item.name === index.name)) mismatch(`${schema}.${table.name}.${index.name}`, 'unexpected unique index');
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 { pool: PostgresPool; schema?: string }
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> {