@yunsoft/yuncms-core 0.1.24 → 0.1.26
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/package.json +1 -1
- package/src/query.js +3 -0
- package/src/services/items-service.js +16 -7
- package/src/transaction.js +54 -7
package/package.json
CHANGED
package/src/query.js
CHANGED
|
@@ -47,6 +47,9 @@ function normalizeDelimited(value, label, { maxItems }) {
|
|
|
47
47
|
|
|
48
48
|
function normalizeInteger(value, fallback, { label, min, max }) {
|
|
49
49
|
if (value == null || value === '') return fallback;
|
|
50
|
+
if (typeof value === 'object' || typeof value === 'boolean') {
|
|
51
|
+
throw queryError(`${label} must be an integer between ${min} and ${max}`, label);
|
|
52
|
+
}
|
|
50
53
|
const number = Number(value);
|
|
51
54
|
if (!Number.isInteger(number) || number < min || number > max) {
|
|
52
55
|
throw queryError(`${label} must be an integer between ${min} and ${max}`, label);
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from '../query.js';
|
|
15
15
|
import { SchemaCache } from '../schema.js';
|
|
16
16
|
import { isSystemManagedField, systemMutationEntries } from '../system-fields.js';
|
|
17
|
-
import { withTransaction } from '../transaction.js';
|
|
17
|
+
import { dispatchAfterCommit, transactionContext, withTransaction } from '../transaction.js';
|
|
18
18
|
import { BaseService } from './base-service.js';
|
|
19
19
|
import { PermissionsService } from './permissions-service.js';
|
|
20
20
|
|
|
@@ -139,6 +139,8 @@ export class ItemsService extends BaseService {
|
|
|
139
139
|
accountability: this.accountability,
|
|
140
140
|
collection: this.collection,
|
|
141
141
|
requestId: this.requestId,
|
|
142
|
+
database: this.database,
|
|
143
|
+
transaction: transactionContext(this.database),
|
|
142
144
|
...extra,
|
|
143
145
|
};
|
|
144
146
|
}
|
|
@@ -150,7 +152,10 @@ export class ItemsService extends BaseService {
|
|
|
150
152
|
|
|
151
153
|
async actionMutation(event, payload, context = {}) {
|
|
152
154
|
if (!this.emitter) return;
|
|
153
|
-
|
|
155
|
+
const eventContext = this.hookContext(context);
|
|
156
|
+
const eventPayload = eventContext.transaction ? structuredClone(payload) : payload;
|
|
157
|
+
await dispatchAfterCommit(this.database, (database, transaction) =>
|
|
158
|
+
this.emitter.action(event, eventPayload, { ...eventContext, database, transaction }));
|
|
154
159
|
}
|
|
155
160
|
|
|
156
161
|
async getCollectionSchema(database = this.database) {
|
|
@@ -226,10 +231,10 @@ export class ItemsService extends BaseService {
|
|
|
226
231
|
return combineCompiledFilters(permissionSql, userSql, searchSql);
|
|
227
232
|
}
|
|
228
233
|
|
|
229
|
-
async normalizeReadQuery(rawQuery) {
|
|
234
|
+
async normalizeReadQuery(rawQuery, context = {}) {
|
|
230
235
|
const parsed = parseItemsQuery(rawQuery);
|
|
231
236
|
const filtered = this.emitter
|
|
232
|
-
? await this.emitter.filter('items.query', parsed, this.hookContext({ operation: 'read' }))
|
|
237
|
+
? await this.emitter.filter('items.query', parsed, this.hookContext({ ...context, operation: 'read' }))
|
|
233
238
|
: parsed;
|
|
234
239
|
const query = parseItemsQuery(filtered);
|
|
235
240
|
assertQueryCost(query);
|
|
@@ -358,14 +363,18 @@ export class ItemsService extends BaseService {
|
|
|
358
363
|
|
|
359
364
|
async readOne(id, { fields = null } = {}) {
|
|
360
365
|
const schema = await this.getCollectionSchema();
|
|
366
|
+
const query = await this.normalizeReadQuery({ fields, limit: 1 }, { single: true, key: id });
|
|
367
|
+
if (query.aggregate) {
|
|
368
|
+
throw serviceError('INVALID_QUERY', 'Single-item reads do not support aggregate queries', 'aggregate');
|
|
369
|
+
}
|
|
361
370
|
const permission = await this.resolvePermission('read');
|
|
362
371
|
const accessSchema = schemaForFields(schema, permission.fields);
|
|
363
|
-
const selected = compileSelectFields(
|
|
372
|
+
const selected = compileSelectFields(query.fields, accessSchema);
|
|
364
373
|
const table = quoteIdentifier(this.collection, 'collection name');
|
|
365
374
|
const primaryKey = schema.primary_key;
|
|
366
375
|
const dynamicVariables = this.dynamicVariables();
|
|
367
376
|
const filter = combineCompiledFilters(
|
|
368
|
-
|
|
377
|
+
this.compileActionFilters(query.filter, permission.filter, accessSchema, schema, query.search, dynamicVariables),
|
|
369
378
|
compileFilter({ [primaryKey]: { _eq: id } }, schema),
|
|
370
379
|
);
|
|
371
380
|
const [rows] = await this.database.query(
|
|
@@ -373,7 +382,7 @@ export class ItemsService extends BaseService {
|
|
|
373
382
|
filter.params,
|
|
374
383
|
);
|
|
375
384
|
const record = rows[0] ?? null;
|
|
376
|
-
if (record) await this.emitReadAction(
|
|
385
|
+
if (record) await this.emitReadAction(query, [record], schema, { single: true });
|
|
377
386
|
return record;
|
|
378
387
|
}
|
|
379
388
|
|
package/src/transaction.js
CHANGED
|
@@ -5,6 +5,33 @@ const ISOLATION_LEVELS = new Set([
|
|
|
5
5
|
'SERIALIZABLE',
|
|
6
6
|
]);
|
|
7
7
|
|
|
8
|
+
const transactions = new WeakMap();
|
|
9
|
+
|
|
10
|
+
export function transactionContext(database) {
|
|
11
|
+
return transactions.has(database) ? Object.freeze({ managed: true, state: 'active' }) : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function dispatchAfterCommit(database, action) {
|
|
15
|
+
const transaction = transactions.get(database);
|
|
16
|
+
if (transaction) {
|
|
17
|
+
transaction.actions.push(action);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
await action(database, null);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function validateOperation(database, operation) {
|
|
24
|
+
if (!database) throw new Error('Database connection is required');
|
|
25
|
+
if (typeof operation !== 'function') throw new Error('Transaction operation is required');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function joinTransaction(connection, operation, options) {
|
|
29
|
+
if (options.isolationLevel && String(options.isolationLevel).toUpperCase() !== transactions.get(connection).isolationLevel) {
|
|
30
|
+
throw new Error('A nested transaction cannot change the outer isolation level');
|
|
31
|
+
}
|
|
32
|
+
return operation(connection);
|
|
33
|
+
}
|
|
34
|
+
|
|
8
35
|
async function setIsolationLevel(connection, isolationLevel) {
|
|
9
36
|
if (!isolationLevel) return;
|
|
10
37
|
const level = String(isolationLevel).toUpperCase();
|
|
@@ -14,17 +41,16 @@ async function setIsolationLevel(connection, isolationLevel) {
|
|
|
14
41
|
await connection.query(`SET TRANSACTION ISOLATION LEVEL ${level}`);
|
|
15
42
|
}
|
|
16
43
|
|
|
17
|
-
|
|
18
|
-
if (!connection) throw new Error('Database connection is required');
|
|
19
|
-
if (typeof operation !== 'function') throw new Error('Transaction operation is required');
|
|
20
|
-
|
|
44
|
+
async function executeTransaction(connection, operation, options) {
|
|
21
45
|
await setIsolationLevel(connection, options.isolationLevel);
|
|
22
46
|
await connection.beginTransaction();
|
|
47
|
+
const transaction = { actions: [], isolationLevel: options.isolationLevel ? String(options.isolationLevel).toUpperCase() : null };
|
|
48
|
+
transactions.set(connection, transaction);
|
|
23
49
|
|
|
24
50
|
try {
|
|
25
51
|
const result = await operation(connection);
|
|
26
52
|
await connection.commit();
|
|
27
|
-
return result;
|
|
53
|
+
return { result, actions: transaction.actions };
|
|
28
54
|
} catch (error) {
|
|
29
55
|
try {
|
|
30
56
|
await connection.rollback();
|
|
@@ -32,15 +58,36 @@ export async function withConnectionTransaction(connection, operation, options =
|
|
|
32
58
|
error.rollbackError = rollbackError;
|
|
33
59
|
}
|
|
34
60
|
throw error;
|
|
61
|
+
} finally {
|
|
62
|
+
transactions.delete(connection);
|
|
35
63
|
}
|
|
36
64
|
}
|
|
37
65
|
|
|
66
|
+
async function finishActions(committed, database) {
|
|
67
|
+
const context = Object.freeze({ managed: true, state: 'committed' });
|
|
68
|
+
for (const action of committed.actions) await action(database, context);
|
|
69
|
+
return committed.result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function withConnectionTransaction(connection, operation, options = {}) {
|
|
73
|
+
validateOperation(connection, operation);
|
|
74
|
+
if (transactions.has(connection)) return joinTransaction(connection, operation, options);
|
|
75
|
+
const committed = await executeTransaction(connection, operation, options);
|
|
76
|
+
// This connection belongs to the caller and remains usable for post-commit actions.
|
|
77
|
+
return finishActions(committed, connection);
|
|
78
|
+
}
|
|
79
|
+
|
|
38
80
|
export async function withTransaction(pool, operation, options = {}) {
|
|
81
|
+
validateOperation(pool, operation);
|
|
82
|
+
if (transactions.has(pool)) return joinTransaction(pool, operation, options);
|
|
83
|
+
if (typeof pool.getConnection !== 'function') return withConnectionTransaction(pool, operation, options);
|
|
39
84
|
const connection = await pool.getConnection();
|
|
40
|
-
|
|
85
|
+
let committed;
|
|
41
86
|
try {
|
|
42
|
-
|
|
87
|
+
committed = await executeTransaction(connection, operation, options);
|
|
43
88
|
} finally {
|
|
44
89
|
connection.release();
|
|
45
90
|
}
|
|
91
|
+
// Return the borrowed connection before hooks acquire connections from the pool.
|
|
92
|
+
return finishActions(committed, pool);
|
|
46
93
|
}
|