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/queue.mjs
CHANGED
|
@@ -1,410 +1,412 @@
|
|
|
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', 'queue', relativePath);
|
|
18
|
-
if (!fs.existsSync(source)) {
|
|
19
|
-
throw new Error(`Missing queue 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/queue', 'workspace:*');
|
|
33
|
-
ensureBuildSteps(packageJson, 'predev', ['pnpm --filter @forgeon/queue 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 { ForgeonQueueModule, queueConfig, queueEnvSchema } from '@forgeon/queue';",
|
|
47
|
-
);
|
|
48
|
-
content = ensureLoadItem(content, 'queueConfig');
|
|
49
|
-
content = ensureValidatorSchema(content, 'queueEnvSchema');
|
|
50
|
-
|
|
51
|
-
if (!content.includes(' ForgeonQueueModule,')) {
|
|
52
|
-
if (content.includes(' ForgeonI18nModule.register({')) {
|
|
53
|
-
content = ensureLineBefore(content, ' ForgeonI18nModule.register({', ' ForgeonQueueModule,');
|
|
54
|
-
} else if (content.includes(' ForgeonAuthModule.register({')) {
|
|
55
|
-
content = ensureLineBefore(content, ' ForgeonAuthModule.register({', ' ForgeonQueueModule,');
|
|
56
|
-
} else if (content.includes(' ForgeonAuthModule.register(),')) {
|
|
57
|
-
content = ensureLineBefore(content, ' ForgeonAuthModule.register(),', ' ForgeonQueueModule,');
|
|
58
|
-
} else if (content.includes(' DbPrismaModule,')) {
|
|
59
|
-
content = ensureLineAfter(content, ' DbPrismaModule,', ' ForgeonQueueModule,');
|
|
60
|
-
} else if (content.includes(' ForgeonLoggerModule,')) {
|
|
61
|
-
content = ensureLineAfter(content, ' ForgeonLoggerModule,', ' ForgeonQueueModule,');
|
|
62
|
-
} else if (content.includes(' ForgeonSwaggerModule,')) {
|
|
63
|
-
content = ensureLineAfter(content, ' ForgeonSwaggerModule,', ' ForgeonQueueModule,');
|
|
64
|
-
} else {
|
|
65
|
-
content = ensureLineAfter(content, ' CoreErrorsModule,', ' ForgeonQueueModule,');
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function patchHealthController(targetRoot) {
|
|
73
|
-
const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts');
|
|
74
|
-
if (!fs.existsSync(filePath)) {
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
79
|
-
content = ensureImportLine(content, "import { QueueService } from '@forgeon/queue';");
|
|
80
|
-
|
|
81
|
-
if (!content.includes('private readonly queueService: QueueService')) {
|
|
82
|
-
const constructorMatch = content.match(/constructor\(([\s\S]*?)\)\s*\{/m);
|
|
83
|
-
if (constructorMatch) {
|
|
84
|
-
const original = constructorMatch[0];
|
|
85
|
-
const inner = constructorMatch[1].trimEnd();
|
|
86
|
-
const normalizedInner = inner.replace(/,\s*$/, '');
|
|
87
|
-
const separator = normalizedInner.length > 0 ? ',' : '';
|
|
88
|
-
const next = `constructor(${normalizedInner}${separator}
|
|
89
|
-
private readonly queueService: QueueService,
|
|
90
|
-
) {`;
|
|
91
|
-
content = content.replace(original, next);
|
|
92
|
-
} else {
|
|
93
|
-
const classAnchor = 'export class HealthController {';
|
|
94
|
-
if (content.includes(classAnchor)) {
|
|
95
|
-
content = content.replace(
|
|
96
|
-
classAnchor,
|
|
97
|
-
`${classAnchor}
|
|
98
|
-
constructor(private readonly queueService: QueueService) {}
|
|
99
|
-
`,
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
if (!content.includes("@Get('queue')")) {
|
|
106
|
-
const method = `
|
|
107
|
-
@Get('queue')
|
|
108
|
-
async getQueueProbe() {
|
|
109
|
-
return this.queueService.getProbeStatus();
|
|
110
|
-
}
|
|
111
|
-
`;
|
|
112
|
-
content = ensureClassMember(content, 'HealthController', method, { beforeNeedle: 'private translate(' });
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function patchWebApp(targetRoot) {
|
|
119
|
-
const filePath = path.join(targetRoot, 'apps', 'web', 'src', 'App.tsx');
|
|
120
|
-
if (!fs.existsSync(filePath)) {
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
125
|
-
content = content
|
|
126
|
-
.replace(/^\s*\{\/\* forgeon:probes:actions:start \*\/\}\r?\n?/gm, '')
|
|
127
|
-
.replace(/^\s*\{\/\* forgeon:probes:actions:end \*\/\}\r?\n?/gm, '')
|
|
128
|
-
.replace(/^\s*\{\/\* forgeon:probes:results:start \*\/\}\r?\n?/gm, '')
|
|
129
|
-
.replace(/^\s*\{\/\* forgeon:probes:results:end \*\/\}\r?\n?/gm, '');
|
|
130
|
-
|
|
131
|
-
if (!content.includes('queueProbeResult')) {
|
|
132
|
-
const stateAnchors = [
|
|
133
|
-
' const [filesImageProbeResult, setFilesImageProbeResult] = useState<ProbeResult | null>(null);',
|
|
134
|
-
' const [filesQuotasProbeResult, setFilesQuotasProbeResult] = useState<ProbeResult | null>(null);',
|
|
135
|
-
' const [filesAccessProbeResult, setFilesAccessProbeResult] = useState<ProbeResult | null>(null);',
|
|
136
|
-
' const [filesVariantsProbeResult, setFilesVariantsProbeResult] = useState<ProbeResult | null>(null);',
|
|
137
|
-
' const [filesProbeResult, setFilesProbeResult] = useState<ProbeResult | null>(null);',
|
|
138
|
-
' const [rbacProbeResult, setRbacProbeResult] = useState<ProbeResult | null>(null);',
|
|
139
|
-
' const [rateLimitProbeResult, setRateLimitProbeResult] = useState<ProbeResult | null>(null);',
|
|
140
|
-
' const [authProbeResult, setAuthProbeResult] = useState<ProbeResult | null>(null);',
|
|
141
|
-
' const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);',
|
|
142
|
-
' const [validationProbeResult, setValidationProbeResult] = useState<ProbeResult | null>(null);',
|
|
143
|
-
];
|
|
144
|
-
const stateAnchor = stateAnchors.find((line) => content.includes(line));
|
|
145
|
-
if (stateAnchor) {
|
|
146
|
-
content = ensureLineAfter(
|
|
147
|
-
content,
|
|
148
|
-
stateAnchor,
|
|
149
|
-
' const [queueProbeResult, setQueueProbeResult] = useState<ProbeResult | null>(null);',
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (!content.includes('Check queue health')) {
|
|
155
|
-
const probePath = content.includes("runProbe(setHealthResult, '/health')")
|
|
156
|
-
? '/health/queue'
|
|
157
|
-
: '/api/health/queue';
|
|
158
|
-
const button = ` <button onClick={() => runProbe(setQueueProbeResult, '${probePath}')}>\n Check queue health\n </button>`;
|
|
159
|
-
|
|
160
|
-
const actionsStart = content.indexOf('<div className="actions">');
|
|
161
|
-
if (actionsStart >= 0) {
|
|
162
|
-
const actionsEnd = content.indexOf('\n </div>', actionsStart);
|
|
163
|
-
if (actionsEnd >= 0) {
|
|
164
|
-
content = `${content.slice(0, actionsEnd)}\n${button}${content.slice(actionsEnd)}`;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
if (!content.includes("{renderResult('Queue probe response', queueProbeResult)}")) {
|
|
170
|
-
const resultLine = " {renderResult('Queue probe response', queueProbeResult)}";
|
|
171
|
-
const networkLine = ' {networkError ? <p className="error">{networkError}</p> : null}';
|
|
172
|
-
if (content.includes(networkLine)) {
|
|
173
|
-
content = content.replace(networkLine, `${resultLine}\n${networkLine}`);
|
|
174
|
-
} else {
|
|
175
|
-
const anchors = [
|
|
176
|
-
"{renderResult('Files image probe response', filesImageProbeResult)}",
|
|
177
|
-
"{renderResult('Files quotas probe response', filesQuotasProbeResult)}",
|
|
178
|
-
"{renderResult('Files access probe response', filesAccessProbeResult)}",
|
|
179
|
-
"{renderResult('Files variants probe response', filesVariantsProbeResult)}",
|
|
180
|
-
"{renderResult('Files probe response', filesProbeResult)}",
|
|
181
|
-
"{renderResult('RBAC probe response', rbacProbeResult)}",
|
|
182
|
-
"{renderResult('Rate limit probe response', rateLimitProbeResult)}",
|
|
183
|
-
"{renderResult('Auth probe response', authProbeResult)}",
|
|
184
|
-
"{renderResult('DB probe response', dbProbeResult)}",
|
|
185
|
-
"{renderResult('Validation probe response', validationProbeResult)}",
|
|
186
|
-
];
|
|
187
|
-
const anchor = anchors.find((line) => content.includes(line));
|
|
188
|
-
if (anchor) {
|
|
189
|
-
content = ensureLineAfter(content, anchor, resultLine);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function patchApiDockerfile(targetRoot) {
|
|
198
|
-
const dockerfilePath = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
|
|
199
|
-
if (!fs.existsSync(dockerfilePath)) {
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
|
|
204
|
-
const packageAnchors = [
|
|
205
|
-
'COPY packages/files-image/package.json packages/files-image/package.json',
|
|
206
|
-
'COPY packages/files-quotas/package.json packages/files-quotas/package.json',
|
|
207
|
-
'COPY packages/files-access/package.json packages/files-access/package.json',
|
|
208
|
-
'COPY packages/files-s3/package.json packages/files-s3/package.json',
|
|
209
|
-
'COPY packages/files-local/package.json packages/files-local/package.json',
|
|
210
|
-
'COPY packages/files/package.json packages/files/package.json',
|
|
211
|
-
'COPY packages/auth-api/package.json packages/auth-api/package.json',
|
|
212
|
-
'COPY packages/rbac/package.json packages/rbac/package.json',
|
|
213
|
-
'COPY packages/rate-limit/package.json packages/rate-limit/package.json',
|
|
214
|
-
'COPY packages/logger/package.json packages/logger/package.json',
|
|
215
|
-
'COPY packages/swagger/package.json packages/swagger/package.json',
|
|
216
|
-
'COPY packages/i18n/package.json packages/i18n/package.json',
|
|
217
|
-
'COPY packages/db-prisma/package.json packages/db-prisma/package.json',
|
|
218
|
-
'COPY packages/core/package.json packages/core/package.json',
|
|
219
|
-
];
|
|
220
|
-
const packageAnchor = packageAnchors.find((line) => content.includes(line)) ?? packageAnchors.at(-1);
|
|
221
|
-
content = ensureLineAfter(content, packageAnchor, 'COPY packages/queue/package.json packages/queue/package.json');
|
|
222
|
-
|
|
223
|
-
const sourceAnchors = [
|
|
224
|
-
'COPY packages/files-image packages/files-image',
|
|
225
|
-
'COPY packages/files-quotas packages/files-quotas',
|
|
226
|
-
'COPY packages/files-access packages/files-access',
|
|
227
|
-
'COPY packages/files-s3 packages/files-s3',
|
|
228
|
-
'COPY packages/files-local packages/files-local',
|
|
229
|
-
'COPY packages/files packages/files',
|
|
230
|
-
'COPY packages/auth-api packages/auth-api',
|
|
231
|
-
'COPY packages/rbac packages/rbac',
|
|
232
|
-
'COPY packages/rate-limit packages/rate-limit',
|
|
233
|
-
'COPY packages/logger packages/logger',
|
|
234
|
-
'COPY packages/swagger packages/swagger',
|
|
235
|
-
'COPY packages/i18n packages/i18n',
|
|
236
|
-
'COPY packages/db-prisma packages/db-prisma',
|
|
237
|
-
'COPY packages/core packages/core',
|
|
238
|
-
];
|
|
239
|
-
const sourceAnchor = sourceAnchors.find((line) => content.includes(line)) ?? sourceAnchors.at(-1);
|
|
240
|
-
content = ensureLineAfter(content, sourceAnchor, 'COPY packages/queue packages/queue');
|
|
241
|
-
|
|
242
|
-
content = content.replace(/^RUN pnpm --filter @forgeon\/queue build\r?\n?/gm, '');
|
|
243
|
-
const buildAnchor = content.includes('RUN pnpm --filter @forgeon/api prisma:generate')
|
|
244
|
-
? 'RUN pnpm --filter @forgeon/api prisma:generate'
|
|
245
|
-
: 'RUN pnpm --filter @forgeon/api build';
|
|
246
|
-
content = ensureLineBefore(content, buildAnchor, 'RUN pnpm --filter @forgeon/queue build');
|
|
247
|
-
|
|
248
|
-
fs.writeFileSync(dockerfilePath, `${content.trimEnd()}\n`, 'utf8');
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function patchCompose(targetRoot) {
|
|
252
|
-
const composePath = path.join(targetRoot, 'infra', 'docker', 'compose.yml');
|
|
253
|
-
if (!fs.existsSync(composePath)) {
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
let content = fs.readFileSync(composePath, 'utf8').replace(/\r\n/g, '\n');
|
|
258
|
-
|
|
259
|
-
const redisServiceBlock = ` redis:
|
|
260
|
-
image: redis:7-alpine
|
|
261
|
-
restart: unless-stopped
|
|
262
|
-
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
|
263
|
-
ports:
|
|
264
|
-
- "6379:6379"
|
|
265
|
-
healthcheck:
|
|
266
|
-
test: ["CMD", "redis-cli", "ping"]
|
|
267
|
-
interval: 10s
|
|
268
|
-
timeout: 5s
|
|
269
|
-
retries: 10`;
|
|
270
|
-
|
|
271
|
-
if (!/\n\s{2}redis:\n/.test(content)) {
|
|
272
|
-
content = content.replace(/^services:\n/m, `services:\n${redisServiceBlock}\n\n`);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
if (!content.includes('QUEUE_ENABLED: ${QUEUE_ENABLED}')) {
|
|
276
|
-
const anchors = [
|
|
277
|
-
/^(\s+FILES_IMAGE_STRIP_METADATA:.*)$/m,
|
|
278
|
-
/^(\s+FILES_QUOTA_MAX_BYTES_PER_OWNER:.*)$/m,
|
|
279
|
-
/^(\s+FILES_ACCESS_DEFAULT_VISIBILITY:.*)$/m,
|
|
280
|
-
/^(\s+FILES_S3_MAX_ATTEMPTS:.*)$/m,
|
|
281
|
-
/^(\s+FILES_LOCAL_ROOT:.*)$/m,
|
|
282
|
-
/^(\s+FILES_PUBLIC_BASE_PATH:.*)$/m,
|
|
283
|
-
/^(\s+AUTH_DEMO_PASSWORD:.*)$/m,
|
|
284
|
-
/^(\s+THROTTLE_TRUST_PROXY:.*)$/m,
|
|
285
|
-
/^(\s+LOGGER_REQUEST_ID_HEADER:.*)$/m,
|
|
286
|
-
/^(\s+SWAGGER_DOCS_PATH:.*)$/m,
|
|
287
|
-
/^(\s+I18N_FALLBACK_LANG:.*)$/m,
|
|
288
|
-
/^(\s+DATABASE_URL:.*)$/m,
|
|
289
|
-
/^(\s+API_PREFIX:.*)$/m,
|
|
290
|
-
];
|
|
291
|
-
const anchorPattern = anchors.find((pattern) => pattern.test(content)) ?? anchors.at(-1);
|
|
292
|
-
content = content.replace(
|
|
293
|
-
anchorPattern,
|
|
294
|
-
`$1
|
|
295
|
-
QUEUE_ENABLED: \${QUEUE_ENABLED}
|
|
296
|
-
QUEUE_REDIS_URL: \${QUEUE_REDIS_URL}
|
|
297
|
-
QUEUE_PREFIX: \${QUEUE_PREFIX}
|
|
298
|
-
QUEUE_DEFAULT_ATTEMPTS: \${QUEUE_DEFAULT_ATTEMPTS}
|
|
299
|
-
QUEUE_DEFAULT_BACKOFF_MS: \${QUEUE_DEFAULT_BACKOFF_MS}`,
|
|
300
|
-
);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
const apiBlockMatch = content.match(/^ api:\n[\s\S]*?(?=^ [a-zA-Z0-9_-]+:\n|^volumes:\n|$)/m);
|
|
304
|
-
if (apiBlockMatch) {
|
|
305
|
-
let apiBlock = apiBlockMatch[0];
|
|
306
|
-
if (!/^\s{6}redis:\s*$/m.test(apiBlock) && !/^\s{6}-\s*redis\s*$/m.test(apiBlock)) {
|
|
307
|
-
if (/^\s{4}depends_on:\s*$/m.test(apiBlock)) {
|
|
308
|
-
if (/^\s{6}-\s+/m.test(apiBlock)) {
|
|
309
|
-
apiBlock = apiBlock.replace(
|
|
310
|
-
/^(\s{4}depends_on:\n(?:\s{6}-\s+.+\n)+)/m,
|
|
311
|
-
`$1 - redis
|
|
312
|
-
`,
|
|
313
|
-
);
|
|
314
|
-
} else {
|
|
315
|
-
apiBlock = apiBlock.replace(
|
|
316
|
-
/^(\s{4}depends_on:\n)/m,
|
|
317
|
-
`$1 redis:
|
|
318
|
-
condition: service_healthy
|
|
319
|
-
`,
|
|
320
|
-
);
|
|
321
|
-
}
|
|
322
|
-
} else {
|
|
323
|
-
const withDependsOn = apiBlock.replace(
|
|
324
|
-
/^(\s{4}environment:\n(?:\s{6}.+\n)+)/m,
|
|
325
|
-
`$1 depends_on:
|
|
326
|
-
redis:
|
|
327
|
-
condition: service_healthy
|
|
328
|
-
`,
|
|
329
|
-
);
|
|
330
|
-
apiBlock =
|
|
331
|
-
withDependsOn === apiBlock
|
|
332
|
-
? `${apiBlock.trimEnd()}\n depends_on:\n redis:\n condition: service_healthy\n`
|
|
333
|
-
: withDependsOn;
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
content = content.replace(apiBlockMatch[0], apiBlock);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
fs.writeFileSync(composePath, `${content.trimEnd()}\n`, 'utf8');
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
function patchReadme(targetRoot) {
|
|
343
|
-
const readmePath = path.join(targetRoot, 'README.md');
|
|
344
|
-
if (!fs.existsSync(readmePath)) {
|
|
345
|
-
return;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
const marker = '## Queue Module';
|
|
349
|
-
let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
|
|
350
|
-
if (content.includes(marker)) {
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
const section = `## Queue Module
|
|
355
|
-
|
|
356
|
-
The queue add-module provides an async job runtime baseline backed by Redis.
|
|
357
|
-
|
|
358
|
-
What it adds:
|
|
359
|
-
- \`@forgeon/queue\` package
|
|
360
|
-
- typed queue env config (module-owned)
|
|
361
|
-
- queue probe endpoint: \`GET /api/health/queue\`
|
|
362
|
-
- web probe button for quick runtime verification
|
|
363
|
-
- Redis service wiring in Docker Compose
|
|
364
|
-
|
|
365
|
-
Configuration (env):
|
|
366
|
-
- \`QUEUE_ENABLED=true\`
|
|
367
|
-
- \`QUEUE_REDIS_URL=redis://localhost:6379\`
|
|
368
|
-
- \`QUEUE_PREFIX=forgeon\`
|
|
369
|
-
- \`QUEUE_DEFAULT_ATTEMPTS=3\`
|
|
370
|
-
- \`QUEUE_DEFAULT_BACKOFF_MS=1000\`
|
|
371
|
-
|
|
372
|
-
Operational notes:
|
|
373
|
-
- this stage is the queue foundation (runtime + connectivity)
|
|
374
|
-
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
'
|
|
399
|
-
'
|
|
400
|
-
'
|
|
401
|
-
'
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
'
|
|
406
|
-
'
|
|
407
|
-
'
|
|
408
|
-
'
|
|
409
|
-
|
|
410
|
-
|
|
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', 'queue', relativePath);
|
|
18
|
+
if (!fs.existsSync(source)) {
|
|
19
|
+
throw new Error(`Missing queue 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/queue', 'workspace:*');
|
|
33
|
+
ensureBuildSteps(packageJson, 'predev', ['pnpm --filter @forgeon/queue 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 { ForgeonQueueModule, queueConfig, queueEnvSchema } from '@forgeon/queue';",
|
|
47
|
+
);
|
|
48
|
+
content = ensureLoadItem(content, 'queueConfig');
|
|
49
|
+
content = ensureValidatorSchema(content, 'queueEnvSchema');
|
|
50
|
+
|
|
51
|
+
if (!content.includes(' ForgeonQueueModule,')) {
|
|
52
|
+
if (content.includes(' ForgeonI18nModule.register({')) {
|
|
53
|
+
content = ensureLineBefore(content, ' ForgeonI18nModule.register({', ' ForgeonQueueModule,');
|
|
54
|
+
} else if (content.includes(' ForgeonAuthModule.register({')) {
|
|
55
|
+
content = ensureLineBefore(content, ' ForgeonAuthModule.register({', ' ForgeonQueueModule,');
|
|
56
|
+
} else if (content.includes(' ForgeonAuthModule.register(),')) {
|
|
57
|
+
content = ensureLineBefore(content, ' ForgeonAuthModule.register(),', ' ForgeonQueueModule,');
|
|
58
|
+
} else if (content.includes(' DbPrismaModule,')) {
|
|
59
|
+
content = ensureLineAfter(content, ' DbPrismaModule,', ' ForgeonQueueModule,');
|
|
60
|
+
} else if (content.includes(' ForgeonLoggerModule,')) {
|
|
61
|
+
content = ensureLineAfter(content, ' ForgeonLoggerModule,', ' ForgeonQueueModule,');
|
|
62
|
+
} else if (content.includes(' ForgeonSwaggerModule,')) {
|
|
63
|
+
content = ensureLineAfter(content, ' ForgeonSwaggerModule,', ' ForgeonQueueModule,');
|
|
64
|
+
} else {
|
|
65
|
+
content = ensureLineAfter(content, ' CoreErrorsModule,', ' ForgeonQueueModule,');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function patchHealthController(targetRoot) {
|
|
73
|
+
const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts');
|
|
74
|
+
if (!fs.existsSync(filePath)) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
79
|
+
content = ensureImportLine(content, "import { QueueService } from '@forgeon/queue';");
|
|
80
|
+
|
|
81
|
+
if (!content.includes('private readonly queueService: QueueService')) {
|
|
82
|
+
const constructorMatch = content.match(/constructor\(([\s\S]*?)\)\s*\{/m);
|
|
83
|
+
if (constructorMatch) {
|
|
84
|
+
const original = constructorMatch[0];
|
|
85
|
+
const inner = constructorMatch[1].trimEnd();
|
|
86
|
+
const normalizedInner = inner.replace(/,\s*$/, '');
|
|
87
|
+
const separator = normalizedInner.length > 0 ? ',' : '';
|
|
88
|
+
const next = `constructor(${normalizedInner}${separator}
|
|
89
|
+
private readonly queueService: QueueService,
|
|
90
|
+
) {`;
|
|
91
|
+
content = content.replace(original, next);
|
|
92
|
+
} else {
|
|
93
|
+
const classAnchor = 'export class HealthController {';
|
|
94
|
+
if (content.includes(classAnchor)) {
|
|
95
|
+
content = content.replace(
|
|
96
|
+
classAnchor,
|
|
97
|
+
`${classAnchor}
|
|
98
|
+
constructor(private readonly queueService: QueueService) {}
|
|
99
|
+
`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!content.includes("@Get('queue')")) {
|
|
106
|
+
const method = `
|
|
107
|
+
@Get('queue')
|
|
108
|
+
async getQueueProbe() {
|
|
109
|
+
return this.queueService.getProbeStatus();
|
|
110
|
+
}
|
|
111
|
+
`;
|
|
112
|
+
content = ensureClassMember(content, 'HealthController', method, { beforeNeedle: 'private translate(' });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function patchWebApp(targetRoot) {
|
|
119
|
+
const filePath = path.join(targetRoot, 'apps', 'web', 'src', 'App.tsx');
|
|
120
|
+
if (!fs.existsSync(filePath)) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
125
|
+
content = content
|
|
126
|
+
.replace(/^\s*\{\/\* forgeon:probes:actions:start \*\/\}\r?\n?/gm, '')
|
|
127
|
+
.replace(/^\s*\{\/\* forgeon:probes:actions:end \*\/\}\r?\n?/gm, '')
|
|
128
|
+
.replace(/^\s*\{\/\* forgeon:probes:results:start \*\/\}\r?\n?/gm, '')
|
|
129
|
+
.replace(/^\s*\{\/\* forgeon:probes:results:end \*\/\}\r?\n?/gm, '');
|
|
130
|
+
|
|
131
|
+
if (!content.includes('queueProbeResult')) {
|
|
132
|
+
const stateAnchors = [
|
|
133
|
+
' const [filesImageProbeResult, setFilesImageProbeResult] = useState<ProbeResult | null>(null);',
|
|
134
|
+
' const [filesQuotasProbeResult, setFilesQuotasProbeResult] = useState<ProbeResult | null>(null);',
|
|
135
|
+
' const [filesAccessProbeResult, setFilesAccessProbeResult] = useState<ProbeResult | null>(null);',
|
|
136
|
+
' const [filesVariantsProbeResult, setFilesVariantsProbeResult] = useState<ProbeResult | null>(null);',
|
|
137
|
+
' const [filesProbeResult, setFilesProbeResult] = useState<ProbeResult | null>(null);',
|
|
138
|
+
' const [rbacProbeResult, setRbacProbeResult] = useState<ProbeResult | null>(null);',
|
|
139
|
+
' const [rateLimitProbeResult, setRateLimitProbeResult] = useState<ProbeResult | null>(null);',
|
|
140
|
+
' const [authProbeResult, setAuthProbeResult] = useState<ProbeResult | null>(null);',
|
|
141
|
+
' const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);',
|
|
142
|
+
' const [validationProbeResult, setValidationProbeResult] = useState<ProbeResult | null>(null);',
|
|
143
|
+
];
|
|
144
|
+
const stateAnchor = stateAnchors.find((line) => content.includes(line));
|
|
145
|
+
if (stateAnchor) {
|
|
146
|
+
content = ensureLineAfter(
|
|
147
|
+
content,
|
|
148
|
+
stateAnchor,
|
|
149
|
+
' const [queueProbeResult, setQueueProbeResult] = useState<ProbeResult | null>(null);',
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!content.includes('Check queue health')) {
|
|
155
|
+
const probePath = content.includes("runProbe(setHealthResult, '/health')")
|
|
156
|
+
? '/health/queue'
|
|
157
|
+
: '/api/health/queue';
|
|
158
|
+
const button = ` <button onClick={() => runProbe(setQueueProbeResult, '${probePath}')}>\n Check queue health\n </button>`;
|
|
159
|
+
|
|
160
|
+
const actionsStart = content.indexOf('<div className="actions">');
|
|
161
|
+
if (actionsStart >= 0) {
|
|
162
|
+
const actionsEnd = content.indexOf('\n </div>', actionsStart);
|
|
163
|
+
if (actionsEnd >= 0) {
|
|
164
|
+
content = `${content.slice(0, actionsEnd)}\n${button}${content.slice(actionsEnd)}`;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!content.includes("{renderResult('Queue probe response', queueProbeResult)}")) {
|
|
170
|
+
const resultLine = " {renderResult('Queue probe response', queueProbeResult)}";
|
|
171
|
+
const networkLine = ' {networkError ? <p className="error">{networkError}</p> : null}';
|
|
172
|
+
if (content.includes(networkLine)) {
|
|
173
|
+
content = content.replace(networkLine, `${resultLine}\n${networkLine}`);
|
|
174
|
+
} else {
|
|
175
|
+
const anchors = [
|
|
176
|
+
"{renderResult('Files image probe response', filesImageProbeResult)}",
|
|
177
|
+
"{renderResult('Files quotas probe response', filesQuotasProbeResult)}",
|
|
178
|
+
"{renderResult('Files access probe response', filesAccessProbeResult)}",
|
|
179
|
+
"{renderResult('Files variants probe response', filesVariantsProbeResult)}",
|
|
180
|
+
"{renderResult('Files probe response', filesProbeResult)}",
|
|
181
|
+
"{renderResult('RBAC probe response', rbacProbeResult)}",
|
|
182
|
+
"{renderResult('Rate limit probe response', rateLimitProbeResult)}",
|
|
183
|
+
"{renderResult('Auth probe response', authProbeResult)}",
|
|
184
|
+
"{renderResult('DB probe response', dbProbeResult)}",
|
|
185
|
+
"{renderResult('Validation probe response', validationProbeResult)}",
|
|
186
|
+
];
|
|
187
|
+
const anchor = anchors.find((line) => content.includes(line));
|
|
188
|
+
if (anchor) {
|
|
189
|
+
content = ensureLineAfter(content, anchor, resultLine);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function patchApiDockerfile(targetRoot) {
|
|
198
|
+
const dockerfilePath = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
|
|
199
|
+
if (!fs.existsSync(dockerfilePath)) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
|
|
204
|
+
const packageAnchors = [
|
|
205
|
+
'COPY packages/files-image/package.json packages/files-image/package.json',
|
|
206
|
+
'COPY packages/files-quotas/package.json packages/files-quotas/package.json',
|
|
207
|
+
'COPY packages/files-access/package.json packages/files-access/package.json',
|
|
208
|
+
'COPY packages/files-s3/package.json packages/files-s3/package.json',
|
|
209
|
+
'COPY packages/files-local/package.json packages/files-local/package.json',
|
|
210
|
+
'COPY packages/files/package.json packages/files/package.json',
|
|
211
|
+
'COPY packages/auth-api/package.json packages/auth-api/package.json',
|
|
212
|
+
'COPY packages/rbac/package.json packages/rbac/package.json',
|
|
213
|
+
'COPY packages/rate-limit/package.json packages/rate-limit/package.json',
|
|
214
|
+
'COPY packages/logger/package.json packages/logger/package.json',
|
|
215
|
+
'COPY packages/swagger/package.json packages/swagger/package.json',
|
|
216
|
+
'COPY packages/i18n/package.json packages/i18n/package.json',
|
|
217
|
+
'COPY packages/db-prisma/package.json packages/db-prisma/package.json',
|
|
218
|
+
'COPY packages/core/package.json packages/core/package.json',
|
|
219
|
+
];
|
|
220
|
+
const packageAnchor = packageAnchors.find((line) => content.includes(line)) ?? packageAnchors.at(-1);
|
|
221
|
+
content = ensureLineAfter(content, packageAnchor, 'COPY packages/queue/package.json packages/queue/package.json');
|
|
222
|
+
|
|
223
|
+
const sourceAnchors = [
|
|
224
|
+
'COPY packages/files-image packages/files-image',
|
|
225
|
+
'COPY packages/files-quotas packages/files-quotas',
|
|
226
|
+
'COPY packages/files-access packages/files-access',
|
|
227
|
+
'COPY packages/files-s3 packages/files-s3',
|
|
228
|
+
'COPY packages/files-local packages/files-local',
|
|
229
|
+
'COPY packages/files packages/files',
|
|
230
|
+
'COPY packages/auth-api packages/auth-api',
|
|
231
|
+
'COPY packages/rbac packages/rbac',
|
|
232
|
+
'COPY packages/rate-limit packages/rate-limit',
|
|
233
|
+
'COPY packages/logger packages/logger',
|
|
234
|
+
'COPY packages/swagger packages/swagger',
|
|
235
|
+
'COPY packages/i18n packages/i18n',
|
|
236
|
+
'COPY packages/db-prisma packages/db-prisma',
|
|
237
|
+
'COPY packages/core packages/core',
|
|
238
|
+
];
|
|
239
|
+
const sourceAnchor = sourceAnchors.find((line) => content.includes(line)) ?? sourceAnchors.at(-1);
|
|
240
|
+
content = ensureLineAfter(content, sourceAnchor, 'COPY packages/queue packages/queue');
|
|
241
|
+
|
|
242
|
+
content = content.replace(/^RUN pnpm --filter @forgeon\/queue build\r?\n?/gm, '');
|
|
243
|
+
const buildAnchor = content.includes('RUN pnpm --filter @forgeon/api prisma:generate')
|
|
244
|
+
? 'RUN pnpm --filter @forgeon/api prisma:generate'
|
|
245
|
+
: 'RUN pnpm --filter @forgeon/api build';
|
|
246
|
+
content = ensureLineBefore(content, buildAnchor, 'RUN pnpm --filter @forgeon/queue build');
|
|
247
|
+
|
|
248
|
+
fs.writeFileSync(dockerfilePath, `${content.trimEnd()}\n`, 'utf8');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function patchCompose(targetRoot) {
|
|
252
|
+
const composePath = path.join(targetRoot, 'infra', 'docker', 'compose.yml');
|
|
253
|
+
if (!fs.existsSync(composePath)) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let content = fs.readFileSync(composePath, 'utf8').replace(/\r\n/g, '\n');
|
|
258
|
+
|
|
259
|
+
const redisServiceBlock = ` redis:
|
|
260
|
+
image: redis:7-alpine
|
|
261
|
+
restart: unless-stopped
|
|
262
|
+
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
|
263
|
+
ports:
|
|
264
|
+
- "6379:6379"
|
|
265
|
+
healthcheck:
|
|
266
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
267
|
+
interval: 10s
|
|
268
|
+
timeout: 5s
|
|
269
|
+
retries: 10`;
|
|
270
|
+
|
|
271
|
+
if (!/\n\s{2}redis:\n/.test(content)) {
|
|
272
|
+
content = content.replace(/^services:\n/m, `services:\n${redisServiceBlock}\n\n`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!content.includes('QUEUE_ENABLED: ${QUEUE_ENABLED}')) {
|
|
276
|
+
const anchors = [
|
|
277
|
+
/^(\s+FILES_IMAGE_STRIP_METADATA:.*)$/m,
|
|
278
|
+
/^(\s+FILES_QUOTA_MAX_BYTES_PER_OWNER:.*)$/m,
|
|
279
|
+
/^(\s+FILES_ACCESS_DEFAULT_VISIBILITY:.*)$/m,
|
|
280
|
+
/^(\s+FILES_S3_MAX_ATTEMPTS:.*)$/m,
|
|
281
|
+
/^(\s+FILES_LOCAL_ROOT:.*)$/m,
|
|
282
|
+
/^(\s+FILES_PUBLIC_BASE_PATH:.*)$/m,
|
|
283
|
+
/^(\s+AUTH_DEMO_PASSWORD:.*)$/m,
|
|
284
|
+
/^(\s+THROTTLE_TRUST_PROXY:.*)$/m,
|
|
285
|
+
/^(\s+LOGGER_REQUEST_ID_HEADER:.*)$/m,
|
|
286
|
+
/^(\s+SWAGGER_DOCS_PATH:.*)$/m,
|
|
287
|
+
/^(\s+I18N_FALLBACK_LANG:.*)$/m,
|
|
288
|
+
/^(\s+DATABASE_URL:.*)$/m,
|
|
289
|
+
/^(\s+API_PREFIX:.*)$/m,
|
|
290
|
+
];
|
|
291
|
+
const anchorPattern = anchors.find((pattern) => pattern.test(content)) ?? anchors.at(-1);
|
|
292
|
+
content = content.replace(
|
|
293
|
+
anchorPattern,
|
|
294
|
+
`$1
|
|
295
|
+
QUEUE_ENABLED: \${QUEUE_ENABLED}
|
|
296
|
+
QUEUE_REDIS_URL: \${QUEUE_REDIS_URL}
|
|
297
|
+
QUEUE_PREFIX: \${QUEUE_PREFIX}
|
|
298
|
+
QUEUE_DEFAULT_ATTEMPTS: \${QUEUE_DEFAULT_ATTEMPTS}
|
|
299
|
+
QUEUE_DEFAULT_BACKOFF_MS: \${QUEUE_DEFAULT_BACKOFF_MS}`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const apiBlockMatch = content.match(/^ api:\n[\s\S]*?(?=^ [a-zA-Z0-9_-]+:\n|^volumes:\n|$)/m);
|
|
304
|
+
if (apiBlockMatch) {
|
|
305
|
+
let apiBlock = apiBlockMatch[0];
|
|
306
|
+
if (!/^\s{6}redis:\s*$/m.test(apiBlock) && !/^\s{6}-\s*redis\s*$/m.test(apiBlock)) {
|
|
307
|
+
if (/^\s{4}depends_on:\s*$/m.test(apiBlock)) {
|
|
308
|
+
if (/^\s{6}-\s+/m.test(apiBlock)) {
|
|
309
|
+
apiBlock = apiBlock.replace(
|
|
310
|
+
/^(\s{4}depends_on:\n(?:\s{6}-\s+.+\n)+)/m,
|
|
311
|
+
`$1 - redis
|
|
312
|
+
`,
|
|
313
|
+
);
|
|
314
|
+
} else {
|
|
315
|
+
apiBlock = apiBlock.replace(
|
|
316
|
+
/^(\s{4}depends_on:\n)/m,
|
|
317
|
+
`$1 redis:
|
|
318
|
+
condition: service_healthy
|
|
319
|
+
`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
} else {
|
|
323
|
+
const withDependsOn = apiBlock.replace(
|
|
324
|
+
/^(\s{4}environment:\n(?:\s{6}.+\n)+)/m,
|
|
325
|
+
`$1 depends_on:
|
|
326
|
+
redis:
|
|
327
|
+
condition: service_healthy
|
|
328
|
+
`,
|
|
329
|
+
);
|
|
330
|
+
apiBlock =
|
|
331
|
+
withDependsOn === apiBlock
|
|
332
|
+
? `${apiBlock.trimEnd()}\n depends_on:\n redis:\n condition: service_healthy\n`
|
|
333
|
+
: withDependsOn;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
content = content.replace(apiBlockMatch[0], apiBlock);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
fs.writeFileSync(composePath, `${content.trimEnd()}\n`, 'utf8');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function patchReadme(targetRoot) {
|
|
343
|
+
const readmePath = path.join(targetRoot, 'README.md');
|
|
344
|
+
if (!fs.existsSync(readmePath)) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const marker = '## Queue Module';
|
|
349
|
+
let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
|
|
350
|
+
if (content.includes(marker)) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const section = `## Queue Module
|
|
355
|
+
|
|
356
|
+
The queue add-module provides an async job runtime baseline backed by Redis.
|
|
357
|
+
|
|
358
|
+
What it adds:
|
|
359
|
+
- \`@forgeon/queue\` package
|
|
360
|
+
- typed queue env config (module-owned)
|
|
361
|
+
- queue probe endpoint: \`GET /api/health/queue\`
|
|
362
|
+
- web probe button for quick runtime verification
|
|
363
|
+
- Redis service wiring in Docker Compose
|
|
364
|
+
|
|
365
|
+
Configuration (env):
|
|
366
|
+
- \`QUEUE_ENABLED=true\`
|
|
367
|
+
- \`QUEUE_REDIS_URL=redis://localhost:6379\`
|
|
368
|
+
- \`QUEUE_PREFIX=forgeon\`
|
|
369
|
+
- \`QUEUE_DEFAULT_ATTEMPTS=3\`
|
|
370
|
+
- \`QUEUE_DEFAULT_BACKOFF_MS=1000\`
|
|
371
|
+
|
|
372
|
+
Operational notes:
|
|
373
|
+
- this stage is the queue foundation (runtime + connectivity)
|
|
374
|
+
- cron orchestration is available through the scheduler module
|
|
375
|
+
- job execution workers remain intentionally deferred to a later module`;
|
|
376
|
+
|
|
377
|
+
if (content.includes('## Prisma In Docker Start')) {
|
|
378
|
+
content = content.replace('## Prisma In Docker Start', `${section}\n\n## Prisma In Docker Start`);
|
|
379
|
+
} else {
|
|
380
|
+
content = `${content.trimEnd()}\n\n${section}\n`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
fs.writeFileSync(readmePath, `${content.trimEnd()}\n`, 'utf8');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function applyQueueModule({ packageRoot, targetRoot }) {
|
|
387
|
+
copyFromPreset(packageRoot, targetRoot, path.join('packages', 'queue'));
|
|
388
|
+
|
|
389
|
+
patchApiPackage(targetRoot);
|
|
390
|
+
patchAppModule(targetRoot);
|
|
391
|
+
patchHealthController(targetRoot);
|
|
392
|
+
patchWebApp(targetRoot);
|
|
393
|
+
patchApiDockerfile(targetRoot);
|
|
394
|
+
patchCompose(targetRoot);
|
|
395
|
+
patchReadme(targetRoot);
|
|
396
|
+
|
|
397
|
+
upsertEnvLines(path.join(targetRoot, 'apps', 'api', '.env.example'), [
|
|
398
|
+
'QUEUE_ENABLED=true',
|
|
399
|
+
'QUEUE_REDIS_URL=redis://localhost:6379',
|
|
400
|
+
'QUEUE_PREFIX=forgeon',
|
|
401
|
+
'QUEUE_DEFAULT_ATTEMPTS=3',
|
|
402
|
+
'QUEUE_DEFAULT_BACKOFF_MS=1000',
|
|
403
|
+
]);
|
|
404
|
+
upsertEnvLines(path.join(targetRoot, 'infra', 'docker', '.env.example'), [
|
|
405
|
+
'QUEUE_ENABLED=true',
|
|
406
|
+
'QUEUE_REDIS_URL=redis://redis:6379',
|
|
407
|
+
'QUEUE_PREFIX=forgeon',
|
|
408
|
+
'QUEUE_DEFAULT_ATTEMPTS=3',
|
|
409
|
+
'QUEUE_DEFAULT_BACKOFF_MS=1000',
|
|
410
|
+
]);
|
|
411
|
+
}
|
|
412
|
+
|