@simtlix/simfinity-postgres 3.12.0 → 3.14.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 +15 -3
- package/package.json +3 -3
- package/src/codecs.js +6 -1
- package/src/index.js +2 -0
- package/src/plugin.js +3 -2
- package/src/query/compiler.js +126 -41
- package/src/record-statements.js +7 -0
- package/src/schema/describe.js +1 -1
- package/src/schema/initialize.js +34 -3
- package/types/index.d.ts +16 -2
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.
|
|
34
|
+
Install `@simtlix/simfinity-sql@3.14.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, 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, named views, 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
|
|
|
@@ -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.
|
|
65
|
+
npm install @simtlix/simfinity-mcp@3.14.0 @modelcontextprotocol/sdk@^1.31.0
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
```javascript
|
|
@@ -79,7 +79,9 @@ 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.
|
|
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. An emptiness path that continues inside an embedded list, such as `lines.qty`, fails with `INVALID_FILTER_PATH` (400), since emptiness of an item inside that list is not defined. Through a collection, use an explicit frame and test the element's own field (for example `ANY seasons` with `year IS_EMPTY`); the flattened `seasons.year IS_EMPTY` shorthand is excluded. The [queries guide](https://simtlix.github.io/simfinity.js/guide/queries.html) describes the rules.
|
|
83
|
+
|
|
84
|
+
The plugin declares `queryFeatures.quantifiers: true`: collection filter paths compile to correlated `EXISTS` frames, so lists, counts and pagination return each matching parent once. Terms on the same collection prefix in one filter group must match the same child; separate groups may match different children. Explicit `ANY`, `NONE` and `ALL` groups and relation filters' `quantifier` also support single references. `NONE` and `ALL` are true when there are no visible elements; `ALL` uses `NOT EXISTS` over a null-safe failing body, so a null operand fails an ordinary ordering condition. A frame without conditions is equivalent to collection emptiness (`ANY` to `IS_NOT_EMPTY`, `NONE` to `IS_EMPTY`); `ALL` without conditions is literal `TRUE`, including inside `OR`. Client frames and references inside them apply the target's `find` scope; trusted frames read stored data. A reference reached through an embedded list keeps its vector join when the target scope adds nothing, including after a restricted single-reference prefix; a target scope that adds conditions there is forbidden. Aggregate filters on collection prefixes already joined by groupId or facts keep those joins and their multiplicity, and later collection frames correlate to the joined alias. Sort paths cannot cross collections in any mode. Under a top-level `AND`, a frame can probe the child's connection index as a semi-join or anti-join; under `OR`, PostgreSQL may run a correlated or hashed subplan, so place selective record conditions in the top-level `AND`. Direct `createQueryPlan()` calls default to these frames; pass the plugin's declared flags when supplying operators. A plugin copy that overrides `compileQuery` must declare its own quantifier support. See the [query guide](https://simtlix.github.io/simfinity.js/guide/queries.html) for grouping and scope semantics.
|
|
83
85
|
|
|
84
86
|
The plugin also declares `queryFeatures.referenceEmptiness`, so `IS_EMPTY` and `IS_NOT_EMPTY` cross single references and end at references and collections, such as `serie.name`, `serie` and `seasons`, and an embedded object's reference members count. A client's condition reads as missing every record that the target's `find` scope hides. The references it crosses share the plain `LEFT JOIN` of other conditions while the scopes crossed so far add nothing, as for an administrator or a target without a `find` scope; from the first scope that adds conditions on, each crossing is a `LEFT JOIN LATERAL (SELECT ... WHERE id = <reference> AND <scope> LIMIT 1)`, and later hops correlate to that join even when their own scope adds nothing. A reference end, and a reference member of an owned row, whose scope adds conditions is a `NOT EXISTS` subquery with the scope, `EXISTS` for `IS_NOT_EMPTY`, which probes the target's primary key; one whose scope adds nothing compiles to `"ref" IS NULL`, `IS NOT NULL` for `IS_NOT_EMPTY`, as a trusted one does, which the generated btree on the reference column serves. A collection end or member is always a `NOT EXISTS` subquery, `EXISTS` for `IS_NOT_EMPTY`, on the child's indexed connection column, with the scope when it adds conditions. So a client's condition whose scopes all add nothing compiles exactly as a trusted one. The target's `id` right after a reference is its uuid key, under the null rule, whatever scalar it is declared with. Scope values are bound parameters, and each subquery has aliases of its own, numbered across the statement. Conditions that middleware and scopes add read stored data: a reference is empty when its column is `NULL`, which a btree serves and which its foreign key, validated and not deferred, keeps exact, a collection when no child row points to the record, and a leaf through a reference reads the plain `LEFT JOIN` that other conditions on that path share. A transaction that defers the foreign keys with `SET CONSTRAINTS ... DEFERRED` can read a reference to a row it deleted as not empty in the `"ref" IS NULL` form, trusted or client, until it commits. A copy such as `{ ...postgresPlugin(options), compileQuery: myCompileQuery }` inherits a declaration that does not describe its compiler, so it gets `UNSUPPORTED_QUERY` (400) for these paths, as for the operators added after 3.7.0, until it declares its own.
|
|
85
87
|
|
|
@@ -93,6 +95,16 @@ Scalar-list leaves inside nested embedded lists support filters, sorts, ragged g
|
|
|
93
95
|
|
|
94
96
|
The following mappings remain unsupported and fail explicitly: implicit many-to-many reciprocal lists, non-embedded collections inside embedded objects, embedded cycles, nested list wrappers, whole embedded-object uniqueness, conflicting relationship metadata, enum values that collide after text conversion, and stored custom scalars without a recognized base scalar. Query paths cannot sort or group whole embedded objects. Arbitrary MongoDB pipelines and Mongoose-native methods are outside the PostgreSQL contract.
|
|
95
97
|
|
|
98
|
+
## Optimistic concurrency
|
|
99
|
+
|
|
100
|
+
Declare `extensions: { concurrency: { mode: 'optimistic' } }` on an entity before building its schema to receive the managed `version` field. PostgreSQL stores it as `bigint NOT NULL DEFAULT 1`; migrating an existing table requires an explicit migration, such as `ALTER TABLE "library"."Book" ADD COLUMN "version" bigint NOT NULL DEFAULT 1;`. Updates atomically match the ID and expected version and advance it, including version-only updates. Those updates fire the touch and refresh triggers of maintained roots. Stale writes fail with `VERSION_CONFLICT` (409); an update at `MAX_RECORD_VERSION` fails with `VERSION_EXHAUSTED` (409), while a delete at that version remains valid. Valid bigint values from `pg`, including a custom BigInt parser, are decoded to numbers; unsafe stored values remain invalid rather than being rounded into valid versions. `RecordVersion` and `MAX_RECORD_VERSION` are shared core exports.
|
|
101
|
+
|
|
102
|
+
Initialization in both `create` and `validate` modes rejects a missing or differently defined version column with `SCHEMA_MISMATCH`, explaining the required definition and, when absent, the qualified `ALTER TABLE ... ADD COLUMN` statement. An instance without the concurrency declaration also cannot initialize while its table retains the bigint version column: deploy the declaration on that instance, or stop every protected instance before dropping the column for a rollback. Run the adding migration immediately before the first protected instance starts; instances already running without the declaration can still write without advancing versions. Initialization never migrates the column automatically.
|
|
103
|
+
|
|
104
|
+
Protected native handles initialize `model.create(data)` at version 1. Use `model.update(id, { ...changes, version: record.version }, { session })` and `model.delete(id, { session, version: record.version })`. The update captures the version once before transaction retries without mutating your changes. Missing versions fail with `REQUIRED_VALUE` (400) and a message explaining the handle protocol. For a derived value that a hook maintains in the same transaction, `model.update(id, changes, { session, version: false })` writes without checking or advancing the version and ignores `changes.version`; delete also accepts `version: false`. Handles still bypass validators, hooks, scopes and authorization. Unprotected handles keep their existing behavior and ignore the option. Native SQL writers must follow the same conditional-write protocol to participate; writes that bypass it are not detected.
|
|
105
|
+
|
|
106
|
+
The plugin declares `optimisticConcurrency`. Copies overriding `compileRecord` must honor `operation.version` or delegate versioned updates and deletes to the original compiler. Versioned deletes use `RETURNING id`, including after the SQL runtime's locked read. Record-result checks report `DATABASE_ERROR` for inconsistent update versions or a missing delete result; they are defensive diagnostics and cannot prove that arbitrary overrides preserved the atomic condition.
|
|
107
|
+
|
|
96
108
|
## Low-level schema API
|
|
97
109
|
|
|
98
110
|
The foundation API remains available independently of a runtime:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simtlix/simfinity-postgres",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.14.0",
|
|
4
4
|
"description": "PostgreSQL GraphQL runtime and schema generation with foreign keys for Simfinity",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"directory": "packages/postgres"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@simtlix/simfinity-core": "3.
|
|
31
|
-
"@simtlix/simfinity-sql": "3.
|
|
30
|
+
"@simtlix/simfinity-core": "3.14.0",
|
|
31
|
+
"@simtlix/simfinity-sql": "3.14.0",
|
|
32
32
|
"pg": "^8.16.3"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
package/src/codecs.js
CHANGED
|
@@ -2,7 +2,7 @@ import { getNamedType, isEnumType } from 'graphql';
|
|
|
2
2
|
import { SimfinityError } from '@simtlix/simfinity-core';
|
|
3
3
|
import { isCaseFoldUnsupported } from './query/case-fold.js';
|
|
4
4
|
|
|
5
|
-
export const storageTypes = { ID: 'uuid', String: 'text', Enum: 'text', Int: 'integer', Float: 'double precision', Boolean: 'boolean', DateTime: 'timestamp with time zone' };
|
|
5
|
+
export const storageTypes = { ID: 'uuid', Version: 'bigint', String: 'text', Enum: 'text', Int: 'integer', Float: 'double precision', Boolean: 'boolean', DateTime: 'timestamp with time zone' };
|
|
6
6
|
export const invalidValue = (message) => { throw new SimfinityError(message, 'INVALID_FILTER_VALUE', 400); };
|
|
7
7
|
export const castId = (value) => {
|
|
8
8
|
const id = typeof value === 'object' && value ? value._id || value.id : value;
|
|
@@ -23,6 +23,7 @@ export const encodeScalar = (field, value) => {
|
|
|
23
23
|
}
|
|
24
24
|
case 'String': if (typeof value !== 'string') invalidValue(`Expected a string for ${field.name}`); break;
|
|
25
25
|
case 'Boolean': if (typeof value !== 'boolean') invalidValue(`Expected a boolean for ${field.name}`); break;
|
|
26
|
+
case 'Version': if (!Number.isSafeInteger(value) || value < 1) invalidValue(`Expected a safe positive integer for ${field.name}`); break;
|
|
26
27
|
case 'Int': if (!Number.isInteger(value) || value < -2147483648 || value > 2147483647) invalidValue(`Expected a 32-bit integer for ${field.name}`); break;
|
|
27
28
|
case 'Float': if (typeof value !== 'number' || !Number.isFinite(value)) invalidValue(`Expected a finite number for ${field.name}`); break;
|
|
28
29
|
case 'DateTime': {
|
|
@@ -37,6 +38,10 @@ export const encodeScalar = (field, value) => {
|
|
|
37
38
|
|
|
38
39
|
export const decodeScalar = (field, value, gqlField) => {
|
|
39
40
|
if (value == null) return value;
|
|
41
|
+
if (field.scalar === 'Version' && ['string', 'number', 'bigint'].includes(typeof value)) {
|
|
42
|
+
const version = Number(value);
|
|
43
|
+
return Number.isSafeInteger(version) && version >= 1 ? version : value;
|
|
44
|
+
}
|
|
40
45
|
if (field.scalar === 'Enum' && gqlField) {
|
|
41
46
|
const type = getNamedType(gqlField.type);
|
|
42
47
|
if (isEnumType(type)) return type.getValues().find((item) => String(item.value) === value)?.value ?? value;
|
package/src/index.js
CHANGED
package/src/plugin.js
CHANGED
|
@@ -17,15 +17,16 @@ export const postgresPlugin = (options) => ({
|
|
|
17
17
|
options: options || null,
|
|
18
18
|
// singleStatementReads: one statement reads one snapshot at any isolation level, so a read without owned
|
|
19
19
|
// tables needs no repeatable-read transaction.
|
|
20
|
-
capabilities: ['transactions', 'foreignKeys', 'deferredForeignKeys', 'embeddedValues', 'ownedRecords', 'scalarLists', 'uniqueValues', 'nullableUnique', 'singleStatementReads'],
|
|
20
|
+
capabilities: ['transactions', 'optimisticConcurrency', 'foreignKeys', 'deferredForeignKeys', 'embeddedValues', 'ownedRecords', 'scalarLists', 'uniqueValues', 'nullableUnique', 'singleStatementReads'],
|
|
21
21
|
// Every filter operator compileQuery and compileChildrenQuery compile, listed one by one so that a later
|
|
22
22
|
// QLOperator value is rejected until the compiler supports it, and emptiness through references and collections
|
|
23
23
|
// (#108 part 2b): the scoped joins and frames of client constructs, and stored semantics for the others. The
|
|
24
24
|
// declaration names the compileQuery it describes, so a copy of this plugin with another compileQuery compiles the
|
|
25
|
-
// operators of 3.7.0, without reference emptiness, until it declares its own.
|
|
25
|
+
// operators of 3.7.0, without reference emptiness or quantifiers, until it declares its own.
|
|
26
26
|
queryFeatures: {
|
|
27
27
|
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'],
|
|
28
28
|
referenceEmptiness: true,
|
|
29
|
+
quantifiers: true,
|
|
29
30
|
compileQuery,
|
|
30
31
|
},
|
|
31
32
|
naming: { validateIdentifier: identifier, generatedName },
|
package/src/query/compiler.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { resolveModelPath, SimfinityError } from '@simtlix/simfinity-core';
|
|
1
|
+
import { QLQuantifier, resolveModelPath, SimfinityError } from '@simtlix/simfinity-core';
|
|
2
2
|
import {
|
|
3
|
-
describeEmptiness, describeModelEmptiness, emptinessScopePaths, isEmptinessOperator, textQueryOperator, validateTextFilterValue,
|
|
3
|
+
crossingKeys, describeEmptiness, describeModelEmptiness, emptinessScopePaths, isEmptinessOperator, textQueryOperator, validateQuantifiedGroups, validateTextFilterValue,
|
|
4
4
|
} from '@simtlix/simfinity-core/internal/query-operators';
|
|
5
5
|
import { identifier as q, literal, qualified } from '../schema/sql.js';
|
|
6
6
|
import { encodeScalar, storageTypes, invalidValue } from '../codecs.js';
|
|
@@ -13,9 +13,12 @@ const detached = (message) => {
|
|
|
13
13
|
throw new SimfinityError(`${message}; only createQueryPlan writes the scopes of a predicate, from the client scope markers the runtime fills (docs/guide/middleware.md)`, 'DETACHED_CLIENT_FILTER', 500);
|
|
14
14
|
};
|
|
15
15
|
// The compiler compiles emptiness through single references and at references and collections (#108 part 2b), which
|
|
16
|
-
// the PostgreSQL plugin declares as queryFeatures.referenceEmptiness.
|
|
16
|
+
// the PostgreSQL plugin declares as queryFeatures.referenceEmptiness. Its quantifiers declaration additionally
|
|
17
|
+
// enables correlated frames with element-relative conditions.
|
|
17
18
|
const REFERENCE_EMPTINESS = true;
|
|
18
|
-
const SCOPE_KINDS = ['predicate', 'and', 'or'];
|
|
19
|
+
const SCOPE_KINDS = ['predicate', 'and', 'or', 'quantified'];
|
|
20
|
+
// Entity identities use UUID operands even when GraphQL exposes id as String; embedded ids keep their scalar.
|
|
21
|
+
const operandField = (field, identity) => (identity ? { ...field, scalar: 'ID' } : field);
|
|
19
22
|
|
|
20
23
|
// The case-insensitive text operators compare the case fold of both sides (see case-fold.js), so an application
|
|
21
24
|
// index serves them only on that expression, which caseFoldExpression writes.
|
|
@@ -127,6 +130,12 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
|
|
|
127
130
|
return joins.get(key).alias;
|
|
128
131
|
};
|
|
129
132
|
const joinsOf = (frame) => [...frame.joins.values()].map((item) => item.sql).join(' ');
|
|
133
|
+
// One correlated truth value, even when its joins produce several witnesses for the containing row.
|
|
134
|
+
// The constant FROM row lets every join retain its original correlation to frame.alias outside this SELECT.
|
|
135
|
+
const witness = (frame, condition) => {
|
|
136
|
+
const joins = joinsOf(frame);
|
|
137
|
+
return `EXISTS (SELECT 1 FROM (SELECT 1) simfinity_witness${joins ? ` ${joins}` : ''} WHERE ${condition})`;
|
|
138
|
+
};
|
|
130
139
|
// The owned table that stores `field` of the row `context`, or undefined when the row stores it; values inside
|
|
131
140
|
// jsonb never own tables.
|
|
132
141
|
const ownedOf = (context, field) => context.table && database.tables.find((item) => item.ownership?.ownerTable === context.table.name && item.ownership.field === field.name);
|
|
@@ -170,13 +179,22 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
|
|
|
170
179
|
const child = project({ json: `${name}.value` }, rest);
|
|
171
180
|
return `CASE WHEN jsonb_typeof(${value}) = 'array' THEN (SELECT COALESCE(jsonb_agg(${child} ORDER BY ${name}.ordinality) FILTER (WHERE ${child} IS NOT NULL), '[]'::jsonb) FROM ${jsonItems(value)} WITH ORDINALITY ${name}(value, ordinality)) END`;
|
|
172
181
|
};
|
|
173
|
-
const
|
|
182
|
+
const sortPath = (parts, frame = rootFrame) => {
|
|
174
183
|
const steps = resolveModelPath(models, frame.entity, parts);
|
|
175
|
-
|
|
176
|
-
|
|
184
|
+
const index = steps.findIndex((field) => field.kind === 'collection');
|
|
185
|
+
if (index >= 0) throw new SimfinityError(`Sort path ${parts.join('.')} crosses the collection ${index ? steps[index - 1].target : frame.entity}.${steps[index].name}; a record has no single value there`, 'INVALID_FILTER_PATH', 400);
|
|
186
|
+
return steps;
|
|
187
|
+
};
|
|
188
|
+
const path = (parts, usage = 'filter', frame = rootFrame, joins = null) => {
|
|
189
|
+
const steps = usage === 'sort' ? sortPath(parts, frame) : resolveModelPath(models, frame.entity, parts);
|
|
190
|
+
let context = joins && usage !== 'cross' ? cross(frame, parts.slice(0, -1), joins) : { table: frame.table, alias: frame.alias, json: null, vector: null };
|
|
191
|
+
const chain = [];
|
|
192
|
+
let scoped = false;
|
|
193
|
+
const chainKey = (key) => `scoped ${key} ${scopeIdentity(chain, identities)}`;
|
|
194
|
+
for (let index = joins && usage !== 'cross' ? steps.length - 1 : 0; index < steps.length; index++) {
|
|
177
195
|
const field = steps[index];
|
|
178
196
|
const key = parts.slice(0, index + 1).join('.');
|
|
179
|
-
const last = index === steps.length - 1;
|
|
197
|
+
const last = usage !== 'cross' && index === steps.length - 1;
|
|
180
198
|
if (usage === 'aggregate' && steps.slice(index).some((item) => item.list) && !steps.slice(index).some((item, offset, remaining) => item.target && offset < remaining.length - 1)) {
|
|
181
199
|
const leaf = steps.at(-1);
|
|
182
200
|
if (leaf.kind !== 'scalar') unsupported('Whole embedded objects cannot be sorted or aggregated');
|
|
@@ -191,7 +209,7 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
|
|
|
191
209
|
const state = `${col(ownerAlias, owned.ownership.stateColumn)} = 'present'`;
|
|
192
210
|
const presence = (name) => owned.ownership.nullableItems && usage !== 'sort' ? ` AND ${col(name, '__item_present')}` : '';
|
|
193
211
|
if (!field.list && !context.vector) {
|
|
194
|
-
context = { table: owned, alias: joinOwned(frame, key, context, owned), json: null, vector: null };
|
|
212
|
+
context = { table: owned, alias: joinOwned(frame, scoped ? chainKey(key) : key, context, owned), json: null, vector: null };
|
|
195
213
|
} else {
|
|
196
214
|
const name = alias();
|
|
197
215
|
const stored = alias();
|
|
@@ -223,13 +241,23 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
|
|
|
223
241
|
} else if (field.target && !last) {
|
|
224
242
|
if (context.json) unsupported('Unnormalized embedded reference');
|
|
225
243
|
const source = context;
|
|
226
|
-
const
|
|
244
|
+
const scope = field.kind === 'reference' ? scopeAt(joins, key) : null;
|
|
245
|
+
if (field.kind === 'reference') chain.push(scope);
|
|
246
|
+
scoped ||= scope !== null;
|
|
247
|
+
if (source.vector && scope !== null) throw new SimfinityError('A reference inside an embedded list cannot apply a target scope', 'FORBIDDEN_FILTER_PATH', 403);
|
|
248
|
+
if (source.vector) frame.multiplying = true;
|
|
249
|
+
const correlation = (name) => {
|
|
227
250
|
if (field.kind === 'collection') return `${col(name, field.connectionField)} = ${col(source.alias, source.table.primaryKey.columns[0])}`;
|
|
228
251
|
const reference = col(source.alias, field.storageName);
|
|
229
252
|
return source.vector
|
|
230
253
|
? `${col(name, 'id')} = ANY (${arrayFrom(source.vector, reference)})`
|
|
231
254
|
: `${col(name, 'id')} = ${reference}`;
|
|
232
|
-
}
|
|
255
|
+
};
|
|
256
|
+
// A vector with an empty target scope keeps every referenced row. Its restricted base and
|
|
257
|
+
// chain-isolated key still prevent sharing with trusted joins; LIMIT 1 would lose targets.
|
|
258
|
+
const joined = scoped && !source.vector
|
|
259
|
+
? scopedJoin(frame, chainKey(key), field, context, scope, correlation)
|
|
260
|
+
: join(frame, scoped ? chainKey(key) : key, field.target, correlation);
|
|
233
261
|
context = { table: tableByName(field.target), alias: joined, json: null, vector: null };
|
|
234
262
|
} else {
|
|
235
263
|
if (!last || (field.kind !== 'scalar' && !field.inferred)) unsupported('A query path must end at a scalar field');
|
|
@@ -267,9 +295,10 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
|
|
|
267
295
|
// embedded paths can be NULL even for NOT NULL columns, so they keep the clause.
|
|
268
296
|
const notNull = !context.json && !context.vector && !field.list && context.alias === frame.alias
|
|
269
297
|
&& context.table.columns.find((item) => item.name === (identity ? 'id' : field.storageName))?.nullable === false;
|
|
270
|
-
return { sql, uncollated, field:
|
|
298
|
+
return { sql, uncollated, field: operandField(field, identity), type: dateNumeric ? 'double precision' : type, list: field.list || !!context.vector, vector: !!context.vector, dateNumeric, sortKey, notNull };
|
|
271
299
|
}
|
|
272
300
|
}
|
|
301
|
+
if (usage === 'cross') return context;
|
|
273
302
|
unsupported('Invalid query path');
|
|
274
303
|
};
|
|
275
304
|
/**
|
|
@@ -367,16 +396,17 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
|
|
|
367
396
|
* A subquery on the entity `entity` in a frame of its own (#108 part 2b), as `{ alias, from }`, `from` being its
|
|
368
397
|
* text from FROM to the end of its WHERE. `correlation(alias)` ties its rows to the enclosing row, and `scope`, a
|
|
369
398
|
* predicate planned on the entity, or null, restricts them. The frame's alias comes first, then the scope's joins
|
|
370
|
-
* and conditions,
|
|
371
|
-
* it reads stored data
|
|
399
|
+
* and conditions, followed by the optional body, compile before the text is assembled so that FROM holds their
|
|
400
|
+
* joins. A scope is trusted: it reads stored data in its own frame, with plain reference joins and collection frames.
|
|
372
401
|
*/
|
|
373
|
-
const subquery = (entity, correlation, scope) => {
|
|
402
|
+
const subquery = (entity, correlation, scope, body) => {
|
|
374
403
|
const table = tableByName(entity);
|
|
375
404
|
if (!table) unsupported(`Unknown persistent entity ${entity}`);
|
|
376
405
|
const frame = { entity, table, alias: alias(), joins: new Map() };
|
|
377
406
|
const restriction = scope ? ` AND ${where(scope, frame)}` : '';
|
|
407
|
+
const condition = body ? ` AND ${body(frame)}` : '';
|
|
378
408
|
const joins = joinsOf(frame);
|
|
379
|
-
return { alias: frame.alias, from: `FROM ${qualified(database.schema, table.name)} ${frame.alias}${joins ? ` ${joins}` : ''} WHERE ${correlation(frame.alias)}${restriction}` };
|
|
409
|
+
return { alias: frame.alias, from: `FROM ${qualified(database.schema, table.name)} ${frame.alias}${joins ? ` ${joins}` : ''} WHERE ${correlation(frame.alias)}${restriction}${condition}` };
|
|
380
410
|
};
|
|
381
411
|
/**
|
|
382
412
|
* The scoped join of a client construct's crossing (#108 part 2b): the record that the reference `field` of the row
|
|
@@ -384,10 +414,10 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
|
|
|
384
414
|
* keeps one row whatever joins the scope makes. Its key, apart from those of plain joins, names the path and the
|
|
385
415
|
* scopes of every reference crossed up to this one, so only constructs with the same chain share it.
|
|
386
416
|
*/
|
|
387
|
-
const scopedJoin = (frame, key, field, context, scope) => {
|
|
417
|
+
const scopedJoin = (frame, key, field, context, scope, correlation = (row) => `${col(row, 'id')} = ${col(context.alias, field.storageName)}`) => {
|
|
388
418
|
if (!frame.joins.has(key)) {
|
|
389
419
|
const name = alias();
|
|
390
|
-
const target = subquery(field.target,
|
|
420
|
+
const target = subquery(field.target, correlation, scope);
|
|
391
421
|
frame.joins.set(key, { alias: name, sql: `LEFT JOIN LATERAL (SELECT ${target.alias}.* ${target.from} LIMIT 1) ${name} ON TRUE` });
|
|
392
422
|
}
|
|
393
423
|
return frame.joins.get(key).alias;
|
|
@@ -411,6 +441,10 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
|
|
|
411
441
|
}
|
|
412
442
|
return scopes;
|
|
413
443
|
};
|
|
444
|
+
// Reuse legacy vector/owned traversal while applying the validated scoped chain in this frame.
|
|
445
|
+
const cross = (frame, parts, joins) => (parts.length
|
|
446
|
+
? path(parts, 'cross', frame, joins)
|
|
447
|
+
: { table: frame.table, alias: frame.alias, json: null, vector: null });
|
|
414
448
|
/**
|
|
415
449
|
* NONE over a reference or a collection (#108 part 2b), the IS_EMPTY form, or with `empty` false the IS_NOT_EMPTY
|
|
416
450
|
* form, EXISTS: whether a record that `scope` admits is the one the reference `field` of the row `context` points
|
|
@@ -499,42 +533,93 @@ const compileSelect = (models, database, plan, extra, configuration, batched) =>
|
|
|
499
533
|
const gqltype = models.entities.find((item) => item.name === frame.entity)?.gqltype;
|
|
500
534
|
const shape = gqltype ? describeEmptiness(gqltype, parts, options) : describeModelEmptiness(steps, options);
|
|
501
535
|
const scopes = scopesOf(node, shape, `${operator} on ${label}`);
|
|
502
|
-
|
|
503
|
-
// The scopes of the references crossed so far; once one of them is not null, the chain is scoped.
|
|
504
|
-
const chain = [];
|
|
505
|
-
let scoped = false;
|
|
506
|
-
const chainKey = (key) => `scoped ${key} ${scopeIdentity(chain, identities)}`;
|
|
507
|
-
steps.slice(0, -1).forEach((field, index) => {
|
|
508
|
-
const key = parts.slice(0, index + 1).join('.');
|
|
509
|
-
if (field.kind === 'embedded' && !field.list) {
|
|
510
|
-
const owned = ownedOf(context, field);
|
|
511
|
-
context = owned
|
|
512
|
-
? { table: owned, alias: joinOwned(frame, scoped ? chainKey(key) : key, context, owned), json: null }
|
|
513
|
-
: { table: null, alias: null, json: context.json ? `(${context.json} -> ${literal(field.storageName)})` : col(context.alias, field.storageName) };
|
|
514
|
-
} else if (field.kind === 'reference' && !field.inferred && context.table) {
|
|
515
|
-
const scope = scopeAt(scopes, key);
|
|
516
|
-
chain.push(scope);
|
|
517
|
-
scoped ||= scope !== null;
|
|
518
|
-
const joined = scoped
|
|
519
|
-
? scopedJoin(frame, chainKey(key), field, context, scope)
|
|
520
|
-
: join(frame, key, field.target, (name) => `${col(name, 'id')} = ${col(context.alias, field.storageName)}`);
|
|
521
|
-
context = { table: tableByName(field.target), alias: joined, json: null };
|
|
522
|
-
} else unsupported('Invalid query path');
|
|
523
|
-
});
|
|
536
|
+
const context = cross(frame, parts.slice(0, -1), scopes);
|
|
524
537
|
return emptyForm(context, steps.at(-1), shape, operator === 'IS_EMPTY', { path: label, scopes });
|
|
525
538
|
};
|
|
539
|
+
const queryScope = (scope) => scope === null || (scope && typeof scope === 'object' && !Array.isArray(scope) && SCOPE_KINDS.includes(scope.kind));
|
|
540
|
+
const inspectJoins = (node, frame) => {
|
|
541
|
+
if (!Object.hasOwn(node, 'joins')) return { joins: null };
|
|
542
|
+
if (node.anchor) return { reason: 'A quantified anchor has joins' };
|
|
543
|
+
const { joins } = node;
|
|
544
|
+
if (!joins || typeof joins !== 'object' || Array.isArray(joins) || ![Object.prototype, null].includes(Object.getPrototypeOf(joins))) return { reason: 'A predicate has joins that are not a plain object' };
|
|
545
|
+
for (const key of crossingKeys(node.path, { models, entity: frame.entity })) {
|
|
546
|
+
if (!Object.hasOwn(joins, key) || !queryScope(joins[key])) return { reason: `A predicate has no valid join scope for ${key}` };
|
|
547
|
+
}
|
|
548
|
+
return { joins };
|
|
549
|
+
};
|
|
550
|
+
const joinsOfNode = (node, frame) => {
|
|
551
|
+
const result = inspectJoins(node, frame);
|
|
552
|
+
if (result.reason) detached(result.reason);
|
|
553
|
+
return result.joins;
|
|
554
|
+
};
|
|
555
|
+
const quantified = (node, frame) => {
|
|
556
|
+
const parts = node.path;
|
|
557
|
+
// Resolve before crossing so malformed client metadata cannot fall back to trusted joins.
|
|
558
|
+
const targetEntity = node.anchor ? resolveModelPath(models, frame.entity, node.anchor).at(-1).target : frame.entity;
|
|
559
|
+
const steps = resolveModelPath(models, targetEntity, parts);
|
|
560
|
+
const field = steps.at(-1);
|
|
561
|
+
if (field?.kind === 'embedded' && field.list) unsupported('Quantified groups over embedded lists are not supported by this query compiler');
|
|
562
|
+
if (!field || field.inferred || !['reference', 'collection'].includes(field.kind) || field.list && field.kind !== 'collection') {
|
|
563
|
+
throw new SimfinityError('A quantified path must end at a reference or collection', 'INVALID_FILTER_PATH', 400);
|
|
564
|
+
}
|
|
565
|
+
if (!QLQuantifier.getValue(node.quantifier)) throw new SimfinityError('Unknown quantifier', 'INVALID_FILTER_VALUE', 400);
|
|
566
|
+
if (node.anchor && parts.length !== 1) detached('An anchored quantified path must name the collection or reference alone');
|
|
567
|
+
const joins = joinsOfNode(node, frame);
|
|
568
|
+
if (Object.hasOwn(node, 'scope') && !queryScope(node.scope)) detached('A quantified predicate has an invalid scope');
|
|
569
|
+
if (node.quantifier === 'ALL' && node.where === null) return 'TRUE';
|
|
570
|
+
// An implicit vector prefix belongs to this predicate, never the containing record's outer row stream.
|
|
571
|
+
// Anchors deliberately retain the legacy joins/multiplicity of aggregate groupId and facts.
|
|
572
|
+
const isolatedPrefix = !node.anchor && steps.slice(0, -1).some((step) => step.list);
|
|
573
|
+
const prefix = isolatedPrefix ? { ...frame, joins: new Map(), multiplying: false } : frame;
|
|
574
|
+
const context = node.anchor ? cross(frame, node.anchor, null) : cross(prefix, parts.slice(0, -1), joins);
|
|
575
|
+
if (!context.table) unsupported('Unnormalized embedded reference');
|
|
576
|
+
const correlation = field.kind === 'reference'
|
|
577
|
+
? (row) => `${col(row, 'id')} = ${col(context.alias, field.storageName)}`
|
|
578
|
+
: (row) => `${col(row, field.connectionField)} = ${col(context.alias, context.table.primaryKey.columns[0])}`;
|
|
579
|
+
const body = node.where === null ? undefined : (child) => {
|
|
580
|
+
// A scope admits the child independently of the body's vector witness. Otherwise retain shared
|
|
581
|
+
// nonmultiplying joins and their aliases; complete compounds within each body still share one map.
|
|
582
|
+
const scopedVector = child.multiplying === true;
|
|
583
|
+
const positiveFrame = { ...child, joins: new Map(scopedVector ? [] : child.joins), multiplying: false };
|
|
584
|
+
const positive = where(node.where, positiveFrame, node.quantifier === 'ALL');
|
|
585
|
+
if (scopedVector || (node.quantifier === 'ALL' && positiveFrame.multiplying)) {
|
|
586
|
+
const complete = witness(positiveFrame, positive);
|
|
587
|
+
return node.quantifier === 'ALL' ? `NOT ${complete}` : complete;
|
|
588
|
+
}
|
|
589
|
+
child.joins = positiveFrame.joins;
|
|
590
|
+
return node.quantifier === 'ALL' ? `NOT ${positive}` : positive;
|
|
591
|
+
};
|
|
592
|
+
const candidate = `EXISTS (SELECT 1 ${subquery(field.target, correlation, node.scope, body).from})`;
|
|
593
|
+
return `(${node.quantifier === 'ANY' ? '' : 'NOT '}${isolatedPrefix ? witness(prefix, candidate) : candidate})`;
|
|
594
|
+
};
|
|
526
595
|
// A negation reaches every predicate below it, including those nested in AND/OR groups; the emptiness forms are
|
|
527
596
|
// two-valued, so they need no null-safe form under it. A frame's scope is never negated.
|
|
528
597
|
const where = (node, frame = rootFrame, negated = false) => {
|
|
529
598
|
if (!node) return 'TRUE';
|
|
599
|
+
if (node.kind === 'quantified') return quantified(node, frame);
|
|
530
600
|
if (node.kind === 'predicate' && isEmptinessOperator(node.operator)) return `(${emptiness(node, frame)})`;
|
|
531
601
|
if (node.kind === 'predicate') {
|
|
532
|
-
|
|
602
|
+
// Check ordinary paths and values before the new client metadata, without creating joins or aliases.
|
|
603
|
+
if (Object.hasOwn(node, 'joins')) validateQuantifiedGroups({ AND: [{ conditions: [{ field: node.path.join('.'), operator: node.operator, value: node.value }] }] }, { models, entity: frame.entity, referenceEmptiness: true, quantifiers: true });
|
|
604
|
+
const checked = inspectJoins(node, frame);
|
|
605
|
+
if (checked.reason) {
|
|
606
|
+
// Only this already-rejected request needs a non-crossing native-value checkpoint. Accepted
|
|
607
|
+
// requests retain path/scope compilation before encoding, including its bind order and single coercion.
|
|
608
|
+
const steps = resolveModelPath(models, frame.entity, node.path);
|
|
609
|
+
const leaf = steps.at(-1);
|
|
610
|
+
const identity = ['id', '_id'].includes(leaf.name) && (steps.length === 1 ? !frame.table.ownership : steps.at(-2).kind !== 'embedded');
|
|
611
|
+
const field = operandField(leaf, identity);
|
|
612
|
+
predicate({ field, type: storageTypes[field.scalar], list: field.list, sql: '', uncollated: '' }, node.operator, node.value, negated);
|
|
613
|
+
detached(checked.reason);
|
|
614
|
+
}
|
|
615
|
+
const sql = predicate(path(node.path, 'filter', frame, checked.joins), node.operator, node.value, negated);
|
|
533
616
|
if (Object.hasOwn(node, 'scopes') && node.scopes !== undefined) detached(`${node.operator} on ${[].concat(node.path).join('.')} has scopes, but is not an emptiness condition`);
|
|
534
617
|
return `(${sql})`;
|
|
535
618
|
}
|
|
536
619
|
return `(${node.terms.map((term) => where(term, frame, negated)).join(node.kind === 'and' ? ' AND ' : ' OR ')})`;
|
|
537
620
|
};
|
|
621
|
+
// Count ignores valid ordering, but still rejects a collection sort without adding joins to its statement.
|
|
622
|
+
if (plan.mode === 'count') for (const term of plan.sort) sortPath(term.path);
|
|
538
623
|
let condition = where(plan.where);
|
|
539
624
|
if (extra) {
|
|
540
625
|
const column = root.columns.find((item) => item.name === extra.column);
|
package/src/record-statements.js
CHANGED
|
@@ -7,6 +7,7 @@ export const compileRecord = (database, operation) => {
|
|
|
7
7
|
const target = qualified(database.schema, storage.name);
|
|
8
8
|
if (kind === 'selectById') return { text: `SELECT * FROM ${target} WHERE id = $1::uuid${operation.lock ? ' FOR UPDATE' : ''}`, values: [id] };
|
|
9
9
|
if (kind === 'selectOwned') return { text: `SELECT * FROM ${target} WHERE "__owner_id" = ANY ($1::uuid[])${operation.ordered ? ' ORDER BY "__position"' : ''}`, values: [operation.ids] };
|
|
10
|
+
if (kind === 'deleteById' && operation.version) return { text: `DELETE FROM ${target} WHERE id = $1::uuid AND ${q(operation.version.column)} = $2::bigint RETURNING id`, values: [id, operation.version.expected] };
|
|
10
11
|
if (kind === 'deleteById') return { text: `DELETE FROM ${target} WHERE id = $1::uuid`, values: [id] };
|
|
11
12
|
if (kind === 'deleteOwned') return { text: `DELETE FROM ${target} WHERE "__owner_id" = $1::uuid`, values: [operation.ownerId] };
|
|
12
13
|
if (kind === 'insert') {
|
|
@@ -16,6 +17,12 @@ export const compileRecord = (database, operation) => {
|
|
|
16
17
|
}
|
|
17
18
|
if (kind === 'update') {
|
|
18
19
|
const entries = Object.entries(data);
|
|
20
|
+
if (operation.version) {
|
|
21
|
+
const column = q(operation.version.column);
|
|
22
|
+
const assignments = entries.map(([key], index) => `${q(key)} = $${index + 2}::${storage.columns.find((column) => column.name === key).type}`);
|
|
23
|
+
assignments.push(`${column} = ${column} + 1`);
|
|
24
|
+
return { text: `UPDATE ${target} SET ${assignments.join(', ')} WHERE id = $1::uuid AND ${column} = $${entries.length + 2}::bigint RETURNING *`, values: [id, ...entries.map(([, value]) => value), operation.version.expected] };
|
|
25
|
+
}
|
|
19
26
|
return { text: `UPDATE ${target} SET ${entries.map(([key], index) => `${q(key)} = $${index + 2}::${storage.columns.find((column) => column.name === key).type}`).join(', ')} WHERE id = $1::uuid RETURNING *`, values: [id, ...entries.map(([, value]) => value)] };
|
|
20
27
|
}
|
|
21
28
|
throw new SimfinityError(`Unknown record operation ${kind}`, 'INVALID_RECORD_OPERATION', 400);
|
package/src/schema/describe.js
CHANGED
|
@@ -4,7 +4,7 @@ import { queryFunctions } from '../query/functions.js';
|
|
|
4
4
|
import { constraintBuilder, withLayouts } from './constraints.js';
|
|
5
5
|
import { identifier, literal, generatedName, invalid } from './sql.js';
|
|
6
6
|
|
|
7
|
-
const scalarTypes = { ID: 'uuid', String: 'text', Enum: 'text', Int: 'integer', Float: 'double precision', Boolean: 'boolean', DateTime: 'timestamp with time zone', Embedded: 'jsonb' };
|
|
7
|
+
const scalarTypes = { ID: 'uuid', Version: 'bigint', 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
10
|
// The first value is the default, which keeps the 3.5.9 description.
|
package/src/schema/initialize.js
CHANGED
|
@@ -560,14 +560,45 @@ const virtualLeftovers = (description, catalog, virtualFields) => {
|
|
|
560
560
|
return `${series(fields)} ${many ? 'are' : 'is'} virtual (extensions.virtual), so ${many ? 'they are' : 'it is'} never stored, but this database still holds ${many ? 'their' : 'its'} `
|
|
561
561
|
+ `earlier storage. Once nothing reads that storage, migrate it: ${statements.join(' ')} See the upgrade notes at ${UPGRADE_NOTES}`;
|
|
562
562
|
};
|
|
563
|
-
//
|
|
563
|
+
// Explain version-column drift from catalog contents, including when equal column counts hide a missing
|
|
564
|
+
// or leftover version in the original mismatch. Private owned and auxiliary tables have no record version.
|
|
565
|
+
const versionColumns = (description, catalog) => {
|
|
566
|
+
const explanations = [];
|
|
567
|
+
for (const table of description.tables) {
|
|
568
|
+
if (table.ownership || table.auxiliary) continue;
|
|
569
|
+
const actual = catalog.tables.get(table.name);
|
|
570
|
+
if (!actual) continue;
|
|
571
|
+
const column = table.columns.find((item) => item.name === 'version');
|
|
572
|
+
const stored = actual.columns.find((item) => item.name === 'version');
|
|
573
|
+
const target = qualified(description.schema, table.name);
|
|
574
|
+
if (column?.type === 'bigint') {
|
|
575
|
+
const declaration = `${table.name} declares optimistic concurrency (extensions.concurrency), so its table needs the version column`;
|
|
576
|
+
if (!stored) explanations.push(`${declaration}. Migrate it: ALTER TABLE ${target} ADD COLUMN "version" bigint NOT NULL DEFAULT 1;`);
|
|
577
|
+
else if (stored.type !== column.type || stored.nullable !== column.nullable
|
|
578
|
+
|| (column.collation && (stored.collation !== column.collation || stored.collation_schema !== 'pg_catalog'))
|
|
579
|
+
|| normalizeExpression(stored.default) !== normalizeExpression(column.default || null) || stored.attidentity || stored.attgenerated) {
|
|
580
|
+
explanations.push(`${declaration} defined as bigint NOT NULL DEFAULT 1.`);
|
|
581
|
+
}
|
|
582
|
+
} else if (!column && stored?.type === 'bigint') {
|
|
583
|
+
explanations.push(`${table.name} does not declare optimistic concurrency, but its table has the version column. `
|
|
584
|
+
+ 'If other instances of this application declare it, deploy the declaration here: while the column exists, instances without it cannot start. '
|
|
585
|
+
+ `Otherwise, once no instance declares it: ALTER TABLE ${target} DROP COLUMN "version";`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return explanations.length ? explanations.join(' ') : null;
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
// A SCHEMA_MISMATCH also explains optimistic-concurrency columns and earlier storage of virtual fields.
|
|
564
592
|
const explainMismatch = (error, description, catalog, layouts, virtualFields) => {
|
|
565
|
-
if (!
|
|
593
|
+
if (!catalog || !(error instanceof SimfinityError) || error.extensions?.code !== 'SCHEMA_MISMATCH') return error;
|
|
566
594
|
let resolved = description;
|
|
567
595
|
// Refresh-trigger objects of two layouts in one root are themselves the mismatch; compare with the description.
|
|
568
596
|
try { resolved = resolveLayouts(description, catalog, layouts); } catch { /* keep the description */ }
|
|
569
597
|
try {
|
|
570
|
-
const explanation =
|
|
598
|
+
const explanation = [
|
|
599
|
+
versionColumns(resolved, catalog),
|
|
600
|
+
virtualFields.length ? virtualLeftovers(resolved, catalog, virtualFields) : null,
|
|
601
|
+
].filter(Boolean).join(' ');
|
|
571
602
|
return explanation ? new SimfinityError(`${error.message} ${explanation}`, 'SCHEMA_MISMATCH', 409) : error;
|
|
572
603
|
} catch {
|
|
573
604
|
// An explanation never replaces the mismatch it explains.
|
package/types/index.d.ts
CHANGED
|
@@ -7,7 +7,9 @@ export {
|
|
|
7
7
|
buildErrorFormatter,
|
|
8
8
|
createValidatedScalar,
|
|
9
9
|
InternalServerError,
|
|
10
|
+
MAX_RECORD_VERSION,
|
|
10
11
|
plugins,
|
|
12
|
+
RecordVersion,
|
|
11
13
|
scalars,
|
|
12
14
|
SimfinityError,
|
|
13
15
|
validators,
|
|
@@ -17,6 +19,7 @@ export type {
|
|
|
17
19
|
AuthRule,
|
|
18
20
|
AuthRuleFunction,
|
|
19
21
|
AuthSchemaPlugin,
|
|
22
|
+
ConcurrencyDeclaration,
|
|
20
23
|
CreateSchemaOptions,
|
|
21
24
|
EntityController,
|
|
22
25
|
EnvelopSchemaPlugin,
|
|
@@ -174,8 +177,10 @@ export interface PostgresModel {
|
|
|
174
177
|
find(args?: Record<string, any>, options?: { session?: PostgresSession }): Promise<PostgresRecord[]>;
|
|
175
178
|
/** Native helpers bypass GraphQL validators/controllers; references use storage column names. */
|
|
176
179
|
create(data: Record<string, any>, options?: { session?: PostgresSession }): Promise<PostgresRecord>;
|
|
177
|
-
|
|
178
|
-
|
|
180
|
+
/** Protected entities require changes.version; version:false ignores that field and does not advance it. */
|
|
181
|
+
update(id: string, changes: Record<string, any>, options?: { session?: PostgresSession; version?: false }): Promise<PostgresRecord | null>;
|
|
182
|
+
/** Protected entities require the version read, or false to bypass the version precondition. */
|
|
183
|
+
delete(id: string, options?: { session?: PostgresSession; version?: number | false }): Promise<PostgresRecord | null>;
|
|
179
184
|
}
|
|
180
185
|
export interface PostgresConfiguration {
|
|
181
186
|
pool: PostgresPool;
|
|
@@ -249,6 +254,15 @@ export interface PostgresRuntime extends Runtime<PostgresModel, PostgresSession>
|
|
|
249
254
|
* state column and a `NOT EXISTS` subquery on the owned table, null list items counting as items. `IS_NOT_EMPTY` is
|
|
250
255
|
* the positive form of a leaf, which a btree can serve, and the `NOT` of an object or owned form.
|
|
251
256
|
*
|
|
257
|
+
* The plugin declares `queryFeatures.quantifiers: true`: collection filters become correlated EXISTS frames,
|
|
258
|
+
* preserving one parent row, and explicit ANY/NONE/ALL frames also admit single references. Client frames and
|
|
259
|
+
* crossings apply target find scopes; trusted frames read stored data. ALL uses NOT EXISTS with a two-valued
|
|
260
|
+
* failing body (null operands fail ordering comparisons), and an absent body is TRUE. A frame's aliases and
|
|
261
|
+
* scope/body joins are local to its subquery. Aggregate anchors share the joins of groupId and facts; filters
|
|
262
|
+
* whose collection prefixes are already joined keep their multiplicity. A reference reached through an embedded
|
|
263
|
+
* list keeps its vector join when the target scope is null, including after a restricted prefix; a non-null
|
|
264
|
+
* target scope there is FORBIDDEN_FILTER_PATH (403). Sorting through a collection is invalid.
|
|
265
|
+
*
|
|
252
266
|
* The plugin also declares `queryFeatures.referenceEmptiness`. A client's condition reads as missing every record that
|
|
253
267
|
* the target's `find` scope hides. The references it crosses share the plain `LEFT JOIN` of other conditions while
|
|
254
268
|
* the scopes crossed so far add nothing; from the first scope that adds conditions on, each crossing is a
|