@yunsoft/yuncms-core 0.1.22 → 0.1.24
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 +45 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yunsoft/yuncms-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.24",
|
|
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
|
@@ -222,26 +222,60 @@ export function compileAggregate(aggregate, groupBy, schema) {
|
|
|
222
222
|
};
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
function
|
|
225
|
+
function datetimeFilterValue(value, path, { allowUnresolvedDynamicVariables = false } = {}) {
|
|
226
|
+
// Schema-only permission validation keeps recognized dynamic values unresolved.
|
|
227
|
+
// resolveDynamicVariables already validates their syntax before compilation.
|
|
228
|
+
if (allowUnresolvedDynamicVariables && typeof value === 'string'
|
|
229
|
+
&& (value === '$NOW' || value.toUpperCase().startsWith('$NOW('))) return value;
|
|
230
|
+
if (value instanceof Date) {
|
|
231
|
+
if (Number.isNaN(value.getTime()) || value.getUTCFullYear() < 1000 || value.getUTCFullYear() > 9999) {
|
|
232
|
+
throw queryError('Invalid datetime filter value', path);
|
|
233
|
+
}
|
|
234
|
+
return value;
|
|
235
|
+
}
|
|
236
|
+
const match = typeof value === 'string'
|
|
237
|
+
? /^(\d{4})-(\d{2})-(\d{2})([T ])(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?(Z|[+-]\d{2}:\d{2})?$/u.exec(value)
|
|
238
|
+
: null;
|
|
239
|
+
if (!match || (match[4] === 'T' ? !match[9] : Boolean(match[9]))) {
|
|
240
|
+
throw queryError('Datetime filters require a timezone-bearing ISO value or a MySQL datetime', path);
|
|
241
|
+
}
|
|
242
|
+
const [, year, month, day, , hour, minute, second, , zone] = match;
|
|
243
|
+
const daysInMonth = new Date(Date.UTC(Number(year), Number(month), 0)).getUTCDate();
|
|
244
|
+
if (Number(year) < 1000 || Number(month) < 1 || Number(month) > 12
|
|
245
|
+
|| Number(day) < 1 || Number(day) > daysInMonth
|
|
246
|
+
|| Number(hour) > 23 || Number(minute) > 59 || Number(second) > 59) {
|
|
247
|
+
throw queryError('Invalid datetime filter value', path);
|
|
248
|
+
}
|
|
249
|
+
if (!zone) return value;
|
|
250
|
+
const date = new Date(value);
|
|
251
|
+
if (Number.isNaN(date.getTime()) || date.getUTCFullYear() < 1000 || date.getUTCFullYear() > 9999) {
|
|
252
|
+
throw queryError('Invalid datetime filter value', path);
|
|
253
|
+
}
|
|
254
|
+
return date;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function compileOperator(fieldSql, operator, value, path, limits, field) {
|
|
226
258
|
if (!FILTER_OPERATORS.has(operator)) throw queryError(`Unknown filter operator: ${operator}`, path);
|
|
259
|
+
const temporal = field.type === 'datetime' || field.type === 'timestamp';
|
|
260
|
+
const normalize = (entry) => temporal ? datetimeFilterValue(entry, path, limits) : entry;
|
|
227
261
|
switch (operator) {
|
|
228
262
|
case '_eq':
|
|
229
263
|
if (value === null) throw queryError('Use _null for NULL comparisons', path);
|
|
230
|
-
return { sql: `${fieldSql} = ?`, params: [value] };
|
|
264
|
+
return { sql: `${fieldSql} = ?`, params: [normalize(value)] };
|
|
231
265
|
case '_neq':
|
|
232
266
|
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] };
|
|
267
|
+
return { sql: `${fieldSql} <> ?`, params: [normalize(value)] };
|
|
268
|
+
case '_lt': return { sql: `${fieldSql} < ?`, params: [normalize(value)] };
|
|
269
|
+
case '_lte': return { sql: `${fieldSql} <= ?`, params: [normalize(value)] };
|
|
270
|
+
case '_gt': return { sql: `${fieldSql} > ?`, params: [normalize(value)] };
|
|
271
|
+
case '_gte': return { sql: `${fieldSql} >= ?`, params: [normalize(value)] };
|
|
238
272
|
case '_in':
|
|
239
273
|
case '_nin': {
|
|
240
274
|
if (!Array.isArray(value)) throw queryError(`${operator} requires an array`, path);
|
|
241
275
|
if (value.length > limits.maxInValues) throw queryError(`${operator} accepts at most ${limits.maxInValues} values`, path);
|
|
242
276
|
if (value.length === 0) return { sql: operator === '_in' ? '0 = 1' : '1 = 1', params: [] };
|
|
243
277
|
const placeholders = value.map(() => '?').join(', ');
|
|
244
|
-
return { sql: `${fieldSql} ${operator === '_in' ? 'IN' : 'NOT IN'} (${placeholders})`, params: value };
|
|
278
|
+
return { sql: `${fieldSql} ${operator === '_in' ? 'IN' : 'NOT IN'} (${placeholders})`, params: value.map(normalize) };
|
|
245
279
|
}
|
|
246
280
|
case '_null':
|
|
247
281
|
case '_nnull': {
|
|
@@ -272,12 +306,12 @@ function compileFilterObject(filter, schema, path, limits, state, depth) {
|
|
|
272
306
|
for (const child of children) params.push(...child.params);
|
|
273
307
|
continue;
|
|
274
308
|
}
|
|
275
|
-
resolveField(schema, key, `${path}.${key}`);
|
|
309
|
+
const field = resolveField(schema, key, `${path}.${key}`);
|
|
276
310
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw queryError('Field filters must be operator objects', `${path}.${key}`);
|
|
277
311
|
const fieldSql = quoteIdentifier(key, 'field name');
|
|
278
312
|
const fieldFragments = [];
|
|
279
313
|
for (const [operator, operatorValue] of Object.entries(value)) {
|
|
280
|
-
const compiled = compileOperator(fieldSql, operator, operatorValue, `${path}.${key}.${operator}`, limits);
|
|
314
|
+
const compiled = compileOperator(fieldSql, operator, operatorValue, `${path}.${key}.${operator}`, limits, field);
|
|
281
315
|
fieldFragments.push(compiled.sql);
|
|
282
316
|
params.push(...compiled.params);
|
|
283
317
|
}
|
|
@@ -295,7 +329,7 @@ export function compileFilter(filter, schema, options = {}) {
|
|
|
295
329
|
allowUnresolvedDynamicVariables = false,
|
|
296
330
|
...limitOptions
|
|
297
331
|
} = options;
|
|
298
|
-
const limits = { ...QUERY_LIMITS, ...limitOptions };
|
|
332
|
+
const limits = { ...QUERY_LIMITS, ...limitOptions, allowUnresolvedDynamicVariables };
|
|
299
333
|
const resolvedFilter = resolveDynamicVariables(filter, dynamicVariables, {
|
|
300
334
|
allowUnresolved: allowUnresolvedDynamicVariables,
|
|
301
335
|
path: 'filter',
|