@simtlix/simfinity-postgres 3.9.0 → 3.10.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,9 +31,9 @@ import { postgresPlugin } from '@simtlix/simfinity-postgres';
31
31
  const simfinity = createSQL({ plugin: postgresPlugin({ pool, schema: 'library' }) });
32
32
  ```
33
33
 
34
- Install `@simtlix/simfinity-sql@3.9.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.10.0` as a direct dependency when importing its factory. PostgreSQL 15 or later is the only supported SQL plugin. The [SQL package guide](https://github.com/simtlix/simfinity.js/tree/master/packages/sql) documents relational planning and the versioned plugin contract.
35
35
 
36
- Both factories permanently bind one pool and schema, and five optional settings: `multiRowInserts: false` keeps one `INSERT` per owned row instead of multi-row statements; `explicitNullsOrder: true` keeps the `NULLS` clause on sorts of `id` and required fields, as 3.5.9 did, instead of letting indexes serve them; `batchedCollections: true` reads the generated collection fields of the parents that share their arguments with one statement per 100 parents, in a transaction that turns JIT off for that transaction only, instead of one statement per field as 3.6.0 did and as the default still does, and can return unsorted and tied rows in another order; the opt-in `refreshTriggers: 'perRoot'` refreshes each changed root's owned shape and unique keys once per transaction, while its guard trigger stays deferred, instead of once per changed row; and the opt-in `ownerIndexes: 'minimal'` leaves out the `(__owner_id)` indexes that the primary key or a unique index covers on new owned and guard tables. An unknown `refreshTriggers` or `ownerIndexes` value throws `INVALID_DATABASE_SCHEMA` (400). Read the [upgrade notes](https://simtlix.github.io/simfinity.js/resources/compatibility.html#upgrade-to-3-6-0) before enabling the opt-ins: versions up to 3.5.9 cannot validate databases created with them. The returned runtime supports registration, generated queries and mutations, middleware and scopes, controllers and validators, custom mutations, state machines, nested relationship writes, embedded reconstruction, and native PostgreSQL model handles. Native handles expose `findById`, `find`, `create`, `update`, and `delete`; they are not Mongoose models. `find(args, { session })` accepts the generated GraphQL list query arguments. Existing `pg.Pool` configuration and `pg.PoolClient` query interfaces remain compatible across both factories. A custom pool or client wrapper should pass `pg`'s `command` through in query results: the plugin reads it from the `COMMIT` result to detect a transaction that PostgreSQL rolled back because one of its statements failed, and without it such a transaction reports success with nothing stored.
36
+ Both factories permanently bind one pool and schema, and five optional settings: `multiRowInserts: false` keeps one `INSERT` per owned row instead of multi-row statements; `explicitNullsOrder: true` keeps the `NULLS` clause on sorts of `id` and required fields, as 3.5.9 did, instead of letting indexes serve them; `batchedCollections: true` reads the generated collection fields of the parents that share their arguments with one statement per 100 parents, in a transaction that turns JIT off for that transaction only, instead of one statement per field as 3.6.0 did and as the default still does, and can return unsorted and tied rows in another order; the opt-in `refreshTriggers: 'perRoot'` refreshes each changed root's owned shape and unique keys once per transaction, while its guard trigger stays deferred, instead of once per changed row; and the opt-in `ownerIndexes: 'minimal'` leaves out the `(__owner_id)` indexes that the primary key or a unique index covers on new owned and guard tables. An unknown `refreshTriggers` or `ownerIndexes` value throws `INVALID_DATABASE_SCHEMA` (400). Read the [upgrade notes](https://simtlix.github.io/simfinity.js/resources/compatibility.html#upgrade-to-3-6-0) before enabling the opt-ins: versions up to 3.5.9 cannot validate databases created with them. The returned runtime supports registration, generated queries and mutations, middleware and scopes, controllers and validators, custom queries and mutations, state machines, nested relationship writes, embedded reconstruction, and native PostgreSQL model handles. Native handles expose `findById`, `find`, `create`, `update`, and `delete`; they are not Mongoose models. `find(args, { session })` accepts the generated GraphQL list query arguments. Existing `pg.Pool` configuration and `pg.PoolClient` query interfaces remain compatible across both factories. A custom pool or client wrapper should pass `pg`'s `command` through in query results: the plugin reads it from the `COMMIT` result to detect a transaction that PostgreSQL rolled back because one of its statements failed, and without it such a transaction reports success with nothing stored.
37
37
 
38
38
  Operations attempted before successful initialization fail with `DATABASE_NOT_INITIALIZED`. Call `createSchema` before `initializeDatabase`. Configuration and registrations become immutable once schema preparation begins. `preventCreatingCollection(true)` makes instance initialization validate existing storage without issuing creation DDL. A rejected initialization request leaves readiness as it was; a ready instance keeps serving while it is initialized again, and becomes unavailable if that initialization fails. `create` mode reads the catalog without table locks and locks nothing when nothing is missing. When it creates objects, writers of the tables that it changes, of existing tables that a new foreign key references, or of roots whose keys it rebuilds wait until it commits, so use `mode: 'validate'` to check a live instance. `simfinity.compileDatabaseSchema()` returns the instance's DDL for review or export.
39
39
 
@@ -62,7 +62,7 @@ The module and every `createPostgres` instance expose the shared `auth`, `valida
62
62
  MCP integration is an independent opt-in and does not add MCP dependencies to PostgreSQL applications:
63
63
 
64
64
  ```sh
65
- npm install @simtlix/simfinity-mcp@3.9.0 @modelcontextprotocol/sdk@^1.31.0
65
+ npm install @simtlix/simfinity-mcp@3.10.0 @modelcontextprotocol/sdk@^1.31.0
66
66
  ```
67
67
 
68
68
  ```javascript
@@ -79,6 +79,8 @@ Queries support nested scalar/reference paths, logical filter groups, scopes, so
79
79
 
80
80
  The text operators take literal values as bound parameters. `LIKE`, `STARTS_WITH` and `ENDS_WITH` compare code points, and `STARTS_WITH` can use a column's btree index. `ILIKE`, `ISTARTS_WITH`, `IENDS_WITH` and `IEQ` fold both sides with the Unicode simple case foldings that MongoDB's case-insensitive matching applies, through one expression, `translate(lower((x) COLLATE "pg_catalog"."und-x-icu"), from, to)`, where `translate()` maps the 22 lowercase variants that ICU `lower()` keeps, such as `ſ`, `µ` and the final sigma `ς`, to their usual letters; so each returns every row that its case-sensitive form returns, and `ISTARTS_WITH "ΚΑΣ"` finds `ΚΑΣΤΡΟ`. The first three match a `LIKE` pattern whose `%`, `_` and `\` are escaped. The two backends still differ on `İ`, which ICU lowers to `i` followed by U+0307, and on letters newer than one side's Unicode tables. The fold needs a `UTF8` database on a server built with ICU: on another, such as one in `LATIN1`, these four operators fail with `UNSUPPORTED_QUERY` (400), and every other operation works. Indexes serve them only on exactly the compiled expression, which the exported `caseFoldExpression(sqlExpression)` returns, such as `caseFoldExpression('"name"')`: a btree with `text_pattern_ops` on it serves `IEQ` and `ISTARTS_WITH`, and a `pg_trgm` GIN index with `gin_trgm_ops` serves `ILIKE`, `ISTARTS_WITH` and `IENDS_WITH`. The [queries guide](https://simtlix.github.io/simfinity.js/guide/queries.html#case-folding) gives the details and the index statements.
81
81
 
82
+ `IS_EMPTY` and `IS_NOT_EMPTY` take no value and test the queried record's own fields: a missing, null or `""` String, a missing or null value of any other scalar, a missing, null or `[]` list, and an embedded object that is missing, null or has every stored member empty. They compile from where the path is stored and bind no parameters: `(c IS NULL OR c = '')` for a String column, so that a btree serves it through a `BitmapOr`, `c IS NULL` for any other column, including the uuid identity of a record whose `id` is declared `String`, and `COALESCE(cardinality(c), 0) = 0` for a native array, also on owned rows; a jsonb member is read as text with `->>` and never cast, a jsonb list through a `jsonb_typeof` guard, and a jsonb object as SQL `NULL`, JSON `null` or all of its members empty; an owned single object is empty when its state is not `present` or no owned row has a member that is not empty, and an owned list when its state is not `present` or it has no row, null items included, with a subquery alias of its own at each level of owned storage. `IS_NOT_EMPTY` is the positive form of a leaf and the `NOT` of an object or owned form, so every record matches exactly one of the two. A path that continues inside an embedded list or a collection, such as `lines.qty` or `seasons.year`, fails with the permanent `INVALID_FILTER_PATH` (400), since emptiness of a value inside a list is not defined. In this release, any other path that crosses or ends at a reference, a path that ends at a collection, such as `seasons`, and an embedded object with a reference member fail with `UNSUPPORTED_QUERY` (400). The [queries guide](https://simtlix.github.io/simfinity.js/guide/queries.html) describes the rules.
83
+
82
84
  Errors that PostgreSQL reports become `SimfinityError`s without SQL details, with the driver error as `getCause()`; errors that application code throws propagate unchanged. Text cannot contain NUL and native date-times start at 4714-11-24 BC: such input fails with `INVALID_VALUE` (400).
83
85
 
84
86
  PostgreSQL enforces required values, real FKs, JSONB/owned embedded shapes, root and embedded scalar/reference uniqueness, and scalar-list multikey uniqueness. Root unique indexes use `NULLS NOT DISTINCT`; private typed owner-key tables allow repeated keys within one owner but reject the same key across owners. Presence markers preserve missing versus explicit null fields. This is stronger and broader than generated Mongoose storage.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-postgres",
3
- "version": "3.9.0",
3
+ "version": "3.10.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.9.0",
31
- "@simtlix/simfinity-sql": "3.9.0",
30
+ "@simtlix/simfinity-core": "3.10.0",
31
+ "@simtlix/simfinity-sql": "3.10.0",
32
32
  "pg": "^8.16.3"
33
33
  },
34
34
  "peerDependencies": {
package/src/index.js CHANGED
@@ -20,7 +20,7 @@ export {
20
20
  /** Each instance permanently binds one pool/schema; getModel exposes PostgreSQL native helpers. */
21
21
  export const createPostgres = (options) => createSQL({ plugin: postgresPlugin(options) });
22
22
  const defaultInstance = createPostgres();
23
- export const { configureQueryLimits, configureMutationLimits, configure, connect, addNoEndpointType, createSchema, getModel, getType, getInputType, getRegistrations, use, registerMutation, saveObject, preventCreatingCollection, withTransaction } = defaultInstance;
23
+ export const { configureQueryLimits, configureMutationLimits, configure, connect, addNoEndpointType, createSchema, getModel, getType, getInputType, getRegistrations, use, registerMutation, registerCustomQuery, saveObject, preventCreatingCollection, withTransaction } = defaultInstance;
24
24
 
25
25
  /** Preserve the foundation low-level API while allowing initialization of the configured default instance. */
26
26
  export const initializeDatabase = (poolOrOptions, description, options) => description
package/src/plugin.js CHANGED
@@ -22,7 +22,7 @@ export const postgresPlugin = (options) => ({
22
22
  // QLOperator value is rejected until the compiler supports it. The declaration names the compileQuery it describes,
23
23
  // so a copy of this plugin with another compileQuery compiles the operators of 3.7.0 until it declares its own.
24
24
  queryFeatures: {
25
- operators: ['EQ', 'NE', 'LT', 'LTE', 'GT', 'GTE', 'BTW', 'IN', 'NIN', 'LIKE', 'ILIKE', 'STARTS_WITH', 'ISTARTS_WITH', 'ENDS_WITH', 'IENDS_WITH', 'IEQ'],
25
+ operators: ['EQ', 'NE', 'LT', 'LTE', 'GT', 'GTE', 'BTW', 'IN', 'NIN', 'LIKE', 'ILIKE', 'STARTS_WITH', 'ISTARTS_WITH', 'ENDS_WITH', 'IENDS_WITH', 'IEQ', 'IS_EMPTY', 'IS_NOT_EMPTY'],
26
26
  compileQuery,
27
27
  },
28
28
  naming: { validateIdentifier: identifier, generatedName },
@@ -1,5 +1,7 @@
1
1
  import { resolveModelPath, SimfinityError } from '@simtlix/simfinity-core';
2
- import { textQueryOperator, validateTextFilterValue } from '@simtlix/simfinity-core/internal/query-operators';
2
+ import {
3
+ describeEmptiness, describeModelEmptiness, isEmptinessOperator, textQueryOperator, validateTextFilterValue,
4
+ } from '@simtlix/simfinity-core/internal/query-operators';
3
5
  import { identifier as q, literal, qualified } from '../schema/sql.js';
4
6
  import { encodeScalar, storageTypes, invalidValue } from '../codecs.js';
5
7
  import { caseFoldExpression as fold } from './case-fold.js';
@@ -88,6 +90,12 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
88
90
  }
89
91
  return joins.get(key).alias;
90
92
  };
93
+ // The owned table that stores `field` of the row `context`, or undefined when the row stores it; values inside
94
+ // jsonb never own tables.
95
+ const ownedOf = (context, field) => context.table && database.tables.find((item) => item.ownership?.ownerTable === context.table.name && item.ownership.field === field.name);
96
+ // The LEFT JOIN of an owned single object, one per path, which filters and the emptiness forms share. A missed
97
+ // join, and an object in another state, read as NULL.
98
+ const joinOwned = (key, context, owned) => join(key, owned.name, (name) => `${col(name, '__owner_id')} = ${col(context.alias, context.table.primaryKey.columns[0])} AND ${col(context.alias, owned.ownership.stateColumn)} = 'present'`);
91
99
  const arrayFrom = (vector, expression) => `ARRAY(SELECT ${expression} FROM ${vector.from} WHERE ${vector.where})`;
92
100
  const jsonItems = (expression, missing = '[]') => `jsonb_array_elements(CASE WHEN jsonb_typeof(${expression}) = 'array' THEN ${expression} ELSE '${missing}'::jsonb END)`;
93
101
  const helper = (name, ...args) => `${qualified(database.schema, `__simfinity_${name}`)}(${args.join(', ')})`;
@@ -146,8 +154,7 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
146
154
  const state = `${col(ownerAlias, owned.ownership.stateColumn)} = 'present'`;
147
155
  const presence = (name) => owned.ownership.nullableItems && usage !== 'sort' ? ` AND ${col(name, '__item_present')}` : '';
148
156
  if (!field.list && !context.vector) {
149
- const joined = join(key, owned.name, (name) => `${col(name, '__owner_id')} = ${ownerId} AND ${state}`);
150
- context = { table: owned, alias: joined, json: null, vector: null };
157
+ context = { table: owned, alias: joinOwned(key, context, owned), json: null, vector: null };
151
158
  } else {
152
159
  const name = alias();
153
160
  const stored = alias();
@@ -319,9 +326,84 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
319
326
  const left = ignoreCase ? uncollated : sql;
320
327
  return negated ? `COALESCE(${compare(left)}, FALSE)` : compare(left);
321
328
  };
322
- // A negation reaches every predicate below it, including those nested in AND/OR groups.
329
+ /**
330
+ * The IS_EMPTY form of `field`, stored on the row or jsonb value `context`, by its emptiness shape, or with `empty`
331
+ * false the IS_NOT_EMPTY form: the positive form of a leaf, which a btree can serve, and the NOT of an object or an
332
+ * owned form. Every form is two-valued and binds no parameters. A leaf is a column of any row, or the text of a
333
+ * jsonb member, never cast, since a cast can fail; a list a native array column, a jsonb list, or an owned list,
334
+ * whose null items are rows too, so `[null]` is not empty; an object a jsonb value or an owned single object whose
335
+ * stored members are all empty. An owned table is read in a subquery with an alias of its own, correlated to the
336
+ * key of the row that owns it: the root's id, or the `__id` of a joined or enclosing owned row.
337
+ */
338
+ const emptyForm = (context, field, shape, empty = true) => {
339
+ const compare = empty ? '=' : '>';
340
+ const negate = (form) => (empty ? form : `NOT ${form}`);
341
+ const leaf = (value) => {
342
+ if (shape.rule === 'string') return empty ? `(${value} IS NULL OR ${value} = '')` : `(${value} IS NOT NULL AND ${value} <> '')`;
343
+ return `${value} IS ${empty ? '' : 'NOT '}NULL`;
344
+ };
345
+ // The CASE keeps jsonb_array_length from failing (22023) on a value that is not an array.
346
+ const jsonList = (json) => `COALESCE(jsonb_array_length(CASE WHEN jsonb_typeof(${json}) = 'array' THEN ${json} END), 0) ${compare} 0`;
347
+ // An embedded type without stored members is always empty.
348
+ const members = (row) => {
349
+ const forms = shape.members.map((member) => {
350
+ const stored = field.fields?.find((item) => item.name === member.name);
351
+ if (!stored) unsupported('Invalid query path');
352
+ return emptyForm(row, stored, member.shape);
353
+ });
354
+ return forms.length ? `(${forms.join(' AND ')})` : 'TRUE';
355
+ };
356
+ const jsonObject = (json) => negate(`(${json} IS NULL OR ${json} = 'null'::jsonb OR ${members({ table: null, alias: null, json })})`);
357
+ if (context.json) {
358
+ const json = `(${context.json} -> ${literal(field.storageName)})`;
359
+ if (shape.rule === 'object') return jsonObject(json);
360
+ if (shape.rule === 'list') return jsonList(json);
361
+ return leaf(`(${context.json} ->> ${literal(field.storageName)})`);
362
+ }
363
+ const owned = ownedOf(context, field);
364
+ if (owned) {
365
+ // A missed join leaves the state NULL, which is not 'present'.
366
+ const state = `${col(context.alias, owned.ownership.stateColumn)} IS DISTINCT FROM 'present'`;
367
+ const name = alias();
368
+ const rows = `SELECT 1 FROM ${qualified(database.schema, owned.name)} ${name} WHERE ${col(name, '__owner_id')} = ${col(context.alias, context.table.primaryKey.columns[0])}`;
369
+ if (shape.rule === 'list') return negate(`(${state} OR NOT EXISTS (${rows}))`);
370
+ // An object marked present without a row, which the owned-shape check rejects, is empty too.
371
+ return negate(`(${state} OR NOT EXISTS (${rows} AND NOT ${members({ table: owned, alias: name, json: null })}))`);
372
+ }
373
+ // The record identity is the uuid primary key, whatever scalar it is declared with.
374
+ const identity = !context.table.ownership && ['id', '_id'].includes(field.name);
375
+ const column = col(context.alias, identity ? 'id' : field.storageName);
376
+ if (field.kind === 'embedded') return shape.rule === 'object' ? jsonObject(column) : jsonList(column);
377
+ if (shape.rule === 'list') return `COALESCE(cardinality(${column}), 0) ${compare} 0`;
378
+ return leaf(column);
379
+ };
380
+ /**
381
+ * IS_EMPTY and IS_NOT_EMPTY (#108, part 2a). Every emptiness predicate, leaves included, compiles here from where
382
+ * its path is stored, not from path(), whose values cast jsonb members and materialize jsonb lists, and which
383
+ * rejects paths that end at an embedded field. The checks of createQueryPlan run again, with its codes, on the
384
+ * entity's GraphQL type or on the field descriptions, so a plan built by hand gets the same answers; they leave
385
+ * only single embedded objects before the last field, reached through the LEFT JOIN of each owned single object.
386
+ */
387
+ const emptiness = ({ path: parts, operator, value }) => {
388
+ const steps = resolveModelPath(models, plan.entity, parts);
389
+ const options = { operator, value, path: parts.join('.') };
390
+ const gqltype = models.entities.find((item) => item.name === plan.entity)?.gqltype;
391
+ const shape = gqltype ? describeEmptiness(gqltype, parts, options) : describeModelEmptiness(steps, options);
392
+ let context = { table: root, alias: 't0', json: null };
393
+ steps.slice(0, -1).forEach((field, index) => {
394
+ if (field.kind !== 'embedded' || field.list) unsupported('Invalid query path');
395
+ const owned = ownedOf(context, field);
396
+ context = owned
397
+ ? { table: owned, alias: joinOwned(parts.slice(0, index + 1).join('.'), context, owned), json: null }
398
+ : { table: null, alias: null, json: context.json ? `(${context.json} -> ${literal(field.storageName)})` : col(context.alias, field.storageName) };
399
+ });
400
+ return emptyForm(context, steps.at(-1), shape, operator === 'IS_EMPTY');
401
+ };
402
+ // A negation reaches every predicate below it, including those nested in AND/OR groups; the emptiness forms are
403
+ // two-valued, so they need no null-safe form under it.
323
404
  const where = (node, negated = false) => {
324
405
  if (!node) return 'TRUE';
406
+ if (node.kind === 'predicate' && isEmptinessOperator(node.operator)) return `(${emptiness(node)})`;
325
407
  if (node.kind === 'predicate') return `(${predicate(path(node.path), node.operator, node.value, negated)})`;
326
408
  return `(${node.terms.map((term) => where(term, negated)).join(node.kind === 'and' ? ' AND ' : ' OR ')})`;
327
409
  };
package/types/index.d.ts CHANGED
@@ -17,6 +17,7 @@ export type {
17
17
  AuthRule,
18
18
  AuthRuleFunction,
19
19
  AuthSchemaPlugin,
20
+ CreateSchemaOptions,
20
21
  EntityController,
21
22
  EnvelopSchemaPlugin,
22
23
  FieldValidations,
@@ -235,6 +236,14 @@ export interface PostgresRuntime extends Runtime<PostgresModel, PostgresSession>
235
236
  * `QLOperator` value. The case-insensitive text operators fold case with {@link caseFoldExpression}, which needs a UTF8
236
237
  * database with the ICU collation `und-x-icu`: on another database, such as one in LATIN1 or on a server built without
237
238
  * ICU, they fail with UNSUPPORTED_QUERY (400), and every other operation works.
239
+ *
240
+ * `IS_EMPTY` and `IS_NOT_EMPTY` compile from where a path is stored, binding no parameters: `(c IS NULL OR c = '')`
241
+ * for a String column and `c IS NULL` for any other, the uuid identity included; `COALESCE(cardinality(c), 0) = 0`
242
+ * for a native array; jsonb members as text without a cast, and jsonb lists through a `jsonb_typeof` guard; an
243
+ * embedded object as missing, JSON `null`, or every stored member empty; and owned objects and lists through their
244
+ * state column and a `NOT EXISTS` subquery on the owned table, null list items counting as items. `IS_NOT_EMPTY` is
245
+ * the positive form of a leaf, which a btree can serve, and the `NOT` of an object or owned form. Declaring the two
246
+ * operators does not lift the UNSUPPORTED_QUERY (400) that emptiness through references and collections gets.
238
247
  */
239
248
  export function postgresPlugin(options?: PostgresConfiguration): SQLPlugin<
240
249
  PostgresConfiguration,
@@ -269,6 +278,7 @@ export const getInputType: PostgresRuntime['getInputType'];
269
278
  export const getRegistrations: PostgresRuntime['getRegistrations'];
270
279
  export const use: PostgresRuntime['use'];
271
280
  export const registerMutation: PostgresRuntime['registerMutation'];
281
+ export const registerCustomQuery: PostgresRuntime['registerCustomQuery'];
272
282
  export const saveObject: PostgresRuntime['saveObject'];
273
283
  export const preventCreatingCollection: PostgresRuntime['preventCreatingCollection'];
274
284
  export const withTransaction: PostgresRuntime['withTransaction'];