@simtlix/simfinity-postgres 3.4.1 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,7 +31,7 @@ import { postgresPlugin } from '@simtlix/simfinity-postgres';
31
31
  const simfinity = createSQL({ plugin: postgresPlugin({ pool, schema: 'library' }) });
32
32
  ```
33
33
 
34
- Install `@simtlix/simfinity-sql@3.3.0` as a direct dependency when importing its factory. PostgreSQL 15 or later is the only supported SQL plugin. The [SQL package guide](https://github.com/simtlix/simfinity.js/tree/master/packages/sql) documents relational planning and the versioned plugin contract.
34
+ Install `@simtlix/simfinity-sql@3.5.0` as a direct dependency when importing its factory. PostgreSQL 15 or later is the only supported SQL plugin. The [SQL package guide](https://github.com/simtlix/simfinity.js/tree/master/packages/sql) documents relational planning and the versioned plugin contract.
35
35
 
36
36
  Both factories permanently bind one pool and schema. 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.
37
37
 
@@ -60,7 +60,7 @@ The module and every `createPostgres` instance expose the shared `auth`, `valida
60
60
  MCP integration is an independent opt-in and does not add MCP dependencies to PostgreSQL applications:
61
61
 
62
62
  ```sh
63
- npm install @simtlix/simfinity-mcp@3.3.0 @modelcontextprotocol/sdk@^1.13.0
63
+ npm install @simtlix/simfinity-mcp@3.5.0 @modelcontextprotocol/sdk@^1.13.0
64
64
  ```
65
65
 
66
66
  ```javascript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-postgres",
3
- "version": "3.4.1",
3
+ "version": "3.5.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,11 +27,19 @@
27
27
  "directory": "packages/postgres"
28
28
  },
29
29
  "dependencies": {
30
- "@simtlix/simfinity-core": "3.4.1",
31
- "@simtlix/simfinity-sql": "3.4.1",
30
+ "@simtlix/simfinity-core": "3.5.0",
31
+ "@simtlix/simfinity-sql": "3.5.0",
32
32
  "pg": "^8.16.3"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "graphql": "^16.11.0"
36
- }
36
+ },
37
+ "keywords": [
38
+ "simfinity",
39
+ "graphql",
40
+ "postgresql",
41
+ "postgres",
42
+ "sql",
43
+ "nodejs"
44
+ ]
37
45
  }
package/src/plugin.js CHANGED
@@ -31,7 +31,8 @@ export const postgresPlugin = (options) => ({
31
31
  begin: (client) => client.query('BEGIN ISOLATION LEVEL REPEATABLE READ'),
32
32
  commit: (client) => client.query('COMMIT'),
33
33
  rollback: (client) => client.query('ROLLBACK'),
34
- release: (client) => client.release(),
34
+ // A release error makes pg-pool destroy a connection whose transaction may still be open.
35
+ release: (client, error) => client.release(error),
35
36
  isRetryable: (error) => ['40001', '40P01'].includes(error.code),
36
37
  normalizeError: normalizeDatabaseError,
37
38
  },
@@ -153,7 +153,11 @@ export const compileQuery = (models, database, plan, extra = null) => {
153
153
  }
154
154
  unsupported('Invalid query path');
155
155
  };
156
- const predicate = (expression, operator, value) => {
156
+ /**
157
+ * WHERE rejects NULL and FALSE alike, so positive scalar filters use bare, index-friendly operators.
158
+ * Anything under a NOT (NE, NIN or a negated group) keeps the null-safe forms that never yield NULL.
159
+ */
160
+ const predicate = (expression, operator, value, negated = false) => {
157
161
  const { sql, field, type, list, vector } = expression;
158
162
  // Filters accept member names first; writes already receive internal enum values.
159
163
  const encodeFilter = (item) => {
@@ -164,8 +168,9 @@ export const compileQuery = (models, database, plan, extra = null) => {
164
168
  return encodeScalar(field, entry.value);
165
169
  };
166
170
  if (operator === 'LIKE' && field.scalar !== 'String') invalidValue('LIKE requires a string field');
167
- const scalarBind = (item) => { const value = encodeFilter(item); return bind(expression.dateNumeric && value ? value.getTime() : value, type); };
168
- const equality = (item) => {
171
+ const encodeValue = (item) => { const value = encodeFilter(item); return expression.dateNumeric && value ? value.getTime() : value; };
172
+ const scalarBind = (item) => bind(encodeValue(item), type);
173
+ const equality = (item, nullSafe = negated) => {
169
174
  if (Array.isArray(item)) {
170
175
  if (!field.list || vector) invalidValue('Array equality requires a scalar-list field');
171
176
  return `${sql} IS NOT DISTINCT FROM ${bind(item.map(encodeFilter), `${type}[]`)}`;
@@ -174,18 +179,26 @@ export const compileQuery = (models, database, plan, extra = null) => {
174
179
  const contains = `array_position(${sql}, ${scalarBind(item)}) IS NOT NULL`;
175
180
  return item == null ? `(${sql} IS NULL OR ${contains})` : contains;
176
181
  }
177
- return item == null ? `${sql} IS NULL` : `${sql} IS NOT DISTINCT FROM ${scalarBind(item)}`;
182
+ if (item == null) return `${sql} IS NULL`;
183
+ return `${sql} ${nullSafe ? 'IS NOT DISTINCT FROM' : '='} ${scalarBind(item)}`;
178
184
  };
179
185
  if (operator === 'EQ') return equality(value);
180
186
  if (operator === 'NE') {
181
187
  if (!list && !Array.isArray(value)) return value == null ? `${sql} IS NOT NULL` : `${sql} IS DISTINCT FROM ${scalarBind(value)}`;
182
- return `NOT (${equality(value)})`;
188
+ return `NOT (${equality(value, true)})`;
183
189
  }
184
190
  if (operator === 'IN' || operator === 'NIN') {
185
- const expressionSQL = value.length ? `(${value.map(equality).join(' OR ')})` : 'FALSE';
186
- return operator === 'IN' ? expressionSQL : value.length ? `NOT ${expressionSQL}` : 'TRUE';
191
+ if (!value.length) return operator === 'IN' ? 'FALSE' : 'TRUE';
192
+ let expressionSQL;
193
+ if (list) expressionSQL = `(${value.map((item) => equality(item)).join(' OR ')})`;
194
+ else {
195
+ // For scalar columns, one array parameter keeps large lists within the 65,535 bind limit.
196
+ const member = `${sql} = ANY (${bind(value.map(encodeValue), `${type}[]`)})`;
197
+ expressionSQL = negated || operator === 'NIN' ? `COALESCE(${member}, FALSE)` : member;
198
+ }
199
+ return operator === 'IN' ? expressionSQL : `NOT ${expressionSQL}`;
187
200
  }
188
- if (operator === 'BTW') return `(${predicate(expression, 'GTE', value[0])} AND ${predicate(expression, 'LTE', value[1])})`;
201
+ if (operator === 'BTW') return `(${predicate(expression, 'GTE', value[0], negated)} AND ${predicate(expression, 'LTE', value[1], negated)})`;
189
202
  if (value == null || Array.isArray(value)) invalidValue(`${operator} requires a non-null scalar value`);
190
203
  const parameter = scalarBind(value);
191
204
  const compare = (left) => {
@@ -194,12 +207,14 @@ export const compileQuery = (models, database, plan, extra = null) => {
194
207
  }
195
208
  return `${left} ${{ LT: '<', LTE: '<=', GT: '>', GTE: '>=' }[operator]} ${parameter}`;
196
209
  };
197
- return list ? `EXISTS (SELECT 1 FROM unnest(${sql}) v(value) WHERE ${compare('v.value')})` : `COALESCE(${compare(sql)}, FALSE)`;
210
+ if (list) return `EXISTS (SELECT 1 FROM unnest(${sql}) v(value) WHERE ${compare('v.value')})`;
211
+ return negated ? `COALESCE(${compare(sql)}, FALSE)` : compare(sql);
198
212
  };
199
- const where = (node) => {
213
+ // A negation reaches every predicate below it, including those nested in AND/OR groups.
214
+ const where = (node, negated = false) => {
200
215
  if (!node) return 'TRUE';
201
- if (node.kind === 'predicate') return `(${predicate(path(node.path), node.operator, node.value)})`;
202
- return `(${node.terms.map(where).join(node.kind === 'and' ? ' AND ' : ' OR ')})`;
216
+ if (node.kind === 'predicate') return `(${predicate(path(node.path), node.operator, node.value, negated)})`;
217
+ return `(${node.terms.map((term) => where(term, negated)).join(node.kind === 'and' ? ' AND ' : ' OR ')})`;
203
218
  };
204
219
  let condition = where(plan.where);
205
220
  if (extra) {
@@ -205,6 +205,7 @@ export const initializeDatabase = async (pool, description, { mode = 'create' }
205
205
  if (schema.startsWith('pg_') || schema === 'information_schema') throw new SimfinityError('System schemas cannot be managed', 'INVALID_DATABASE_SCHEMA', 400);
206
206
  const client = await pool.connect();
207
207
  const created = [];
208
+ let rollbackError;
208
209
  try {
209
210
  await client.query(mode === 'validate' ? 'BEGIN READ ONLY' : 'BEGIN');
210
211
  await client.query('SET LOCAL search_path = pg_catalog');
@@ -263,9 +264,10 @@ export const initializeDatabase = async (pool, description, { mode = 'create' }
263
264
  await client.query('COMMIT');
264
265
  return { mode, created };
265
266
  } catch (error) {
266
- await client.query('ROLLBACK').catch(() => {});
267
+ try { await client.query('ROLLBACK'); } catch (failure) { rollbackError = failure; }
267
268
  throw error;
268
269
  } finally {
269
- client.release();
270
+ // A failed ROLLBACK can leave the transaction open; the error makes pg-pool destroy the connection.
271
+ client.release(rollbackError);
270
272
  }
271
273
  };
package/types/index.d.ts CHANGED
@@ -105,7 +105,8 @@ export interface DatabaseDescription {
105
105
  export interface DatabasePool {
106
106
  connect(): Promise<{
107
107
  query(sql: string, values?: unknown[]): Promise<{ rows: any[]; rowCount: number | null }>;
108
- release(): void;
108
+ /** An error means the connection must be destroyed instead of reused. */
109
+ release(error?: unknown): void;
109
110
  }>;
110
111
  }
111
112
  export function describeDatabase(registrations: ModelRegistration[], options?: { schema?: string }): DatabaseDescription;