@simtlix/simfinity-postgres 3.2.0 → 3.4.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 +16 -5
- package/package.json +3 -2
- package/src/adapter.js +3 -124
- package/src/index.js +4 -23
- package/src/plugin.js +38 -0
- package/src/record-statements.js +22 -0
- package/src/records.js +3 -218
- package/src/schema/describe.js +46 -83
- package/src/transactions.js +3 -31
- package/types/index.d.ts +8 -0
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @simtlix/simfinity-postgres
|
|
2
2
|
|
|
3
|
-
PostgreSQL 15 or later support for the Simfinity GraphQL runtime. The package requires GraphQL 16 and Node.js 18.18 or later, depends on `pg
|
|
3
|
+
PostgreSQL 15 or later support for the Simfinity GraphQL runtime. The package requires GraphQL 16 and Node.js 18.18 or later, depends on `pg`, `@simtlix/simfinity-sql`, and `@simtlix/simfinity-core`, and has no Mongoose or MongoDB dependency.
|
|
4
4
|
|
|
5
|
-
Version 3.
|
|
5
|
+
Version 3.3.0 is released together with the other Simfinity packages. Install from npm and keep their versions aligned. Source and the complete startup guide: [simtlix/simfinity.js](https://github.com/simtlix/simfinity.js), [`docs/guide/postgresql.md`](https://github.com/simtlix/simfinity.js/blob/master/docs/guide/postgresql.md).
|
|
6
6
|
|
|
7
7
|
## Runtime
|
|
8
8
|
|
|
@@ -22,7 +22,18 @@ await simfinity.initializeDatabase({ mode: 'create' });
|
|
|
22
22
|
// Serve `schema`; the application remains responsible for `await pool.end()`.
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
`createPostgres`
|
|
25
|
+
`createPostgres` uses the driver-free SQL runtime with the PostgreSQL plugin. The equivalent explicit entry point is:
|
|
26
|
+
|
|
27
|
+
```javascript
|
|
28
|
+
import { createSQL } from '@simtlix/simfinity-sql';
|
|
29
|
+
import { postgresPlugin } from '@simtlix/simfinity-postgres';
|
|
30
|
+
|
|
31
|
+
const simfinity = createSQL({ plugin: postgresPlugin({ pool, schema: 'library' }) });
|
|
32
|
+
```
|
|
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.
|
|
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.
|
|
26
37
|
|
|
27
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.
|
|
28
39
|
|
|
@@ -42,14 +53,14 @@ const schema = createSchema();
|
|
|
42
53
|
await initializeDatabase({ mode: 'validate' });
|
|
43
54
|
```
|
|
44
55
|
|
|
45
|
-
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; mutation input is reset for each attempt. The caller owns the pool lifecycle.
|
|
56
|
+
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; mutation input is reset for each attempt. Unknown commit outcomes are not replayed. The caller owns the pool lifecycle.
|
|
46
57
|
|
|
47
58
|
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.
|
|
48
59
|
|
|
49
60
|
MCP integration is an independent opt-in and does not add MCP dependencies to PostgreSQL applications:
|
|
50
61
|
|
|
51
62
|
```sh
|
|
52
|
-
npm install @simtlix/simfinity-mcp@3.
|
|
63
|
+
npm install @simtlix/simfinity-mcp@3.3.0 @modelcontextprotocol/sdk@^1.13.0
|
|
53
64
|
```
|
|
54
65
|
|
|
55
66
|
```javascript
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simtlix/simfinity-postgres",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.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,7 +27,8 @@
|
|
|
27
27
|
"directory": "packages/postgres"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@simtlix/simfinity-core": "3.
|
|
30
|
+
"@simtlix/simfinity-core": "3.4.0",
|
|
31
|
+
"@simtlix/simfinity-sql": "3.4.0",
|
|
31
32
|
"pg": "^8.16.3"
|
|
32
33
|
},
|
|
33
34
|
"peerDependencies": {
|
package/src/adapter.js
CHANGED
|
@@ -1,125 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { describeModels, createQueryPlan, SimfinityError } from '@simtlix/simfinity-core';
|
|
4
|
-
import { describeDatabase } from './schema/describe.js';
|
|
5
|
-
import { initializeDatabase } from './schema/initialize.js';
|
|
6
|
-
import { compileQuery } from './query/compiler.js';
|
|
7
|
-
import { createRecordStore } from './records.js';
|
|
8
|
-
import { createTransactions } from './transactions.js';
|
|
9
|
-
import { castId, encodeParameter, normalizeDatabaseError } from './codecs.js';
|
|
1
|
+
import { createSQLAdapter } from '@simtlix/simfinity-sql/internal/adapter';
|
|
2
|
+
import { postgresPlugin } from './plugin.js';
|
|
10
3
|
|
|
11
|
-
export const createPostgresAdapter = (options) =>
|
|
12
|
-
let configured = options ? Object.freeze({ ...options }) : null;
|
|
13
|
-
let prepared = false;
|
|
14
|
-
let ready = false;
|
|
15
|
-
let allowSchemaCreation = true;
|
|
16
|
-
let models; let database; let records;
|
|
17
|
-
const handles = new Map();
|
|
18
|
-
const assertReady = () => {
|
|
19
|
-
if (!ready) throw new SimfinityError('Initialize the PostgreSQL database before executing operations', 'DATABASE_NOT_INITIALIZED', 503);
|
|
20
|
-
};
|
|
21
|
-
const getPool = () => configured.pool;
|
|
22
|
-
const transactions = createTransactions(getPool, assertReady);
|
|
23
|
-
const query = async (text, values, session) => {
|
|
24
|
-
assertReady();
|
|
25
|
-
// Retryable errors must reach withTransaction unchanged, so normalize only at operation boundaries.
|
|
26
|
-
const client = session ? transactions.requireSession(session) : getPool();
|
|
27
|
-
return client.query(text, values?.map(encodeParameter));
|
|
28
|
-
};
|
|
29
|
-
const safe = (operation) => async (...args) => {
|
|
30
|
-
try { return await operation(...args); } catch (error) {
|
|
31
|
-
if (['40001', '40P01'].includes(error.code)) throw error;
|
|
32
|
-
throw normalizeDatabaseError(error);
|
|
33
|
-
}
|
|
34
|
-
};
|
|
35
|
-
const nameOf = (model) => {
|
|
36
|
-
assertReady();
|
|
37
|
-
if (!model || handles.get(model.name) !== model || !models.entities.some((item) => item.name === model.name)) throw new SimfinityError('Invalid PostgreSQL model handle', 'INVALID_MODEL', 400);
|
|
38
|
-
return model.name;
|
|
39
|
-
};
|
|
40
|
-
const execute = async (model, gqltype, args, session, mode, extra) => {
|
|
41
|
-
const name = nameOf(model);
|
|
42
|
-
const plan = createQueryPlan(models, name, args, { mode });
|
|
43
|
-
const compiled = compileQuery(models, database, plan, extra);
|
|
44
|
-
const { rows } = await query(compiled.text, compiled.values, session);
|
|
45
|
-
if (mode === 'count') return Number(rows[0]?.size || 0);
|
|
46
|
-
const decodeAggregate = (value, field) => Array.isArray(value) ? value.map((item) => decodeAggregate(item, field)) : value != null && field.scalar === 'DateTime' ? new Date(value) : value;
|
|
47
|
-
if (mode === 'aggregate') return rows.map((row) => ({
|
|
48
|
-
groupId: decodeAggregate(row.groupId, compiled.aggregateFields[0]),
|
|
49
|
-
facts: Object.fromEntries(plan.aggregation.facts.map((fact, index) => {
|
|
50
|
-
const value = row[`fact_${index}`];
|
|
51
|
-
return [fact.factName, value == null ? null : ['SUM', 'COUNT', 'AVG'].includes(fact.operation) ? Number(value) : decodeAggregate(value, compiled.aggregateFields[index + 1])];
|
|
52
|
-
})),
|
|
53
|
-
}));
|
|
54
|
-
return records.hydrateRows(name, rows, session);
|
|
55
|
-
};
|
|
56
|
-
const adapter = {
|
|
57
|
-
configure(value) {
|
|
58
|
-
if (configured || prepared) throw new SimfinityError('PostgreSQL configuration is already bound', 'DATABASE_ALREADY_CONFIGURED', 409);
|
|
59
|
-
configured = Object.freeze({ ...value });
|
|
60
|
-
},
|
|
61
|
-
validateRegistration(registration) {
|
|
62
|
-
if (prepared) throw new SimfinityError('Register types before creating the schema', 'SCHEMA_ALREADY_CREATED', 409);
|
|
63
|
-
const model = registration.model;
|
|
64
|
-
if (model && !handles.has(model.name)) throw new SimfinityError('External Mongoose models cannot be used with PostgreSQL', 'INVALID_MODEL', 400);
|
|
65
|
-
},
|
|
66
|
-
prepare(registrations, { createCollection = true } = {}) {
|
|
67
|
-
if (prepared) return;
|
|
68
|
-
if (!configured?.pool?.connect || !configured.pool.query) throw new SimfinityError('Configure a pg.Pool before creating the schema', 'DATABASE_NOT_CONFIGURED', 500);
|
|
69
|
-
models = describeModels(registrations);
|
|
70
|
-
for (const registration of registrations.filter((item) => item.stateMachine)) {
|
|
71
|
-
const state = models.entities.find((item) => item.name === registration.gqltype.name)?.fields.find((item) => item.name === 'state');
|
|
72
|
-
if (state) state.stateNames = getNamedType(registration.gqltype.getFields().state.type).getValues().map((item) => ({ value: String(item.value), name: item.name }));
|
|
73
|
-
}
|
|
74
|
-
database = describeDatabase(registrations, { schema: configured.schema || 'public' });
|
|
75
|
-
allowSchemaCreation = createCollection;
|
|
76
|
-
records = createRecordStore(models, database, query);
|
|
77
|
-
prepared = true;
|
|
78
|
-
},
|
|
79
|
-
createModel(gqltype, onModelCreated) {
|
|
80
|
-
const model = {
|
|
81
|
-
name: gqltype.name, gqltype,
|
|
82
|
-
findById: (id, { session } = {}) => adapter.getById(model, id, session),
|
|
83
|
-
find: (args = {}, { session } = {}) => adapter.find(model, gqltype, args, session),
|
|
84
|
-
create: (data, { session } = {}) => adapter.withTransaction(session, (transaction) => adapter.saveRecord(model, adapter.newRecord(model, data, transaction), transaction)),
|
|
85
|
-
update: (id, changes, { session } = {}) => adapter.withTransaction(session, (transaction) => adapter.update(model, id, changes, transaction)),
|
|
86
|
-
delete: (id, { session } = {}) => adapter.withTransaction(session, (transaction) => adapter.delete(model, id, transaction)),
|
|
87
|
-
};
|
|
88
|
-
handles.set(model.name, model);
|
|
89
|
-
if (onModelCreated) onModelCreated(model);
|
|
90
|
-
return model;
|
|
91
|
-
},
|
|
92
|
-
castId,
|
|
93
|
-
stateValue: (state) => state.value,
|
|
94
|
-
withTransaction: transactions.withTransaction,
|
|
95
|
-
newRecord(model, data) { nameOf(model); const id = data._id || data.id || randomUUID(); return { ...data, _id: castId(id), id: castId(id) }; },
|
|
96
|
-
toObject(record) { return record == null ? record : { ...record }; },
|
|
97
|
-
saveRecord: safe((model, record, session) => transactions.withTransaction(session, (transaction) => records.create(nameOf(model), record, transaction))),
|
|
98
|
-
getById: safe((model, id, session, options = {}) => transactions.withTransaction(session, (transaction) => records.getById(nameOf(model), id, transaction, options))),
|
|
99
|
-
prepareUpdate(set, unset) { return { ...set, ...(Object.keys(unset).length ? { $unset: { ...unset } } : {}) }; },
|
|
100
|
-
update: safe((model, id, update, session) => records.update(nameOf(model), id, update, session)),
|
|
101
|
-
delete: safe((model, id, session) => records.remove(nameOf(model), id, session)),
|
|
102
|
-
find: safe((model, gqltype, args, session, { requiredId } = {}) => transactions.withTransaction(session, (transaction) => execute(model, gqltype, args, transaction, 'find', requiredId == null ? null : { column: 'id', id: castId(requiredId) }))),
|
|
103
|
-
count: safe((model, gqltype, args, session) => execute(model, gqltype, args, session, 'count')),
|
|
104
|
-
aggregate: safe((model, gqltype, args, session) => execute(model, gqltype, args, session, 'aggregate')),
|
|
105
|
-
findChildren: safe((model, gqltype, connectionField, parentId, args, session) => {
|
|
106
|
-
const entity = models.entities.find((item) => item.name === nameOf(model));
|
|
107
|
-
const field = entity.fields.find((item) => item.name === connectionField || item.storageName === connectionField);
|
|
108
|
-
if (!field || field.kind !== 'reference') throw new SimfinityError('Invalid inverse connection field', 'INVALID_MODEL', 400);
|
|
109
|
-
return transactions.withTransaction(session, (transaction) => execute(model, gqltype, args, transaction, 'find', { column: field.storageName, id: castId(parentId) }));
|
|
110
|
-
}),
|
|
111
|
-
async initialize(options = {}) {
|
|
112
|
-
if (!prepared) throw new SimfinityError('Create the GraphQL schema before initializing storage', 'SCHEMA_NOT_CREATED', 500);
|
|
113
|
-
ready = false;
|
|
114
|
-
if (!allowSchemaCreation && options.mode && options.mode !== 'validate') throw new SimfinityError('Database creation is disabled; initialize in validate mode', 'DATABASE_CREATION_DISABLED', 409);
|
|
115
|
-
const result = await initializeDatabase(getPool(), database, allowSchemaCreation ? options : { ...options, mode: 'validate' });
|
|
116
|
-
ready = true;
|
|
117
|
-
return result;
|
|
118
|
-
},
|
|
119
|
-
describeDatabase() {
|
|
120
|
-
if (!prepared) throw new SimfinityError('Create the GraphQL schema first', 'SCHEMA_NOT_CREATED', 500);
|
|
121
|
-
return structuredClone(database);
|
|
122
|
-
},
|
|
123
|
-
};
|
|
124
|
-
return adapter;
|
|
125
|
-
};
|
|
4
|
+
export const createPostgresAdapter = (options) => createSQLAdapter(postgresPlugin(options));
|
package/src/index.js
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
createRuntime,
|
|
4
|
-
plugins,
|
|
5
|
-
scalars,
|
|
6
|
-
validators,
|
|
7
|
-
} from '@simtlix/simfinity-core';
|
|
8
|
-
import { createPostgresAdapter } from './adapter.js';
|
|
1
|
+
import { createSQL } from '@simtlix/simfinity-sql';
|
|
2
|
+
import { postgresPlugin } from './plugin.js';
|
|
9
3
|
import { initializeDatabase as initializeSchema } from './schema/initialize.js';
|
|
10
4
|
|
|
5
|
+
export { postgresPlugin } from './plugin.js';
|
|
11
6
|
export { describeDatabase } from './schema/describe.js';
|
|
12
7
|
export { compileDatabaseSchema } from './schema/ddl.js';
|
|
13
8
|
export {
|
|
@@ -22,21 +17,7 @@ export {
|
|
|
22
17
|
} from '@simtlix/simfinity-core';
|
|
23
18
|
|
|
24
19
|
/** Each instance permanently binds one pool/schema; getModel exposes PostgreSQL native helpers. */
|
|
25
|
-
export const createPostgres = (options) => {
|
|
26
|
-
const adapter = createPostgresAdapter(options);
|
|
27
|
-
const runtime = createRuntime(adapter);
|
|
28
|
-
return {
|
|
29
|
-
...runtime,
|
|
30
|
-
auth,
|
|
31
|
-
configure: (configuration) => adapter.configure(configuration),
|
|
32
|
-
initializeDatabase: (initialization) => adapter.initialize(initialization),
|
|
33
|
-
describeDatabase: () => adapter.describeDatabase(),
|
|
34
|
-
plugins,
|
|
35
|
-
scalars,
|
|
36
|
-
validators,
|
|
37
|
-
withTransaction: (session, callback) => adapter.withTransaction(session, callback),
|
|
38
|
-
};
|
|
39
|
-
};
|
|
20
|
+
export const createPostgres = (options) => createSQL({ plugin: postgresPlugin(options) });
|
|
40
21
|
const defaultInstance = createPostgres();
|
|
41
22
|
export const { configureQueryLimits, configure, connect, addNoEndpointType, createSchema, getModel, getType, getInputType, getRegistrations, use, registerMutation, saveObject, preventCreatingCollection, withTransaction } = defaultInstance;
|
|
42
23
|
|
package/src/plugin.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { SimfinityError } from '@simtlix/simfinity-core';
|
|
3
|
+
import { identifier, generatedName } from './schema/sql.js';
|
|
4
|
+
import { describeRelationalSchema } from './schema/describe.js';
|
|
5
|
+
import { initializeDatabase } from './schema/initialize.js';
|
|
6
|
+
import { compileDatabaseSchema } from './schema/ddl.js';
|
|
7
|
+
import { compileQuery } from './query/compiler.js';
|
|
8
|
+
import { compileRecord } from './record-statements.js';
|
|
9
|
+
import { castId, encodeScalar, decodeScalar, encodeParameter, normalizeDatabaseError } from './codecs.js';
|
|
10
|
+
|
|
11
|
+
export const postgresPlugin = (options) => ({
|
|
12
|
+
apiVersion: 1,
|
|
13
|
+
name: 'postgres',
|
|
14
|
+
displayName: 'PostgreSQL',
|
|
15
|
+
defaultSchema: 'public',
|
|
16
|
+
options: options || null,
|
|
17
|
+
capabilities: ['transactions', 'foreignKeys', 'deferredForeignKeys', 'embeddedValues', 'ownedRecords', 'scalarLists', 'uniqueValues', 'nullableUnique'],
|
|
18
|
+
naming: { validateIdentifier: identifier, generatedName },
|
|
19
|
+
describeSchema: describeRelationalSchema,
|
|
20
|
+
initialize: (configuration, description, initialization) => initializeDatabase(configuration.pool, description, initialization),
|
|
21
|
+
compileSchema: compileDatabaseSchema,
|
|
22
|
+
compileQuery,
|
|
23
|
+
compileRecord,
|
|
24
|
+
values: { createId: randomUUID, castId, encodeScalar, decodeScalar, encodeEmbedded: JSON.stringify },
|
|
25
|
+
driver: {
|
|
26
|
+
assertConfiguration(configuration) {
|
|
27
|
+
if (!configuration?.pool?.connect || !configuration.pool.query) throw new SimfinityError('Configure a pg.Pool before creating the schema', 'DATABASE_NOT_CONFIGURED', 500);
|
|
28
|
+
},
|
|
29
|
+
query: (configuration, statement, client) => (client || configuration.pool).query(statement.text, statement.values?.map(encodeParameter)),
|
|
30
|
+
acquire: (configuration) => configuration.pool.connect(),
|
|
31
|
+
begin: (client) => client.query('BEGIN ISOLATION LEVEL REPEATABLE READ'),
|
|
32
|
+
commit: (client) => client.query('COMMIT'),
|
|
33
|
+
rollback: (client) => client.query('ROLLBACK'),
|
|
34
|
+
release: (client) => client.release(),
|
|
35
|
+
isRetryable: (error) => ['40001', '40P01'].includes(error.code),
|
|
36
|
+
normalizeError: normalizeDatabaseError,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { SimfinityError } from '@simtlix/simfinity-core';
|
|
2
|
+
import { identifier as q, qualified } from './schema/sql.js';
|
|
3
|
+
|
|
4
|
+
/** operation.table is the physical table metadata returned by describeSchema. */
|
|
5
|
+
export const compileRecord = (database, operation) => {
|
|
6
|
+
const { kind, table: storage, id, data } = operation;
|
|
7
|
+
const target = qualified(database.schema, storage.name);
|
|
8
|
+
if (kind === 'selectById') return { text: `SELECT * FROM ${target} WHERE id = $1::uuid${operation.lock ? ' FOR UPDATE' : ''}`, values: [id] };
|
|
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') return { text: `DELETE FROM ${target} WHERE id = $1::uuid`, values: [id] };
|
|
11
|
+
if (kind === 'deleteOwned') return { text: `DELETE FROM ${target} WHERE "__owner_id" = $1::uuid`, values: [operation.ownerId] };
|
|
12
|
+
if (kind === 'insert') {
|
|
13
|
+
const entries = Object.entries(data);
|
|
14
|
+
const placeholders = entries.map(([name], index) => `$${index + 1}::${storage.columns.find((column) => column.name === name).type}`);
|
|
15
|
+
return { text: `INSERT INTO ${target} (${entries.map(([name]) => q(name)).join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *`, values: entries.map(([, value]) => value) };
|
|
16
|
+
}
|
|
17
|
+
if (kind === 'update') {
|
|
18
|
+
const entries = Object.entries(data);
|
|
19
|
+
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
|
+
}
|
|
21
|
+
throw new SimfinityError(`Unknown record operation ${kind}`, 'INVALID_RECORD_OPERATION', 400);
|
|
22
|
+
};
|
package/src/records.js
CHANGED
|
@@ -1,219 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { SimfinityError } from '@simtlix/simfinity-core';
|
|
4
|
-
import { identifier as q, qualified } from './schema/sql.js';
|
|
5
|
-
import { castId, encodeScalar, decodeScalar } from './codecs.js';
|
|
1
|
+
import { createRecordStore as createSQLRecordStore } from '@simtlix/simfinity-sql/internal/records';
|
|
2
|
+
import { postgresPlugin } from './plugin.js';
|
|
6
3
|
|
|
7
|
-
const
|
|
8
|
-
const shapeError = (name) => { throw new SimfinityError(`Invalid value for ${name}`, 'INVALID_VALUE', 400); };
|
|
9
|
-
|
|
10
|
-
export const createRecordStore = (models, database, query) => {
|
|
11
|
-
const model = (name) => models.entities.find((item) => item.name === name);
|
|
12
|
-
const table = (name) => database.tables.find((item) => item.name === name);
|
|
13
|
-
const children = (owner) => database.tables.filter((item) => item.ownership?.ownerTable === owner.name);
|
|
14
|
-
const child = (owner, field) => children(owner).find((item) => item.ownership.field === field.name);
|
|
15
|
-
const gqlFields = (type) => type?.getFields() || {};
|
|
16
|
-
const encodeValue = (field, value) => {
|
|
17
|
-
if (value == null) return null;
|
|
18
|
-
if (field.kind === 'embedded') return mapEmbedded(field, value, encodeFields);
|
|
19
|
-
if (field.list) {
|
|
20
|
-
if (!Array.isArray(value)) shapeError(field.name);
|
|
21
|
-
return value.map((item) => {
|
|
22
|
-
if (item == null && field.itemRequired) missing(`${field.name}[]`);
|
|
23
|
-
return encodeScalar(field, item);
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
return encodeScalar(field, value);
|
|
27
|
-
};
|
|
28
|
-
const mapEmbedded = (field, value, mapper, gqltype) => {
|
|
29
|
-
if (value == null) return value;
|
|
30
|
-
if (field.list) {
|
|
31
|
-
if (!Array.isArray(value)) shapeError(field.name);
|
|
32
|
-
return value.map((item) => {
|
|
33
|
-
if (item == null) { if (field.itemRequired) missing(`${field.name}[]`); return null; }
|
|
34
|
-
return mapper(field.fields, item, gqltype);
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
return mapper(field.fields, value, gqltype);
|
|
38
|
-
};
|
|
39
|
-
// Mongoose inline objects materialize descendant array defaults and minimize
|
|
40
|
-
// scalar-only empty objects. Embedded array items themselves are retained.
|
|
41
|
-
const normalize = (fields, data, full = true) => {
|
|
42
|
-
if (!data || typeof data !== 'object' || Array.isArray(data)) shapeError('embedded object');
|
|
43
|
-
const result = { ...data };
|
|
44
|
-
for (const field of fields) {
|
|
45
|
-
if (field.kind === 'collection' || (!full && !Object.hasOwn(data, field.storageName))) continue;
|
|
46
|
-
let value = data[field.storageName];
|
|
47
|
-
if (value === undefined && !full) continue;
|
|
48
|
-
if (value === undefined && full && field.list) value = [];
|
|
49
|
-
if (field.kind === 'embedded' && value !== null) {
|
|
50
|
-
if (field.list) {
|
|
51
|
-
if (!Array.isArray(value)) shapeError(field.name);
|
|
52
|
-
value = value.map((item) => item == null ? item : normalize(field.fields, item));
|
|
53
|
-
} else {
|
|
54
|
-
value = normalize(field.fields, value ?? {});
|
|
55
|
-
if (!Object.keys(value).length) value = undefined;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
if (value !== undefined) result[field.storageName] = value;
|
|
59
|
-
else if (full) delete result[field.storageName];
|
|
60
|
-
else result[field.storageName] = undefined;
|
|
61
|
-
}
|
|
62
|
-
return result;
|
|
63
|
-
};
|
|
64
|
-
const encodeFields = (fields, data) => {
|
|
65
|
-
if (!data || typeof data !== 'object' || Array.isArray(data)) shapeError('embedded object');
|
|
66
|
-
const result = {};
|
|
67
|
-
for (const field of fields) {
|
|
68
|
-
if (field.kind === 'collection') continue;
|
|
69
|
-
const value = data[field.storageName];
|
|
70
|
-
if (value == null && field.required) missing(field.name);
|
|
71
|
-
if (value !== undefined) result[field.storageName] = encodeValue(field, value);
|
|
72
|
-
else if (field.list) result[field.storageName] = [];
|
|
73
|
-
}
|
|
74
|
-
return result;
|
|
75
|
-
};
|
|
76
|
-
const decodeFields = (fields, data, gqltype) => {
|
|
77
|
-
const result = {};
|
|
78
|
-
for (const field of fields) {
|
|
79
|
-
if (field.kind === 'collection' || data[field.storageName] === undefined) continue;
|
|
80
|
-
const value = data[field.storageName];
|
|
81
|
-
const gqlField = gqlFields(gqltype)[field.name];
|
|
82
|
-
if (field.kind === 'embedded') result[field.storageName] = mapEmbedded(field, value, decodeFields, gqlField && getNamedType(gqlField.type));
|
|
83
|
-
else result[field.storageName] = field.list && value ? value.map((item) => decodeScalar(field, item, gqlField)) : decodeScalar(field, value, gqlField);
|
|
84
|
-
}
|
|
85
|
-
return result;
|
|
86
|
-
};
|
|
87
|
-
const hydrate = async (storage, fields, rows, gqltype, session) => {
|
|
88
|
-
const result = rows.map((row) => {
|
|
89
|
-
const visible = { ...row };
|
|
90
|
-
for (const column of storage.columns) if (column.presenceColumn && row[column.name] == null && !row[column.presenceColumn]) delete visible[column.name];
|
|
91
|
-
const value = decodeFields(fields, visible, gqltype);
|
|
92
|
-
if (!storage.ownership) { value._id = row.id; value.id = row.id; }
|
|
93
|
-
return value;
|
|
94
|
-
});
|
|
95
|
-
if (!rows.length) return result;
|
|
96
|
-
for (const owned of children(storage)) {
|
|
97
|
-
const field = fields.find((item) => item.name === owned.ownership.field);
|
|
98
|
-
const ids = rows.filter((row) => row[owned.ownership.stateColumn] === 'present').map((row) => row[storage.primaryKey.columns[0]]);
|
|
99
|
-
let ownedRows = [];
|
|
100
|
-
if (ids.length) ownedRows = (await query(`SELECT * FROM ${qualified(database.schema, owned.name)} WHERE "__owner_id" = ANY ($1::uuid[])${field.list ? ' ORDER BY "__position"' : ''}`, [ids], session)).rows;
|
|
101
|
-
const decoded = await hydrate(owned, field.fields, ownedRows, getNamedType(gqlFields(gqltype)[field.name].type), session);
|
|
102
|
-
const byOwner = new Map();
|
|
103
|
-
ownedRows.forEach((row, index) => {
|
|
104
|
-
const list = byOwner.get(row.__owner_id) || [];
|
|
105
|
-
list.push(owned.ownership.nullableItems && !row.__item_present ? null : decoded[index]);
|
|
106
|
-
byOwner.set(row.__owner_id, list);
|
|
107
|
-
});
|
|
108
|
-
rows.forEach((row, index) => {
|
|
109
|
-
const state = row[owned.ownership.stateColumn];
|
|
110
|
-
if (state === 'null') result[index][field.storageName] = null;
|
|
111
|
-
else if (state === 'present') {
|
|
112
|
-
const items = byOwner.get(row[storage.primaryKey.columns[0]]) || [];
|
|
113
|
-
result[index][field.storageName] = field.list ? items : items[0] ?? null;
|
|
114
|
-
}
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
return result;
|
|
118
|
-
};
|
|
119
|
-
const columnValues = (storage, fields, data, full, nullItem = false) => {
|
|
120
|
-
const result = {};
|
|
121
|
-
for (const field of fields) {
|
|
122
|
-
if (field.kind === 'collection' || (!storage.ownership && ['id', '_id'].includes(field.name))) continue;
|
|
123
|
-
const present = Object.hasOwn(data, field.storageName);
|
|
124
|
-
let value = data[field.storageName];
|
|
125
|
-
if (!present && !full) continue;
|
|
126
|
-
if (!present && full && field.list && !nullItem) value = [];
|
|
127
|
-
const owned = child(storage, field);
|
|
128
|
-
if (owned) {
|
|
129
|
-
if (value == null && field.required && !nullItem) missing(field.name);
|
|
130
|
-
result[owned.ownership.stateColumn] = value === undefined ? 'missing' : value === null ? 'null' : 'present';
|
|
131
|
-
} else {
|
|
132
|
-
if (value == null && field.required && !nullItem) missing(field.name);
|
|
133
|
-
const presenceColumn = storage.columns.find((column) => column.name === field.storageName)?.presenceColumn;
|
|
134
|
-
if (presenceColumn) result[presenceColumn] = value !== undefined && !nullItem;
|
|
135
|
-
if (value !== undefined) {
|
|
136
|
-
const encoded = encodeValue(field, value);
|
|
137
|
-
result[field.storageName] = field.kind === 'embedded' && encoded != null ? JSON.stringify(encoded) : encoded;
|
|
138
|
-
} else if (full || field.kind === 'embedded') result[field.storageName] = null;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
return result;
|
|
142
|
-
};
|
|
143
|
-
const insertRow = async (storage, data, session) => {
|
|
144
|
-
const entries = Object.entries(data);
|
|
145
|
-
const placeholders = entries.map(([name], index) => `$${index + 1}::${storage.columns.find((column) => column.name === name).type}`);
|
|
146
|
-
return (await query(`INSERT INTO ${qualified(database.schema, storage.name)} (${entries.map(([name]) => q(name)).join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *`, entries.map(([, value]) => value), session)).rows[0];
|
|
147
|
-
};
|
|
148
|
-
const persistOwned = async (storage, fields, ownerId, data, session, replace = false) => {
|
|
149
|
-
for (const owned of children(storage)) {
|
|
150
|
-
const field = fields.find((item) => item.name === owned.ownership.field);
|
|
151
|
-
if (!Object.hasOwn(data, field.storageName)) continue;
|
|
152
|
-
if (replace) await query(`DELETE FROM ${qualified(database.schema, owned.name)} WHERE "__owner_id" = $1::uuid`, [ownerId], session);
|
|
153
|
-
const value = data[field.storageName];
|
|
154
|
-
if (value == null) continue;
|
|
155
|
-
if (field.list && !Array.isArray(value)) shapeError(field.name);
|
|
156
|
-
const entries = field.list ? value : [value];
|
|
157
|
-
for (let index = 0; index < entries.length; index++) {
|
|
158
|
-
const entry = entries[index];
|
|
159
|
-
if (entry == null && (!field.list || field.itemRequired)) missing(`${field.name}[]`);
|
|
160
|
-
if (entry != null && (typeof entry !== 'object' || Array.isArray(entry))) shapeError(field.name);
|
|
161
|
-
const row = { __id: randomUUID(), __owner_id: ownerId, ...columnValues(owned, field.fields, entry || {}, true, entry == null) };
|
|
162
|
-
if (field.list) row.__position = index;
|
|
163
|
-
if (owned.ownership.nullableItems) row.__item_present = entry != null;
|
|
164
|
-
await insertRow(owned, row, session);
|
|
165
|
-
if (entry) await persistOwned(owned, field.fields, row.__id, entry, session);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
};
|
|
169
|
-
const hydrateRows = async (name, rows, session) => {
|
|
170
|
-
const entity = model(name);
|
|
171
|
-
if (!entity) throw new SimfinityError(`Type ${name} is not an entity`, 'INVALID_MODEL', 400);
|
|
172
|
-
return hydrate(table(name), entity.fields, rows, entity.gqltype, session);
|
|
173
|
-
};
|
|
174
|
-
const getById = async (name, value, session, { projection, lock, requiredId } = {}) => {
|
|
175
|
-
if (value == null) return null;
|
|
176
|
-
const id = castId(value);
|
|
177
|
-
if (requiredId != null && id !== castId(requiredId)) return null;
|
|
178
|
-
const rows = (await query(`SELECT * FROM ${qualified(database.schema, name)} WHERE id = $1::uuid${lock ? ' FOR UPDATE' : ''}`, [id], session)).rows;
|
|
179
|
-
const [record] = await hydrateRows(name, rows, session);
|
|
180
|
-
if (!record) return null;
|
|
181
|
-
return projection ? Object.fromEntries(Object.entries(record).filter(([key]) => key === '_id' || key === 'id' || projection[key])) : record;
|
|
182
|
-
};
|
|
183
|
-
const create = async (name, record, session) => {
|
|
184
|
-
const entity = model(name);
|
|
185
|
-
if (!entity) throw new SimfinityError(`Type ${name} is not an entity`, 'INVALID_MODEL', 400);
|
|
186
|
-
const storage = table(name);
|
|
187
|
-
const normalized = normalize(entity.fields, record);
|
|
188
|
-
const data = { id: castId(normalized._id || normalized.id), ...columnValues(storage, entity.fields, normalized, true) };
|
|
189
|
-
const row = await insertRow(storage, data, session);
|
|
190
|
-
await persistOwned(storage, entity.fields, row.id, normalized, session);
|
|
191
|
-
return (await hydrateRows(name, [row], session))[0];
|
|
192
|
-
};
|
|
193
|
-
const update = async (name, value, changes, session) => {
|
|
194
|
-
const id = castId(value);
|
|
195
|
-
const entity = model(name); const storage = table(name);
|
|
196
|
-
const source = normalize(entity.fields, changes, false);
|
|
197
|
-
delete source.$unset;
|
|
198
|
-
for (const field of Object.keys(changes.$unset || {})) source[field] = undefined;
|
|
199
|
-
const data = columnValues(storage, entity.fields, source, false);
|
|
200
|
-
for (const field of Object.keys(changes.$unset || {})) {
|
|
201
|
-
const column = storage.columns.find((item) => item.name === field);
|
|
202
|
-
if (column && !['id', '_id'].includes(field)) data[field] = null;
|
|
203
|
-
}
|
|
204
|
-
const entries = Object.entries(data);
|
|
205
|
-
let rows;
|
|
206
|
-
if (entries.length) rows = (await query(`UPDATE ${qualified(database.schema, name)} SET ${entries.map(([key], index) => `${q(key)} = $${index + 2}::${storage.columns.find((column) => column.name === key).type}`).join(', ')} WHERE id = $1::uuid RETURNING *`, [id, ...entries.map(([, value]) => value)], session)).rows;
|
|
207
|
-
else rows = (await query(`SELECT * FROM ${qualified(database.schema, name)} WHERE id = $1::uuid`, [id], session)).rows;
|
|
208
|
-
if (!rows.length) return null;
|
|
209
|
-
await persistOwned(storage, entity.fields, id, source, session, true);
|
|
210
|
-
return (await hydrateRows(name, rows, session))[0];
|
|
211
|
-
};
|
|
212
|
-
const remove = async (name, value, session) => {
|
|
213
|
-
const record = await getById(name, value, session);
|
|
214
|
-
if (!record) return null;
|
|
215
|
-
await query(`DELETE FROM ${qualified(database.schema, name)} WHERE id = $1::uuid`, [record._id], session);
|
|
216
|
-
return record;
|
|
217
|
-
};
|
|
218
|
-
return { create, update, remove, getById, hydrateRows };
|
|
219
|
-
};
|
|
4
|
+
export const createRecordStore = (models, database, query) => createSQLRecordStore(models, database, (statement, session) => query(statement.text, statement.values, session), postgresPlugin());
|
package/src/schema/describe.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { describeModels } from '@simtlix/simfinity-core';
|
|
2
|
+
import { planRelationalSchema } from '@simtlix/simfinity-sql';
|
|
2
3
|
import { queryFunctions } from '../query/functions.js';
|
|
3
4
|
import { constraintBuilder } from './constraints.js';
|
|
4
5
|
import { identifier, literal, generatedName, invalid } from './sql.js';
|
|
5
6
|
|
|
6
|
-
const scalarTypes = { ID: 'uuid', String: 'text', Enum: 'text', Int: 'integer', Float: 'double precision', Boolean: 'boolean', DateTime: 'timestamp with time zone' };
|
|
7
|
-
const
|
|
7
|
+
const scalarTypes = { ID: 'uuid', String: 'text', Enum: 'text', Int: 'integer', Float: 'double precision', Boolean: 'boolean', DateTime: 'timestamp with time zone', Embedded: 'jsonb' };
|
|
8
|
+
const physicalType = (column) => `${scalarTypes[column.scalar]}${column.list ? '[]' : ''}`;
|
|
9
|
+
const physicalDefault = (value) => value.kind === 'identity' ? 'gen_random_uuid()' : typeof value.value === 'string' ? `${literal(value.value)}::text` : String(value.value);
|
|
8
10
|
|
|
9
|
-
/**
|
|
10
|
-
export const
|
|
11
|
+
/** Lower engine-independent relational guarantees into PostgreSQL storage and enforcement. */
|
|
12
|
+
export const describeRelationalSchema = (plan) => {
|
|
13
|
+
const { schema } = plan;
|
|
11
14
|
identifier(schema);
|
|
12
|
-
const { entities } = describeModels(registrations);
|
|
13
15
|
const tables = [];
|
|
14
16
|
const names = new Set();
|
|
15
17
|
const addTable = (name, ownership) => {
|
|
@@ -36,88 +38,44 @@ export const describeDatabase = (registrations, { schema = 'public' } = {}) => {
|
|
|
36
38
|
if (table.indexes.some((index) => JSON.stringify(index.columns) === JSON.stringify(columns) && index.unique === unique)) return;
|
|
37
39
|
table.indexes.push({ name: generatedName(table.name, ...columns, unique ? 'uq' : 'idx'), columns, unique, nullsNotDistinct: unique });
|
|
38
40
|
};
|
|
39
|
-
const addCheck = (table, field, suffix, expression) => {
|
|
40
|
-
table.checks.push({ name: generatedName(table.name, field, suffix), expression });
|
|
41
|
-
};
|
|
41
|
+
const addCheck = (table, field, suffix, expression) => table.checks.push({ name: generatedName(table.name, field, suffix), expression });
|
|
42
42
|
const addReference = (table, column, targetTable, targetColumn = 'id', owned = false) => {
|
|
43
|
-
table.foreignKeys.push({
|
|
44
|
-
name: generatedName(table.name, column, 'fk'), columns: [column], targetTable, targetColumns: [targetColumn],
|
|
45
|
-
onDelete: owned ? 'CASCADE' : 'NO ACTION', onUpdate: 'NO ACTION', deferrable: true, initiallyDeferred: false,
|
|
46
|
-
});
|
|
43
|
+
table.foreignKeys.push({ name: generatedName(table.name, column, 'fk'), columns: [column], targetTable, targetColumns: [targetColumn], onDelete: owned ? 'CASCADE' : 'NO ACTION', onUpdate: 'NO ACTION', deferrable: true, initiallyDeferred: false });
|
|
47
44
|
addIndex(table, [column]);
|
|
48
45
|
};
|
|
46
|
+
for (const logical of plan.tables) {
|
|
47
|
+
const table = addTable(logical.name, logical.ownership && structuredClone(logical.ownership));
|
|
48
|
+
table.columns = [];
|
|
49
|
+
for (const column of logical.columns) addColumn(table, {
|
|
50
|
+
name: column.name, type: physicalType(column), nullable: column.nullable,
|
|
51
|
+
...(column.default ? { default: physicalDefault(column.default) } : {}),
|
|
52
|
+
...(column.presenceColumn ? { presenceColumn: column.presenceColumn } : {}),
|
|
53
|
+
});
|
|
54
|
+
table.primaryKey = structuredClone(logical.primaryKey);
|
|
55
|
+
table.foreignKeys = structuredClone(logical.foreignKeys);
|
|
56
|
+
table.indexes = logical.indexes.map(({ name, columns, unique, nullsEqual }) => ({ name, columns: [...columns], unique, nullsNotDistinct: nullsEqual }));
|
|
57
|
+
}
|
|
49
58
|
const constraints = constraintBuilder(schema, tables, { addTable, addColumn, addReference, addIndex, addCheck });
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
path: [...path, field.name], list: field.list, required: field.required,
|
|
71
|
-
nullableItems: field.list && !field.itemRequired, stateColumn,
|
|
72
|
-
});
|
|
73
|
-
addColumn(owned, { name: '__owner_id', type: 'uuid', nullable: false });
|
|
74
|
-
addReference(owned, '__owner_id', table.name, table.primaryKey.columns[0], true);
|
|
75
|
-
if (field.list) {
|
|
76
|
-
addColumn(owned, { name: '__position', type: 'integer', nullable: false });
|
|
77
|
-
addCheck(owned, '__position', 'nonnegative', `${identifier('__position')} >= 0`);
|
|
78
|
-
addIndex(owned, ['__owner_id', '__position'], true);
|
|
79
|
-
if (!field.itemRequired) addColumn(owned, { name: '__item_present', type: 'boolean', nullable: false, default: 'true' });
|
|
80
|
-
} else addIndex(owned, ['__owner_id'], true);
|
|
81
|
-
fieldsInto(owned, field.fields, [...path, field.name]);
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
const type = field.kind === 'embedded' ? 'jsonb' : field.kind === 'reference' ? 'uuid' : `${scalarTypes[field.scalar]}${field.list ? '[]' : ''}`;
|
|
85
|
-
if (!type || type.startsWith('undefined')) invalid(`Unsupported scalar at ${table.name}.${field.name}`);
|
|
86
|
-
const presenceColumn = table.ownership || field.kind === 'embedded' ? generatedName('__field', field.name, 'present') : null;
|
|
87
|
-
addColumn(table, { name: field.storageName, type, nullable: !required, ...(presenceColumn ? { presenceColumn } : {}) });
|
|
88
|
-
if (presenceColumn) addColumn(table, { name: presenceColumn, type: 'boolean', nullable: false, default: 'false' });
|
|
89
|
-
if (conditional && field.required) addCheck(table, field.storageName, 'required', `(NOT ${identifier('__item_present')}) OR (${identifier(field.storageName)} IS NOT NULL)`);
|
|
90
|
-
if (field.kind === 'reference') addReference(table, field.storageName, field.target);
|
|
91
|
-
if (field.scalar === 'ID') addIndex(table, [field.storageName]);
|
|
92
|
-
if (field.kind === 'embedded') constraints.addJSON(table, field);
|
|
93
|
-
if (field.unique) {
|
|
94
|
-
if (field.list || table.ownership) constraints.addUnique(table, field, type);
|
|
95
|
-
else addIndex(table, [field.storageName], true);
|
|
96
|
-
}
|
|
97
|
-
if (field.scalar === 'Enum') {
|
|
98
|
-
const values = `ARRAY[${field.values.map((value) => `${literal(value)}::text`).join(', ')}]`;
|
|
99
|
-
const expression = field.list
|
|
100
|
-
? `array_remove(${identifier(field.storageName)}, NULL::text) <@ ${values}`
|
|
101
|
-
: `${identifier(field.storageName)} = ANY (${values})`;
|
|
102
|
-
addCheck(table, field.storageName, 'enum', expression);
|
|
103
|
-
}
|
|
104
|
-
if (field.kind === 'scalar' && field.list && field.itemRequired) {
|
|
105
|
-
addCheck(table, field.storageName, 'items', `array_position(${identifier(field.storageName)}, NULL::${scalarTypes[field.scalar]}) IS NULL`);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
};
|
|
109
|
-
for (const entity of entities) addTable(entity.name);
|
|
110
|
-
for (const entity of entities) {
|
|
111
|
-
const table = tables.find((item) => item.name === entity.name);
|
|
112
|
-
fieldsInto(table, entity.fields);
|
|
113
|
-
for (const index of entity.indexes) {
|
|
114
|
-
const columns = index.fields.map((name) => {
|
|
115
|
-
const field = entity.fields.find((item) => item.name === name || item.storageName === name);
|
|
116
|
-
if (!field || !['scalar', 'reference'].includes(field.kind) || field.list) invalid(`Index ${entity.name}.${name} must refer to a stored scalar or reference`);
|
|
117
|
-
return field.name === '_id' ? 'id' : field.storageName;
|
|
118
|
-
});
|
|
119
|
-
addIndex(table, columns, index.unique);
|
|
120
|
-
}
|
|
59
|
+
for (const item of plan.checkOrder) {
|
|
60
|
+
const table = tables.find((entry) => entry.name === item.table);
|
|
61
|
+
const check = plan.tables.find((entry) => entry.name === item.table).checks.find((entry) => entry.name === item.name);
|
|
62
|
+
const column = identifier(check.column);
|
|
63
|
+
if (check.kind === 'embeddedShape') { constraints.addJSON(table, check.field); continue; }
|
|
64
|
+
let expression;
|
|
65
|
+
if (check.kind === 'presentWhenItem') expression = `(NOT ${identifier('__item_present')}) OR (${column} = 'present'::text)`;
|
|
66
|
+
else if (check.kind === 'requiredWhenItem') expression = `(NOT ${identifier('__item_present')}) OR (${column} IS NOT NULL)`;
|
|
67
|
+
else if (check.kind === 'nonnegative') expression = `${column} >= 0`;
|
|
68
|
+
else if (check.kind === 'state') expression = `${column} = ANY (ARRAY[${check.values.map((value) => `${literal(value)}::text`).join(', ')}])`;
|
|
69
|
+
else if (check.kind === 'enum') {
|
|
70
|
+
const values = `ARRAY[${check.values.map((value) => `${literal(value)}::text`).join(', ')}]`;
|
|
71
|
+
expression = check.list ? `array_remove(${column}, NULL::text) <@ ${values}` : `${column} = ANY (${values})`;
|
|
72
|
+
} else if (check.kind === 'itemsRequired') expression = `array_position(${column}, NULL::${scalarTypes[check.scalar]}) IS NULL`;
|
|
73
|
+
else invalid(`Unsupported relational check: ${check.kind}`);
|
|
74
|
+
table.checks.push({ name: check.name, expression });
|
|
75
|
+
}
|
|
76
|
+
for (const item of plan.uniqueValues) {
|
|
77
|
+
const table = tables.find((entry) => entry.name === item.table);
|
|
78
|
+
constraints.addUnique(table, item.field, table.columns.find((entry) => entry.name === item.field.storageName).type);
|
|
121
79
|
}
|
|
122
80
|
const generated = constraints.finish();
|
|
123
81
|
generated.functions.push(...queryFunctions(schema));
|
|
@@ -136,3 +94,8 @@ export const describeDatabase = (registrations, { schema = 'public' } = {}) => {
|
|
|
136
94
|
}
|
|
137
95
|
return { schema, tables: tables.sort((a, b) => a.name.localeCompare(b.name)), ...generated };
|
|
138
96
|
};
|
|
97
|
+
|
|
98
|
+
/** Compile GraphQL storage metadata into the compatible PostgreSQL description. */
|
|
99
|
+
export const describeDatabase = (registrations, { schema = 'public' } = {}) => describeRelationalSchema(planRelationalSchema(describeModels(registrations), {
|
|
100
|
+
schema, naming: { validateIdentifier: identifier, generatedName },
|
|
101
|
+
}));
|
package/src/transactions.js
CHANGED
|
@@ -1,32 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createTransactions as createSQLTransactions } from '@simtlix/simfinity-sql/internal/transactions';
|
|
2
|
+
import { postgresPlugin } from './plugin.js';
|
|
3
3
|
|
|
4
|
-
export const createTransactions = (getPool, assertReady) => {
|
|
5
|
-
const sessions = new WeakSet();
|
|
6
|
-
const requireSession = (session) => {
|
|
7
|
-
if (!sessions.has(session) || !session.inTransaction()) throw new SimfinityError('Session is not an active transaction of this PostgreSQL instance', 'INVALID_SESSION', 400);
|
|
8
|
-
return session.client;
|
|
9
|
-
};
|
|
10
|
-
const withTransaction = async (session, body) => {
|
|
11
|
-
assertReady();
|
|
12
|
-
if (session) { requireSession(session); return body(session); }
|
|
13
|
-
for (let attempt = 0; attempt <= 5; attempt++) {
|
|
14
|
-
const client = await getPool().connect();
|
|
15
|
-
let active = true;
|
|
16
|
-
const handle = { client, inTransaction: () => active, query: (...args) => requireSession(handle).query(...args) };
|
|
17
|
-
sessions.add(handle);
|
|
18
|
-
try {
|
|
19
|
-
await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ');
|
|
20
|
-
const result = await body(handle);
|
|
21
|
-
await client.query('COMMIT');
|
|
22
|
-
return result;
|
|
23
|
-
} catch (error) {
|
|
24
|
-
await client.query('ROLLBACK').catch(() => {});
|
|
25
|
-
// Only confirmed aborted transactions are retried, never unknown commit outcomes.
|
|
26
|
-
if (['40001', '40P01'].includes(error.code) && attempt < 5) continue;
|
|
27
|
-
throw normalizeDatabaseError(error);
|
|
28
|
-
} finally { active = false; sessions.delete(handle); client.release(); }
|
|
29
|
-
}
|
|
30
|
-
};
|
|
31
|
-
return { withTransaction, requireSession };
|
|
32
|
-
};
|
|
4
|
+
export const createTransactions = (getPool, assertReady) => createSQLTransactions(() => ({ pool: getPool() }), assertReady, postgresPlugin());
|
package/types/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ModelRegistration, Runtime } from '@simtlix/simfinity-core';
|
|
2
|
+
import type { SQLPlugin } from '@simtlix/simfinity-sql';
|
|
2
3
|
import { auth, plugins, scalars, validators } from '@simtlix/simfinity-core';
|
|
3
4
|
import type { GraphQLObjectType } from 'graphql';
|
|
4
5
|
export {
|
|
@@ -153,6 +154,13 @@ export interface PostgresRuntime extends Runtime<PostgresModel, PostgresSession>
|
|
|
153
154
|
initializeDatabase(options?: InitializationOptions): Promise<InitializationResult>;
|
|
154
155
|
withTransaction<T>(session: PostgresSession | null | undefined, callback: (session: PostgresSession) => Promise<T> | T): Promise<T>;
|
|
155
156
|
}
|
|
157
|
+
/** PostgreSQL 15+ plugin for createSQL; omitted options allow later runtime.configure. */
|
|
158
|
+
export function postgresPlugin(options?: PostgresConfiguration): SQLPlugin<
|
|
159
|
+
PostgresConfiguration,
|
|
160
|
+
DatabaseQueryable,
|
|
161
|
+
DatabaseDescription,
|
|
162
|
+
{ rows: Record<string, unknown>[]; rowCount: number | null }
|
|
163
|
+
>;
|
|
156
164
|
export function createPostgres(options?: PostgresConfiguration): PostgresRuntime;
|
|
157
165
|
export const configure: PostgresRuntime['configure'];
|
|
158
166
|
export const connect: PostgresRuntime['connect'];
|