@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunsoft/yuncms-core",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "Core services, schema, authentication, permissions and storage primitives for YunCMS.",
5
5
  "license": "MIT",
6
6
  "type": "module",
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
- await this.emitter.action(event, payload, this.hookContext(context));
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(normalizeFields(fields), accessSchema);
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
- compileFilter(permission.filter, schema, { dynamicVariables }),
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({ fields: normalizeFields(fields) }, [record], schema, { single: true });
385
+ if (record) await this.emitReadAction(query, [record], schema, { single: true });
377
386
  return record;
378
387
  }
379
388
 
@@ -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
- export async function withConnectionTransaction(connection, operation, options = {}) {
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
- return await withConnectionTransaction(connection, operation, options);
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
  }