@yunsoft/yuncms-api 0.1.5 → 0.1.6

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-api",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Express API runtime and bundled Studio server for YunCMS.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,7 +32,13 @@
32
32
  "test": "node --test"
33
33
  },
34
34
  "dependencies": {
35
- "@yunsoft/yuncms-core": "0.1.5",
36
- "express": "5.2.1"
35
+ "@modelcontextprotocol/node": "2.0.0",
36
+ "@modelcontextprotocol/server": "2.0.0",
37
+ "@node-saml/node-saml": "5.1.0",
38
+ "@yunsoft/yuncms-core": "0.1.6",
39
+ "express": "5.2.1",
40
+ "ldapts": "9.0.0",
41
+ "openid-client": "6.8.7",
42
+ "zod": "4.4.3"
37
43
  }
38
44
  }
package/src/app.js CHANGED
@@ -23,19 +23,10 @@ import { createStudioMiddleware } from './studio.js';
23
23
 
24
24
  const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;
25
25
  const CONTENT_SECURITY_POLICY = [
26
- "default-src 'self'",
27
- "base-uri 'self'",
28
- "object-src 'none'",
29
- "frame-ancestors 'none'",
30
- "script-src 'self'",
31
- "style-src 'self' 'unsafe-inline'",
32
- "img-src 'self' data: blob: https://yunsoft.com",
33
- "font-src 'self' data:",
34
- "connect-src 'self'",
35
- "media-src 'self' blob:",
36
- "frame-src 'self' blob:",
37
- "worker-src 'self' blob:",
38
- "form-action 'self'",
26
+ "default-src 'self'", "base-uri 'self'", "object-src 'none'", "frame-ancestors 'none'",
27
+ "script-src 'self'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob: https://yunsoft.com",
28
+ "font-src 'self' data:", "connect-src 'self'", "media-src 'self' blob:", "frame-src 'self' blob:",
29
+ "worker-src 'self' blob:", "form-action 'self'",
39
30
  ].join('; ');
40
31
 
41
32
  function securityHeaders(req, res, next) {
@@ -47,9 +38,7 @@ function securityHeaders(req, res, next) {
47
38
  res.set('cross-origin-opener-policy', 'same-origin');
48
39
  res.set('x-dns-prefetch-control', 'off');
49
40
  res.set('content-security-policy', CONTENT_SECURITY_POLICY);
50
- if (req.secure === true) {
51
- res.set('strict-transport-security', 'max-age=15552000; includeSubDomains');
52
- }
41
+ if (req.secure === true) res.set('strict-transport-security', 'max-age=15552000; includeSubDomains');
53
42
  next();
54
43
  }
55
44
 
@@ -57,14 +46,12 @@ function studioCors(config) {
57
46
  return (req, res, next) => {
58
47
  const origin = req.get('origin');
59
48
  const allowedOrigin = config?.server?.studioOrigin;
60
-
61
49
  if (origin && allowedOrigin && origin === allowedOrigin) {
62
50
  res.set('access-control-allow-origin', origin);
63
51
  res.set('vary', 'Origin');
64
- res.set('access-control-allow-headers', 'content-type, authorization, x-request-id, x-filename, x-title, x-mimetype');
52
+ res.set('access-control-allow-headers', 'content-type, authorization, x-request-id, x-filename, x-title, x-mimetype, mcp-protocol-version, mcp-method, mcp-name');
65
53
  res.set('access-control-allow-methods', 'GET,POST,PATCH,DELETE,OPTIONS');
66
54
  }
67
-
68
55
  if (req.method === 'OPTIONS') return res.sendStatus(204);
69
56
  return next();
70
57
  };
@@ -77,16 +64,55 @@ function requestIdentity(req, res, next) {
77
64
  next();
78
65
  }
79
66
 
80
- function createApiRateLimit(config) {
67
+ function createApiRateLimit(config, rateLimitStore = null, logger = console) {
81
68
  const limits = config?.server?.rateLimit;
82
69
  if (!limits?.enabled) return null;
83
70
  return createFixedWindowRateLimit({
84
71
  windowMs: limits.windowMs,
85
72
  max: limits.max,
86
73
  maxBuckets: limits.maxBuckets,
74
+ store: limits.store === 'redis' ? rateLimitStore : null,
75
+ scope: 'api',
76
+ failureMode: limits.failureMode ?? 'best-effort',
77
+ logger,
87
78
  });
88
79
  }
89
80
 
81
+ function requestEventMetadata(req, res, startedAt) {
82
+ return {
83
+ requestId: req.id ?? null,
84
+ method: req.method,
85
+ route: req.route?.path ?? req.path,
86
+ status: res.statusCode,
87
+ durationMs: Math.max(0, Date.now() - startedAt),
88
+ accountability: req.accountability ? {
89
+ user: req.accountability.user ?? null,
90
+ role: req.accountability.role ?? null,
91
+ admin: req.accountability.admin === true,
92
+ system: req.accountability.system === true,
93
+ } : null,
94
+ ip: req.ip ?? null,
95
+ };
96
+ }
97
+
98
+ function createRequestEvents(emitter) {
99
+ if (!emitter) return null;
100
+ return (req, res, next) => {
101
+ const startedAt = Date.now();
102
+ emitter.action('request.received', requestEventMetadata(req, res, startedAt), {
103
+ accountability: req.accountability,
104
+ requestId: req.id,
105
+ }).catch(() => {});
106
+ res.once('finish', () => {
107
+ emitter.action('request.completed', requestEventMetadata(req, res, startedAt), {
108
+ accountability: req.accountability,
109
+ requestId: req.id,
110
+ }).catch(() => {});
111
+ });
112
+ next();
113
+ };
114
+ }
115
+
90
116
  export function createApp({
91
117
  pool,
92
118
  config,
@@ -97,7 +123,11 @@ export function createApp({
97
123
  emitter = null,
98
124
  storage = null,
99
125
  mailer = null,
126
+ rateLimitStore = null,
127
+ redisClient = null,
128
+ externalAuthRegistry = null,
100
129
  endpointExtensions = [],
130
+ mcpRouter = null,
101
131
  studioRoot = undefined,
102
132
  }) {
103
133
  if (!pool) throw new Error('Database pool is required');
@@ -118,40 +148,48 @@ export function createApp({
118
148
  });
119
149
 
120
150
  app.get('/ready', async (req, res) => {
151
+ const failures = [];
121
152
  try {
122
153
  const ready = await pingDatabase(pool);
123
- if (!ready) throw new Error('Database ping returned an unexpected result');
124
- res.json({ status: 'ready', request_id: req.id });
125
- } catch (error) {
126
- logger.warn?.('YunCMS readiness check failed', { requestId: req.id, error });
127
- res.status(503).json({
128
- status: 'not_ready',
129
- request_id: req.id,
130
- errors: [{ code: 'DATABASE_UNAVAILABLE', message: 'Database is not ready' }],
131
- });
154
+ if (!ready) failures.push({ code: 'DATABASE_UNAVAILABLE', message: 'Database is not ready' });
155
+ } catch {
156
+ failures.push({ code: 'DATABASE_UNAVAILABLE', message: 'Database is not ready' });
132
157
  }
158
+ if (config.redis?.required && redisClient) {
159
+ try {
160
+ if (!await redisClient.ping()) throw new Error('Unexpected Redis ping result');
161
+ } catch {
162
+ failures.push({ code: 'SHARED_STATE_UNAVAILABLE', message: 'Required shared state is not ready' });
163
+ }
164
+ }
165
+ if (failures.length) {
166
+ logger.warn?.('YunCMS readiness check failed', { requestId: req.id, failures: failures.map((entry) => entry.code) });
167
+ return res.status(503).json({ status: 'not_ready', request_id: req.id, errors: failures });
168
+ }
169
+ return res.json({
170
+ status: 'ready',
171
+ request_id: req.id,
172
+ shared_state: {
173
+ cache: config.cache?.store ?? 'memory',
174
+ api_rate_limit: config.server?.rateLimit?.store ?? 'memory',
175
+ auth_rate_limit: config.auth?.rateLimit?.store ?? 'memory',
176
+ },
177
+ });
133
178
  });
134
179
 
135
180
  app.use(createStudioMiddleware({ root: studioRoot }));
136
-
137
181
  const pressureLimit = createPressureLimit(config.server?.pressure);
138
182
  if (pressureLimit) app.use(pressureLimit);
139
-
140
- const apiRateLimit = createApiRateLimit(config);
183
+ const apiRateLimit = createApiRateLimit(config, rateLimitStore, logger);
141
184
  if (apiRateLimit) app.use(apiRateLimit);
142
185
 
143
- app.use(createAuthenticationMiddleware({
144
- pool,
145
- config,
146
- logger,
147
- services,
148
- schemaCache,
149
- permissionCache,
150
- emitter,
151
- storage,
152
- }));
186
+ app.use(createAuthenticationMiddleware({ pool, config, logger, services, schemaCache, permissionCache, emitter, storage }));
187
+ const requestEvents = createRequestEvents(emitter);
188
+ if (requestEvents) app.use(requestEvents);
189
+
190
+ if (mcpRouter) app.use('/mcp', mcpRouter);
153
191
  app.use('/studio-settings', createStudioSettingsRouter());
154
- app.use('/auth', createAuthRouter({ mailer, config, logger }));
192
+ app.use('/auth', createAuthRouter({ mailer, config, logger, rateLimitStore, externalAuthRegistry }));
155
193
  app.use('/items', createItemsRouter());
156
194
  app.use('/schema', createSystemSchemaRouter({ schemaCache }));
157
195
  app.use('/schema', createSchemaRouter({ schemaCache }));
@@ -162,18 +200,22 @@ export function createApp({
162
200
  app.use('/audit', createAuditRouter());
163
201
 
164
202
  for (const extension of endpointExtensions) {
165
- if (!extension?.id || !extension?.router) {
166
- throw new Error('Invalid endpoint extension runtime entry');
167
- }
203
+ if (!extension?.id || !extension?.router) throw new Error('Invalid endpoint extension runtime entry');
168
204
  app.use(`/extensions/${encodeURIComponent(extension.id)}`, extension.router);
169
205
  }
170
206
 
171
207
  app.use((req, res) => {
172
- res.status(404).json({
173
- errors: [{ code: 'NOT_FOUND', message: 'Route not found', request_id: req.id }],
174
- });
208
+ res.status(404).json({ errors: [{ code: 'NOT_FOUND', message: 'Route not found', request_id: req.id }] });
209
+ });
210
+ app.use((error, req, res, next) => {
211
+ if (emitter) {
212
+ emitter.action('request.failed', {
213
+ ...requestEventMetadata(req, res, Date.now()),
214
+ error: { code: error?.code ?? 'INTERNAL_ERROR' },
215
+ }, { accountability: req.accountability, requestId: req.id }).catch(() => {});
216
+ }
217
+ next(error);
175
218
  });
176
-
177
219
  app.use(apiErrorHandler(logger));
178
220
  return app;
179
221
  }
@@ -181,6 +223,7 @@ export function createApp({
181
223
  export {
182
224
  CONTENT_SECURITY_POLICY,
183
225
  createApiRateLimit,
226
+ createRequestEvents,
184
227
  requestIdentity,
185
228
  securityHeaders,
186
229
  studioCors,
@@ -41,6 +41,7 @@ const STATUS_BY_CODE = new Map([
41
41
  ['INVALID_ON_DELETE', 400],
42
42
  ['INVALID_STORAGE_KEY', 400],
43
43
  ['INVALID_FILE_CONTENT', 400],
44
+ ['FILE_MIME_MISMATCH', 400],
44
45
  ['INVALID_MAIL_MESSAGE', 400],
45
46
  ['STORAGE_NOT_FOUND', 400],
46
47
  ['STORAGE_INVENTORY_UNSUPPORTED', 400],
@@ -2,6 +2,7 @@ import { pathToFileURL } from 'node:url';
2
2
  import express from 'express';
3
3
 
4
4
  import { discoverExtensions } from './discovery.js';
5
+ import { ExtensionScheduler } from './scheduler.js';
5
6
 
6
7
  function extensionError(code, message) {
7
8
  const error = new Error(message);
@@ -54,19 +55,39 @@ function createBaseContext({ services, database, schemaCache, emitter, storage,
54
55
  });
55
56
  }
56
57
 
57
- function hookRegistrationApi(emitter, baseContext) {
58
+ function hookRegistrationApi(emitter, baseContext, manifest, scheduler) {
59
+ const registration = Object.freeze({
60
+ extensionId: manifest.id,
61
+ priority: Number.isInteger(manifest.priority) ? manifest.priority : 0,
62
+ });
63
+
58
64
  return Object.freeze({
59
- filter(event, handler) {
65
+ filter(event, handler, options = {}) {
60
66
  return emitter.registerFilter(event, (payload, eventContext) =>
61
- handler(payload, { ...baseContext, ...eventContext }));
67
+ handler(payload, { ...baseContext, ...eventContext }), {
68
+ ...registration,
69
+ ...options,
70
+ extensionId: manifest.id,
71
+ });
62
72
  },
63
- action(event, handler) {
73
+ action(event, handler, options = {}) {
64
74
  return emitter.registerAction(event, (payload, eventContext) =>
65
- handler(payload, { ...baseContext, ...eventContext }));
75
+ handler(payload, { ...baseContext, ...eventContext }), {
76
+ ...registration,
77
+ ...options,
78
+ extensionId: manifest.id,
79
+ });
66
80
  },
67
- init(event, handler) {
81
+ init(event, handler, options = {}) {
68
82
  return emitter.registerInit(event, (eventContext) =>
69
- handler({ ...baseContext, ...eventContext }));
83
+ handler({ ...baseContext, ...eventContext }), {
84
+ ...registration,
85
+ ...options,
86
+ extensionId: manifest.id,
87
+ });
88
+ },
89
+ schedule(expression, handler, options = {}) {
90
+ return scheduler.register(manifest.id, expression, handler, options);
70
91
  },
71
92
  });
72
93
  }
@@ -89,14 +110,22 @@ export async function loadExtensionRuntime({
89
110
 
90
111
  const manifests = await discoverExtensions({ rootDir, localDirectory, includeDependencies });
91
112
  const baseContext = createBaseContext({ services, database, schemaCache, emitter, storage, logger, env });
113
+ const scheduler = new ExtensionScheduler({
114
+ database,
115
+ services,
116
+ schemaCache,
117
+ emitter,
118
+ storage,
119
+ logger,
120
+ env,
121
+ });
92
122
  const endpointExtensions = [];
93
- const hookApi = hookRegistrationApi(emitter, baseContext);
94
123
 
95
124
  for (const manifest of manifests) {
96
125
  const definition = await importExtension(manifest);
97
126
 
98
127
  if (manifest.type === 'hook') {
99
- await definition.register(hookApi, baseContext);
128
+ await definition.register(hookRegistrationApi(emitter, baseContext, manifest, scheduler), baseContext);
100
129
  logger.info?.(`Loaded YunCMS hook extension: ${manifest.id}`);
101
130
  continue;
102
131
  }
@@ -117,5 +146,11 @@ export async function loadExtensionRuntime({
117
146
  async init(event) {
118
147
  await emitter.init(event, baseContext);
119
148
  },
149
+ startSchedules() {
150
+ scheduler.start();
151
+ },
152
+ async stopSchedules(options = {}) {
153
+ return scheduler.stop(options);
154
+ },
120
155
  });
121
156
  }
@@ -0,0 +1,288 @@
1
+ import { createHash } from 'node:crypto';
2
+ import {
3
+ createSystemAccountability,
4
+ withAdvisoryLock,
5
+ } from '@yunsoft/yuncms-core';
6
+
7
+ const CRON_FIELD_RULES = Object.freeze([
8
+ { name: 'minute', min: 0, max: 59 },
9
+ { name: 'hour', min: 0, max: 23 },
10
+ { name: 'day', min: 1, max: 31 },
11
+ { name: 'month', min: 1, max: 12 },
12
+ { name: 'weekday', min: 0, max: 6 },
13
+ ]);
14
+ const JOB_ID_PATTERN = /^[A-Za-z0-9._:-]{1,100}$/;
15
+
16
+ function scheduleError(message) {
17
+ const error = new Error(message);
18
+ error.code = 'INVALID_EXTENSION_SCHEDULE';
19
+ return error;
20
+ }
21
+
22
+ function integerToken(value, rule, label) {
23
+ if (!/^\d+$/.test(value)) throw scheduleError(`Invalid ${label} cron token: ${value}`);
24
+ const number = Number(value);
25
+ if (!Number.isInteger(number) || number < rule.min || number > rule.max) {
26
+ throw scheduleError(`${label} must be between ${rule.min} and ${rule.max}`);
27
+ }
28
+ return number;
29
+ }
30
+
31
+ function rangeValues(start, end, step, rule, label) {
32
+ if (start > end) throw scheduleError(`Invalid ${label} cron range: ${start}-${end}`);
33
+ const values = [];
34
+ for (let value = start; value <= end; value += step) values.push(value);
35
+ return values;
36
+ }
37
+
38
+ function parseCronPart(part, rule) {
39
+ const [base, rawStep, extra] = part.split('/');
40
+ if (extra !== undefined) throw scheduleError(`Invalid ${rule.name} cron token: ${part}`);
41
+ const step = rawStep === undefined ? 1 : integerToken(rawStep, { min: 1, max: rule.max - rule.min + 1 }, `${rule.name} step`);
42
+
43
+ if (base === '*') return rangeValues(rule.min, rule.max, step, rule, rule.name);
44
+ if (base.includes('-')) {
45
+ const bits = base.split('-');
46
+ if (bits.length !== 2) throw scheduleError(`Invalid ${rule.name} cron range: ${base}`);
47
+ return rangeValues(
48
+ integerToken(bits[0], rule, rule.name),
49
+ integerToken(bits[1], rule, rule.name),
50
+ step,
51
+ rule,
52
+ rule.name,
53
+ );
54
+ }
55
+ if (rawStep !== undefined) throw scheduleError(`Cron step requires * or range for ${rule.name}`);
56
+ return [integerToken(base, rule, rule.name)];
57
+ }
58
+
59
+ function parseCronField(value, rule) {
60
+ if (!value) throw scheduleError(`Cron ${rule.name} field is required`);
61
+ const values = new Set();
62
+ for (const part of value.split(',')) {
63
+ if (!part) throw scheduleError(`Invalid ${rule.name} cron list`);
64
+ for (const number of parseCronPart(part, rule)) values.add(number);
65
+ }
66
+ return values;
67
+ }
68
+
69
+ export function parseCronExpression(expression) {
70
+ if (typeof expression !== 'string') throw scheduleError('Cron expression must be a string');
71
+ const fields = expression.trim().split(/\s+/);
72
+ if (fields.length !== 5) throw scheduleError('Cron expression must contain exactly 5 fields');
73
+ return Object.freeze(CRON_FIELD_RULES.map((rule, index) => parseCronField(fields[index], rule)));
74
+ }
75
+
76
+ export function cronMatches(parsed, date) {
77
+ if (!(date instanceof Date) || Number.isNaN(date.getTime())) throw new Error('Valid date is required');
78
+ const values = [
79
+ date.getMinutes(),
80
+ date.getHours(),
81
+ date.getDate(),
82
+ date.getMonth() + 1,
83
+ date.getDay(),
84
+ ];
85
+ return parsed.every((allowed, index) => allowed.has(values[index]));
86
+ }
87
+
88
+ function normalizeJobOptions(options = {}) {
89
+ const id = String(options.id ?? '').trim();
90
+ if (!JOB_ID_PATTERN.test(id)) {
91
+ throw scheduleError('Scheduled extension job requires a stable id using letters, numbers, dot, underscore, colon or dash');
92
+ }
93
+ const mode = options.mode ?? 'per_process';
94
+ if (!['per_process', 'singleton'].includes(mode)) {
95
+ throw scheduleError('Scheduled extension job mode must be per_process or singleton');
96
+ }
97
+ if ((options.overlap ?? 'skip') !== 'skip') {
98
+ throw scheduleError('Scheduled extension job overlap currently supports skip only');
99
+ }
100
+ if (options.accountability !== 'system') {
101
+ throw scheduleError("Scheduled extension jobs must explicitly set accountability: 'system'");
102
+ }
103
+ return Object.freeze({ id, mode, overlap: 'skip', accountability: 'system' });
104
+ }
105
+
106
+ function minuteKey(date) {
107
+ return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}-${date.getHours()}-${date.getMinutes()}`;
108
+ }
109
+
110
+ export function scheduleLockName(identity) {
111
+ const digest = createHash('sha256').update(String(identity)).digest('hex').slice(0, 40);
112
+ return `yuncms:schedule:${digest}`;
113
+ }
114
+
115
+ export class ExtensionScheduler {
116
+ constructor({
117
+ database,
118
+ services,
119
+ schemaCache,
120
+ emitter,
121
+ storage = null,
122
+ logger = console,
123
+ env,
124
+ now = () => new Date(),
125
+ lockRunner = withAdvisoryLock,
126
+ } = {}) {
127
+ if (!database || !services || !schemaCache || !emitter) {
128
+ throw new Error('Extension scheduler requires database, services, schemaCache and emitter');
129
+ }
130
+ this.database = database;
131
+ this.services = services;
132
+ this.schemaCache = schemaCache;
133
+ this.emitter = emitter;
134
+ this.storage = storage;
135
+ this.logger = logger;
136
+ this.env = env;
137
+ this.now = now;
138
+ this.lockRunner = lockRunner;
139
+ this.jobs = new Map();
140
+ this.timer = null;
141
+ this.stopping = false;
142
+ this.runningPromises = new Set();
143
+ }
144
+
145
+ register(extensionId, expression, handler, options = {}) {
146
+ if (typeof handler !== 'function') throw scheduleError('Scheduled extension handler must be a function');
147
+ const parsed = parseCronExpression(expression);
148
+ const normalized = normalizeJobOptions(options);
149
+ const identity = `${extensionId}:${normalized.id}`;
150
+ if (this.jobs.has(identity)) throw scheduleError(`Duplicate scheduled extension job: ${identity}`);
151
+ const job = {
152
+ identity,
153
+ extensionId,
154
+ expression,
155
+ parsed,
156
+ handler,
157
+ options: normalized,
158
+ running: false,
159
+ lastMinute: null,
160
+ };
161
+ this.jobs.set(identity, job);
162
+ return () => this.jobs.delete(identity);
163
+ }
164
+
165
+ async #context(job, date) {
166
+ const accountability = createSystemAccountability();
167
+ const schema = await this.schemaCache.get(this.database);
168
+ return Object.freeze({
169
+ services: this.services,
170
+ database: this.database,
171
+ logger: this.logger,
172
+ env: this.env,
173
+ emitter: this.emitter,
174
+ storage: this.storage,
175
+ accountability,
176
+ requestId: `schedule:${job.identity}:${date.toISOString()}`,
177
+ getSchema: () => this.schemaCache.get(this.database),
178
+ serviceOptions: async () => ({
179
+ accountability,
180
+ database: this.database,
181
+ schema,
182
+ logger: this.logger,
183
+ emitter: this.emitter,
184
+ storage: this.storage,
185
+ permissionCache: null,
186
+ requestId: `schedule:${job.identity}:${date.toISOString()}`,
187
+ }),
188
+ schedule: Object.freeze({
189
+ id: job.options.id,
190
+ extensionId: job.extensionId,
191
+ expression: job.expression,
192
+ mode: job.options.mode,
193
+ scheduledAt: date,
194
+ }),
195
+ });
196
+ }
197
+
198
+ async #execute(job, date) {
199
+ if (job.running) {
200
+ this.logger?.warn?.('Skipping overlapping YunCMS extension job', { job: job.identity });
201
+ return false;
202
+ }
203
+ job.running = true;
204
+ const startedAt = Date.now();
205
+ this.logger?.info?.('Starting YunCMS extension job', { job: job.identity, mode: job.options.mode });
206
+ try {
207
+ const run = async () => job.handler(await this.#context(job, date));
208
+ if (job.options.mode === 'singleton') {
209
+ try {
210
+ await this.lockRunner(
211
+ this.database,
212
+ scheduleLockName(job.identity),
213
+ run,
214
+ { timeoutSeconds: 0 },
215
+ );
216
+ } catch (error) {
217
+ if (error?.code === 'SCHEMA_LOCK_UNAVAILABLE') {
218
+ this.logger?.info?.('Skipping YunCMS singleton extension job owned by another replica', { job: job.identity });
219
+ return false;
220
+ }
221
+ throw error;
222
+ }
223
+ } else {
224
+ await run();
225
+ }
226
+ this.logger?.info?.('Completed YunCMS extension job', {
227
+ job: job.identity,
228
+ durationMs: Date.now() - startedAt,
229
+ });
230
+ return true;
231
+ } catch (error) {
232
+ this.logger?.error?.('YunCMS extension job failed', {
233
+ job: job.identity,
234
+ durationMs: Date.now() - startedAt,
235
+ code: error?.code ?? null,
236
+ message: error?.message ?? String(error),
237
+ });
238
+ return false;
239
+ } finally {
240
+ job.running = false;
241
+ }
242
+ }
243
+
244
+ async runDue(date = this.now()) {
245
+ if (this.stopping) return [];
246
+ const executions = [];
247
+ const key = minuteKey(date);
248
+ for (const job of this.jobs.values()) {
249
+ if (job.lastMinute === key || !cronMatches(job.parsed, date)) continue;
250
+ job.lastMinute = key;
251
+ const execution = this.#execute(job, date);
252
+ this.runningPromises.add(execution);
253
+ execution.finally(() => this.runningPromises.delete(execution));
254
+ executions.push(execution);
255
+ }
256
+ return Promise.all(executions);
257
+ }
258
+
259
+ start() {
260
+ if (this.timer || this.stopping) return;
261
+ const tick = () => this.runDue().catch((error) => {
262
+ this.logger?.error?.('YunCMS extension scheduler tick failed', { code: error?.code ?? null });
263
+ });
264
+ tick();
265
+ this.timer = setInterval(tick, 15_000);
266
+ this.timer.unref?.();
267
+ }
268
+
269
+ async stop({ timeoutMs = 5_000 } = {}) {
270
+ this.stopping = true;
271
+ if (this.timer) clearInterval(this.timer);
272
+ this.timer = null;
273
+ const running = [...this.runningPromises];
274
+ if (running.length === 0) return true;
275
+ let timeout;
276
+ const completed = await Promise.race([
277
+ Promise.allSettled(running).then(() => true),
278
+ new Promise((resolve) => {
279
+ timeout = setTimeout(() => resolve(false), timeoutMs);
280
+ timeout.unref?.();
281
+ }),
282
+ ]);
283
+ if (timeout) clearTimeout(timeout);
284
+ return completed;
285
+ }
286
+ }
287
+
288
+ export { normalizeJobOptions };