create-forgeon 0.3.11 → 0.3.14
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 +1 -1
- package/src/modules/dependencies.test.mjs +41 -0
- package/src/modules/executor.mjs +3 -0
- package/src/modules/executor.test.mjs +91 -0
- package/src/modules/queue.mjs +412 -410
- package/src/modules/registry.mjs +15 -0
- package/src/modules/scheduler.mjs +368 -0
- package/src/run-add-module.mjs +4 -3
- package/src/utils/fs.mjs +31 -26
- package/templates/module-fragments/scheduler/00_title.md +1 -0
- package/templates/module-fragments/scheduler/10_overview.md +6 -0
- package/templates/module-fragments/scheduler/20_scope.md +8 -0
- package/templates/module-fragments/scheduler/90_status_implemented.md +3 -0
- package/templates/module-presets/scheduler/packages/scheduler/package.json +24 -0
- package/templates/module-presets/scheduler/packages/scheduler/src/forgeon-scheduler.module.ts +12 -0
- package/templates/module-presets/scheduler/packages/scheduler/src/index.ts +6 -0
- package/templates/module-presets/scheduler/packages/scheduler/src/scheduler-config.loader.ts +23 -0
- package/templates/module-presets/scheduler/packages/scheduler/src/scheduler-config.module.ts +11 -0
- package/templates/module-presets/scheduler/packages/scheduler/src/scheduler-config.service.ts +29 -0
- package/templates/module-presets/scheduler/packages/scheduler/src/scheduler-env.schema.ts +15 -0
- package/templates/module-presets/scheduler/packages/scheduler/src/scheduler.service.ts +123 -0
- package/templates/module-presets/scheduler/packages/scheduler/tsconfig.json +9 -0
package/src/modules/registry.mjs
CHANGED
|
@@ -256,6 +256,19 @@ const MODULE_PRESETS = {
|
|
|
256
256
|
optionalIntegrations: [],
|
|
257
257
|
docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
|
|
258
258
|
},
|
|
259
|
+
scheduler: {
|
|
260
|
+
id: 'scheduler',
|
|
261
|
+
label: 'Scheduler',
|
|
262
|
+
category: 'background-jobs',
|
|
263
|
+
implemented: true,
|
|
264
|
+
description:
|
|
265
|
+
'Cron orchestration module built on @nestjs/schedule and the queue foundation, with scheduler health probe and module-owned env config.',
|
|
266
|
+
detectionPaths: ['packages/scheduler/package.json'],
|
|
267
|
+
provides: ['scheduler-runtime'],
|
|
268
|
+
requires: [{ type: 'capability', id: 'queue-runtime' }],
|
|
269
|
+
optionalIntegrations: [],
|
|
270
|
+
docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
|
|
271
|
+
},
|
|
259
272
|
};
|
|
260
273
|
|
|
261
274
|
export function listModulePresets() {
|
|
@@ -285,3 +298,5 @@ export function ensureModuleExists(moduleId) {
|
|
|
285
298
|
}
|
|
286
299
|
return preset;
|
|
287
300
|
}
|
|
301
|
+
|
|
302
|
+
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { copyRecursive, writeJson } from '../utils/fs.mjs';
|
|
4
|
+
import {
|
|
5
|
+
ensureBuildSteps,
|
|
6
|
+
ensureClassMember,
|
|
7
|
+
ensureDependency,
|
|
8
|
+
ensureImportLine,
|
|
9
|
+
ensureLineAfter,
|
|
10
|
+
ensureLineBefore,
|
|
11
|
+
ensureLoadItem,
|
|
12
|
+
ensureValidatorSchema,
|
|
13
|
+
upsertEnvLines,
|
|
14
|
+
} from './shared/patch-utils.mjs';
|
|
15
|
+
|
|
16
|
+
function copyFromPreset(packageRoot, targetRoot, relativePath) {
|
|
17
|
+
const source = path.join(packageRoot, 'templates', 'module-presets', 'scheduler', relativePath);
|
|
18
|
+
if (!fs.existsSync(source)) {
|
|
19
|
+
throw new Error(`Missing scheduler preset template: ${source}`);
|
|
20
|
+
}
|
|
21
|
+
const destination = path.join(targetRoot, relativePath);
|
|
22
|
+
copyRecursive(source, destination);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function patchApiPackage(targetRoot) {
|
|
26
|
+
const packagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
|
|
27
|
+
if (!fs.existsSync(packagePath)) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
32
|
+
ensureDependency(packageJson, '@forgeon/scheduler', 'workspace:*');
|
|
33
|
+
ensureBuildSteps(packageJson, 'predev', ['pnpm --filter @forgeon/scheduler build']);
|
|
34
|
+
writeJson(packagePath, packageJson);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function patchAppModule(targetRoot) {
|
|
38
|
+
const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
|
|
39
|
+
if (!fs.existsSync(filePath)) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
44
|
+
content = ensureImportLine(
|
|
45
|
+
content,
|
|
46
|
+
"import { ForgeonSchedulerModule, schedulerConfig, schedulerEnvSchema } from '@forgeon/scheduler';",
|
|
47
|
+
);
|
|
48
|
+
content = ensureLoadItem(content, 'schedulerConfig');
|
|
49
|
+
content = ensureValidatorSchema(content, 'schedulerEnvSchema');
|
|
50
|
+
|
|
51
|
+
if (!content.includes(' ForgeonSchedulerModule,')) {
|
|
52
|
+
if (content.includes(' ForgeonQueueModule,')) {
|
|
53
|
+
content = ensureLineAfter(content, ' ForgeonQueueModule,', ' ForgeonSchedulerModule,');
|
|
54
|
+
} else if (content.includes(' ForgeonI18nModule.register({')) {
|
|
55
|
+
content = ensureLineBefore(content, ' ForgeonI18nModule.register({', ' ForgeonSchedulerModule,');
|
|
56
|
+
} else if (content.includes(' ForgeonAuthModule.register({')) {
|
|
57
|
+
content = ensureLineBefore(content, ' ForgeonAuthModule.register({', ' ForgeonSchedulerModule,');
|
|
58
|
+
} else if (content.includes(' ForgeonAuthModule.register(),')) {
|
|
59
|
+
content = ensureLineBefore(content, ' ForgeonAuthModule.register(),', ' ForgeonSchedulerModule,');
|
|
60
|
+
} else if (content.includes(' DbPrismaModule,')) {
|
|
61
|
+
content = ensureLineAfter(content, ' DbPrismaModule,', ' ForgeonSchedulerModule,');
|
|
62
|
+
} else if (content.includes(' ForgeonLoggerModule,')) {
|
|
63
|
+
content = ensureLineAfter(content, ' ForgeonLoggerModule,', ' ForgeonSchedulerModule,');
|
|
64
|
+
} else if (content.includes(' ForgeonSwaggerModule,')) {
|
|
65
|
+
content = ensureLineAfter(content, ' ForgeonSwaggerModule,', ' ForgeonSchedulerModule,');
|
|
66
|
+
} else {
|
|
67
|
+
content = ensureLineAfter(content, ' CoreErrorsModule,', ' ForgeonSchedulerModule,');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function patchHealthController(targetRoot) {
|
|
75
|
+
const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts');
|
|
76
|
+
if (!fs.existsSync(filePath)) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
81
|
+
content = ensureImportLine(content, "import { ForgeonSchedulerService } from '@forgeon/scheduler';");
|
|
82
|
+
|
|
83
|
+
if (!content.includes('private readonly schedulerService: ForgeonSchedulerService')) {
|
|
84
|
+
const constructorMatch = content.match(/constructor\(([\s\S]*?)\)\s*\{/m);
|
|
85
|
+
if (constructorMatch) {
|
|
86
|
+
const original = constructorMatch[0];
|
|
87
|
+
const inner = constructorMatch[1].trimEnd();
|
|
88
|
+
const normalizedInner = inner.replace(/,\s*$/, '');
|
|
89
|
+
const separator = normalizedInner.length > 0 ? ',' : '';
|
|
90
|
+
const next = `constructor(${normalizedInner}${separator}
|
|
91
|
+
private readonly schedulerService: ForgeonSchedulerService,
|
|
92
|
+
) {`;
|
|
93
|
+
content = content.replace(original, next);
|
|
94
|
+
} else {
|
|
95
|
+
const classAnchor = 'export class HealthController {';
|
|
96
|
+
if (content.includes(classAnchor)) {
|
|
97
|
+
content = content.replace(
|
|
98
|
+
classAnchor,
|
|
99
|
+
`${classAnchor}
|
|
100
|
+
constructor(private readonly schedulerService: ForgeonSchedulerService) {}
|
|
101
|
+
`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!content.includes("@Get('scheduler')")) {
|
|
108
|
+
const method = `
|
|
109
|
+
@Get('scheduler')
|
|
110
|
+
async getSchedulerProbe() {
|
|
111
|
+
return this.schedulerService.getProbeStatus();
|
|
112
|
+
}
|
|
113
|
+
`;
|
|
114
|
+
content = ensureClassMember(content, 'HealthController', method, { beforeNeedle: 'private translate(' });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function patchWebApp(targetRoot) {
|
|
121
|
+
const filePath = path.join(targetRoot, 'apps', 'web', 'src', 'App.tsx');
|
|
122
|
+
if (!fs.existsSync(filePath)) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
127
|
+
content = content
|
|
128
|
+
.replace(/^\s*\{\/\* forgeon:probes:actions:start \*\/\}\r?\n?/gm, '')
|
|
129
|
+
.replace(/^\s*\{\/\* forgeon:probes:actions:end \*\/\}\r?\n?/gm, '')
|
|
130
|
+
.replace(/^\s*\{\/\* forgeon:probes:results:start \*\/\}\r?\n?/gm, '')
|
|
131
|
+
.replace(/^\s*\{\/\* forgeon:probes:results:end \*\/\}\r?\n?/gm, '');
|
|
132
|
+
|
|
133
|
+
if (!content.includes('schedulerProbeResult')) {
|
|
134
|
+
const stateAnchors = [
|
|
135
|
+
' const [queueProbeResult, setQueueProbeResult] = useState<ProbeResult | null>(null);',
|
|
136
|
+
' const [filesImageProbeResult, setFilesImageProbeResult] = useState<ProbeResult | null>(null);',
|
|
137
|
+
' const [filesQuotasProbeResult, setFilesQuotasProbeResult] = useState<ProbeResult | null>(null);',
|
|
138
|
+
' const [filesAccessProbeResult, setFilesAccessProbeResult] = useState<ProbeResult | null>(null);',
|
|
139
|
+
' const [filesVariantsProbeResult, setFilesVariantsProbeResult] = useState<ProbeResult | null>(null);',
|
|
140
|
+
' const [filesProbeResult, setFilesProbeResult] = useState<ProbeResult | null>(null);',
|
|
141
|
+
' const [rbacProbeResult, setRbacProbeResult] = useState<ProbeResult | null>(null);',
|
|
142
|
+
' const [rateLimitProbeResult, setRateLimitProbeResult] = useState<ProbeResult | null>(null);',
|
|
143
|
+
' const [authProbeResult, setAuthProbeResult] = useState<ProbeResult | null>(null);',
|
|
144
|
+
' const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);',
|
|
145
|
+
' const [validationProbeResult, setValidationProbeResult] = useState<ProbeResult | null>(null);',
|
|
146
|
+
];
|
|
147
|
+
const stateAnchor = stateAnchors.find((line) => content.includes(line));
|
|
148
|
+
if (stateAnchor) {
|
|
149
|
+
content = ensureLineAfter(
|
|
150
|
+
content,
|
|
151
|
+
stateAnchor,
|
|
152
|
+
' const [schedulerProbeResult, setSchedulerProbeResult] = useState<ProbeResult | null>(null);',
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!content.includes('Check scheduler health')) {
|
|
158
|
+
const probePath = content.includes("runProbe(setHealthResult, '/health')")
|
|
159
|
+
? '/health/scheduler'
|
|
160
|
+
: '/api/health/scheduler';
|
|
161
|
+
const button = ` <button onClick={() => runProbe(setSchedulerProbeResult, '${probePath}')}>
|
|
162
|
+
Check scheduler health
|
|
163
|
+
</button>`;
|
|
164
|
+
|
|
165
|
+
const actionsStart = content.indexOf('<div className="actions">');
|
|
166
|
+
if (actionsStart >= 0) {
|
|
167
|
+
const actionsEnd = content.indexOf('\n </div>', actionsStart);
|
|
168
|
+
if (actionsEnd >= 0) {
|
|
169
|
+
content = `${content.slice(0, actionsEnd)}\n${button}${content.slice(actionsEnd)}`;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!content.includes("{renderResult('Scheduler probe response', schedulerProbeResult)}")) {
|
|
175
|
+
const resultLine = " {renderResult('Scheduler probe response', schedulerProbeResult)}";
|
|
176
|
+
const networkLine = ' {networkError ? <p className="error">{networkError}</p> : null}';
|
|
177
|
+
if (content.includes(networkLine)) {
|
|
178
|
+
content = content.replace(networkLine, `${resultLine}\n${networkLine}`);
|
|
179
|
+
} else {
|
|
180
|
+
const anchors = [
|
|
181
|
+
"{renderResult('Queue probe response', queueProbeResult)}",
|
|
182
|
+
"{renderResult('Files image probe response', filesImageProbeResult)}",
|
|
183
|
+
"{renderResult('Files quotas probe response', filesQuotasProbeResult)}",
|
|
184
|
+
"{renderResult('Files access probe response', filesAccessProbeResult)}",
|
|
185
|
+
"{renderResult('Files variants probe response', filesVariantsProbeResult)}",
|
|
186
|
+
"{renderResult('Files probe response', filesProbeResult)}",
|
|
187
|
+
"{renderResult('RBAC probe response', rbacProbeResult)}",
|
|
188
|
+
"{renderResult('Rate limit probe response', rateLimitProbeResult)}",
|
|
189
|
+
"{renderResult('Auth probe response', authProbeResult)}",
|
|
190
|
+
"{renderResult('DB probe response', dbProbeResult)}",
|
|
191
|
+
"{renderResult('Validation probe response', validationProbeResult)}",
|
|
192
|
+
];
|
|
193
|
+
const anchor = anchors.find((line) => content.includes(line));
|
|
194
|
+
if (anchor) {
|
|
195
|
+
content = ensureLineAfter(content, anchor, resultLine);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function patchApiDockerfile(targetRoot) {
|
|
204
|
+
const dockerfilePath = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
|
|
205
|
+
if (!fs.existsSync(dockerfilePath)) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
|
|
210
|
+
const packageAnchors = [
|
|
211
|
+
'COPY packages/scheduler/package.json packages/scheduler/package.json',
|
|
212
|
+
'COPY packages/queue/package.json packages/queue/package.json',
|
|
213
|
+
'COPY packages/files-image/package.json packages/files-image/package.json',
|
|
214
|
+
'COPY packages/files-quotas/package.json packages/files-quotas/package.json',
|
|
215
|
+
'COPY packages/files-access/package.json packages/files-access/package.json',
|
|
216
|
+
'COPY packages/files-s3/package.json packages/files-s3/package.json',
|
|
217
|
+
'COPY packages/files-local/package.json packages/files-local/package.json',
|
|
218
|
+
'COPY packages/files/package.json packages/files/package.json',
|
|
219
|
+
'COPY packages/auth-api/package.json packages/auth-api/package.json',
|
|
220
|
+
'COPY packages/rbac/package.json packages/rbac/package.json',
|
|
221
|
+
'COPY packages/rate-limit/package.json packages/rate-limit/package.json',
|
|
222
|
+
'COPY packages/logger/package.json packages/logger/package.json',
|
|
223
|
+
'COPY packages/swagger/package.json packages/swagger/package.json',
|
|
224
|
+
'COPY packages/i18n/package.json packages/i18n/package.json',
|
|
225
|
+
'COPY packages/db-prisma/package.json packages/db-prisma/package.json',
|
|
226
|
+
'COPY packages/core/package.json packages/core/package.json',
|
|
227
|
+
];
|
|
228
|
+
const packageAnchor = packageAnchors.find((line) => content.includes(line)) ?? packageAnchors.at(-1);
|
|
229
|
+
content = ensureLineAfter(content, packageAnchor, 'COPY packages/scheduler/package.json packages/scheduler/package.json');
|
|
230
|
+
|
|
231
|
+
const sourceAnchors = [
|
|
232
|
+
'COPY packages/scheduler packages/scheduler',
|
|
233
|
+
'COPY packages/queue packages/queue',
|
|
234
|
+
'COPY packages/files-image packages/files-image',
|
|
235
|
+
'COPY packages/files-quotas packages/files-quotas',
|
|
236
|
+
'COPY packages/files-access packages/files-access',
|
|
237
|
+
'COPY packages/files-s3 packages/files-s3',
|
|
238
|
+
'COPY packages/files-local packages/files-local',
|
|
239
|
+
'COPY packages/files packages/files',
|
|
240
|
+
'COPY packages/auth-api packages/auth-api',
|
|
241
|
+
'COPY packages/rbac packages/rbac',
|
|
242
|
+
'COPY packages/rate-limit packages/rate-limit',
|
|
243
|
+
'COPY packages/logger packages/logger',
|
|
244
|
+
'COPY packages/swagger packages/swagger',
|
|
245
|
+
'COPY packages/i18n packages/i18n',
|
|
246
|
+
'COPY packages/db-prisma packages/db-prisma',
|
|
247
|
+
'COPY packages/core packages/core',
|
|
248
|
+
];
|
|
249
|
+
const sourceAnchor = sourceAnchors.find((line) => content.includes(line)) ?? sourceAnchors.at(-1);
|
|
250
|
+
content = ensureLineAfter(content, sourceAnchor, 'COPY packages/scheduler packages/scheduler');
|
|
251
|
+
|
|
252
|
+
content = content.replace(/^RUN pnpm --filter @forgeon\/scheduler build\r?\n?/gm, '');
|
|
253
|
+
if (content.includes('RUN pnpm --filter @forgeon/queue build')) {
|
|
254
|
+
content = ensureLineAfter(content, 'RUN pnpm --filter @forgeon/queue build', 'RUN pnpm --filter @forgeon/scheduler build');
|
|
255
|
+
} else {
|
|
256
|
+
const buildAnchor = content.includes('RUN pnpm --filter @forgeon/api prisma:generate')
|
|
257
|
+
? 'RUN pnpm --filter @forgeon/api prisma:generate'
|
|
258
|
+
: 'RUN pnpm --filter @forgeon/api build';
|
|
259
|
+
content = ensureLineBefore(content, buildAnchor, 'RUN pnpm --filter @forgeon/scheduler build');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
fs.writeFileSync(dockerfilePath, `${content.trimEnd()}\n`, 'utf8');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function patchCompose(targetRoot) {
|
|
266
|
+
const composePath = path.join(targetRoot, 'infra', 'docker', 'compose.yml');
|
|
267
|
+
if (!fs.existsSync(composePath)) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let content = fs.readFileSync(composePath, 'utf8').replace(/\r\n/g, '\n');
|
|
272
|
+
|
|
273
|
+
if (!content.includes('SCHEDULER_ENABLED: ${SCHEDULER_ENABLED}')) {
|
|
274
|
+
const anchors = [
|
|
275
|
+
/^(\s+QUEUE_DEFAULT_BACKOFF_MS:.*)$/m,
|
|
276
|
+
/^(\s+FILES_IMAGE_STRIP_METADATA:.*)$/m,
|
|
277
|
+
/^(\s+FILES_QUOTA_MAX_BYTES_PER_OWNER:.*)$/m,
|
|
278
|
+
/^(\s+FILES_ACCESS_DEFAULT_VISIBILITY:.*)$/m,
|
|
279
|
+
/^(\s+FILES_S3_MAX_ATTEMPTS:.*)$/m,
|
|
280
|
+
/^(\s+FILES_LOCAL_ROOT:.*)$/m,
|
|
281
|
+
/^(\s+FILES_PUBLIC_BASE_PATH:.*)$/m,
|
|
282
|
+
/^(\s+AUTH_DEMO_PASSWORD:.*)$/m,
|
|
283
|
+
/^(\s+THROTTLE_TRUST_PROXY:.*)$/m,
|
|
284
|
+
/^(\s+LOGGER_REQUEST_ID_HEADER:.*)$/m,
|
|
285
|
+
/^(\s+SWAGGER_DOCS_PATH:.*)$/m,
|
|
286
|
+
/^(\s+I18N_FALLBACK_LANG:.*)$/m,
|
|
287
|
+
/^(\s+DATABASE_URL:.*)$/m,
|
|
288
|
+
/^(\s+API_PREFIX:.*)$/m,
|
|
289
|
+
];
|
|
290
|
+
const anchorPattern = anchors.find((pattern) => pattern.test(content)) ?? anchors.at(-1);
|
|
291
|
+
content = content.replace(
|
|
292
|
+
anchorPattern,
|
|
293
|
+
`$1
|
|
294
|
+
SCHEDULER_ENABLED: \${SCHEDULER_ENABLED}
|
|
295
|
+
SCHEDULER_TIMEZONE: \${SCHEDULER_TIMEZONE}
|
|
296
|
+
SCHEDULER_HEARTBEAT_CRON: \${SCHEDULER_HEARTBEAT_CRON}`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
fs.writeFileSync(composePath, `${content.trimEnd()}\n`, 'utf8');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function patchReadme(targetRoot) {
|
|
304
|
+
const readmePath = path.join(targetRoot, 'README.md');
|
|
305
|
+
if (!fs.existsSync(readmePath)) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const marker = '## Scheduler Module';
|
|
310
|
+
let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
|
|
311
|
+
if (content.includes(marker)) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const section = `## Scheduler Module
|
|
316
|
+
|
|
317
|
+
The scheduler add-module provides cron-based orchestration on top of the queue foundation.
|
|
318
|
+
|
|
319
|
+
What it adds:
|
|
320
|
+
- \`@forgeon/scheduler\` package
|
|
321
|
+
- typed scheduler env config (module-owned)
|
|
322
|
+
- scheduler probe endpoint: \`GET /api/health/scheduler\`
|
|
323
|
+
- web probe button for quick runtime verification
|
|
324
|
+
- fixed-id heartbeat scheduling to avoid unbounded queue growth before worker support exists
|
|
325
|
+
|
|
326
|
+
Configuration (env):
|
|
327
|
+
- \`SCHEDULER_ENABLED=true\`
|
|
328
|
+
- \`SCHEDULER_TIMEZONE=UTC\`
|
|
329
|
+
- \`SCHEDULER_HEARTBEAT_CRON=*/5 * * * *\`
|
|
330
|
+
|
|
331
|
+
Operational notes:
|
|
332
|
+
- this stage owns cron orchestration only
|
|
333
|
+
- queue remains responsible for broker/runtime delivery
|
|
334
|
+
- worker execution is intentionally deferred to a later module`;
|
|
335
|
+
|
|
336
|
+
if (content.includes('## Queue Module')) {
|
|
337
|
+
content = content.replace('## Queue Module', `${section}\n\n## Queue Module`);
|
|
338
|
+
} else if (content.includes('## Prisma In Docker Start')) {
|
|
339
|
+
content = content.replace('## Prisma In Docker Start', `${section}\n\n## Prisma In Docker Start`);
|
|
340
|
+
} else {
|
|
341
|
+
content = `${content.trimEnd()}\n\n${section}\n`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
fs.writeFileSync(readmePath, `${content.trimEnd()}\n`, 'utf8');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function applySchedulerModule({ packageRoot, targetRoot }) {
|
|
348
|
+
copyFromPreset(packageRoot, targetRoot, path.join('packages', 'scheduler'));
|
|
349
|
+
|
|
350
|
+
patchApiPackage(targetRoot);
|
|
351
|
+
patchAppModule(targetRoot);
|
|
352
|
+
patchHealthController(targetRoot);
|
|
353
|
+
patchWebApp(targetRoot);
|
|
354
|
+
patchApiDockerfile(targetRoot);
|
|
355
|
+
patchCompose(targetRoot);
|
|
356
|
+
patchReadme(targetRoot);
|
|
357
|
+
|
|
358
|
+
upsertEnvLines(path.join(targetRoot, 'apps', 'api', '.env.example'), [
|
|
359
|
+
'SCHEDULER_ENABLED=true',
|
|
360
|
+
'SCHEDULER_TIMEZONE=UTC',
|
|
361
|
+
'SCHEDULER_HEARTBEAT_CRON=*/5 * * * *',
|
|
362
|
+
]);
|
|
363
|
+
upsertEnvLines(path.join(targetRoot, 'infra', 'docker', '.env.example'), [
|
|
364
|
+
'SCHEDULER_ENABLED=true',
|
|
365
|
+
'SCHEDULER_TIMEZONE=UTC',
|
|
366
|
+
'SCHEDULER_HEARTBEAT_CRON=*/5 * * * *',
|
|
367
|
+
]);
|
|
368
|
+
}
|
package/src/run-add-module.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
printOptionalIntegrationsWarning,
|
|
17
17
|
runIntegrationFlow,
|
|
18
18
|
} from './integrations/flow.mjs';
|
|
19
|
-
import { writeJson } from './utils/fs.mjs';
|
|
19
|
+
import { readJson, writeJson } from './utils/fs.mjs';
|
|
20
20
|
|
|
21
21
|
function printModuleList() {
|
|
22
22
|
const modules = listModulePresets();
|
|
@@ -63,7 +63,7 @@ function collectDependencyManifestState(targetRoot) {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
const filePath = path.join(currentDir, entry.name);
|
|
66
|
-
const packageJson =
|
|
66
|
+
const packageJson = readJson(filePath);
|
|
67
67
|
const snapshot = {
|
|
68
68
|
name: packageJson.name ?? null,
|
|
69
69
|
dependencies: toSortedObject(packageJson.dependencies),
|
|
@@ -114,7 +114,7 @@ function ensureSyncTooling({ packageRoot, targetRoot }) {
|
|
|
114
114
|
return;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
const packageJson =
|
|
117
|
+
const packageJson = readJson(packagePath);
|
|
118
118
|
if (!packageJson.scripts) {
|
|
119
119
|
packageJson.scripts = {};
|
|
120
120
|
}
|
|
@@ -309,3 +309,4 @@ export async function runAddModule(argv = process.argv.slice(2)) {
|
|
|
309
309
|
console.log('Next: run pnpm install');
|
|
310
310
|
}
|
|
311
311
|
}
|
|
312
|
+
|
package/src/utils/fs.mjs
CHANGED
|
@@ -1,26 +1,31 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
|
|
4
|
-
export function copyRecursive(source, destination) {
|
|
5
|
-
const stat = fs.statSync(source);
|
|
6
|
-
|
|
7
|
-
if (stat.isDirectory()) {
|
|
8
|
-
fs.mkdirSync(destination, { recursive: true });
|
|
9
|
-
for (const entry of fs.readdirSync(source)) {
|
|
10
|
-
copyRecursive(path.join(source, entry), path.join(destination, entry));
|
|
11
|
-
}
|
|
12
|
-
return;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
fs.copyFileSync(source, destination);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function writeJson(filePath, data) {
|
|
19
|
-
fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function copyRecursive(source, destination) {
|
|
5
|
+
const stat = fs.statSync(source);
|
|
6
|
+
|
|
7
|
+
if (stat.isDirectory()) {
|
|
8
|
+
fs.mkdirSync(destination, { recursive: true });
|
|
9
|
+
for (const entry of fs.readdirSync(source)) {
|
|
10
|
+
copyRecursive(path.join(source, entry), path.join(destination, entry));
|
|
11
|
+
}
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
fs.copyFileSync(source, destination);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function writeJson(filePath, data) {
|
|
19
|
+
fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function readJson(filePath) {
|
|
23
|
+
const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
|
|
24
|
+
return JSON.parse(raw);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function removeIfExists(targetPath) {
|
|
28
|
+
if (fs.existsSync(targetPath)) {
|
|
29
|
+
fs.rmSync(targetPath, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# MODULE: {{MODULE_LABEL}}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
## Scope
|
|
2
|
+
|
|
3
|
+
Current implementation includes:
|
|
4
|
+
|
|
5
|
+
1. Scheduler runtime package preset (`@forgeon/scheduler`) built on top of `@nestjs/schedule`.
|
|
6
|
+
2. API wiring in `AppModule` (config loader + env schema + scheduler module import).
|
|
7
|
+
3. Scheduler probe endpoint (`GET /api/health/scheduler`) and web probe button wiring.
|
|
8
|
+
4. Heartbeat cron registration that enqueues a fixed-id queue job without unbounded Redis growth.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@forgeon/scheduler",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc -p tsconfig.json"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@forgeon/queue": "workspace:*",
|
|
12
|
+
"@nestjs/common": "^11.0.1",
|
|
13
|
+
"@nestjs/config": "^4.0.2",
|
|
14
|
+
"@nestjs/schedule": "^6.0.0",
|
|
15
|
+
"cron": "^4.3.3",
|
|
16
|
+
"rxjs": "^7.8.1",
|
|
17
|
+
"zod": "^3.23.8"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^22.10.7",
|
|
21
|
+
"typescript": "^5.7.3"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { ScheduleModule } from '@nestjs/schedule';
|
|
3
|
+
import { ForgeonQueueModule } from '@forgeon/queue';
|
|
4
|
+
import { SchedulerConfigModule } from './scheduler-config.module';
|
|
5
|
+
import { ForgeonSchedulerService } from './scheduler.service';
|
|
6
|
+
|
|
7
|
+
@Module({
|
|
8
|
+
imports: [ScheduleModule.forRoot(), ForgeonQueueModule, SchedulerConfigModule],
|
|
9
|
+
providers: [ForgeonSchedulerService],
|
|
10
|
+
exports: [SchedulerConfigModule, ForgeonSchedulerService],
|
|
11
|
+
})
|
|
12
|
+
export class ForgeonSchedulerModule {}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { registerAs } from '@nestjs/config';
|
|
2
|
+
import { parseSchedulerEnv } from './scheduler-env.schema';
|
|
3
|
+
|
|
4
|
+
export const SCHEDULER_CONFIG_NAMESPACE = 'scheduler';
|
|
5
|
+
|
|
6
|
+
export type SchedulerConfigValues = {
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
timezone: string;
|
|
9
|
+
heartbeatCron: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const schedulerConfig = registerAs(
|
|
13
|
+
SCHEDULER_CONFIG_NAMESPACE,
|
|
14
|
+
(): SchedulerConfigValues => {
|
|
15
|
+
const env = parseSchedulerEnv(process.env as unknown as Record<string, unknown>);
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
enabled: env.SCHEDULER_ENABLED,
|
|
19
|
+
timezone: env.SCHEDULER_TIMEZONE,
|
|
20
|
+
heartbeatCron: env.SCHEDULER_HEARTBEAT_CRON,
|
|
21
|
+
};
|
|
22
|
+
},
|
|
23
|
+
);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { ConfigModule } from '@nestjs/config';
|
|
3
|
+
import { schedulerConfig } from './scheduler-config.loader';
|
|
4
|
+
import { SchedulerConfigService } from './scheduler-config.service';
|
|
5
|
+
|
|
6
|
+
@Module({
|
|
7
|
+
imports: [ConfigModule.forFeature(schedulerConfig)],
|
|
8
|
+
providers: [SchedulerConfigService],
|
|
9
|
+
exports: [ConfigModule, SchedulerConfigService],
|
|
10
|
+
})
|
|
11
|
+
export class SchedulerConfigModule {}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { ConfigService } from '@nestjs/config';
|
|
3
|
+
import {
|
|
4
|
+
SCHEDULER_CONFIG_NAMESPACE,
|
|
5
|
+
SchedulerConfigValues,
|
|
6
|
+
} from './scheduler-config.loader';
|
|
7
|
+
|
|
8
|
+
@Injectable()
|
|
9
|
+
export class SchedulerConfigService {
|
|
10
|
+
constructor(private readonly configService: ConfigService) {}
|
|
11
|
+
|
|
12
|
+
get enabled(): SchedulerConfigValues['enabled'] {
|
|
13
|
+
return this.configService.getOrThrow<SchedulerConfigValues['enabled']>(
|
|
14
|
+
`${SCHEDULER_CONFIG_NAMESPACE}.enabled`,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get timezone(): SchedulerConfigValues['timezone'] {
|
|
19
|
+
return this.configService.getOrThrow<SchedulerConfigValues['timezone']>(
|
|
20
|
+
`${SCHEDULER_CONFIG_NAMESPACE}.timezone`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get heartbeatCron(): SchedulerConfigValues['heartbeatCron'] {
|
|
25
|
+
return this.configService.getOrThrow<SchedulerConfigValues['heartbeatCron']>(
|
|
26
|
+
`${SCHEDULER_CONFIG_NAMESPACE}.heartbeatCron`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const schedulerEnvSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
SCHEDULER_ENABLED: z.coerce.boolean().default(true),
|
|
6
|
+
SCHEDULER_TIMEZONE: z.string().trim().min(1).default('UTC'),
|
|
7
|
+
SCHEDULER_HEARTBEAT_CRON: z.string().trim().min(1).default('*/5 * * * *'),
|
|
8
|
+
})
|
|
9
|
+
.passthrough();
|
|
10
|
+
|
|
11
|
+
export type SchedulerEnv = z.infer<typeof schedulerEnvSchema>;
|
|
12
|
+
|
|
13
|
+
export function parseSchedulerEnv(input: Record<string, unknown>): SchedulerEnv {
|
|
14
|
+
return schedulerEnvSchema.parse(input);
|
|
15
|
+
}
|