@yunsoft/yuncms-core 0.1.23 → 0.1.25
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 +6 -2
- package/src/bootstrap.js +2 -0
- package/src/migrations/0021-ai-automations.js +43 -0
- package/src/query.js +48 -11
- package/src/services/items-service.js +7 -2
- package/src/transaction.js +54 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yunsoft/yuncms-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.25",
|
|
4
4
|
"description": "Core services, schema, authentication, permissions and storage primitives for YunCMS.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@aws-sdk/client-s3": "3.1100.0",
|
|
33
33
|
"mysql2": "3.23.2",
|
|
34
|
-
"nodemailer": "
|
|
34
|
+
"nodemailer": "10.0.13"
|
|
35
|
+
},
|
|
36
|
+
"funding": {
|
|
37
|
+
"type": "github",
|
|
38
|
+
"url": "https://github.com/sponsors/Yunsoft-Software"
|
|
35
39
|
}
|
|
36
40
|
}
|
package/src/bootstrap.js
CHANGED
|
@@ -20,6 +20,7 @@ import { mcpSettingsMigration } from './migrations/0017-mcp-settings.js';
|
|
|
20
20
|
import { publicRegistrationSettingsMigration } from './migrations/0018-public-registration-settings.js';
|
|
21
21
|
import { publicRegistrationEmailVerificationMigration } from './migrations/0019-public-registration-email-verification.js';
|
|
22
22
|
import { studioLocalesMigration } from './migrations/0020-studio-locales.js';
|
|
23
|
+
import { aiAutomationsMigration } from './migrations/0021-ai-automations.js';
|
|
23
24
|
import { readSchemaVersion } from './schema-version.js';
|
|
24
25
|
import { ensurePublicRole } from './setup.js';
|
|
25
26
|
|
|
@@ -44,6 +45,7 @@ export const CORE_MIGRATIONS = Object.freeze([
|
|
|
44
45
|
publicRegistrationSettingsMigration,
|
|
45
46
|
publicRegistrationEmailVerificationMigration,
|
|
46
47
|
studioLocalesMigration,
|
|
48
|
+
aiAutomationsMigration,
|
|
47
49
|
]);
|
|
48
50
|
|
|
49
51
|
export const REQUIRED_CORE_MIGRATION_IDS = Object.freeze(
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export const aiAutomationsMigration = Object.freeze({
|
|
2
|
+
id: '0021-ai-automations',
|
|
3
|
+
statements: [
|
|
4
|
+
`CREATE TABLE yuncms_ai_automations (
|
|
5
|
+
id CHAR(36) NOT NULL PRIMARY KEY,
|
|
6
|
+
name VARCHAR(120) NOT NULL,
|
|
7
|
+
collection VARCHAR(64) NOT NULL,
|
|
8
|
+
run_as CHAR(36) NOT NULL,
|
|
9
|
+
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
|
10
|
+
on_create TINYINT(1) NOT NULL DEFAULT 1,
|
|
11
|
+
on_update TINYINT(1) NOT NULL DEFAULT 0,
|
|
12
|
+
input_fields JSON NOT NULL,
|
|
13
|
+
output_fields JSON NOT NULL,
|
|
14
|
+
instruction TEXT NOT NULL,
|
|
15
|
+
revision INT UNSIGNED NOT NULL DEFAULT 1,
|
|
16
|
+
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
17
|
+
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
|
18
|
+
INDEX idx_ai_automations_collection (collection, enabled),
|
|
19
|
+
CONSTRAINT fk_ai_automation_collection FOREIGN KEY (collection)
|
|
20
|
+
REFERENCES yuncms_collections (collection) ON DELETE CASCADE,
|
|
21
|
+
CONSTRAINT fk_ai_automation_user FOREIGN KEY (run_as)
|
|
22
|
+
REFERENCES yuncms_users (id) ON DELETE RESTRICT
|
|
23
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
|
24
|
+
`CREATE TABLE yuncms_ai_automation_runs (
|
|
25
|
+
id CHAR(36) NOT NULL PRIMARY KEY,
|
|
26
|
+
automation_id CHAR(36) NOT NULL,
|
|
27
|
+
revision INT UNSIGNED NOT NULL,
|
|
28
|
+
item_key VARCHAR(191) NOT NULL,
|
|
29
|
+
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
|
30
|
+
attempts INT UNSIGNED NOT NULL DEFAULT 0,
|
|
31
|
+
error_code VARCHAR(100) NULL,
|
|
32
|
+
available_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
33
|
+
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
34
|
+
finished_at DATETIME(3) NULL,
|
|
35
|
+
INDEX idx_ai_automation_queue (status, available_at),
|
|
36
|
+
INDEX idx_ai_automation_history (automation_id, created_at),
|
|
37
|
+
CONSTRAINT fk_ai_automation_run FOREIGN KEY (automation_id)
|
|
38
|
+
REFERENCES yuncms_ai_automations (id) ON DELETE CASCADE,
|
|
39
|
+
CONSTRAINT chk_ai_automation_run_status
|
|
40
|
+
CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'skipped'))
|
|
41
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
|
42
|
+
],
|
|
43
|
+
});
|
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);
|
|
@@ -222,26 +225,60 @@ export function compileAggregate(aggregate, groupBy, schema) {
|
|
|
222
225
|
};
|
|
223
226
|
}
|
|
224
227
|
|
|
225
|
-
function
|
|
228
|
+
function datetimeFilterValue(value, path, { allowUnresolvedDynamicVariables = false } = {}) {
|
|
229
|
+
// Schema-only permission validation keeps recognized dynamic values unresolved.
|
|
230
|
+
// resolveDynamicVariables already validates their syntax before compilation.
|
|
231
|
+
if (allowUnresolvedDynamicVariables && typeof value === 'string'
|
|
232
|
+
&& (value === '$NOW' || value.toUpperCase().startsWith('$NOW('))) return value;
|
|
233
|
+
if (value instanceof Date) {
|
|
234
|
+
if (Number.isNaN(value.getTime()) || value.getUTCFullYear() < 1000 || value.getUTCFullYear() > 9999) {
|
|
235
|
+
throw queryError('Invalid datetime filter value', path);
|
|
236
|
+
}
|
|
237
|
+
return value;
|
|
238
|
+
}
|
|
239
|
+
const match = typeof value === 'string'
|
|
240
|
+
? /^(\d{4})-(\d{2})-(\d{2})([T ])(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?(Z|[+-]\d{2}:\d{2})?$/u.exec(value)
|
|
241
|
+
: null;
|
|
242
|
+
if (!match || (match[4] === 'T' ? !match[9] : Boolean(match[9]))) {
|
|
243
|
+
throw queryError('Datetime filters require a timezone-bearing ISO value or a MySQL datetime', path);
|
|
244
|
+
}
|
|
245
|
+
const [, year, month, day, , hour, minute, second, , zone] = match;
|
|
246
|
+
const daysInMonth = new Date(Date.UTC(Number(year), Number(month), 0)).getUTCDate();
|
|
247
|
+
if (Number(year) < 1000 || Number(month) < 1 || Number(month) > 12
|
|
248
|
+
|| Number(day) < 1 || Number(day) > daysInMonth
|
|
249
|
+
|| Number(hour) > 23 || Number(minute) > 59 || Number(second) > 59) {
|
|
250
|
+
throw queryError('Invalid datetime filter value', path);
|
|
251
|
+
}
|
|
252
|
+
if (!zone) return value;
|
|
253
|
+
const date = new Date(value);
|
|
254
|
+
if (Number.isNaN(date.getTime()) || date.getUTCFullYear() < 1000 || date.getUTCFullYear() > 9999) {
|
|
255
|
+
throw queryError('Invalid datetime filter value', path);
|
|
256
|
+
}
|
|
257
|
+
return date;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function compileOperator(fieldSql, operator, value, path, limits, field) {
|
|
226
261
|
if (!FILTER_OPERATORS.has(operator)) throw queryError(`Unknown filter operator: ${operator}`, path);
|
|
262
|
+
const temporal = field.type === 'datetime' || field.type === 'timestamp';
|
|
263
|
+
const normalize = (entry) => temporal ? datetimeFilterValue(entry, path, limits) : entry;
|
|
227
264
|
switch (operator) {
|
|
228
265
|
case '_eq':
|
|
229
266
|
if (value === null) throw queryError('Use _null for NULL comparisons', path);
|
|
230
|
-
return { sql: `${fieldSql} = ?`, params: [value] };
|
|
267
|
+
return { sql: `${fieldSql} = ?`, params: [normalize(value)] };
|
|
231
268
|
case '_neq':
|
|
232
269
|
if (value === null) throw queryError('Use _nnull for NULL comparisons', path);
|
|
233
|
-
return { sql: `${fieldSql} <> ?`, params: [value] };
|
|
234
|
-
case '_lt': return { sql: `${fieldSql} < ?`, params: [value] };
|
|
235
|
-
case '_lte': return { sql: `${fieldSql} <= ?`, params: [value] };
|
|
236
|
-
case '_gt': return { sql: `${fieldSql} > ?`, params: [value] };
|
|
237
|
-
case '_gte': return { sql: `${fieldSql} >= ?`, params: [value] };
|
|
270
|
+
return { sql: `${fieldSql} <> ?`, params: [normalize(value)] };
|
|
271
|
+
case '_lt': return { sql: `${fieldSql} < ?`, params: [normalize(value)] };
|
|
272
|
+
case '_lte': return { sql: `${fieldSql} <= ?`, params: [normalize(value)] };
|
|
273
|
+
case '_gt': return { sql: `${fieldSql} > ?`, params: [normalize(value)] };
|
|
274
|
+
case '_gte': return { sql: `${fieldSql} >= ?`, params: [normalize(value)] };
|
|
238
275
|
case '_in':
|
|
239
276
|
case '_nin': {
|
|
240
277
|
if (!Array.isArray(value)) throw queryError(`${operator} requires an array`, path);
|
|
241
278
|
if (value.length > limits.maxInValues) throw queryError(`${operator} accepts at most ${limits.maxInValues} values`, path);
|
|
242
279
|
if (value.length === 0) return { sql: operator === '_in' ? '0 = 1' : '1 = 1', params: [] };
|
|
243
280
|
const placeholders = value.map(() => '?').join(', ');
|
|
244
|
-
return { sql: `${fieldSql} ${operator === '_in' ? 'IN' : 'NOT IN'} (${placeholders})`, params: value };
|
|
281
|
+
return { sql: `${fieldSql} ${operator === '_in' ? 'IN' : 'NOT IN'} (${placeholders})`, params: value.map(normalize) };
|
|
245
282
|
}
|
|
246
283
|
case '_null':
|
|
247
284
|
case '_nnull': {
|
|
@@ -272,12 +309,12 @@ function compileFilterObject(filter, schema, path, limits, state, depth) {
|
|
|
272
309
|
for (const child of children) params.push(...child.params);
|
|
273
310
|
continue;
|
|
274
311
|
}
|
|
275
|
-
resolveField(schema, key, `${path}.${key}`);
|
|
312
|
+
const field = resolveField(schema, key, `${path}.${key}`);
|
|
276
313
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw queryError('Field filters must be operator objects', `${path}.${key}`);
|
|
277
314
|
const fieldSql = quoteIdentifier(key, 'field name');
|
|
278
315
|
const fieldFragments = [];
|
|
279
316
|
for (const [operator, operatorValue] of Object.entries(value)) {
|
|
280
|
-
const compiled = compileOperator(fieldSql, operator, operatorValue, `${path}.${key}.${operator}`, limits);
|
|
317
|
+
const compiled = compileOperator(fieldSql, operator, operatorValue, `${path}.${key}.${operator}`, limits, field);
|
|
281
318
|
fieldFragments.push(compiled.sql);
|
|
282
319
|
params.push(...compiled.params);
|
|
283
320
|
}
|
|
@@ -295,7 +332,7 @@ export function compileFilter(filter, schema, options = {}) {
|
|
|
295
332
|
allowUnresolvedDynamicVariables = false,
|
|
296
333
|
...limitOptions
|
|
297
334
|
} = options;
|
|
298
|
-
const limits = { ...QUERY_LIMITS, ...limitOptions };
|
|
335
|
+
const limits = { ...QUERY_LIMITS, ...limitOptions, allowUnresolvedDynamicVariables };
|
|
299
336
|
const resolvedFilter = resolveDynamicVariables(filter, dynamicVariables, {
|
|
300
337
|
allowUnresolved: allowUnresolvedDynamicVariables,
|
|
301
338
|
path: 'filter',
|
|
@@ -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) {
|
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
|
}
|