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.
@@ -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
+ }
@@ -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 = JSON.parse(fs.readFileSync(filePath, 'utf8'));
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 = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
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 removeIfExists(targetPath) {
23
- if (fs.existsSync(targetPath)) {
24
- fs.rmSync(targetPath, { recursive: true, force: true });
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,6 @@
1
+ ## Overview
2
+
3
+ - Id: `{{MODULE_ID}}`
4
+ - Category: `{{MODULE_CATEGORY}}`
5
+ - Status: {{MODULE_STATUS}}
6
+ - Description: {{MODULE_DESCRIPTION}}
@@ -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,3 @@
1
+ ## Status
2
+
3
+ Implemented in the current scaffold.
@@ -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,6 @@
1
+ export * from './forgeon-scheduler.module';
2
+ export * from './scheduler-config.loader';
3
+ export * from './scheduler-config.module';
4
+ export * from './scheduler-config.service';
5
+ export * from './scheduler-env.schema';
6
+ export * from './scheduler.service';
@@ -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
+ }