@yunsoft/yuncms-core 0.1.23 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunsoft/yuncms-core",
3
- "version": "0.1.23",
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": "9.1.1"
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 compileOperator(fieldSql, operator, value, path, limits) {
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',