@nexpress/core 0.1.0
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/LICENSE +21 -0
- package/README.md +69 -0
- package/dist/audit-54XLVCWD.js +14 -0
- package/dist/audit-54XLVCWD.js.map +1 -0
- package/dist/auth.d.ts +640 -0
- package/dist/auth.js +94 -0
- package/dist/auth.js.map +1 -0
- package/dist/can-YLUHRJAB.js +19 -0
- package/dist/can-YLUHRJAB.js.map +1 -0
- package/dist/chunk-2G264RCD.js +68 -0
- package/dist/chunk-2G264RCD.js.map +1 -0
- package/dist/chunk-2YDGE7YX.js +92 -0
- package/dist/chunk-2YDGE7YX.js.map +1 -0
- package/dist/chunk-473S4TER.js +538 -0
- package/dist/chunk-473S4TER.js.map +1 -0
- package/dist/chunk-4ZLMEKFX.js +18 -0
- package/dist/chunk-4ZLMEKFX.js.map +1 -0
- package/dist/chunk-55FU6WED.js +179 -0
- package/dist/chunk-55FU6WED.js.map +1 -0
- package/dist/chunk-6YI5K2TI.js +1959 -0
- package/dist/chunk-6YI5K2TI.js.map +1 -0
- package/dist/chunk-BHK3AD3Q.js +41 -0
- package/dist/chunk-BHK3AD3Q.js.map +1 -0
- package/dist/chunk-CRUQBZUF.js +39 -0
- package/dist/chunk-CRUQBZUF.js.map +1 -0
- package/dist/chunk-CTSQ7BRI.js +175 -0
- package/dist/chunk-CTSQ7BRI.js.map +1 -0
- package/dist/chunk-DK2JBJH7.js +81 -0
- package/dist/chunk-DK2JBJH7.js.map +1 -0
- package/dist/chunk-DP2PREDU.js +597 -0
- package/dist/chunk-DP2PREDU.js.map +1 -0
- package/dist/chunk-EQ2Z3KMD.js +24 -0
- package/dist/chunk-EQ2Z3KMD.js.map +1 -0
- package/dist/chunk-FZ7O6DWI.js +305 -0
- package/dist/chunk-FZ7O6DWI.js.map +1 -0
- package/dist/chunk-ISLYFQWL.js +1270 -0
- package/dist/chunk-ISLYFQWL.js.map +1 -0
- package/dist/chunk-JJL74ZPK.js +68 -0
- package/dist/chunk-JJL74ZPK.js.map +1 -0
- package/dist/chunk-JKXAPSU4.js +24 -0
- package/dist/chunk-JKXAPSU4.js.map +1 -0
- package/dist/chunk-KU5M27ZC.js +24 -0
- package/dist/chunk-KU5M27ZC.js.map +1 -0
- package/dist/chunk-LSHHRDVR.js +34 -0
- package/dist/chunk-LSHHRDVR.js.map +1 -0
- package/dist/chunk-M43PGOQY.js +715 -0
- package/dist/chunk-M43PGOQY.js.map +1 -0
- package/dist/chunk-MEJAHXIO.js +150 -0
- package/dist/chunk-MEJAHXIO.js.map +1 -0
- package/dist/chunk-NUCGHWCF.js +101 -0
- package/dist/chunk-NUCGHWCF.js.map +1 -0
- package/dist/chunk-OK5HOCQI.js +845 -0
- package/dist/chunk-OK5HOCQI.js.map +1 -0
- package/dist/chunk-OROPGO65.js +13 -0
- package/dist/chunk-OROPGO65.js.map +1 -0
- package/dist/chunk-PPAS4SZR.js +176 -0
- package/dist/chunk-PPAS4SZR.js.map +1 -0
- package/dist/chunk-PPBWRKO2.js +171 -0
- package/dist/chunk-PPBWRKO2.js.map +1 -0
- package/dist/chunk-PZ5AY32C.js +10 -0
- package/dist/chunk-PZ5AY32C.js.map +1 -0
- package/dist/chunk-QO7LAQZH.js +321 -0
- package/dist/chunk-QO7LAQZH.js.map +1 -0
- package/dist/chunk-QVJ2HCAX.js +225 -0
- package/dist/chunk-QVJ2HCAX.js.map +1 -0
- package/dist/chunk-RIPHIRPP.js +68 -0
- package/dist/chunk-RIPHIRPP.js.map +1 -0
- package/dist/chunk-S27S42QY.js +134 -0
- package/dist/chunk-S27S42QY.js.map +1 -0
- package/dist/chunk-SBCVAC2Z.js +40 -0
- package/dist/chunk-SBCVAC2Z.js.map +1 -0
- package/dist/chunk-TFJ4MKPH.js +694 -0
- package/dist/chunk-TFJ4MKPH.js.map +1 -0
- package/dist/chunk-THX3SHYA.js +75 -0
- package/dist/chunk-THX3SHYA.js.map +1 -0
- package/dist/chunk-UGQSQO5B.js +222 -0
- package/dist/chunk-UGQSQO5B.js.map +1 -0
- package/dist/chunk-V2UNHGAP.js +26 -0
- package/dist/chunk-V2UNHGAP.js.map +1 -0
- package/dist/chunk-VGTPQXNQ.js +2790 -0
- package/dist/chunk-VGTPQXNQ.js.map +1 -0
- package/dist/chunk-VNIHXQ7W.js +194 -0
- package/dist/chunk-VNIHXQ7W.js.map +1 -0
- package/dist/chunk-WV272MPW.js +31 -0
- package/dist/chunk-WV272MPW.js.map +1 -0
- package/dist/chunk-X5KKBOUS.js +26 -0
- package/dist/chunk-X5KKBOUS.js.map +1 -0
- package/dist/chunk-XANPEOJC.js +17 -0
- package/dist/chunk-XANPEOJC.js.map +1 -0
- package/dist/chunk-XPVQIHAQ.js +83 -0
- package/dist/chunk-XPVQIHAQ.js.map +1 -0
- package/dist/chunk-ZCINJSS4.js +75 -0
- package/dist/chunk-ZCINJSS4.js.map +1 -0
- package/dist/community.d.ts +1425 -0
- package/dist/community.js +206 -0
- package/dist/community.js.map +1 -0
- package/dist/config-2GDU7PCK.js +32 -0
- package/dist/config-2GDU7PCK.js.map +1 -0
- package/dist/context-MNZ4QXPC.js +16 -0
- package/dist/context-MNZ4QXPC.js.map +1 -0
- package/dist/db-schema.d.ts +4 -0
- package/dist/db-schema.js +102 -0
- package/dist/db-schema.js.map +1 -0
- package/dist/db.d.ts +7 -0
- package/dist/db.js +117 -0
- package/dist/db.js.map +1 -0
- package/dist/digest-SY42GQSU.js +17 -0
- package/dist/digest-SY42GQSU.js.map +1 -0
- package/dist/errors-5OS3S2J3.js +22 -0
- package/dist/errors-5OS3S2J3.js.map +1 -0
- package/dist/host-OBOI4MJK.js +51 -0
- package/dist/host-OBOI4MJK.js.map +1 -0
- package/dist/i18n.d.ts +301 -0
- package/dist/i18n.js +68 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index-B6-_vr_m.d.ts +590 -0
- package/dist/index-CY55LC0u.d.ts +4722 -0
- package/dist/index-CeiTvwbp.d.ts +168 -0
- package/dist/index-XwP1ET8b.d.ts +61 -0
- package/dist/index.d.ts +2037 -0
- package/dist/index.js +2205 -0
- package/dist/index.js.map +1 -0
- package/dist/job-log-VZXWQUDK.js +24 -0
- package/dist/job-log-VZXWQUDK.js.map +1 -0
- package/dist/jobs.d.ts +4 -0
- package/dist/jobs.js +76 -0
- package/dist/jobs.js.map +1 -0
- package/dist/logger-DqGaOU_j.d.ts +29 -0
- package/dist/logger-S7REWDNE.js +16 -0
- package/dist/logger-S7REWDNE.js.map +1 -0
- package/dist/media.d.ts +5 -0
- package/dist/media.js +41 -0
- package/dist/media.js.map +1 -0
- package/dist/mentions-2IHFVSHW.js +23 -0
- package/dist/mentions-2IHFVSHW.js.map +1 -0
- package/dist/mutes-EWAE5FZR.js +21 -0
- package/dist/mutes-EWAE5FZR.js.map +1 -0
- package/dist/notification-prefs-VPJDU7I6.js +21 -0
- package/dist/notification-prefs-VPJDU7I6.js.map +1 -0
- package/dist/observability.d.ts +156 -0
- package/dist/observability.js +32 -0
- package/dist/observability.js.map +1 -0
- package/dist/profanity-adapter-NU2JQSLX.js +12 -0
- package/dist/profanity-adapter-NU2JQSLX.js.map +1 -0
- package/dist/queue-XE5BC75T.js +14 -0
- package/dist/queue-XE5BC75T.js.map +1 -0
- package/dist/rate-limit.d.ts +99 -0
- package/dist/rate-limit.js +14 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/registry-XIXDEPVI.js +31 -0
- package/dist/registry-XIXDEPVI.js.map +1 -0
- package/dist/reputation-JRL2YQHM.js +11 -0
- package/dist/reputation-JRL2YQHM.js.map +1 -0
- package/dist/routes.d.ts +43 -0
- package/dist/routes.js +12 -0
- package/dist/routes.js.map +1 -0
- package/dist/scheduled-CIQM57HT.js +20 -0
- package/dist/scheduled-CIQM57HT.js.map +1 -0
- package/dist/seo.d.ts +410 -0
- package/dist/seo.js +44 -0
- package/dist/seo.js.map +1 -0
- package/dist/settings-FOBIESPB.js +17 -0
- package/dist/settings-FOBIESPB.js.map +1 -0
- package/dist/spam-adapter-XX3G737Z.js +12 -0
- package/dist/spam-adapter-XX3G737Z.js.map +1 -0
- package/dist/strings-VAE47B2C.js +29 -0
- package/dist/strings-VAE47B2C.js.map +1 -0
- package/dist/templates-IFVJMCJ6.js +12 -0
- package/dist/templates-IFVJMCJ6.js.map +1 -0
- package/dist/types-TlsbXS0T.d.ts +871 -0
- package/package.json +129 -0
|
@@ -0,0 +1,1270 @@
|
|
|
1
|
+
import {
|
|
2
|
+
recordJobLog,
|
|
3
|
+
runInJobContext
|
|
4
|
+
} from "./chunk-DK2JBJH7.js";
|
|
5
|
+
import {
|
|
6
|
+
getEmailAdapter
|
|
7
|
+
} from "./chunk-LSHHRDVR.js";
|
|
8
|
+
import {
|
|
9
|
+
reportError
|
|
10
|
+
} from "./chunk-WV272MPW.js";
|
|
11
|
+
import {
|
|
12
|
+
setJobQueue
|
|
13
|
+
} from "./chunk-V2UNHGAP.js";
|
|
14
|
+
import {
|
|
15
|
+
readEnvPositiveInt
|
|
16
|
+
} from "./chunk-OROPGO65.js";
|
|
17
|
+
import {
|
|
18
|
+
getLogger
|
|
19
|
+
} from "./chunk-JJL74ZPK.js";
|
|
20
|
+
import {
|
|
21
|
+
getDb
|
|
22
|
+
} from "./chunk-XANPEOJC.js";
|
|
23
|
+
import {
|
|
24
|
+
npSettings,
|
|
25
|
+
npWorkerHeartbeats
|
|
26
|
+
} from "./chunk-M43PGOQY.js";
|
|
27
|
+
|
|
28
|
+
// src/jobs/handlers.ts
|
|
29
|
+
var handlers = /* @__PURE__ */ new Map();
|
|
30
|
+
function registerJobHandler(type, handler) {
|
|
31
|
+
handlers.set(type, handler);
|
|
32
|
+
}
|
|
33
|
+
function getJobHandler(type) {
|
|
34
|
+
return handlers.get(type);
|
|
35
|
+
}
|
|
36
|
+
function getAllJobHandlers() {
|
|
37
|
+
return handlers;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// src/email/templates.ts
|
|
41
|
+
function escapeHtml(value) {
|
|
42
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
43
|
+
}
|
|
44
|
+
function wrap(siteName, contentHtml) {
|
|
45
|
+
return `<!doctype html>
|
|
46
|
+
<html>
|
|
47
|
+
<body style="margin:0;padding:24px;background:#f5f5f5;font-family:system-ui,-apple-system,Segoe UI,sans-serif;color:#111;">
|
|
48
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;margin:0 auto;background:#fff;border-radius:12px;padding:32px;border:1px solid #e5e5e5;">
|
|
49
|
+
<tr>
|
|
50
|
+
<td>
|
|
51
|
+
<h1 style="margin:0 0 16px;font-size:20px;font-weight:600;">${escapeHtml(siteName)}</h1>
|
|
52
|
+
${contentHtml}
|
|
53
|
+
<p style="margin-top:32px;font-size:12px;color:#777;">If you didn't expect this email you can ignore it.</p>
|
|
54
|
+
</td>
|
|
55
|
+
</tr>
|
|
56
|
+
</table>
|
|
57
|
+
</body>
|
|
58
|
+
</html>`;
|
|
59
|
+
}
|
|
60
|
+
function buildInviteEmail(data) {
|
|
61
|
+
const subject = `You're invited to ${data.siteName}`;
|
|
62
|
+
const text = `Hi ${data.name},
|
|
63
|
+
|
|
64
|
+
You've been invited to ${data.siteName}. Set your password to activate your account:
|
|
65
|
+
|
|
66
|
+
${data.resetUrl}
|
|
67
|
+
|
|
68
|
+
This link expires in 7 days.`;
|
|
69
|
+
const html = wrap(
|
|
70
|
+
data.siteName,
|
|
71
|
+
`
|
|
72
|
+
<p style="margin:0 0 16px;">Hi ${escapeHtml(data.name)},</p>
|
|
73
|
+
<p style="margin:0 0 24px;">You've been invited to ${escapeHtml(data.siteName)}. Set your password to activate your account:</p>
|
|
74
|
+
<p style="margin:0 0 24px;"><a href="${escapeHtml(data.resetUrl)}" style="display:inline-block;background:#111;color:#fff;text-decoration:none;padding:10px 20px;border-radius:8px;font-weight:500;">Set my password</a></p>
|
|
75
|
+
<p style="margin:0 0 8px;font-size:13px;color:#555;">Or copy the link:</p>
|
|
76
|
+
<p style="margin:0;font-size:13px;color:#555;word-break:break-all;">${escapeHtml(data.resetUrl)}</p>
|
|
77
|
+
<p style="margin-top:24px;font-size:13px;color:#555;">This link expires in 7 days.</p>
|
|
78
|
+
`
|
|
79
|
+
);
|
|
80
|
+
return { subject, text, html };
|
|
81
|
+
}
|
|
82
|
+
function buildMemberVerifyEmail(data) {
|
|
83
|
+
const subject = `Confirm your ${data.siteName} account`;
|
|
84
|
+
const text = `Hi ${data.displayName},
|
|
85
|
+
|
|
86
|
+
Welcome to ${data.siteName}. Confirm your email so we can activate your account:
|
|
87
|
+
|
|
88
|
+
${data.verifyUrl}
|
|
89
|
+
|
|
90
|
+
This link expires in 24 hours. If you didn't sign up, you can ignore this email.`;
|
|
91
|
+
const html = wrap(
|
|
92
|
+
data.siteName,
|
|
93
|
+
`
|
|
94
|
+
<p style="margin:0 0 16px;">Hi ${escapeHtml(data.displayName)},</p>
|
|
95
|
+
<p style="margin:0 0 24px;">Welcome to ${escapeHtml(data.siteName)}. Confirm your email so we can activate your account:</p>
|
|
96
|
+
<p style="margin:0 0 24px;"><a href="${escapeHtml(data.verifyUrl)}" style="display:inline-block;background:#111;color:#fff;text-decoration:none;padding:10px 20px;border-radius:8px;font-weight:500;">Confirm my email</a></p>
|
|
97
|
+
<p style="margin:0 0 8px;font-size:13px;color:#555;">Or copy the link:</p>
|
|
98
|
+
<p style="margin:0;font-size:13px;color:#555;word-break:break-all;">${escapeHtml(data.verifyUrl)}</p>
|
|
99
|
+
<p style="margin-top:24px;font-size:13px;color:#555;">This link expires in 24 hours. If you didn't sign up, you can ignore this email.</p>
|
|
100
|
+
`
|
|
101
|
+
);
|
|
102
|
+
return { subject, text, html };
|
|
103
|
+
}
|
|
104
|
+
function buildResetEmail(data) {
|
|
105
|
+
const subject = `Reset your ${data.siteName} password`;
|
|
106
|
+
const text = `Hi ${data.name},
|
|
107
|
+
|
|
108
|
+
Someone requested a password reset for your ${data.siteName} account. If that was you, use this link to set a new one:
|
|
109
|
+
|
|
110
|
+
${data.resetUrl}
|
|
111
|
+
|
|
112
|
+
This link expires in 1 hour. If you didn't request it, you can ignore this email.`;
|
|
113
|
+
const html = wrap(
|
|
114
|
+
data.siteName,
|
|
115
|
+
`
|
|
116
|
+
<p style="margin:0 0 16px;">Hi ${escapeHtml(data.name)},</p>
|
|
117
|
+
<p style="margin:0 0 24px;">Someone requested a password reset for your ${escapeHtml(data.siteName)} account. If that was you, use this link to set a new one:</p>
|
|
118
|
+
<p style="margin:0 0 24px;"><a href="${escapeHtml(data.resetUrl)}" style="display:inline-block;background:#111;color:#fff;text-decoration:none;padding:10px 20px;border-radius:8px;font-weight:500;">Reset password</a></p>
|
|
119
|
+
<p style="margin:0 0 8px;font-size:13px;color:#555;">Or copy the link:</p>
|
|
120
|
+
<p style="margin:0;font-size:13px;color:#555;word-break:break-all;">${escapeHtml(data.resetUrl)}</p>
|
|
121
|
+
<p style="margin-top:24px;font-size:13px;color:#555;">This link expires in 1 hour. If you didn't request it you can ignore this email.</p>
|
|
122
|
+
`
|
|
123
|
+
);
|
|
124
|
+
return { subject, text, html };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/jobs/builtin-handlers.ts
|
|
128
|
+
var builtinJobContext = {};
|
|
129
|
+
function configureBuiltinJobContext(context) {
|
|
130
|
+
Object.assign(builtinJobContext, context);
|
|
131
|
+
}
|
|
132
|
+
function registerBuiltinHandlers() {
|
|
133
|
+
registerJobHandler("content:afterSave", handleContentAfterSave);
|
|
134
|
+
registerJobHandler("content:afterDelete", handleContentAfterDelete);
|
|
135
|
+
registerJobHandler("content:publishScheduled", handleContentPublishScheduled);
|
|
136
|
+
registerJobHandler("media:processImage", handleMediaProcessImage);
|
|
137
|
+
registerJobHandler("media:cleanup", handleMediaCleanup);
|
|
138
|
+
registerJobHandler("plugin:scheduledTask", handlePluginScheduledTask);
|
|
139
|
+
registerJobHandler("system:revisionPrune", handleRevisionPrune);
|
|
140
|
+
registerJobHandler("system:sessionCleanup", handleSessionCleanup);
|
|
141
|
+
registerJobHandler("system:jobLogPrune", handleJobLogPrune);
|
|
142
|
+
registerJobHandler("auth:sendPasswordReset", handleAuthSendPasswordReset);
|
|
143
|
+
registerJobHandler("members:sendVerifyEmail", handleMemberSendVerifyEmail);
|
|
144
|
+
registerJobHandler("members:sendPasswordReset", handleMemberSendPasswordReset);
|
|
145
|
+
registerJobHandler("notifications:sendDigest", handleNotificationsSendDigest);
|
|
146
|
+
}
|
|
147
|
+
async function handleContentPublishScheduled(_) {
|
|
148
|
+
const { publishScheduledDocuments } = await import("./scheduled-CIQM57HT.js");
|
|
149
|
+
const result = await publishScheduledDocuments();
|
|
150
|
+
if (result.published > 0) {
|
|
151
|
+
console.info(
|
|
152
|
+
`[nexpress] content:publishScheduled flipped ${result.published} document(s)`,
|
|
153
|
+
result.byCollection
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function handleContentAfterSave(data) {
|
|
158
|
+
const jobData = asContentJobData(data);
|
|
159
|
+
await revalidateCollectionTags(jobData.collection, jobData.documentId);
|
|
160
|
+
const context = await builtinJobContext.resolveContentAfterSaveContext?.(jobData);
|
|
161
|
+
if (!context) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const hooks = jobData.operation === "create" ? context.collectionConfig.hooks?.afterCreate : context.collectionConfig.hooks?.afterUpdate;
|
|
165
|
+
await runCollectionHooks(hooks, {
|
|
166
|
+
data: context.data,
|
|
167
|
+
user: context.user,
|
|
168
|
+
principal: context.principal,
|
|
169
|
+
collection: context.collectionConfig.slug,
|
|
170
|
+
originalDoc: context.originalDoc
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
async function handleContentAfterDelete(data) {
|
|
174
|
+
const jobData = asContentDeleteJobData(data);
|
|
175
|
+
await revalidateCollectionTags(jobData.collection, jobData.documentId);
|
|
176
|
+
const context = await builtinJobContext.resolveContentAfterDeleteContext?.(jobData);
|
|
177
|
+
if (!context) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
await runCollectionHooks(context.collectionConfig.hooks?.afterDelete, {
|
|
181
|
+
data: context.data,
|
|
182
|
+
user: context.user,
|
|
183
|
+
principal: context.principal,
|
|
184
|
+
collection: context.collectionConfig.slug
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
async function handleMediaProcessImage(data) {
|
|
188
|
+
await builtinJobContext.processImage?.(data);
|
|
189
|
+
}
|
|
190
|
+
async function handleMediaCleanup(data) {
|
|
191
|
+
await builtinJobContext.cleanupMedia?.(data);
|
|
192
|
+
}
|
|
193
|
+
async function handlePluginScheduledTask(data) {
|
|
194
|
+
if (isRecord(data) && typeof data.pluginId === "string" && typeof data.taskId === "string") {
|
|
195
|
+
try {
|
|
196
|
+
const { runPluginScheduledTask } = await import("./host-OBOI4MJK.js");
|
|
197
|
+
await runPluginScheduledTask(data.pluginId, data.taskId);
|
|
198
|
+
return;
|
|
199
|
+
} catch (err) {
|
|
200
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
201
|
+
if (!/no scheduled task/.test(message) && !/is not registered/.test(message)) {
|
|
202
|
+
throw err;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
await builtinJobContext.runScheduledPluginTask?.(data);
|
|
207
|
+
}
|
|
208
|
+
async function handleRevisionPrune(_) {
|
|
209
|
+
await builtinJobContext.pruneRevisions?.();
|
|
210
|
+
}
|
|
211
|
+
async function handleSessionCleanup(_) {
|
|
212
|
+
await builtinJobContext.cleanupSessions?.();
|
|
213
|
+
}
|
|
214
|
+
async function handleJobLogPrune(_) {
|
|
215
|
+
const { pruneJobLogsOlderThan: pruneJobLogsOlderThan2, DEFAULT_JOB_LOG_RETENTION_MS: DEFAULT_JOB_LOG_RETENTION_MS2 } = await import("./job-log-VZXWQUDK.js");
|
|
216
|
+
const cutoff = new Date(Date.now() - DEFAULT_JOB_LOG_RETENTION_MS2);
|
|
217
|
+
const deleted = await pruneJobLogsOlderThan2(cutoff);
|
|
218
|
+
if (deleted > 0) {
|
|
219
|
+
console.info(`[nexpress] system:jobLogPrune deleted ${deleted} log row(s)`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async function handleAuthSendPasswordReset(data) {
|
|
223
|
+
if (builtinJobContext.sendPasswordReset) {
|
|
224
|
+
await builtinJobContext.sendPasswordReset(data);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const payload = asPasswordResetJobData(data);
|
|
228
|
+
const templateData = {
|
|
229
|
+
siteName: payload.siteName ?? "your site",
|
|
230
|
+
name: payload.name,
|
|
231
|
+
resetUrl: payload.resetUrl
|
|
232
|
+
};
|
|
233
|
+
const template = payload.purpose === "invite" ? buildInviteEmail(templateData) : buildResetEmail(templateData);
|
|
234
|
+
await getEmailAdapter().send({
|
|
235
|
+
to: payload.email,
|
|
236
|
+
subject: template.subject,
|
|
237
|
+
text: template.text,
|
|
238
|
+
html: template.html
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
function asPasswordResetJobData(data) {
|
|
242
|
+
if (!isRecord(data)) {
|
|
243
|
+
throw new Error("Invalid auth:sendPasswordReset job payload.");
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
email: asString(data.email, "email"),
|
|
247
|
+
name: asString(data.name, "name"),
|
|
248
|
+
token: asString(data.token, "token"),
|
|
249
|
+
siteName: typeof data.siteName === "string" && data.siteName.length > 0 ? data.siteName : void 0,
|
|
250
|
+
purpose: asResetPurpose(data.purpose),
|
|
251
|
+
resetUrl: asString(data.resetUrl, "resetUrl")
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
function asResetPurpose(value) {
|
|
255
|
+
if (value === "invite" || value === "reset") {
|
|
256
|
+
return value;
|
|
257
|
+
}
|
|
258
|
+
throw new Error("Invalid password reset purpose.");
|
|
259
|
+
}
|
|
260
|
+
async function runCollectionHooks(hooks, args) {
|
|
261
|
+
if (!hooks || hooks.length === 0) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
for (const hook of hooks) {
|
|
265
|
+
await hook(args);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async function revalidateCollectionTags(collection, documentId) {
|
|
269
|
+
try {
|
|
270
|
+
const revalidateTag = await loadRevalidateTag();
|
|
271
|
+
if (!revalidateTag) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
revalidateTag(`nx:${collection}`);
|
|
275
|
+
revalidateTag(`nx:${collection}:${documentId}`);
|
|
276
|
+
} catch {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
async function loadRevalidateTag() {
|
|
281
|
+
const moduleId = "next/cache";
|
|
282
|
+
let importedModule;
|
|
283
|
+
try {
|
|
284
|
+
importedModule = await import(moduleId);
|
|
285
|
+
} catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
if (!isRecord(importedModule)) {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
const revalidateTag = importedModule.revalidateTag;
|
|
292
|
+
if (typeof revalidateTag !== "function") {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
return (tag) => {
|
|
296
|
+
if (revalidateTag.length >= 2) {
|
|
297
|
+
revalidateTag(tag, "default");
|
|
298
|
+
} else {
|
|
299
|
+
revalidateTag(tag);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
function asContentJobData(data) {
|
|
304
|
+
if (!isRecord(data)) {
|
|
305
|
+
throw new Error("Invalid content:afterSave job payload.");
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
collection: asString(data.collection, "collection"),
|
|
309
|
+
documentId: asString(data.documentId, "documentId"),
|
|
310
|
+
operation: asContentOperation(data.operation),
|
|
311
|
+
userId: asString(data.userId, "userId")
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
function asContentDeleteJobData(data) {
|
|
315
|
+
if (!isRecord(data)) {
|
|
316
|
+
throw new Error("Invalid content:afterDelete job payload.");
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
collection: asString(data.collection, "collection"),
|
|
320
|
+
documentId: asString(data.documentId, "documentId"),
|
|
321
|
+
userId: asString(data.userId, "userId")
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
function asContentOperation(value) {
|
|
325
|
+
if (value === "create" || value === "update") {
|
|
326
|
+
return value;
|
|
327
|
+
}
|
|
328
|
+
throw new Error("Invalid content operation.");
|
|
329
|
+
}
|
|
330
|
+
function asString(value, fieldName) {
|
|
331
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
332
|
+
throw new Error(`Invalid ${fieldName} field.`);
|
|
333
|
+
}
|
|
334
|
+
return value;
|
|
335
|
+
}
|
|
336
|
+
function isRecord(value) {
|
|
337
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
338
|
+
}
|
|
339
|
+
async function handleMemberSendVerifyEmail(data) {
|
|
340
|
+
if (builtinJobContext.sendMemberVerifyEmail) {
|
|
341
|
+
await builtinJobContext.sendMemberVerifyEmail(data);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (!isRecord(data)) throw new Error("Invalid members:sendVerifyEmail job payload.");
|
|
345
|
+
const payload = {
|
|
346
|
+
email: asString(data.email, "email"),
|
|
347
|
+
displayName: asString(data.displayName, "displayName"),
|
|
348
|
+
verifyUrl: asString(data.verifyUrl, "verifyUrl"),
|
|
349
|
+
siteName: typeof data.siteName === "string" && data.siteName.length > 0 ? data.siteName : void 0
|
|
350
|
+
};
|
|
351
|
+
const template = buildMemberVerifyEmail({
|
|
352
|
+
siteName: payload.siteName ?? "your site",
|
|
353
|
+
displayName: payload.displayName,
|
|
354
|
+
verifyUrl: payload.verifyUrl
|
|
355
|
+
});
|
|
356
|
+
await getEmailAdapter().send({
|
|
357
|
+
to: payload.email,
|
|
358
|
+
subject: template.subject,
|
|
359
|
+
text: template.text,
|
|
360
|
+
html: template.html
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
async function handleMemberSendPasswordReset(data) {
|
|
364
|
+
if (builtinJobContext.sendMemberPasswordReset) {
|
|
365
|
+
await builtinJobContext.sendMemberPasswordReset(data);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (!isRecord(data)) throw new Error("Invalid members:sendPasswordReset job payload.");
|
|
369
|
+
const payload = {
|
|
370
|
+
email: asString(data.email, "email"),
|
|
371
|
+
displayName: asString(data.displayName, "displayName"),
|
|
372
|
+
resetUrl: asString(data.resetUrl, "resetUrl"),
|
|
373
|
+
siteName: typeof data.siteName === "string" && data.siteName.length > 0 ? data.siteName : void 0
|
|
374
|
+
};
|
|
375
|
+
const template = buildResetEmail({
|
|
376
|
+
siteName: payload.siteName ?? "your site",
|
|
377
|
+
name: payload.displayName,
|
|
378
|
+
resetUrl: payload.resetUrl
|
|
379
|
+
});
|
|
380
|
+
await getEmailAdapter().send({
|
|
381
|
+
to: payload.email,
|
|
382
|
+
subject: template.subject,
|
|
383
|
+
text: template.text,
|
|
384
|
+
html: template.html
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
async function handleNotificationsSendDigest(data) {
|
|
388
|
+
const cadence = isRecord(data) && (data.cadence === "daily" || data.cadence === "weekly") ? data.cadence : "daily";
|
|
389
|
+
const siteName = isRecord(data) && typeof data.siteName === "string" ? data.siteName : void 0;
|
|
390
|
+
const { runDigestSweep } = await import("./digest-SY42GQSU.js");
|
|
391
|
+
const result = await runDigestSweep({ cadence, siteName });
|
|
392
|
+
console.info(
|
|
393
|
+
`[nexpress] notifications:sendDigest cadence=${cadence} considered=${result.considered} sent=${result.sent} skipped=${result.skipped} failed=${result.failed}`
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// src/jobs/heartbeat.ts
|
|
398
|
+
import { hostname } from "os";
|
|
399
|
+
import { randomUUID } from "crypto";
|
|
400
|
+
import { desc, eq, gt, lt } from "drizzle-orm";
|
|
401
|
+
var WORKER_HEARTBEAT_INTERVAL_MS = readEnvPositiveInt("NP_WORKER_HEARTBEAT_SECONDS", 30) * 1e3;
|
|
402
|
+
var WORKER_STALE_THRESHOLD_MS = readEnvPositiveInt("NP_WORKER_STALE_THRESHOLD_SECONDS", 90) * 1e3;
|
|
403
|
+
function generateWorkerId() {
|
|
404
|
+
try {
|
|
405
|
+
const host = hostname();
|
|
406
|
+
return `${host}:${process.pid}`;
|
|
407
|
+
} catch {
|
|
408
|
+
return randomUUID();
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
async function recordHeartbeat(workerId, meta = {}) {
|
|
412
|
+
const db = getDb();
|
|
413
|
+
const now = /* @__PURE__ */ new Date();
|
|
414
|
+
await db.insert(npWorkerHeartbeats).values({
|
|
415
|
+
id: workerId,
|
|
416
|
+
status: "running",
|
|
417
|
+
startedAt: now,
|
|
418
|
+
lastSeenAt: now,
|
|
419
|
+
meta
|
|
420
|
+
}).onConflictDoUpdate({
|
|
421
|
+
target: npWorkerHeartbeats.id,
|
|
422
|
+
set: { lastSeenAt: now, status: "running", meta }
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
async function markWorkerStopped(workerId) {
|
|
426
|
+
const db = getDb();
|
|
427
|
+
await db.update(npWorkerHeartbeats).set({ status: "stopped", lastSeenAt: /* @__PURE__ */ new Date() }).where(eq(npWorkerHeartbeats.id, workerId));
|
|
428
|
+
}
|
|
429
|
+
async function listWorkerHealth(now = /* @__PURE__ */ new Date()) {
|
|
430
|
+
const db = getDb();
|
|
431
|
+
const rows = await db.select().from(npWorkerHeartbeats).orderBy(desc(npWorkerHeartbeats.lastSeenAt));
|
|
432
|
+
let aliveCount = 0;
|
|
433
|
+
const decorated = rows.map((row) => {
|
|
434
|
+
const lastSeenAgoMs = Math.max(0, now.getTime() - row.lastSeenAt.getTime());
|
|
435
|
+
const alive = row.status === "running" && lastSeenAgoMs < WORKER_STALE_THRESHOLD_MS;
|
|
436
|
+
if (alive) aliveCount += 1;
|
|
437
|
+
return { ...row, alive, lastSeenAgoMs };
|
|
438
|
+
});
|
|
439
|
+
return {
|
|
440
|
+
workers: decorated,
|
|
441
|
+
aliveCount,
|
|
442
|
+
totalCount: rows.length,
|
|
443
|
+
newestHeartbeat: rows[0]?.lastSeenAt.toISOString() ?? null
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
function startHeartbeatLoop(meta = {}, intervalMs = WORKER_HEARTBEAT_INTERVAL_MS) {
|
|
447
|
+
const workerId = generateWorkerId();
|
|
448
|
+
const log = getLogger();
|
|
449
|
+
const beat = async () => {
|
|
450
|
+
try {
|
|
451
|
+
await recordHeartbeat(workerId, meta);
|
|
452
|
+
} catch (err) {
|
|
453
|
+
log.warn("worker heartbeat failed", {
|
|
454
|
+
workerId,
|
|
455
|
+
error: err instanceof Error ? err.message : String(err)
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
void beat();
|
|
460
|
+
const timer = setInterval(() => {
|
|
461
|
+
void beat();
|
|
462
|
+
}, intervalMs);
|
|
463
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
464
|
+
return {
|
|
465
|
+
workerId,
|
|
466
|
+
async stop() {
|
|
467
|
+
clearInterval(timer);
|
|
468
|
+
try {
|
|
469
|
+
await markWorkerStopped(workerId);
|
|
470
|
+
} catch (err) {
|
|
471
|
+
log.warn("worker heartbeat stop failed to mark row", {
|
|
472
|
+
workerId,
|
|
473
|
+
error: err instanceof Error ? err.message : String(err)
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
async function purgeStaleWorkers(olderThan = new Date(Date.now() - WORKER_STALE_THRESHOLD_MS * 10)) {
|
|
480
|
+
const db = getDb();
|
|
481
|
+
const deleted = await db.delete(npWorkerHeartbeats).where(lt(npWorkerHeartbeats.lastSeenAt, olderThan)).returning({ id: npWorkerHeartbeats.id });
|
|
482
|
+
return deleted.length;
|
|
483
|
+
}
|
|
484
|
+
async function countAliveWorkers(now = /* @__PURE__ */ new Date()) {
|
|
485
|
+
const db = getDb();
|
|
486
|
+
const cutoff = new Date(now.getTime() - WORKER_STALE_THRESHOLD_MS);
|
|
487
|
+
const rows = await db.select({ id: npWorkerHeartbeats.id }).from(npWorkerHeartbeats).where(gt(npWorkerHeartbeats.lastSeenAt, cutoff));
|
|
488
|
+
return rows.length;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// src/jobs/pause-state.ts
|
|
492
|
+
import { and, eq as eq2 } from "drizzle-orm";
|
|
493
|
+
var SYSTEM_SITE_ID = "_system";
|
|
494
|
+
var JOBS_PAUSED_KEY = "jobs.paused";
|
|
495
|
+
var DEFAULT_STATE = {
|
|
496
|
+
paused: false,
|
|
497
|
+
changedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
498
|
+
changedByUserId: null,
|
|
499
|
+
reason: null
|
|
500
|
+
};
|
|
501
|
+
async function getJobsPauseState() {
|
|
502
|
+
const db = getDb();
|
|
503
|
+
const rows = await db.select().from(npSettings).where(and(eq2(npSettings.siteId, SYSTEM_SITE_ID), eq2(npSettings.key, JOBS_PAUSED_KEY))).limit(1);
|
|
504
|
+
const row = rows[0];
|
|
505
|
+
if (!row) return DEFAULT_STATE;
|
|
506
|
+
const value = row.value;
|
|
507
|
+
if (!value || typeof value.paused !== "boolean") return DEFAULT_STATE;
|
|
508
|
+
return {
|
|
509
|
+
paused: value.paused,
|
|
510
|
+
changedAt: typeof value.changedAt === "string" ? value.changedAt : DEFAULT_STATE.changedAt,
|
|
511
|
+
changedByUserId: typeof value.changedByUserId === "string" ? value.changedByUserId : null,
|
|
512
|
+
reason: typeof value.reason === "string" ? value.reason : null
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
async function setJobsPauseState(input) {
|
|
516
|
+
const db = getDb();
|
|
517
|
+
const next = {
|
|
518
|
+
paused: input.paused,
|
|
519
|
+
changedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
520
|
+
changedByUserId: input.changedByUserId ?? null,
|
|
521
|
+
reason: input.reason ?? null
|
|
522
|
+
};
|
|
523
|
+
await db.insert(npSettings).values({
|
|
524
|
+
siteId: SYSTEM_SITE_ID,
|
|
525
|
+
key: JOBS_PAUSED_KEY,
|
|
526
|
+
value: next
|
|
527
|
+
}).onConflictDoUpdate({
|
|
528
|
+
target: [npSettings.siteId, npSettings.key],
|
|
529
|
+
set: {
|
|
530
|
+
value: next,
|
|
531
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
return next;
|
|
535
|
+
}
|
|
536
|
+
var PAUSE_SYNC_INTERVAL_MS = 3e4;
|
|
537
|
+
var PAUSE_SYNC_ESCALATE_AFTER = 3;
|
|
538
|
+
function startPauseSyncLoop(queue, intervalMs = PAUSE_SYNC_INTERVAL_MS) {
|
|
539
|
+
const log = getLogger();
|
|
540
|
+
let consecutiveFailures = 0;
|
|
541
|
+
let escalated = false;
|
|
542
|
+
const tick = async () => {
|
|
543
|
+
try {
|
|
544
|
+
const persisted = await getJobsPauseState();
|
|
545
|
+
const localPaused = typeof queue.isProcessingPaused === "function" ? queue.isProcessingPaused() : false;
|
|
546
|
+
if (persisted.paused && !localPaused && typeof queue.pauseProcessing === "function") {
|
|
547
|
+
await queue.pauseProcessing();
|
|
548
|
+
log.info("Pause sync: applied paused=true from settings", {
|
|
549
|
+
changedAt: persisted.changedAt
|
|
550
|
+
});
|
|
551
|
+
} else if (!persisted.paused && localPaused && typeof queue.resumeProcessing === "function") {
|
|
552
|
+
await queue.resumeProcessing();
|
|
553
|
+
log.info("Pause sync: applied paused=false from settings", {
|
|
554
|
+
changedAt: persisted.changedAt
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
if (consecutiveFailures > 0) {
|
|
558
|
+
log.info("Pause sync: recovered after consecutive failures", {
|
|
559
|
+
previousFailures: consecutiveFailures
|
|
560
|
+
});
|
|
561
|
+
consecutiveFailures = 0;
|
|
562
|
+
escalated = false;
|
|
563
|
+
}
|
|
564
|
+
} catch (err) {
|
|
565
|
+
consecutiveFailures += 1;
|
|
566
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
567
|
+
log.warn("Pause sync tick failed", {
|
|
568
|
+
error: errorMessage,
|
|
569
|
+
consecutiveFailures
|
|
570
|
+
});
|
|
571
|
+
if (consecutiveFailures >= PAUSE_SYNC_ESCALATE_AFTER && !escalated) {
|
|
572
|
+
escalated = true;
|
|
573
|
+
const reportable = err instanceof Error ? err : new Error(errorMessage);
|
|
574
|
+
await reportError(reportable, {
|
|
575
|
+
tags: { source: "worker", subsystem: "pause-sync" },
|
|
576
|
+
extra: { consecutiveFailures }
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
void tick();
|
|
582
|
+
const timer = setInterval(() => {
|
|
583
|
+
void tick();
|
|
584
|
+
}, intervalMs);
|
|
585
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
586
|
+
return {
|
|
587
|
+
stop() {
|
|
588
|
+
clearInterval(timer);
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// src/jobs/pg-boss-adapter.ts
|
|
594
|
+
import { PgBoss } from "pg-boss";
|
|
595
|
+
function toQueueName(type) {
|
|
596
|
+
return type.replace(/:/g, ".");
|
|
597
|
+
}
|
|
598
|
+
var PgBossAdapter = class {
|
|
599
|
+
boss;
|
|
600
|
+
/**
|
|
601
|
+
* Phase 20.2 — every queue we've called `boss.work()` on, plus
|
|
602
|
+
* the function that re-registers it. We need both because
|
|
603
|
+
* `pauseProcessing()` calls `boss.offWork(name)` (drops the
|
|
604
|
+
* worker) and `resumeProcessing()` has to re-call the original
|
|
605
|
+
* `boss.work(...)` to bring it back. Order is preserved so
|
|
606
|
+
* resume registers in the same order as start did.
|
|
607
|
+
*/
|
|
608
|
+
workRegistrations = [];
|
|
609
|
+
paused = false;
|
|
610
|
+
/**
|
|
611
|
+
* Flips `true` after `start()` runs (full worker mode). `startProducer()`
|
|
612
|
+
* doesn't set it. Used by `reconcilePluginSchedules()` to tell admins
|
|
613
|
+
* whether this process owns the `boss.work()` loops for plugin schedules
|
|
614
|
+
* — the same boss instance can act as producer-only in the web server
|
|
615
|
+
* and full worker in the worker process.
|
|
616
|
+
*/
|
|
617
|
+
workerStarted = false;
|
|
618
|
+
constructor(connectionString, options) {
|
|
619
|
+
this.boss = new PgBoss({ connectionString, ...options });
|
|
620
|
+
}
|
|
621
|
+
async enqueue(type, data) {
|
|
622
|
+
const jobId = await this.boss.send(toQueueName(type), asJobPayload(data));
|
|
623
|
+
if (!jobId) {
|
|
624
|
+
throw new Error(`Failed to enqueue job: ${type}`);
|
|
625
|
+
}
|
|
626
|
+
return jobId;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Opens the pg-boss connection and runs its migrations. Safe to call from a
|
|
630
|
+
* non-worker process (e.g. the Next.js server) so it can enqueue jobs.
|
|
631
|
+
*/
|
|
632
|
+
async startProducer() {
|
|
633
|
+
await this.boss.start();
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Full start: opens the connection (idempotent with startProducer) and
|
|
637
|
+
* registers `boss.work()` loops for every handler in the registry. Call
|
|
638
|
+
* this from the dedicated worker process.
|
|
639
|
+
*/
|
|
640
|
+
async start() {
|
|
641
|
+
await this.boss.start();
|
|
642
|
+
for (const [type, handler] of getAllJobHandlers()) {
|
|
643
|
+
const queueName = toQueueName(type);
|
|
644
|
+
await this.boss.createQueue(queueName);
|
|
645
|
+
const register = async () => {
|
|
646
|
+
await this.boss.work(queueName, async (jobs) => {
|
|
647
|
+
for (const job of jobs) {
|
|
648
|
+
await runInJobContext(job.id, async () => {
|
|
649
|
+
try {
|
|
650
|
+
await handler(job.data);
|
|
651
|
+
} catch (error) {
|
|
652
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
653
|
+
getLogger().error("Job handler threw", {
|
|
654
|
+
type,
|
|
655
|
+
jobId: job.id,
|
|
656
|
+
error: err.message,
|
|
657
|
+
stack: err.stack
|
|
658
|
+
});
|
|
659
|
+
await recordJobLog("error", `Job handler threw: ${err.message}`, {
|
|
660
|
+
type,
|
|
661
|
+
stack: err.stack
|
|
662
|
+
});
|
|
663
|
+
void reportError(err, {
|
|
664
|
+
tags: { source: "worker", jobType: type },
|
|
665
|
+
extra: { jobId: job.id }
|
|
666
|
+
});
|
|
667
|
+
throw err;
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
};
|
|
673
|
+
this.workRegistrations.push({ queueName, register });
|
|
674
|
+
await register();
|
|
675
|
+
}
|
|
676
|
+
const { getRegisteredPluginSchedules, runPluginScheduledTask } = await import("./host-OBOI4MJK.js");
|
|
677
|
+
for (const schedule of getRegisteredPluginSchedules()) {
|
|
678
|
+
const queueName = `${toQueueName("plugin:scheduledTask")}.${schedule.pluginId}.${schedule.taskId}`;
|
|
679
|
+
await this.boss.createQueue(queueName);
|
|
680
|
+
const register = async () => {
|
|
681
|
+
await this.boss.work(queueName, async (jobs) => {
|
|
682
|
+
for (const job of jobs) {
|
|
683
|
+
await runInJobContext(job.id, async () => {
|
|
684
|
+
try {
|
|
685
|
+
await runPluginScheduledTask(schedule.pluginId, schedule.taskId);
|
|
686
|
+
} catch (error) {
|
|
687
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
688
|
+
getLogger().error("Plugin scheduled task threw", {
|
|
689
|
+
pluginId: schedule.pluginId,
|
|
690
|
+
taskId: schedule.taskId,
|
|
691
|
+
jobId: job.id,
|
|
692
|
+
error: err.message,
|
|
693
|
+
stack: err.stack
|
|
694
|
+
});
|
|
695
|
+
await recordJobLog("error", `Plugin scheduled task threw: ${err.message}`, {
|
|
696
|
+
pluginId: schedule.pluginId,
|
|
697
|
+
taskId: schedule.taskId,
|
|
698
|
+
stack: err.stack
|
|
699
|
+
});
|
|
700
|
+
void reportError(err, {
|
|
701
|
+
tags: {
|
|
702
|
+
source: "worker",
|
|
703
|
+
pluginId: schedule.pluginId,
|
|
704
|
+
taskId: schedule.taskId
|
|
705
|
+
},
|
|
706
|
+
extra: { jobId: job.id }
|
|
707
|
+
});
|
|
708
|
+
throw err;
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
};
|
|
714
|
+
this.workRegistrations.push({ queueName, register });
|
|
715
|
+
await register();
|
|
716
|
+
}
|
|
717
|
+
this.workerStarted = true;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Phase 20.2 — drop every registered worker so the boss stops
|
|
721
|
+
* claiming new jobs. The pg-boss connection stays open; the
|
|
722
|
+
* producer can keep enqueueing while paused. In-flight jobs
|
|
723
|
+
* picked up before pause finish normally because pg-boss only
|
|
724
|
+
* cancels the polling loop, not the fetch already in flight.
|
|
725
|
+
*/
|
|
726
|
+
async pauseProcessing() {
|
|
727
|
+
if (this.paused) return;
|
|
728
|
+
for (const { queueName } of this.workRegistrations) {
|
|
729
|
+
await this.boss.offWork(queueName);
|
|
730
|
+
}
|
|
731
|
+
this.paused = true;
|
|
732
|
+
getLogger().info("Job processing paused", {
|
|
733
|
+
queues: this.workRegistrations.length
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
/** Phase 20.2 — re-run every captured `boss.work()` registration. Idempotent. */
|
|
737
|
+
async resumeProcessing() {
|
|
738
|
+
if (!this.paused) return;
|
|
739
|
+
for (const { register } of this.workRegistrations) {
|
|
740
|
+
await register();
|
|
741
|
+
}
|
|
742
|
+
this.paused = false;
|
|
743
|
+
getLogger().info("Job processing resumed", {
|
|
744
|
+
queues: this.workRegistrations.length
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
isProcessingPaused() {
|
|
748
|
+
return this.paused;
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Phase 22.4 — readiness probe round-trip. `boss.isInstalled()`
|
|
752
|
+
* issues a single SELECT against `pgboss.version`, so a true
|
|
753
|
+
* answer proves both that the DB connection is alive AND that
|
|
754
|
+
* pg-boss's schema migrations have applied. Any throw — pool
|
|
755
|
+
* dead, schema missing, permissions revoked — is caught and
|
|
756
|
+
* reported as `false`; the readiness probe never sees an
|
|
757
|
+
* exception bubble out of the queue check.
|
|
758
|
+
*/
|
|
759
|
+
async isHealthy() {
|
|
760
|
+
try {
|
|
761
|
+
return await this.boss.isInstalled();
|
|
762
|
+
} catch {
|
|
763
|
+
return false;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
async stop() {
|
|
767
|
+
await this.boss.stop({ graceful: true, timeout: 3e4 });
|
|
768
|
+
}
|
|
769
|
+
async scheduleRecurring() {
|
|
770
|
+
await this.boss.schedule(toQueueName("system:revisionPrune"), "0 3 * * *", {});
|
|
771
|
+
await this.boss.schedule(toQueueName("system:sessionCleanup"), "0 * * * *", {});
|
|
772
|
+
await this.boss.schedule(toQueueName("system:jobLogPrune"), "30 3 * * *", {});
|
|
773
|
+
const digestQueue = toQueueName("notifications:sendDigest");
|
|
774
|
+
await this.boss.unschedule(digestQueue).catch(() => {
|
|
775
|
+
});
|
|
776
|
+
await this.boss.schedule(digestQueue, "0 8 * * *", { cadence: "daily" }, { key: "daily" });
|
|
777
|
+
await this.boss.schedule(digestQueue, "0 8 * * 1", { cadence: "weekly" }, { key: "weekly" });
|
|
778
|
+
const { getRegisteredPluginSchedules } = await import("./host-OBOI4MJK.js");
|
|
779
|
+
for (const schedule of getRegisteredPluginSchedules()) {
|
|
780
|
+
const pgBossName = `${toQueueName("plugin:scheduledTask")}.${schedule.pluginId}.${schedule.taskId}`;
|
|
781
|
+
await this.boss.schedule(pgBossName, schedule.cron, {
|
|
782
|
+
pluginId: schedule.pluginId,
|
|
783
|
+
taskId: schedule.taskId
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
getBoss() {
|
|
788
|
+
return this.boss;
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Phase 13 — admin job introspection. Joins pgboss.job
|
|
792
|
+
* (pending / active / retry) and pgboss.archive (completed
|
|
793
|
+
* / failed / expired) into one unified list.
|
|
794
|
+
*
|
|
795
|
+
* Phase 13.2 — `since` filter for time-bounded queries
|
|
796
|
+
* ("last 24 hours") and accurate `total` via a parallel
|
|
797
|
+
* COUNT(*) so the admin pagination shows the right count.
|
|
798
|
+
* The COUNT runs against the same UNION; the per-page
|
|
799
|
+
* SELECT still gets the row data.
|
|
800
|
+
*/
|
|
801
|
+
async listJobs(options) {
|
|
802
|
+
const limit = Math.min(Math.max(1, options.limit ?? 50), 200);
|
|
803
|
+
const offset = Math.max(0, options.offset ?? 0);
|
|
804
|
+
const db = this.boss.db;
|
|
805
|
+
const params = [];
|
|
806
|
+
const where = [];
|
|
807
|
+
if (options.name) {
|
|
808
|
+
params.push(options.name);
|
|
809
|
+
where.push(`name = $${params.length}`);
|
|
810
|
+
}
|
|
811
|
+
if (options.state) {
|
|
812
|
+
params.push(options.state);
|
|
813
|
+
where.push(`state = $${params.length}`);
|
|
814
|
+
}
|
|
815
|
+
if (options.since) {
|
|
816
|
+
params.push(options.since.toISOString());
|
|
817
|
+
where.push(`created_on >= $${params.length}`);
|
|
818
|
+
}
|
|
819
|
+
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
|
820
|
+
const liveSelect = `
|
|
821
|
+
SELECT id, name, state, data, retry_count,
|
|
822
|
+
output::text AS output, created_on, started_on, completed_on,
|
|
823
|
+
'live' AS source
|
|
824
|
+
FROM pgboss.job`;
|
|
825
|
+
const archiveSelect = `
|
|
826
|
+
SELECT id, name, state, data, retry_count,
|
|
827
|
+
output::text AS output, created_on, started_on, completed_on,
|
|
828
|
+
'archive' AS source
|
|
829
|
+
FROM pgboss.archive`;
|
|
830
|
+
const innerUnion = options.source === "live" ? liveSelect : options.source === "archive" ? archiveSelect : `${liveSelect}
|
|
831
|
+
UNION ALL${archiveSelect}`;
|
|
832
|
+
const listSql = `
|
|
833
|
+
SELECT id, name, state::text AS state, data, retry_count,
|
|
834
|
+
output, created_on, started_on, completed_on, source
|
|
835
|
+
FROM (
|
|
836
|
+
${innerUnion}
|
|
837
|
+
) jobs
|
|
838
|
+
${whereSql}
|
|
839
|
+
ORDER BY created_on DESC
|
|
840
|
+
LIMIT ${limit} OFFSET ${offset}
|
|
841
|
+
`;
|
|
842
|
+
const liveCount = `SELECT id, name, state, data, created_on, 'live' AS source FROM pgboss.job`;
|
|
843
|
+
const archiveCount = `SELECT id, name, state, data, created_on, 'archive' AS source FROM pgboss.archive`;
|
|
844
|
+
const countUnion = options.source === "live" ? liveCount : options.source === "archive" ? archiveCount : `${liveCount} UNION ALL ${archiveCount}`;
|
|
845
|
+
const countSql = `
|
|
846
|
+
SELECT COUNT(*)::bigint AS total
|
|
847
|
+
FROM (
|
|
848
|
+
${countUnion}
|
|
849
|
+
) jobs
|
|
850
|
+
${whereSql}
|
|
851
|
+
`;
|
|
852
|
+
const [listResult, countResult] = await Promise.all([
|
|
853
|
+
db.executeSql(listSql, params),
|
|
854
|
+
db.executeSql(countSql, params)
|
|
855
|
+
]);
|
|
856
|
+
const rows = listResult.rows ?? [];
|
|
857
|
+
const totalRaw = countResult.rows?.[0]?.total;
|
|
858
|
+
const total = typeof totalRaw === "number" ? totalRaw : typeof totalRaw === "string" ? Number.parseInt(totalRaw, 10) : 0;
|
|
859
|
+
return {
|
|
860
|
+
jobs: rows.map(rowToSummary),
|
|
861
|
+
total: Number.isFinite(total) ? total : 0
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Phase 13.2 — list every cron schedule registered in the
|
|
866
|
+
* queue. Reads from `pgboss.schedule`, which is the table
|
|
867
|
+
* pg-boss writes to on each `boss.schedule()` call. Sorted
|
|
868
|
+
* by name for stable display.
|
|
869
|
+
*/
|
|
870
|
+
async listSchedules() {
|
|
871
|
+
const db = this.boss.db;
|
|
872
|
+
const result = await db.executeSql(
|
|
873
|
+
`SELECT name, key, cron, timezone, data, created_on, updated_on
|
|
874
|
+
FROM pgboss.schedule
|
|
875
|
+
ORDER BY name ASC, key ASC`
|
|
876
|
+
);
|
|
877
|
+
return (result.rows ?? []).map(scheduleRowToSummary);
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Phase 4.2 — pulls per-(pluginId, taskId) execution stats from the
|
|
881
|
+
* union of `pgboss.job` (in-flight + recently-completed) and
|
|
882
|
+
* `pgboss.archive` (rolled-over history). One row per taskId so the
|
|
883
|
+
* caller can index without a second pass.
|
|
884
|
+
*
|
|
885
|
+
* The window default is 7 days because longer windows force the
|
|
886
|
+
* archive table into the hot path and admins typically want recent
|
|
887
|
+
* health, not lifetime totals. Increase via `windowDays` if surfacing
|
|
888
|
+
* a "30-day reliability" widget.
|
|
889
|
+
*/
|
|
890
|
+
async getPluginScheduleStats(pluginId, options) {
|
|
891
|
+
const windowDays = Math.max(1, Math.min(365, options?.windowDays ?? 7));
|
|
892
|
+
const db = this.boss.db;
|
|
893
|
+
const result = await db.executeSql(
|
|
894
|
+
`WITH plugin_jobs AS (
|
|
895
|
+
SELECT state, completed_on, data
|
|
896
|
+
FROM pgboss.job
|
|
897
|
+
WHERE (name = 'plugin.scheduledTask' OR name LIKE 'plugin.scheduledTask.%')
|
|
898
|
+
AND data->>'pluginId' = $1
|
|
899
|
+
AND completed_on > NOW() - ($2 || ' days')::interval
|
|
900
|
+
UNION ALL
|
|
901
|
+
SELECT state, completed_on, data
|
|
902
|
+
FROM pgboss.archive
|
|
903
|
+
WHERE (name = 'plugin.scheduledTask' OR name LIKE 'plugin.scheduledTask.%')
|
|
904
|
+
AND data->>'pluginId' = $1
|
|
905
|
+
AND completed_on > NOW() - ($2 || ' days')::interval
|
|
906
|
+
)
|
|
907
|
+
SELECT data->>'taskId' AS task_id,
|
|
908
|
+
MAX(completed_on) AS last_run,
|
|
909
|
+
MAX(CASE WHEN state = 'completed' THEN completed_on END) AS last_success,
|
|
910
|
+
MAX(CASE WHEN state = 'failed' THEN completed_on END) AS last_failure,
|
|
911
|
+
SUM(CASE WHEN state = 'completed' THEN 1 ELSE 0 END) AS completed_count,
|
|
912
|
+
SUM(CASE WHEN state = 'failed' THEN 1 ELSE 0 END) AS failed_count
|
|
913
|
+
FROM plugin_jobs
|
|
914
|
+
WHERE data->>'taskId' IS NOT NULL
|
|
915
|
+
GROUP BY data->>'taskId'`,
|
|
916
|
+
[pluginId, String(windowDays)]
|
|
917
|
+
);
|
|
918
|
+
return (result.rows ?? []).filter((row) => typeof row.task_id === "string").map((row) => ({
|
|
919
|
+
taskId: row.task_id,
|
|
920
|
+
lastRunAt: toIso(row.last_run),
|
|
921
|
+
lastSuccessAt: toIso(row.last_success),
|
|
922
|
+
lastFailureAt: toIso(row.last_failure),
|
|
923
|
+
completedCount: Number(row.completed_count) || 0,
|
|
924
|
+
failedCount: Number(row.failed_count) || 0,
|
|
925
|
+
windowDays
|
|
926
|
+
}));
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Issue #461 — diff the in-memory plugin schedule registry against the
|
|
930
|
+
* `pgboss.schedule` rows whose name starts with `plugin.scheduledTask.*`
|
|
931
|
+
* and bring pg-boss in line. Without this, `reloadPlugins()` only
|
|
932
|
+
* rebuilt the in-process registry and pg-boss kept firing the old set
|
|
933
|
+
* of crons until the worker process restarted — the admin "Reload all"
|
|
934
|
+
* toast was promising behavior the system didn't deliver.
|
|
935
|
+
*
|
|
936
|
+
* Worker `boss.work()` registrations stay untouched. In production the
|
|
937
|
+
* worker is a separate process with its own boss instance; the web
|
|
938
|
+
* process can't add or drop work loops there. We surface that via
|
|
939
|
+
* `workerOwnsRegistrations` so the admin UI can warn the operator.
|
|
940
|
+
*/
|
|
941
|
+
async reconcilePluginSchedules() {
|
|
942
|
+
const { getRegisteredPluginSchedules } = await import("./host-OBOI4MJK.js");
|
|
943
|
+
const wantedList = getRegisteredPluginSchedules();
|
|
944
|
+
const wantedByName = /* @__PURE__ */ new Map();
|
|
945
|
+
for (const schedule of wantedList) {
|
|
946
|
+
const name = `${toQueueName("plugin:scheduledTask")}.${schedule.pluginId}.${schedule.taskId}`;
|
|
947
|
+
wantedByName.set(name, {
|
|
948
|
+
pluginId: schedule.pluginId,
|
|
949
|
+
taskId: schedule.taskId,
|
|
950
|
+
cron: schedule.cron
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
const existingAll = await this.listSchedules();
|
|
954
|
+
const existingByName = /* @__PURE__ */ new Map();
|
|
955
|
+
for (const entry of existingAll) {
|
|
956
|
+
if (entry.name.startsWith("plugin.scheduledTask.")) {
|
|
957
|
+
existingByName.set(entry.name, entry);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
let added = 0;
|
|
961
|
+
let updated = 0;
|
|
962
|
+
let removed = 0;
|
|
963
|
+
for (const [name, want] of wantedByName) {
|
|
964
|
+
const existing = existingByName.get(name);
|
|
965
|
+
if (!existing) {
|
|
966
|
+
await this.boss.schedule(name, want.cron, {
|
|
967
|
+
pluginId: want.pluginId,
|
|
968
|
+
taskId: want.taskId
|
|
969
|
+
});
|
|
970
|
+
added++;
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
if (existing.cron !== want.cron) {
|
|
974
|
+
await this.boss.unschedule(name).catch(() => {
|
|
975
|
+
});
|
|
976
|
+
await this.boss.schedule(name, want.cron, {
|
|
977
|
+
pluginId: want.pluginId,
|
|
978
|
+
taskId: want.taskId
|
|
979
|
+
});
|
|
980
|
+
updated++;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
for (const [name] of existingByName) {
|
|
984
|
+
if (!wantedByName.has(name)) {
|
|
985
|
+
await this.boss.unschedule(name).catch(() => {
|
|
986
|
+
});
|
|
987
|
+
removed++;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
return {
|
|
991
|
+
added,
|
|
992
|
+
updated,
|
|
993
|
+
removed,
|
|
994
|
+
workerOwnsRegistrations: this.workerStarted
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Phase 23.5 — `GROUP BY state` across the union of pgboss.job
|
|
999
|
+
* (live) and pgboss.archive (rolled). Returns a fully-populated
|
|
1000
|
+
* record so callers can index without optional chaining.
|
|
1001
|
+
*
|
|
1002
|
+
* Uses `created_on` for the optional `since` filter. Both tables
|
|
1003
|
+
* carry the same column, so the union pre-filter is a single
|
|
1004
|
+
* predicate.
|
|
1005
|
+
*/
|
|
1006
|
+
async countByState(options) {
|
|
1007
|
+
const db = this.boss.db;
|
|
1008
|
+
const params = [];
|
|
1009
|
+
let whereSql = "";
|
|
1010
|
+
if (options?.since) {
|
|
1011
|
+
params.push(options.since.toISOString());
|
|
1012
|
+
whereSql = `WHERE created_on >= $${params.length}`;
|
|
1013
|
+
}
|
|
1014
|
+
const result = await db.executeSql(
|
|
1015
|
+
`SELECT state::text AS state, COUNT(*)::bigint AS count
|
|
1016
|
+
FROM (
|
|
1017
|
+
SELECT state, created_on FROM pgboss.job
|
|
1018
|
+
UNION ALL
|
|
1019
|
+
SELECT state, created_on FROM pgboss.archive
|
|
1020
|
+
) jobs
|
|
1021
|
+
${whereSql}
|
|
1022
|
+
GROUP BY state`,
|
|
1023
|
+
params
|
|
1024
|
+
);
|
|
1025
|
+
const counts = {
|
|
1026
|
+
created: 0,
|
|
1027
|
+
active: 0,
|
|
1028
|
+
completed: 0,
|
|
1029
|
+
failed: 0,
|
|
1030
|
+
retry: 0,
|
|
1031
|
+
cancelled: 0,
|
|
1032
|
+
expired: 0
|
|
1033
|
+
};
|
|
1034
|
+
for (const row of result.rows ?? []) {
|
|
1035
|
+
const key = row.state;
|
|
1036
|
+
if (key in counts) {
|
|
1037
|
+
const value = typeof row.count === "number" ? row.count : Number.parseInt(row.count, 10);
|
|
1038
|
+
counts[key] = Number.isFinite(value) ? value : 0;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
return counts;
|
|
1042
|
+
}
|
|
1043
|
+
async retryJob(id) {
|
|
1044
|
+
const db = this.boss.db;
|
|
1045
|
+
const result = await db.executeSql(
|
|
1046
|
+
`SELECT id, name, state::text AS state, data, retry_count,
|
|
1047
|
+
output::text AS output, created_on, started_on, completed_on
|
|
1048
|
+
FROM pgboss.job WHERE id = $1
|
|
1049
|
+
UNION ALL
|
|
1050
|
+
SELECT id, name, state, data, retry_count,
|
|
1051
|
+
output::text AS output, created_on, started_on, completed_on
|
|
1052
|
+
FROM pgboss.archive WHERE id = $1
|
|
1053
|
+
LIMIT 1`,
|
|
1054
|
+
[id]
|
|
1055
|
+
);
|
|
1056
|
+
const row = result.rows?.[0];
|
|
1057
|
+
if (!row) {
|
|
1058
|
+
throw new Error(`Job ${id} not found`);
|
|
1059
|
+
}
|
|
1060
|
+
const newId = await this.boss.send(row.name, row.data ?? {});
|
|
1061
|
+
if (!newId) {
|
|
1062
|
+
throw new Error(`Failed to re-enqueue ${row.name}`);
|
|
1063
|
+
}
|
|
1064
|
+
return newId;
|
|
1065
|
+
}
|
|
1066
|
+
async cancelJob(id) {
|
|
1067
|
+
const db = this.boss.db;
|
|
1068
|
+
const result = await db.executeSql(`SELECT name FROM pgboss.job WHERE id = $1`, [id]);
|
|
1069
|
+
const row = result.rows?.[0];
|
|
1070
|
+
if (!row) {
|
|
1071
|
+
throw new Error(`Job ${id} not found or already terminal`);
|
|
1072
|
+
}
|
|
1073
|
+
await this.boss.cancel(row.name, id);
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
function scheduleRowToSummary(row) {
|
|
1077
|
+
return {
|
|
1078
|
+
name: row.name,
|
|
1079
|
+
key: row.key ?? "",
|
|
1080
|
+
cron: row.cron,
|
|
1081
|
+
timezone: row.timezone ?? null,
|
|
1082
|
+
data: row.data ?? null,
|
|
1083
|
+
createdOn: toIso(row.created_on) ?? (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
1084
|
+
updatedOn: toIso(row.updated_on)
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
function rowToSummary(row) {
|
|
1088
|
+
return {
|
|
1089
|
+
id: row.id,
|
|
1090
|
+
name: row.name,
|
|
1091
|
+
state: row.state,
|
|
1092
|
+
data: row.data,
|
|
1093
|
+
retryCount: typeof row.retry_count === "number" ? row.retry_count : void 0,
|
|
1094
|
+
output: row.output ?? null,
|
|
1095
|
+
createdOn: toIso(row.created_on) ?? (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
1096
|
+
startedOn: toIso(row.started_on),
|
|
1097
|
+
completedOn: toIso(row.completed_on),
|
|
1098
|
+
source: row.source === "archive" ? "archive" : row.source === "live" ? "live" : void 0
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
function toIso(value) {
|
|
1102
|
+
if (!value) return null;
|
|
1103
|
+
if (value instanceof Date) return value.toISOString();
|
|
1104
|
+
const parsed = new Date(value);
|
|
1105
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
|
1106
|
+
}
|
|
1107
|
+
function asJobPayload(data) {
|
|
1108
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
1109
|
+
return { payload: data };
|
|
1110
|
+
}
|
|
1111
|
+
return data;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// src/jobs/worker.ts
|
|
1115
|
+
var workerAdapter = null;
|
|
1116
|
+
var producerAdapter = null;
|
|
1117
|
+
var heartbeatHandle = null;
|
|
1118
|
+
var pauseSyncHandle = null;
|
|
1119
|
+
var signalHandlersInstalled = false;
|
|
1120
|
+
var installedSignalHandlers = /* @__PURE__ */ new Map();
|
|
1121
|
+
function installShutdownSignalHandlers() {
|
|
1122
|
+
if (signalHandlersInstalled) return;
|
|
1123
|
+
signalHandlersInstalled = true;
|
|
1124
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
1125
|
+
const handler = () => {
|
|
1126
|
+
void (async () => {
|
|
1127
|
+
try {
|
|
1128
|
+
await stopWorker();
|
|
1129
|
+
} catch (err) {
|
|
1130
|
+
getLogger().warn("Worker shutdown handler failed", {
|
|
1131
|
+
signal,
|
|
1132
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1133
|
+
});
|
|
1134
|
+
} finally {
|
|
1135
|
+
process.exit(0);
|
|
1136
|
+
}
|
|
1137
|
+
})();
|
|
1138
|
+
};
|
|
1139
|
+
process.on(signal, handler);
|
|
1140
|
+
installedSignalHandlers.set(signal, handler);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
function removeShutdownSignalHandlers() {
|
|
1144
|
+
if (!signalHandlersInstalled) return;
|
|
1145
|
+
for (const [signal, handler] of installedSignalHandlers) {
|
|
1146
|
+
process.off(signal, handler);
|
|
1147
|
+
}
|
|
1148
|
+
installedSignalHandlers.clear();
|
|
1149
|
+
signalHandlersInstalled = false;
|
|
1150
|
+
}
|
|
1151
|
+
async function startWorker(connectionString, options) {
|
|
1152
|
+
if (workerAdapter) {
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
registerBuiltinHandlers();
|
|
1156
|
+
workerAdapter = new PgBossAdapter(connectionString, {
|
|
1157
|
+
schema: options?.schema ?? "public"
|
|
1158
|
+
});
|
|
1159
|
+
setJobQueue(workerAdapter);
|
|
1160
|
+
try {
|
|
1161
|
+
await workerAdapter.start();
|
|
1162
|
+
await workerAdapter.scheduleRecurring();
|
|
1163
|
+
try {
|
|
1164
|
+
const pauseState = await getJobsPauseState();
|
|
1165
|
+
if (pauseState.paused) {
|
|
1166
|
+
await workerAdapter.pauseProcessing();
|
|
1167
|
+
getLogger().info("Worker booted in paused state", {
|
|
1168
|
+
changedAt: pauseState.changedAt,
|
|
1169
|
+
reason: pauseState.reason
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
} catch (err) {
|
|
1173
|
+
getLogger().warn("Could not read jobs pause state on worker boot", {
|
|
1174
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
const heartbeatOpt = options?.heartbeat ?? true;
|
|
1178
|
+
if (heartbeatOpt !== false) {
|
|
1179
|
+
const meta = typeof heartbeatOpt === "object" ? heartbeatOpt.meta ?? {} : {};
|
|
1180
|
+
heartbeatHandle = startHeartbeatLoop(meta);
|
|
1181
|
+
pauseSyncHandle = startPauseSyncLoop(workerAdapter);
|
|
1182
|
+
}
|
|
1183
|
+
if (options?.installSignalHandlers !== false) {
|
|
1184
|
+
installShutdownSignalHandlers();
|
|
1185
|
+
}
|
|
1186
|
+
} catch (err) {
|
|
1187
|
+
if (heartbeatHandle) {
|
|
1188
|
+
try {
|
|
1189
|
+
await heartbeatHandle.stop();
|
|
1190
|
+
} catch {
|
|
1191
|
+
}
|
|
1192
|
+
heartbeatHandle = null;
|
|
1193
|
+
}
|
|
1194
|
+
if (pauseSyncHandle) {
|
|
1195
|
+
try {
|
|
1196
|
+
pauseSyncHandle.stop();
|
|
1197
|
+
} catch {
|
|
1198
|
+
}
|
|
1199
|
+
pauseSyncHandle = null;
|
|
1200
|
+
}
|
|
1201
|
+
if (workerAdapter) {
|
|
1202
|
+
try {
|
|
1203
|
+
await workerAdapter.stop();
|
|
1204
|
+
} catch {
|
|
1205
|
+
}
|
|
1206
|
+
workerAdapter = null;
|
|
1207
|
+
}
|
|
1208
|
+
removeShutdownSignalHandlers();
|
|
1209
|
+
throw err;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
async function startProducer(connectionString, options) {
|
|
1213
|
+
if (producerAdapter) {
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
producerAdapter = new PgBossAdapter(connectionString, {
|
|
1217
|
+
schema: options?.schema ?? "public"
|
|
1218
|
+
});
|
|
1219
|
+
setJobQueue(producerAdapter);
|
|
1220
|
+
await producerAdapter.startProducer();
|
|
1221
|
+
}
|
|
1222
|
+
async function stopWorker() {
|
|
1223
|
+
if (!workerAdapter) {
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
if (heartbeatHandle) {
|
|
1227
|
+
await heartbeatHandle.stop();
|
|
1228
|
+
heartbeatHandle = null;
|
|
1229
|
+
}
|
|
1230
|
+
if (pauseSyncHandle) {
|
|
1231
|
+
pauseSyncHandle.stop();
|
|
1232
|
+
pauseSyncHandle = null;
|
|
1233
|
+
}
|
|
1234
|
+
await workerAdapter.stop();
|
|
1235
|
+
workerAdapter = null;
|
|
1236
|
+
removeShutdownSignalHandlers();
|
|
1237
|
+
}
|
|
1238
|
+
async function stopProducer() {
|
|
1239
|
+
if (!producerAdapter) {
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
await producerAdapter.stop();
|
|
1243
|
+
producerAdapter = null;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
export {
|
|
1247
|
+
registerJobHandler,
|
|
1248
|
+
getJobHandler,
|
|
1249
|
+
getAllJobHandlers,
|
|
1250
|
+
buildInviteEmail,
|
|
1251
|
+
buildResetEmail,
|
|
1252
|
+
configureBuiltinJobContext,
|
|
1253
|
+
registerBuiltinHandlers,
|
|
1254
|
+
WORKER_HEARTBEAT_INTERVAL_MS,
|
|
1255
|
+
WORKER_STALE_THRESHOLD_MS,
|
|
1256
|
+
recordHeartbeat,
|
|
1257
|
+
markWorkerStopped,
|
|
1258
|
+
listWorkerHealth,
|
|
1259
|
+
purgeStaleWorkers,
|
|
1260
|
+
countAliveWorkers,
|
|
1261
|
+
getJobsPauseState,
|
|
1262
|
+
setJobsPauseState,
|
|
1263
|
+
PAUSE_SYNC_INTERVAL_MS,
|
|
1264
|
+
PgBossAdapter,
|
|
1265
|
+
startWorker,
|
|
1266
|
+
startProducer,
|
|
1267
|
+
stopWorker,
|
|
1268
|
+
stopProducer
|
|
1269
|
+
};
|
|
1270
|
+
//# sourceMappingURL=chunk-ISLYFQWL.js.map
|