gorsee 0.2.10 → 0.2.12

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.
Files changed (49) hide show
  1. package/README.md +52 -4
  2. package/bin/gorsee.js +38 -0
  3. package/dist-pkg/ai/bundle.d.ts +1 -0
  4. package/dist-pkg/ai/framework-context.d.ts +2 -0
  5. package/dist-pkg/ai/framework-context.js +6 -1
  6. package/dist-pkg/ai/ide.d.ts +1 -0
  7. package/dist-pkg/ai/ide.js +3 -0
  8. package/dist-pkg/ai/index.d.ts +10 -1
  9. package/dist-pkg/ai/index.js +13 -2
  10. package/dist-pkg/ai/mcp.js +4 -0
  11. package/dist-pkg/ai/session-pack.d.ts +8 -0
  12. package/dist-pkg/ai/session-pack.js +51 -1
  13. package/dist-pkg/ai/store.d.ts +25 -1
  14. package/dist-pkg/ai/store.js +89 -3
  15. package/dist-pkg/ai/summary.d.ts +88 -0
  16. package/dist-pkg/ai/summary.js +310 -1
  17. package/dist-pkg/build/manifest.d.ts +4 -2
  18. package/dist-pkg/build/manifest.js +32 -2
  19. package/dist-pkg/cli/cmd-ai.js +66 -0
  20. package/dist-pkg/cli/cmd-build.js +72 -26
  21. package/dist-pkg/cli/cmd-check.js +104 -11
  22. package/dist-pkg/cli/cmd-create.js +333 -7
  23. package/dist-pkg/cli/cmd-deploy.js +17 -3
  24. package/dist-pkg/cli/cmd-docs.d.ts +3 -1
  25. package/dist-pkg/cli/cmd-docs.js +5 -3
  26. package/dist-pkg/cli/cmd-start.js +8 -1
  27. package/dist-pkg/cli/cmd-upgrade.d.ts +3 -0
  28. package/dist-pkg/cli/cmd-upgrade.js +14 -2
  29. package/dist-pkg/cli/cmd-worker.d.ts +9 -0
  30. package/dist-pkg/cli/cmd-worker.js +78 -0
  31. package/dist-pkg/cli/framework-md.js +16 -4
  32. package/dist-pkg/cli/index.js +5 -0
  33. package/dist-pkg/runtime/app-config.d.ts +5 -0
  34. package/dist-pkg/runtime/app-config.js +26 -5
  35. package/dist-pkg/server/index.d.ts +2 -1
  36. package/dist-pkg/server/index.js +1 -0
  37. package/dist-pkg/server/jobs.d.ts +35 -1
  38. package/dist-pkg/server/jobs.js +226 -3
  39. package/dist-pkg/server/manifest.d.ts +30 -0
  40. package/dist-pkg/server/manifest.js +30 -1
  41. package/dist-pkg/server/redis-client.d.ts +9 -0
  42. package/dist-pkg/server/redis-client.js +4 -1
  43. package/dist-pkg/server/redis-job-queue.d.ts +2 -0
  44. package/dist-pkg/server/redis-job-queue.js +434 -16
  45. package/dist-pkg/server/worker-service.d.ts +33 -0
  46. package/dist-pkg/server/worker-service.js +135 -0
  47. package/dist-pkg/server-entry.d.ts +2 -1
  48. package/dist-pkg/server-entry.js +4 -0
  49. package/package.json +3 -2
@@ -1,15 +1,24 @@
1
1
  import { join } from "node:path";
2
2
  import { readFile } from "node:fs/promises";
3
- export const BUILD_MANIFEST_SCHEMA_VERSION = 1;
3
+ export const BUILD_MANIFEST_SCHEMA_VERSION = 1, RELEASE_ARTIFACT_SCHEMA_VERSION = 1;
4
4
  export async function loadBuildManifest(distDir) {
5
5
  const raw = await readFile(join(distDir, "manifest.json"), "utf-8");
6
6
  return parseBuildManifest(raw);
7
7
  }
8
+ export async function loadReleaseArtifact(distDir) {
9
+ const raw = await readFile(join(distDir, "release.json"), "utf-8");
10
+ return parseReleaseArtifact(raw);
11
+ }
8
12
  export function parseBuildManifest(raw) {
9
13
  const manifest = JSON.parse(raw);
10
14
  validateBuildManifest(manifest);
11
15
  return manifest;
12
16
  }
17
+ export function parseReleaseArtifact(raw) {
18
+ const artifact = JSON.parse(raw);
19
+ validateReleaseArtifact(artifact);
20
+ return artifact;
21
+ }
13
22
  function validateBuildManifest(manifest) {
14
23
  if (manifest.schemaVersion !== BUILD_MANIFEST_SCHEMA_VERSION)
15
24
  throw Error(`Unsupported build manifest schema version: expected ${BUILD_MANIFEST_SCHEMA_VERSION}, received ${String(manifest.schemaVersion)}`);
@@ -22,6 +31,26 @@ function validateBuildManifest(manifest) {
22
31
  if (typeof manifest.buildTime !== "string" || manifest.buildTime.length === 0)
23
32
  throw Error("Invalid build manifest: buildTime must be a non-empty string");
24
33
  }
34
+ function validateReleaseArtifact(artifact) {
35
+ if (artifact.schemaVersion !== RELEASE_ARTIFACT_SCHEMA_VERSION)
36
+ throw Error(`Unsupported release artifact schema version: expected ${RELEASE_ARTIFACT_SCHEMA_VERSION}, received ${String(artifact.schemaVersion)}`);
37
+ if (artifact.appMode !== "frontend" && artifact.appMode !== "fullstack" && artifact.appMode !== "server")
38
+ throw Error("Invalid release artifact: appMode must be frontend, fullstack, or server");
39
+ if (typeof artifact.generatedAt !== "string" || artifact.generatedAt.length === 0)
40
+ throw Error("Invalid release artifact: generatedAt must be a non-empty string");
41
+ if (!artifact.summary || typeof artifact.summary !== "object")
42
+ throw Error("Invalid release artifact: summary must be an object");
43
+ if (!artifact.runtime || typeof artifact.runtime !== "object")
44
+ throw Error("Invalid release artifact: runtime must be an object");
45
+ if (!artifact.artifacts || typeof artifact.artifacts !== "object")
46
+ throw Error("Invalid release artifact: artifacts must be an object");
47
+ if (!Array.isArray(artifact.artifacts.clientAssets) || !Array.isArray(artifact.artifacts.serverEntries) || !Array.isArray(artifact.artifacts.prerenderedHtml))
48
+ throw Error("Invalid release artifact: artifact file groups must be arrays");
49
+ if (typeof artifact.artifacts.buildManifest !== "string" || artifact.artifacts.buildManifest.length === 0)
50
+ throw Error("Invalid release artifact: buildManifest must be a non-empty string");
51
+ if (!Array.isArray(artifact.runtime.processEntrypoints) || !Array.isArray(artifact.runtime.handlerEntrypoints))
52
+ throw Error("Invalid release artifact: runtime entrypoint groups must be arrays");
53
+ }
25
54
  export function getRouteBuildEntry(manifest, pathname) {
26
55
  return manifest.routes[pathname];
27
56
  }
@@ -9,6 +9,9 @@ export interface RedisLikeClient {
9
9
  pttl?(key: string): Awaitable<number>;
10
10
  ttl?(key: string): Awaitable<number>;
11
11
  setnx?(key: string, value: string): Awaitable<number>;
12
+ zadd?(key: string, score: number, member: string): Awaitable<number>;
13
+ zrangebyscore?(key: string, min: number | "-inf", max: number | "+inf"): Awaitable<string[]>;
14
+ zrem?(key: string, ...members: string[]): Awaitable<number>;
12
15
  }
13
16
  export declare function buildRedisKey(prefix: string, key: string): string;
14
17
  export declare function stripRedisPrefix(prefix: string, key: string): string;
@@ -22,6 +25,9 @@ export interface NodeRedisClientLike {
22
25
  pttl?(key: string): Awaitable<number>;
23
26
  ttl?(key: string): Awaitable<number>;
24
27
  setnx?(key: string, value: string): Awaitable<number>;
28
+ zadd?(key: string, score: number, member: string): Awaitable<number>;
29
+ zrangebyscore?(key: string, min: number | "-inf", max: number | "+inf"): Awaitable<string[]>;
30
+ zrem?(key: string, ...members: string[]): Awaitable<number>;
25
31
  }
26
32
  export interface IORedisClientLike {
27
33
  get(key: string): Awaitable<string | null>;
@@ -33,6 +39,9 @@ export interface IORedisClientLike {
33
39
  pttl?(key: string): Awaitable<number>;
34
40
  ttl?(key: string): Awaitable<number>;
35
41
  setnx?(key: string, value: string): Awaitable<number>;
42
+ zadd?(key: string, score: number, member: string): Awaitable<number>;
43
+ zrangebyscore?(key: string, min: number | "-inf", max: number | "+inf"): Awaitable<string[]>;
44
+ zrem?(key: string, ...members: string[]): Awaitable<number>;
36
45
  }
37
46
  export declare function createNodeRedisLikeClient(client: NodeRedisClientLike): RedisLikeClient;
38
47
  export declare function createIORedisLikeClient(client: IORedisClientLike): RedisLikeClient;
@@ -15,7 +15,10 @@ export function createNodeRedisLikeClient(client) {
15
15
  expire: client.expire ? (key, seconds) => client.expire(key, seconds) : void 0,
16
16
  pttl: client.pttl ? (key) => client.pttl(key) : void 0,
17
17
  ttl: client.ttl ? (key) => client.ttl(key) : void 0,
18
- setnx: client.setnx ? (key, value) => client.setnx(key, value) : void 0
18
+ setnx: client.setnx ? (key, value) => client.setnx(key, value) : void 0,
19
+ zadd: client.zadd ? (key, score, member) => client.zadd(key, score, member) : void 0,
20
+ zrangebyscore: client.zrangebyscore ? (key, min, max) => client.zrangebyscore(key, min, max) : void 0,
21
+ zrem: client.zrem ? (key, ...members) => client.zrem(key, ...members) : void 0
19
22
  };
20
23
  }
21
24
  export function createIORedisLikeClient(client) {
@@ -3,6 +3,8 @@ import { type RedisLikeClient } from "./redis-client.js";
3
3
  interface RedisJobQueueOptions {
4
4
  prefix?: string;
5
5
  lockTtlSeconds?: number;
6
+ lockRenewIntervalMs?: number;
7
+ historyLimit?: number;
6
8
  jobs?: Array<JobDefinition<unknown>>;
7
9
  instanceId?: string;
8
10
  }
@@ -1,26 +1,41 @@
1
+ import { emitJobLifecycleEvent } from "./jobs.js";
1
2
  import { buildRedisKey, stripRedisPrefix } from "./redis-client.js";
2
3
  export function createRedisJobQueue(client, options = {}) {
3
4
  if (!client.incr || !client.expire || !client.setnx)
4
5
  throw Error("Redis job queue requires incr(), expire(), and setnx() support on the Redis client.");
5
- const prefix = options.prefix ?? "gorsee:jobs", lockTtlSeconds = options.lockTtlSeconds ?? 30, instanceId = options.instanceId ?? crypto.randomUUID(), handlers = new Map;
6
+ const prefix = options.prefix ?? "gorsee:jobs", lockTtlSeconds = options.lockTtlSeconds ?? 30, lockRenewIntervalMs = options.lockRenewIntervalMs ?? Math.max(1000, Math.floor(lockTtlSeconds * 1000 / 3)), historyLimit = options.historyLimit ?? 100, instanceId = options.instanceId ?? crypto.randomUUID(), handlers = new Map;
6
7
  for (const job of options.jobs ?? [])
7
8
  handlers.set(job.name, job);
8
9
  return {
9
10
  async enqueue(job, payload, enqueueOptions = {}) {
10
11
  handlers.set(job.name, job);
11
- const sequence = await client.incr(buildRedisKey(prefix, "__sequence")), id = `${job.name}:${sequence}:${crypto.randomUUID()}`, enqueued = {
12
+ const sequence = await client.incr(buildRedisKey(prefix, "__sequence")), id = `${job.name}:${sequence}:${crypto.randomUUID()}`, runAt = enqueueOptions.runAt ?? Date.now(), enqueued = {
12
13
  id,
13
14
  name: job.name,
14
15
  payload,
15
- runAt: enqueueOptions.runAt ?? Date.now(),
16
+ runAt,
16
17
  attempts: 0,
17
18
  maxAttempts: enqueueOptions.maxAttempts ?? 3,
18
19
  backoffMs: enqueueOptions.backoffMs ?? 1000
19
- };
20
- await client.set(jobKey(prefix, id), JSON.stringify({
20
+ }, stored = {
21
21
  ...enqueued,
22
- sequence
23
- }));
22
+ sequence,
23
+ createdAt: Date.now(),
24
+ updatedAt: Date.now(),
25
+ scheduleMember: buildScheduleMember(sequence, id)
26
+ };
27
+ await writeStoredJob(client, prefix, stored);
28
+ await emitJobLifecycleEvent({
29
+ kind: "job.enqueue",
30
+ severity: "info",
31
+ queue: "redis",
32
+ id: stored.id,
33
+ name: stored.name,
34
+ attempts: stored.attempts,
35
+ maxAttempts: stored.maxAttempts,
36
+ runAt: stored.runAt,
37
+ workerInstanceId: instanceId
38
+ });
24
39
  return enqueued;
25
40
  },
26
41
  async runNext(now = Date.now()) {
@@ -30,13 +45,45 @@ export function createRedisJobQueue(client, options = {}) {
30
45
  if (await client.setnx(lockKey, instanceId) !== 1)
31
46
  continue;
32
47
  await client.expire(lockKey, lockTtlSeconds);
48
+ const renewTimer = startLockRenewal(client, lockKey, lockTtlSeconds, lockRenewIntervalMs);
33
49
  try {
34
50
  const current = await readStoredJob(client, prefix, job.id);
35
51
  if (!current || current.runAt > now)
36
52
  continue;
53
+ await emitJobLifecycleEvent({
54
+ kind: "job.start",
55
+ severity: "info",
56
+ queue: "redis",
57
+ id: current.id,
58
+ name: current.name,
59
+ attempts: current.attempts + 1,
60
+ maxAttempts: current.maxAttempts,
61
+ runAt: current.runAt,
62
+ workerInstanceId: instanceId
63
+ });
37
64
  const handler = handlers.get(current.name);
38
65
  if (!handler) {
39
- await client.del(jobKey(prefix, current.id));
66
+ await archiveTerminalJob(client, prefix, historyLimit, {
67
+ ...current,
68
+ attempts: current.attempts + 1,
69
+ lastError: `Missing Redis job handler registration for "${current.name}"`,
70
+ finishedAt: Date.now(),
71
+ status: "failed",
72
+ historyMember: buildHistoryMember(current.sequence, current.id)
73
+ });
74
+ await deleteStoredJob(client, prefix, current);
75
+ await emitJobLifecycleEvent({
76
+ kind: "job.fail",
77
+ severity: "error",
78
+ queue: "redis",
79
+ id: current.id,
80
+ name: current.name,
81
+ attempts: current.attempts + 1,
82
+ maxAttempts: current.maxAttempts,
83
+ runAt: current.runAt,
84
+ workerInstanceId: instanceId,
85
+ error: `Missing Redis job handler registration for "${current.name}"`
86
+ });
40
87
  return {
41
88
  id: current.id,
42
89
  name: current.name,
@@ -51,7 +98,25 @@ export function createRedisJobQueue(client, options = {}) {
51
98
  attempt: current.attempts,
52
99
  maxAttempts: current.maxAttempts
53
100
  });
54
- await client.del(jobKey(prefix, current.id));
101
+ current.updatedAt = Date.now();
102
+ await archiveTerminalJob(client, prefix, historyLimit, {
103
+ ...current,
104
+ finishedAt: Date.now(),
105
+ status: "completed",
106
+ historyMember: buildHistoryMember(current.sequence, current.id)
107
+ });
108
+ await deleteStoredJob(client, prefix, current);
109
+ await emitJobLifecycleEvent({
110
+ kind: "job.complete",
111
+ severity: "info",
112
+ queue: "redis",
113
+ id: current.id,
114
+ name: current.name,
115
+ attempts: current.attempts,
116
+ maxAttempts: current.maxAttempts,
117
+ runAt: current.runAt,
118
+ workerInstanceId: instanceId
119
+ });
55
120
  return {
56
121
  id: current.id,
57
122
  name: current.name,
@@ -63,7 +128,21 @@ export function createRedisJobQueue(client, options = {}) {
63
128
  if (current.attempts < current.maxAttempts) {
64
129
  current.lastError = message;
65
130
  current.runAt = now + current.backoffMs * current.attempts;
66
- await client.set(jobKey(prefix, current.id), JSON.stringify(current));
131
+ current.updatedAt = Date.now();
132
+ await writeStoredJob(client, prefix, current);
133
+ await emitJobLifecycleEvent({
134
+ kind: "job.retry",
135
+ severity: "warn",
136
+ queue: "redis",
137
+ id: current.id,
138
+ name: current.name,
139
+ attempts: current.attempts,
140
+ maxAttempts: current.maxAttempts,
141
+ runAt: now,
142
+ nextRunAt: current.runAt,
143
+ workerInstanceId: instanceId,
144
+ error: message
145
+ });
67
146
  return {
68
147
  id: current.id,
69
148
  name: current.name,
@@ -73,7 +152,27 @@ export function createRedisJobQueue(client, options = {}) {
73
152
  error: message
74
153
  };
75
154
  }
76
- await client.del(jobKey(prefix, current.id));
155
+ current.lastError = message;
156
+ current.updatedAt = Date.now();
157
+ await archiveTerminalJob(client, prefix, historyLimit, {
158
+ ...current,
159
+ finishedAt: Date.now(),
160
+ status: "failed",
161
+ historyMember: buildHistoryMember(current.sequence, current.id)
162
+ });
163
+ await deleteStoredJob(client, prefix, current);
164
+ await emitJobLifecycleEvent({
165
+ kind: "job.fail",
166
+ severity: "error",
167
+ queue: "redis",
168
+ id: current.id,
169
+ name: current.name,
170
+ attempts: current.attempts,
171
+ maxAttempts: current.maxAttempts,
172
+ runAt: current.runAt,
173
+ workerInstanceId: instanceId,
174
+ error: message
175
+ });
77
176
  return {
78
177
  id: current.id,
79
178
  name: current.name,
@@ -83,6 +182,7 @@ export function createRedisJobQueue(client, options = {}) {
83
182
  };
84
183
  }
85
184
  } finally {
185
+ clearLockRenewal(renewTimer);
86
186
  await client.del(lockKey);
87
187
  }
88
188
  }
@@ -99,6 +199,66 @@ export function createRedisJobQueue(client, options = {}) {
99
199
  },
100
200
  async size() {
101
201
  return (await listStoredJobs(client, prefix)).length;
202
+ },
203
+ async get(id) {
204
+ const job = await readStoredJob(client, prefix, id);
205
+ if (!job)
206
+ return null;
207
+ return toQueuedJobRecord(job);
208
+ },
209
+ async peek(limit = Number.POSITIVE_INFINITY) {
210
+ return (await listStoredJobs(client, prefix)).sort((a, b) => a.runAt - b.runAt || a.sequence - b.sequence).slice(0, limit).map((job) => toQueuedJobRecord(job));
211
+ },
212
+ async cancel(id) {
213
+ const job = await readStoredJob(client, prefix, id);
214
+ if (!job)
215
+ return !1;
216
+ if (await client.get(claimKey(prefix, id)))
217
+ return !1;
218
+ await deleteStoredJob(client, prefix, job);
219
+ await emitJobLifecycleEvent({
220
+ kind: "job.cancel",
221
+ severity: "info",
222
+ queue: "redis",
223
+ id: job.id,
224
+ name: job.name,
225
+ attempts: job.attempts,
226
+ maxAttempts: job.maxAttempts,
227
+ runAt: job.runAt,
228
+ workerInstanceId: instanceId
229
+ });
230
+ return !0;
231
+ },
232
+ async recent(limit = 50) {
233
+ return (await listTerminalJobs(client, prefix, limit)).map(toTerminalJobRecord);
234
+ },
235
+ async failures(limit = 50) {
236
+ return (await listFailedTerminalJobs(client, prefix, limit)).map(toTerminalJobRecord);
237
+ },
238
+ async retryFailed(id, options = {}) {
239
+ const failed = await readStoredTerminalJob(client, prefix, id);
240
+ if (!failed || failed.status !== "failed")
241
+ return null;
242
+ const handler = handlers.get(failed.name);
243
+ if (!handler)
244
+ return null;
245
+ const retried = await this.enqueue(handler, failed.payload, {
246
+ runAt: options.runAt ?? Date.now(),
247
+ maxAttempts: options.maxAttempts ?? failed.maxAttempts,
248
+ backoffMs: options.backoffMs ?? failed.backoffMs
249
+ });
250
+ await emitJobLifecycleEvent({
251
+ kind: "job.dead-letter.retry",
252
+ severity: "warn",
253
+ queue: "redis",
254
+ id: retried.id,
255
+ name: retried.name,
256
+ attempts: retried.attempts,
257
+ maxAttempts: retried.maxAttempts,
258
+ runAt: retried.runAt,
259
+ workerInstanceId: instanceId
260
+ });
261
+ return retried;
102
262
  }
103
263
  };
104
264
  }
@@ -108,7 +268,32 @@ function jobKey(prefix, id) {
108
268
  function claimKey(prefix, id) {
109
269
  return buildRedisKey(`${prefix}:lock`, id);
110
270
  }
271
+ function scheduleKey(prefix) {
272
+ return buildRedisKey(prefix, "__schedule");
273
+ }
274
+ function historyIndexKey(prefix) {
275
+ return buildRedisKey(prefix, "__history");
276
+ }
277
+ function failedIndexKey(prefix) {
278
+ return buildRedisKey(prefix, "__failed");
279
+ }
280
+ function historyItemKey(prefix, id) {
281
+ return buildRedisKey(`${prefix}:history`, id);
282
+ }
111
283
  async function listStoredJobs(client, prefix) {
284
+ if (client.zrangebyscore) {
285
+ const members = await client.zrangebyscore(scheduleKey(prefix), "-inf", "+inf"), jobs = [];
286
+ for (const member of members) {
287
+ const job = await readStoredJob(client, prefix, extractJobIdFromScheduleMember(member));
288
+ if (job) {
289
+ jobs.push(job);
290
+ continue;
291
+ }
292
+ if (client.zrem)
293
+ await client.zrem(scheduleKey(prefix), member);
294
+ }
295
+ return jobs;
296
+ }
112
297
  const keys = await client.keys(`${prefix}:*`), jobs = [];
113
298
  for (const key of keys) {
114
299
  const visibleKey = stripRedisPrefix(prefix, key);
@@ -121,6 +306,19 @@ async function listStoredJobs(client, prefix) {
121
306
  return jobs;
122
307
  }
123
308
  async function listDueJobs(client, prefix, now) {
309
+ if (client.zrangebyscore) {
310
+ const members = await client.zrangebyscore(scheduleKey(prefix), "-inf", now), jobs = [];
311
+ for (const member of members) {
312
+ const job = await readStoredJob(client, prefix, extractJobIdFromScheduleMember(member));
313
+ if (job) {
314
+ jobs.push(job);
315
+ continue;
316
+ }
317
+ if (client.zrem)
318
+ await client.zrem(scheduleKey(prefix), member);
319
+ }
320
+ return jobs;
321
+ }
124
322
  return (await listStoredJobs(client, prefix)).filter((job) => job.runAt <= now).sort((a, b) => a.runAt - b.runAt || a.sequence - b.sequence);
125
323
  }
126
324
  async function readStoredJob(client, prefix, id) {
@@ -128,14 +326,234 @@ async function readStoredJob(client, prefix, id) {
128
326
  if (!raw)
129
327
  return null;
130
328
  try {
131
- const parsed = JSON.parse(raw);
132
- if (typeof parsed.id !== "string" || typeof parsed.name !== "string") {
133
- await client.del(jobKey(prefix, id));
329
+ const parsed = JSON.parse(raw), normalized = normalizeStoredJob(parsed, id);
330
+ if (!normalized) {
331
+ await deleteCorruptStoredJob(client, prefix, id);
332
+ return null;
333
+ }
334
+ if (!isStoredJob(parsed))
335
+ await writeStoredJob(client, prefix, normalized);
336
+ return normalized;
337
+ } catch {
338
+ await deleteCorruptStoredJob(client, prefix, id);
339
+ return null;
340
+ }
341
+ }
342
+ async function writeStoredJob(client, prefix, job) {
343
+ await client.set(jobKey(prefix, job.id), JSON.stringify(job));
344
+ if (client.zadd)
345
+ await client.zadd(scheduleKey(prefix), job.runAt, job.scheduleMember);
346
+ }
347
+ async function deleteStoredJob(client, prefix, job) {
348
+ await client.del(jobKey(prefix, job.id));
349
+ if (client.zrem)
350
+ await client.zrem(scheduleKey(prefix), job.scheduleMember);
351
+ }
352
+ async function deleteCorruptStoredJob(client, prefix, id) {
353
+ await client.del(jobKey(prefix, id));
354
+ if (client.zrem)
355
+ await client.zrem(scheduleKey(prefix), ...candidateScheduleMembersForCorruptJob(id));
356
+ }
357
+ async function archiveTerminalJob(client, prefix, historyLimit, job) {
358
+ await client.set(historyItemKey(prefix, job.id), JSON.stringify(job));
359
+ if (client.zadd) {
360
+ await client.zadd(historyIndexKey(prefix), job.finishedAt, job.historyMember);
361
+ if (job.status === "failed")
362
+ await client.zadd(failedIndexKey(prefix), job.finishedAt, job.historyMember);
363
+ else if (client.zrem)
364
+ await client.zrem(failedIndexKey(prefix), job.historyMember);
365
+ await trimTerminalHistory(client, prefix, historyLimit);
366
+ }
367
+ }
368
+ async function trimTerminalHistory(client, prefix, historyLimit) {
369
+ if (!client.zrangebyscore || !client.zrem || historyLimit < 0)
370
+ return;
371
+ const history = await client.zrangebyscore(historyIndexKey(prefix), "-inf", "+inf"), overflow = history.length - historyLimit;
372
+ if (overflow <= 0)
373
+ return;
374
+ const toDelete = history.slice(0, overflow);
375
+ for (const member of toDelete) {
376
+ const id = extractJobIdFromHistoryMember(member);
377
+ await client.del(historyItemKey(prefix, id));
378
+ await client.zrem(historyIndexKey(prefix), member);
379
+ await client.zrem(failedIndexKey(prefix), member);
380
+ }
381
+ }
382
+ async function listTerminalJobs(client, prefix, limit) {
383
+ return listIndexedTerminalJobs(client, prefix, historyIndexKey(prefix), limit);
384
+ }
385
+ async function listFailedTerminalJobs(client, prefix, limit) {
386
+ return listIndexedTerminalJobs(client, prefix, failedIndexKey(prefix), limit);
387
+ }
388
+ async function listIndexedTerminalJobs(client, prefix, indexKey, limit) {
389
+ if (!client.zrangebyscore)
390
+ return [];
391
+ const members = await client.zrangebyscore(indexKey, "-inf", "+inf"), records = [];
392
+ for (const member of members.slice(-limit).reverse()) {
393
+ const record = await readStoredTerminalJob(client, prefix, extractJobIdFromHistoryMember(member));
394
+ if (record) {
395
+ records.push(record);
396
+ continue;
397
+ }
398
+ if (client.zrem)
399
+ await client.zrem(indexKey, member);
400
+ }
401
+ return records;
402
+ }
403
+ async function readStoredTerminalJob(client, prefix, id) {
404
+ const raw = await client.get(historyItemKey(prefix, id));
405
+ if (!raw)
406
+ return null;
407
+ try {
408
+ const parsed = JSON.parse(raw), normalized = normalizeStoredTerminalJob(parsed, id);
409
+ if (!normalized) {
410
+ await client.del(historyItemKey(prefix, id));
134
411
  return null;
135
412
  }
136
- return parsed;
413
+ return normalized;
137
414
  } catch {
138
- await client.del(jobKey(prefix, id));
415
+ await client.del(historyItemKey(prefix, id));
139
416
  return null;
140
417
  }
141
418
  }
419
+ function buildScheduleMember(sequence, id) {
420
+ return `${String(sequence).padStart(12, "0")}:${id}`;
421
+ }
422
+ function buildHistoryMember(sequence, id) {
423
+ return `${String(sequence).padStart(12, "0")}:${id}`;
424
+ }
425
+ function extractJobIdFromScheduleMember(member) {
426
+ const delimiterIndex = member.indexOf(":");
427
+ return delimiterIndex === -1 ? member : member.slice(delimiterIndex + 1);
428
+ }
429
+ function extractJobIdFromHistoryMember(member) {
430
+ const delimiterIndex = member.indexOf(":");
431
+ return delimiterIndex === -1 ? member : member.slice(delimiterIndex + 1);
432
+ }
433
+ function candidateScheduleMembersForCorruptJob(id) {
434
+ return [
435
+ id,
436
+ `000000000000:${id}`
437
+ ];
438
+ }
439
+ function isStoredJob(value) {
440
+ if (!value || typeof value !== "object")
441
+ return !1;
442
+ const candidate = value;
443
+ return typeof candidate.id === "string" && typeof candidate.name === "string" && typeof candidate.runAt === "number" && typeof candidate.attempts === "number" && typeof candidate.maxAttempts === "number" && typeof candidate.backoffMs === "number" && typeof candidate.sequence === "number" && typeof candidate.createdAt === "number" && typeof candidate.updatedAt === "number" && typeof candidate.scheduleMember === "string";
444
+ }
445
+ function toQueuedJobRecord(job) {
446
+ return {
447
+ id: job.id,
448
+ name: job.name,
449
+ payload: job.payload,
450
+ runAt: job.runAt,
451
+ attempts: job.attempts,
452
+ maxAttempts: job.maxAttempts,
453
+ backoffMs: job.backoffMs,
454
+ lastError: job.lastError,
455
+ createdAt: job.createdAt,
456
+ updatedAt: job.updatedAt
457
+ };
458
+ }
459
+ function toTerminalJobRecord(job) {
460
+ return {
461
+ id: job.id,
462
+ name: job.name,
463
+ payload: job.payload,
464
+ runAt: job.runAt,
465
+ attempts: job.attempts,
466
+ maxAttempts: job.maxAttempts,
467
+ backoffMs: job.backoffMs,
468
+ lastError: job.lastError,
469
+ createdAt: job.createdAt,
470
+ updatedAt: job.updatedAt,
471
+ finishedAt: job.finishedAt,
472
+ status: job.status
473
+ };
474
+ }
475
+ function normalizeStoredJob(value, id) {
476
+ if (typeof value.id !== "string" || value.id !== id)
477
+ return null;
478
+ if (typeof value.name !== "string")
479
+ return null;
480
+ if (typeof value.runAt !== "number")
481
+ return null;
482
+ if (typeof value.attempts !== "number")
483
+ return null;
484
+ if (typeof value.maxAttempts !== "number")
485
+ return null;
486
+ if (typeof value.backoffMs !== "number")
487
+ return null;
488
+ if (typeof value.sequence !== "number")
489
+ return null;
490
+ const createdAt = typeof value.createdAt === "number" ? value.createdAt : value.runAt, updatedAt = typeof value.updatedAt === "number" ? value.updatedAt : createdAt, scheduleMember = typeof value.scheduleMember === "string" ? value.scheduleMember : buildScheduleMember(value.sequence, value.id);
491
+ return {
492
+ id: value.id,
493
+ name: value.name,
494
+ payload: value.payload,
495
+ runAt: value.runAt,
496
+ attempts: value.attempts,
497
+ maxAttempts: value.maxAttempts,
498
+ backoffMs: value.backoffMs,
499
+ lastError: value.lastError,
500
+ sequence: value.sequence,
501
+ createdAt,
502
+ updatedAt,
503
+ scheduleMember
504
+ };
505
+ }
506
+ function normalizeStoredTerminalJob(value, id) {
507
+ if (typeof value.id !== "string" || value.id !== id)
508
+ return null;
509
+ if (typeof value.name !== "string")
510
+ return null;
511
+ if (typeof value.runAt !== "number")
512
+ return null;
513
+ if (typeof value.attempts !== "number")
514
+ return null;
515
+ if (typeof value.maxAttempts !== "number")
516
+ return null;
517
+ if (typeof value.backoffMs !== "number")
518
+ return null;
519
+ if (typeof value.sequence !== "number")
520
+ return null;
521
+ if (typeof value.createdAt !== "number")
522
+ return null;
523
+ if (typeof value.updatedAt !== "number")
524
+ return null;
525
+ if (typeof value.finishedAt !== "number")
526
+ return null;
527
+ if (value.status !== "completed" && value.status !== "failed")
528
+ return null;
529
+ return {
530
+ id: value.id,
531
+ name: value.name,
532
+ payload: value.payload,
533
+ runAt: value.runAt,
534
+ attempts: value.attempts,
535
+ maxAttempts: value.maxAttempts,
536
+ backoffMs: value.backoffMs,
537
+ lastError: value.lastError,
538
+ createdAt: value.createdAt,
539
+ updatedAt: value.updatedAt,
540
+ finishedAt: value.finishedAt,
541
+ status: value.status,
542
+ sequence: value.sequence,
543
+ historyMember: typeof value.historyMember === "string" ? value.historyMember : buildHistoryMember(value.sequence, value.id)
544
+ };
545
+ }
546
+ function startLockRenewal(client, lockKey, lockTtlSeconds, renewIntervalMs) {
547
+ if (renewIntervalMs <= 0)
548
+ return null;
549
+ const timer = setInterval(() => {
550
+ client.expire?.(lockKey, lockTtlSeconds);
551
+ }, renewIntervalMs);
552
+ if (typeof timer.unref === "function")
553
+ timer.unref();
554
+ return timer;
555
+ }
556
+ function clearLockRenewal(timer) {
557
+ if (timer)
558
+ clearInterval(timer);
559
+ }
@@ -0,0 +1,33 @@
1
+ type Awaitable<T> = T | Promise<T>;
2
+ export interface WorkerServiceContext {
3
+ name: string;
4
+ workerId: string;
5
+ startedAt: number;
6
+ signal: AbortSignal;
7
+ emitReady(): Promise<void>;
8
+ emitHeartbeat(message?: string, data?: Record<string, unknown>): Promise<void>;
9
+ waitForShutdown(): Promise<void>;
10
+ }
11
+ export interface WorkerServiceStartHandle {
12
+ ready?: Promise<void>;
13
+ stop?: () => Awaitable<void>;
14
+ }
15
+ export interface WorkerServiceDefinition {
16
+ name: string;
17
+ start(context: WorkerServiceContext): Awaitable<void | (() => Awaitable<void>) | WorkerServiceStartHandle>;
18
+ }
19
+ export interface RunWorkerServiceOptions {
20
+ registerSignalHandlers?: boolean;
21
+ workerId?: string;
22
+ signal?: AbortSignal;
23
+ }
24
+ export interface RunningWorkerService {
25
+ name: string;
26
+ workerId: string;
27
+ startedAt: number;
28
+ ready: Promise<void>;
29
+ stop(): Promise<void>;
30
+ }
31
+ export declare function defineWorkerService(name: string, start: WorkerServiceDefinition["start"]): WorkerServiceDefinition;
32
+ export declare function runWorkerService(service: WorkerServiceDefinition, options?: RunWorkerServiceOptions): Promise<RunningWorkerService>;
33
+ export {};