@plitzi/sdk-server 0.37.1 → 0.37.2

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 (70) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +26 -0
  3. package/dist/actions.d.ts +14 -6
  4. package/dist/actions.js +7 -3
  5. package/dist/core/server/baseServer.d.ts +4 -1
  6. package/dist/core/server/baseServer.js +2 -1
  7. package/dist/core/server/closeOnSignals.d.ts +28 -0
  8. package/dist/core/server/closeOnSignals.js +46 -0
  9. package/dist/core/server/pageServer.js +3 -1
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.js +2 -1
  12. package/dist/modules/actions/index.d.ts +12 -3
  13. package/dist/modules/actions/index.js +9 -3
  14. package/dist/modules/actions/jobs/index.d.ts +32 -0
  15. package/dist/modules/actions/jobs/index.js +57 -0
  16. package/dist/modules/actions/jobs/memoryQueue.d.ts +30 -0
  17. package/dist/modules/actions/jobs/memoryQueue.js +178 -0
  18. package/dist/modules/actions/jobs/memoryStores.test.d.ts +1 -0
  19. package/dist/modules/actions/jobs/scheduler.d.ts +62 -0
  20. package/dist/modules/actions/jobs/scheduler.js +125 -0
  21. package/dist/modules/actions/jobs/scheduler.test.d.ts +1 -0
  22. package/dist/modules/actions/jobs/schedules.d.ts +23 -0
  23. package/dist/modules/actions/jobs/schedules.js +47 -0
  24. package/dist/modules/actions/jobs/storeClock.d.ts +16 -0
  25. package/dist/modules/actions/jobs/storeClock.js +32 -0
  26. package/dist/modules/actions/jobs/testing/jobQueueContract.d.ts +25 -0
  27. package/dist/modules/actions/jobs/worker.d.ts +62 -0
  28. package/dist/modules/actions/jobs/worker.js +262 -0
  29. package/dist/modules/actions/jobs/worker.test.d.ts +1 -0
  30. package/dist/modules/actions/moduleFor.js +1 -0
  31. package/dist/modules/actions/runtime/precheck.js +1 -1
  32. package/dist/modules/actions/runtime/renderResolver.js +2 -2
  33. package/dist/modules/actions/transport/callHandler.js +1 -1
  34. package/dist/modules/actions/transport/webhookHandler.js +1 -1
  35. package/dist/modules/actions/types.d.ts +76 -1
  36. package/dist/modules/mongo/index.d.ts +4 -0
  37. package/dist/modules/mongo/jobQueue.d.ts +38 -0
  38. package/dist/modules/mongo/jobQueue.js +335 -0
  39. package/dist/modules/mongo/kv.d.ts +15 -0
  40. package/dist/modules/mongo/kv.js +98 -0
  41. package/dist/modules/mongo/stores.integration.test.d.ts +1 -0
  42. package/dist/modules/mysql/jobs/index.d.ts +5 -0
  43. package/dist/modules/mysql/jobs/jobQueue.d.ts +40 -0
  44. package/dist/modules/mysql/jobs/jobQueue.js +375 -0
  45. package/dist/modules/mysql/jobs/kv.d.ts +18 -0
  46. package/dist/modules/mysql/jobs/kv.js +78 -0
  47. package/dist/modules/mysql/jobs/schema.d.ts +24 -0
  48. package/dist/modules/mysql/jobs/schema.js +96 -0
  49. package/dist/modules/mysql/jobs/stores.integration.test.d.ts +1 -0
  50. package/dist/modules/ssr/Component.d.ts +2 -5
  51. package/dist/modules/ssr/Component.js +1 -2
  52. package/dist/modules/ssr/prepareRender.js +0 -3
  53. package/dist/mongo.d.ts +25 -0
  54. package/dist/mongo.js +3 -0
  55. package/dist/mysql.d.ts +11 -0
  56. package/dist/mysql.js +4 -1
  57. package/package.json +16 -6
  58. package/skills/plitzi-authoring/SKILL.md +73 -267
  59. package/skills/plitzi-authoring/reference/data-and-visibility.md +114 -0
  60. package/skills/plitzi-authoring/reference/elements-and-styles.md +107 -0
  61. package/skills/plitzi-authoring/reference/flows.md +80 -0
  62. package/skills/plitzi-authoring/reference/layouts.md +140 -0
  63. package/skills/plitzi-authoring/reference/review-checklist.md +50 -0
  64. package/skills/plitzi-authoring/reference/structure.md +83 -0
  65. package/skills/plitzi-authoring/reference/templates-and-export.md +54 -0
  66. package/skills/plitzi-authoring/reference/templates.md +69 -0
  67. package/skills/plitzi-authoring/reference/testing.md +52 -0
  68. package/dist/modules/actions/runtime/schedule.d.ts +0 -35
  69. package/dist/modules/actions/runtime/schedule.js +0 -76
  70. /package/dist/{modules/actions/runtime/schedule.test.d.ts → core/server/closeOnSignals.test.d.ts} +0 -0
@@ -0,0 +1,178 @@
1
+ //#region src/modules/actions/jobs/memoryQueue.ts
2
+ var TERMINAL = /* @__PURE__ */ new Set([
3
+ "succeeded",
4
+ "failed",
5
+ "dead",
6
+ "cancelled"
7
+ ]);
8
+ /**
9
+ * The default queue: one process's Maps.
10
+ *
11
+ * Correct for a deployment that runs ONE replica, which is most self-hosted ones, and honest about the rest — a
12
+ * second replica running on this gets its own schedules and its own jobs, so a nightly digest goes out twice. The
13
+ * cure is any store the replicas share, behind the same {@link ActionJobQueue} seam.
14
+ *
15
+ * It keeps the contract exactly, rather than approximately: enqueue is idempotent by id, a claim reaps lapsed
16
+ * leases, and every instant is read here rather than accepted from a caller. A test that passes against this one
17
+ * is testing the rules a shared store has to keep too.
18
+ */
19
+ var createMemoryJobQueue = ({ retainFinished = 500, clock = Date.now } = {}) => {
20
+ const jobs = /* @__PURE__ */ new Map();
21
+ const schedules = /* @__PURE__ */ new Map();
22
+ const key = (spaceId, actionId) => `${spaceId}:${actionId}`;
23
+ const now = () => clock();
24
+ /** Oldest finished jobs first, so the bound drops history rather than anything anybody is waiting on. */
25
+ const evict = () => {
26
+ const finished = [...jobs.values()].filter((job) => TERMINAL.has(job.status)).sort((a, b) => a.updatedAt - b.updatedAt);
27
+ for (const job of finished.slice(0, finished.length - retainFinished)) jobs.delete(job.id);
28
+ };
29
+ const owned = (spaceIds, jobId) => {
30
+ const job = jobs.get(jobId);
31
+ return job && spaceIds.includes(job.spaceId) ? job : void 0;
32
+ };
33
+ /** Numbered by the history itself, not by `attempts`: an operator who repeats a finished job starts its attempt
34
+ * count again, and the record of what has already been tried must not start again with it. */
35
+ const appendAttempt = (job, attempt) => {
36
+ job.history = [...job.history, {
37
+ ...attempt,
38
+ attempt: job.history.length + 1
39
+ }];
40
+ };
41
+ return {
42
+ now: () => Promise.resolve(new Date(now())),
43
+ enqueue: (job) => {
44
+ if (jobs.has(job.id)) return Promise.resolve(false);
45
+ const at = now();
46
+ jobs.set(job.id, {
47
+ ...job,
48
+ status: "pending",
49
+ runAt: job.dueAt,
50
+ attempts: 0,
51
+ createdAt: at,
52
+ updatedAt: at,
53
+ history: []
54
+ });
55
+ evict();
56
+ return Promise.resolve(true);
57
+ },
58
+ claim: ({ workerId, leaseMs, limit }) => {
59
+ const at = now();
60
+ const taken = [];
61
+ const candidates = [...jobs.values()].filter((job) => job.status === "pending" && job.runAt <= at || job.status === "running" && (job.leaseUntil ?? 0) < at).sort((a, b) => a.runAt - b.runAt);
62
+ for (const job of candidates.slice(0, limit)) {
63
+ if (job.status === "running") appendAttempt(job, {
64
+ status: "lost",
65
+ startedAt: job.updatedAt,
66
+ endedAt: at,
67
+ workerId: job.workerId ?? "unknown",
68
+ error: "the worker holding this job stopped reporting"
69
+ });
70
+ job.status = "running";
71
+ job.attempts += 1;
72
+ job.workerId = workerId;
73
+ job.leaseUntil = at + leaseMs;
74
+ job.updatedAt = at;
75
+ taken.push({ ...job });
76
+ }
77
+ return Promise.resolve(taken);
78
+ },
79
+ heartbeat: ({ jobIds, workerId, leaseMs }) => {
80
+ const at = now();
81
+ const cancelled = [];
82
+ for (const id of jobIds) {
83
+ const job = jobs.get(id);
84
+ if (!job || job.workerId !== workerId || job.status !== "running") continue;
85
+ job.leaseUntil = at + leaseMs;
86
+ if (job.cancelRequested) cancelled.push(id);
87
+ }
88
+ return Promise.resolve(cancelled);
89
+ },
90
+ settle: ({ jobId, workerId, status, delayMs, runId, error, attempt }) => {
91
+ const job = jobs.get(jobId);
92
+ if (!job || job.workerId !== workerId) return Promise.resolve();
93
+ const at = now();
94
+ if (attempt) appendAttempt(job, {
95
+ ...attempt,
96
+ startedAt: job.updatedAt,
97
+ endedAt: at
98
+ });
99
+ job.status = status;
100
+ job.updatedAt = at;
101
+ job.leaseUntil = void 0;
102
+ job.error = error;
103
+ if (runId) job.runId = runId;
104
+ if (status === "pending") {
105
+ job.runAt = at + (delayMs ?? 0);
106
+ job.workerId = void 0;
107
+ if (!attempt) job.attempts = Math.max(0, job.attempts - 1);
108
+ }
109
+ evict();
110
+ return Promise.resolve();
111
+ },
112
+ dueSchedules: (limit) => {
113
+ const at = now();
114
+ return Promise.resolve([...schedules.values()].filter((schedule) => schedule.enabled && schedule.nextRunAt <= at).sort((a, b) => a.nextRunAt - b.nextRunAt).slice(0, limit).map((schedule) => ({ ...schedule })));
115
+ },
116
+ advanceSchedule: ({ spaceId, actionId, from, to, missed }) => {
117
+ const schedule = schedules.get(key(spaceId, actionId));
118
+ if (!schedule || schedule.nextRunAt !== from) return Promise.resolve(false);
119
+ schedule.nextRunAt = to;
120
+ schedule.lastFireAt = from;
121
+ schedule.missed += missed;
122
+ schedule.updatedAt = now();
123
+ return Promise.resolve(true);
124
+ },
125
+ putSchedules: ({ spaceId, schedules: incoming }) => {
126
+ const at = now();
127
+ const keep = new Set(incoming.map((schedule) => key(spaceId, schedule.actionId)));
128
+ for (const existing of schedules.keys()) if (existing.startsWith(`${spaceId}:`) && !keep.has(existing)) schedules.delete(existing);
129
+ for (const incomingSchedule of incoming) {
130
+ const id = key(spaceId, incomingSchedule.actionId);
131
+ const existing = schedules.get(id);
132
+ const unchanged = existing && existing.cron === incomingSchedule.cron && existing.timezone === incomingSchedule.timezone && existing.environment === incomingSchedule.environment;
133
+ schedules.set(id, {
134
+ ...incomingSchedule,
135
+ missed: existing?.missed ?? 0,
136
+ ...existing?.lastFireAt === void 0 ? {} : { lastFireAt: existing.lastFireAt },
137
+ nextRunAt: unchanged ? existing.nextRunAt : incomingSchedule.nextRunAt,
138
+ updatedAt: at
139
+ });
140
+ }
141
+ return Promise.resolve();
142
+ },
143
+ listJobs: ({ spaceIds, actionId, status, limit, offset }) => {
144
+ const matching = [...jobs.values()].filter((job) => spaceIds.includes(job.spaceId) && (actionId === void 0 || job.actionId === actionId) && (status === void 0 || job.status === status)).sort((a, b) => b.updatedAt - a.updatedAt);
145
+ return Promise.resolve({
146
+ jobs: matching.slice(offset, offset + limit).map((job) => ({ ...job })),
147
+ total: matching.length
148
+ });
149
+ },
150
+ getJob: (spaceIds, jobId) => Promise.resolve(owned(spaceIds, jobId)),
151
+ listSchedules: (spaceIds) => Promise.resolve([...schedules.values()].filter((schedule) => spaceIds.includes(schedule.spaceId))),
152
+ requeue: (spaceIds, jobId) => {
153
+ const job = owned(spaceIds, jobId);
154
+ if (!job || !TERMINAL.has(job.status)) return Promise.resolve(false);
155
+ job.status = "pending";
156
+ job.runAt = now();
157
+ job.attempts = 0;
158
+ job.workerId = void 0;
159
+ job.leaseUntil = void 0;
160
+ job.cancelRequested = false;
161
+ job.error = void 0;
162
+ job.updatedAt = now();
163
+ return Promise.resolve(true);
164
+ },
165
+ cancel: (spaceIds, jobId) => {
166
+ const job = owned(spaceIds, jobId);
167
+ if (!job || TERMINAL.has(job.status)) return Promise.resolve(false);
168
+ if (job.status === "pending") {
169
+ job.status = "cancelled";
170
+ job.workerId = void 0;
171
+ } else job.cancelRequested = true;
172
+ job.updatedAt = now();
173
+ return Promise.resolve(true);
174
+ }
175
+ };
176
+ };
177
+ //#endregion
178
+ export { createMemoryJobQueue };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,62 @@
1
+ import { ActionLookups } from '../types';
2
+ import { ActionJobQueue, Environment } from '@plitzi/sdk-shared';
3
+ export type SchedulerOptions = {
4
+ queue: ActionJobQueue;
5
+ lookups: ActionLookups;
6
+ /** Which environment scheduled runs execute in. Defaults to `main`, the document the builder edits. */
7
+ environment?: Environment;
8
+ /** How often this replica looks for due schedules. Default 15s — the resolution of a cron is a minute. */
9
+ pollMs?: number;
10
+ /** Schedules swept per pass. */
11
+ batch?: number;
12
+ /** Attempts a job produced here is given. */
13
+ maxAttempts?: number;
14
+ /** How often a space's schedules are re-derived from its documents. Default 15 minutes. A deployment that
15
+ * reconciles on save needs this only as a safety net. */
16
+ reconcileMs?: number;
17
+ /** The spaces to reconcile, for a server that serves a known few. `listScheduledSpaces` wins over it. */
18
+ spaces?: number[];
19
+ onError?: (error: unknown) => void;
20
+ };
21
+ export type SweepResult = {
22
+ /** Fires this pass turned into jobs. */
23
+ enqueued: string[];
24
+ /** Fires another replica had already enqueued. Not a problem: it is the guarantee working. */
25
+ duplicates: string[];
26
+ /** Fires that went by while nothing was running, by schedule. */
27
+ missed: {
28
+ spaceId: number;
29
+ actionId: string;
30
+ count: number;
31
+ }[];
32
+ };
33
+ export type Scheduler = {
34
+ /** One pass. Exposed because a deployment driving this from its own cron — a k8s CronJob, a queue tick — needs
35
+ * a way in that is not a timer, and because a test must not have to wait for one. */
36
+ sweep: () => Promise<SweepResult>;
37
+ /** Re-derives one space's schedules from its action documents. Called by a deployment whenever they change. */
38
+ reconcile: (spaceId: number) => Promise<void>;
39
+ start: () => void;
40
+ stop: () => void;
41
+ };
42
+ /**
43
+ * Turns due schedules into jobs. Nothing else.
44
+ *
45
+ * **There is no leader.** Every replica sweeps, and that is safe because a fire's job id is derived from the fire's
46
+ * own stored instant: two replicas reaching the same fire write the same id, and the queue keeps the first. A
47
+ * leader lock would only add a way for the cluster to have no producer at all for a minute — which is exactly the
48
+ * failure a nightly digest cannot afford.
49
+ *
50
+ * **No clock is trusted but the queue's.** Which schedules are due is the queue's answer, the fire instant is the
51
+ * one the schedule row already held, and the next one is computed from that row. A replica in Santiago and a
52
+ * replica in Frankfurt produce the same fires, once each, however far apart their clocks have drifted.
53
+ *
54
+ * **A fire is enqueued BEFORE the schedule moves on.** If this process dies between the two, the schedule is still
55
+ * due, the next sweep enqueues the same id — a no-op — and advances. Nothing is produced twice and nothing is
56
+ * lost; the ordering is the whole of that guarantee.
57
+ *
58
+ * **An outage does not replay as a storm.** The overdue fire becomes one job, and the schedule then jumps to the
59
+ * first occurrence after now, recording how many went by. A digest missed for a day sends once and says it missed
60
+ * 23 — rather than sending 23 times, which is how a scheduler that "catches up" turns an outage into an incident.
61
+ */
62
+ export declare const createScheduler: ({ queue, lookups, environment, pollMs, batch, maxAttempts, reconcileMs, spaces, onError }: SchedulerOptions) => Scheduler;
@@ -0,0 +1,125 @@
1
+ import { scheduleJobId, schedulesFor } from "./schedules.js";
2
+ import { cronFiresBetween, cronNextFire } from "@plitzi/sdk-shared/actions";
3
+ //#region src/modules/actions/jobs/scheduler.ts
4
+ var MINUTE_MS = 6e4;
5
+ /** Where a schedule is parked when its expression has no next fire at all. Four years out, which is the horizon
6
+ * {@link cronNextFire} searches: nothing inside it matched, so nothing inside it will. */
7
+ var PARK_MS = 2108160 * MINUTE_MS;
8
+ /**
9
+ * Turns due schedules into jobs. Nothing else.
10
+ *
11
+ * **There is no leader.** Every replica sweeps, and that is safe because a fire's job id is derived from the fire's
12
+ * own stored instant: two replicas reaching the same fire write the same id, and the queue keeps the first. A
13
+ * leader lock would only add a way for the cluster to have no producer at all for a minute — which is exactly the
14
+ * failure a nightly digest cannot afford.
15
+ *
16
+ * **No clock is trusted but the queue's.** Which schedules are due is the queue's answer, the fire instant is the
17
+ * one the schedule row already held, and the next one is computed from that row. A replica in Santiago and a
18
+ * replica in Frankfurt produce the same fires, once each, however far apart their clocks have drifted.
19
+ *
20
+ * **A fire is enqueued BEFORE the schedule moves on.** If this process dies between the two, the schedule is still
21
+ * due, the next sweep enqueues the same id — a no-op — and advances. Nothing is produced twice and nothing is
22
+ * lost; the ordering is the whole of that guarantee.
23
+ *
24
+ * **An outage does not replay as a storm.** The overdue fire becomes one job, and the schedule then jumps to the
25
+ * first occurrence after now, recording how many went by. A digest missed for a day sends once and says it missed
26
+ * 23 — rather than sending 23 times, which is how a scheduler that "catches up" turns an outage into an incident.
27
+ */
28
+ var createScheduler = ({ queue, lookups, environment = "main", pollMs = 15e3, batch = 200, maxAttempts = 3, reconcileMs = 15 * MINUTE_MS, spaces, onError = (error) => console.error("[Actions] schedule sweep failed:", error) }) => {
29
+ let timer;
30
+ let reconcileTimer;
31
+ let running = false;
32
+ const produce = async (schedule, at, result) => {
33
+ const { spaceId, actionId, cron, timezone, nextRunAt: fireAt } = schedule;
34
+ const id = scheduleJobId(spaceId, actionId, fireAt);
35
+ (await queue.enqueue({
36
+ id,
37
+ spaceId,
38
+ actionId,
39
+ environment: schedule.environment,
40
+ trigger: "schedule",
41
+ input: {},
42
+ dueAt: fireAt,
43
+ maxAttempts: schedule.maxAttempts
44
+ }) ? result.enqueued : result.duplicates).push(id);
45
+ const lateness = Math.max(fireAt, at.getTime());
46
+ const missed = cronFiresBetween(cron, new Date(fireAt), new Date(lateness), timezone);
47
+ /**
48
+ * The first occurrence strictly after `lateness` — which, fires being whole minutes, is the first at or after
49
+ * the minute following the one `lateness` falls in. Not `lateness + 1 minute`: `cronNextFire` rounds up to a
50
+ * whole minute, so a sweep seven seconds late would ask from 00:02:07, get 00:03, and an every-minute schedule
51
+ * would fire every other minute.
52
+ */
53
+ const next = cronNextFire(cron, /* @__PURE__ */ new Date(Math.floor(lateness / MINUTE_MS) * MINUTE_MS + MINUTE_MS), timezone);
54
+ await queue.advanceSchedule({
55
+ spaceId,
56
+ actionId,
57
+ from: fireAt,
58
+ to: next ? next.getTime() : lateness + PARK_MS,
59
+ missed
60
+ });
61
+ if (missed > 0) result.missed.push({
62
+ spaceId,
63
+ actionId,
64
+ count: missed
65
+ });
66
+ };
67
+ const sweep = async () => {
68
+ const result = {
69
+ enqueued: [],
70
+ duplicates: [],
71
+ missed: []
72
+ };
73
+ const at = await queue.now();
74
+ for (const schedule of await queue.dueSchedules(batch)) await produce(schedule, at, result);
75
+ return result;
76
+ };
77
+ const reconcile = async (spaceId) => {
78
+ const entries = await lookups.listActions?.(spaceId) ?? [];
79
+ const at = await queue.now();
80
+ await queue.putSchedules({
81
+ spaceId,
82
+ schedules: schedulesFor(entries, spaceId, environment, at, maxAttempts)
83
+ });
84
+ };
85
+ const scheduledSpaces = async () => lookups.listScheduledSpaces ? lookups.listScheduledSpaces() : spaces ?? [];
86
+ const reconcileAll = async () => {
87
+ for (const spaceId of await scheduledSpaces()) await reconcile(spaceId);
88
+ };
89
+ /** One pass at a time per replica: a sweep that outlives its interval must not have a second one land on top. */
90
+ const guarded = async (pass) => {
91
+ if (running) return;
92
+ running = true;
93
+ try {
94
+ await pass();
95
+ } catch (error) {
96
+ onError(error);
97
+ } finally {
98
+ running = false;
99
+ }
100
+ };
101
+ return {
102
+ sweep,
103
+ reconcile,
104
+ start: () => {
105
+ if (timer) return;
106
+ timer = setInterval(() => void guarded(sweep), pollMs + Math.floor(Math.random() * 1e3));
107
+ timer.unref();
108
+ if (!lookups.listScheduledSpaces && !spaces?.length) {
109
+ console.warn("[Actions] schedules are running but nothing says which spaces to watch. Name them as `action.jobs.spaces`, or answer `action.lookups.listScheduledSpaces`.");
110
+ return;
111
+ }
112
+ guarded(reconcileAll);
113
+ reconcileTimer = setInterval(() => void guarded(reconcileAll), reconcileMs);
114
+ reconcileTimer.unref();
115
+ },
116
+ stop: () => {
117
+ clearInterval(timer);
118
+ clearInterval(reconcileTimer);
119
+ timer = void 0;
120
+ reconcileTimer = void 0;
121
+ }
122
+ };
123
+ };
124
+ //#endregion
125
+ export { createScheduler };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import { ActionEntry, ActionScheduleInput, Environment } from '@plitzi/sdk-shared';
2
+ /** Attempts a scheduled job is given before it is left for an operator. Overridable per deployment. */
3
+ export declare const DEFAULT_MAX_ATTEMPTS = 3;
4
+ /**
5
+ * The schedule a document declares, or nothing.
6
+ *
7
+ * The cron and the zone are read through the same fields the runner used to read at every tick — but read ONCE,
8
+ * when the document is saved, and written to a row. What the tick then reads is a due time, not a JSON blob: the
9
+ * difference between one indexed query a minute and every action document in the deployment, parsed.
10
+ *
11
+ * A zone `Intl` does not know is dropped rather than silently treated as UTC. The validator already tells the
12
+ * author, and a schedule that fires at the wrong hour is worse than one that visibly never fires at all.
13
+ */
14
+ export declare const scheduleFor: (entry: ActionEntry, spaceId: number, environment: Environment, from: Date, maxAttempts?: number) => ActionScheduleInput | undefined;
15
+ /** Every schedule a space's actions declare, as of one instant. */
16
+ export declare const schedulesFor: (entries: ActionEntry[], spaceId: number, environment: Environment, from: Date, maxAttempts?: number) => ActionScheduleInput[];
17
+ /**
18
+ * The id a fire gets, and the whole of the exactly-once guarantee.
19
+ *
20
+ * Derived from the STORED fire instant rather than from any node's reading of the clock, so two replicas that
21
+ * disagree about what time it is still write the same id — and the second one's enqueue is a no-op.
22
+ */
23
+ export declare const scheduleJobId: (spaceId: number, actionId: string, fireAt: number) => string;
@@ -0,0 +1,47 @@
1
+ import { findTriggerNode, triggerParams } from "../runtime/triggers.js";
2
+ import { cronNextFire, isKnownTimeZone } from "@plitzi/sdk-shared/actions";
3
+ //#region src/modules/actions/jobs/schedules.ts
4
+ /** Attempts a scheduled job is given before it is left for an operator. Overridable per deployment. */
5
+ var DEFAULT_MAX_ATTEMPTS = 3;
6
+ /**
7
+ * The schedule a document declares, or nothing.
8
+ *
9
+ * The cron and the zone are read through the same fields the runner used to read at every tick — but read ONCE,
10
+ * when the document is saved, and written to a row. What the tick then reads is a due time, not a JSON blob: the
11
+ * difference between one indexed query a minute and every action document in the deployment, parsed.
12
+ *
13
+ * A zone `Intl` does not know is dropped rather than silently treated as UTC. The validator already tells the
14
+ * author, and a schedule that fires at the wrong hour is worse than one that visibly never fires at all.
15
+ */
16
+ var scheduleFor = (entry, spaceId, environment, from, maxAttempts = 3) => {
17
+ const node = findTriggerNode(entry.document.nodes, "schedule");
18
+ if (!node) return;
19
+ const params = triggerParams(node);
20
+ const cron = params.cron?.trim();
21
+ if (!cron) return;
22
+ const zone = params.timezone?.trim();
23
+ const timezone = zone && isKnownTimeZone(zone) ? zone : void 0;
24
+ const nextRunAt = cronNextFire(cron, from, timezone);
25
+ if (!nextRunAt) return;
26
+ return {
27
+ spaceId,
28
+ actionId: entry.id,
29
+ cron,
30
+ ...timezone ? { timezone } : {},
31
+ environment,
32
+ enabled: node.enabled,
33
+ nextRunAt: nextRunAt.getTime(),
34
+ maxAttempts
35
+ };
36
+ };
37
+ /** Every schedule a space's actions declare, as of one instant. */
38
+ var schedulesFor = (entries, spaceId, environment, from, maxAttempts = 3) => entries.map((entry) => scheduleFor(entry, spaceId, environment, from, maxAttempts)).filter((schedule) => schedule !== void 0);
39
+ /**
40
+ * The id a fire gets, and the whole of the exactly-once guarantee.
41
+ *
42
+ * Derived from the STORED fire instant rather than from any node's reading of the clock, so two replicas that
43
+ * disagree about what time it is still write the same id — and the second one's enqueue is a no-op.
44
+ */
45
+ var scheduleJobId = (spaceId, actionId, fireAt) => `schedule:${spaceId}:${actionId}:${fireAt}`;
46
+ //#endregion
47
+ export { DEFAULT_MAX_ATTEMPTS, scheduleFor, scheduleJobId, schedulesFor };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The clock of the store a queue lives in, read cheaply.
3
+ *
4
+ * Every comparison a queue makes — is this due, has this lease lapsed — is made against ONE clock, and a cluster whose
5
+ * replicas sit in different countries only agrees on the store's. Reading it on every call would put a round trip on
6
+ * the path of every claim and every heartbeat, so it is read once and then trusted for the ELAPSED time only: the one
7
+ * thing a wrong local clock still gets right is how long thirty seconds is.
8
+ */
9
+ export type StoreClock = {
10
+ /** The store's now, in epoch ms. */
11
+ now: () => Promise<number>;
12
+ /** Forgets the reading, so the next call takes it again — after a failover to another primary, or in a test. */
13
+ resync: () => void;
14
+ };
15
+ /** `read` answers the store's own time in epoch ms: `hello.localTime` on Mongo, `NOW(3)` on MySQL. */
16
+ export declare const createStoreClock: (read: () => Promise<number>, resyncMs?: number) => StoreClock;
@@ -0,0 +1,32 @@
1
+ //#region src/modules/actions/jobs/storeClock.ts
2
+ /** How long a reading is trusted before it is taken again. */
3
+ var RESYNC_MS = 3e4;
4
+ /** `read` answers the store's own time in epoch ms: `hello.localTime` on Mongo, `NOW(3)` on MySQL. */
5
+ var createStoreClock = (read, resyncMs = RESYNC_MS) => {
6
+ let offset = 0;
7
+ let readAt = Number.NEGATIVE_INFINITY;
8
+ let pending;
9
+ const sync = async () => {
10
+ const before = Date.now();
11
+ const storeTime = await read();
12
+ const after = Date.now();
13
+ offset = storeTime - Math.round((before + after) / 2);
14
+ readAt = after;
15
+ };
16
+ return {
17
+ now: async () => {
18
+ if (Date.now() - readAt >= resyncMs) {
19
+ pending ??= sync().finally(() => {
20
+ pending = void 0;
21
+ });
22
+ await pending;
23
+ }
24
+ return Date.now() + offset;
25
+ },
26
+ resync: () => {
27
+ readAt = Number.NEGATIVE_INFINITY;
28
+ }
29
+ };
30
+ };
31
+ //#endregion
32
+ export { createStoreClock };
@@ -0,0 +1,25 @@
1
+ import { ActionKvAdapter } from '../../types';
2
+ import { ActionJobQueue } from '@plitzi/sdk-shared';
3
+ /**
4
+ * What every {@link ActionJobQueue} must do, written once and run against each one this package ships.
5
+ *
6
+ * The memory queue, the Mongo helper and the MySQL helper are three implementations of one contract, and the contract
7
+ * is where a scheduling bug lives: a claim that is not atomic runs a job twice, a heartbeat that does not renew hands a
8
+ * running job to another replica, a settlement from a worker that lost its lease overwrites a live attempt. Asserting
9
+ * those once per store is what makes "swap the store" a one-line change rather than a new set of bugs.
10
+ *
11
+ * Real time, real stores: leases are tens of milliseconds and the tests wait them out, because the store's own clock
12
+ * is part of what is being tested.
13
+ */
14
+ export type QueueSubject = {
15
+ queue: ActionJobQueue;
16
+ /** Empties whatever the queue keeps, between tests. */
17
+ clear: () => Promise<void>;
18
+ };
19
+ export declare const describeJobQueue: (name: string, open: () => Promise<QueueSubject>, enabled?: boolean) => void;
20
+ export type KvSubject = {
21
+ kv: ActionKvAdapter;
22
+ clear: () => Promise<void>;
23
+ };
24
+ /** What every {@link ActionKvAdapter} must do: strings, a TTL the store enforces, and an increment nobody can race. */
25
+ export declare const describeKv: (name: string, open: () => Promise<KvSubject>, enabled?: boolean) => void;
@@ -0,0 +1,62 @@
1
+ import { ActionsModule } from '../index';
2
+ import { ActionLookups } from '../types';
3
+ import { ActionJobQueue } from '@plitzi/sdk-shared';
4
+ export type JobWorkerOptions = {
5
+ queue: ActionJobQueue;
6
+ lookups: ActionLookups;
7
+ module: ActionsModule;
8
+ /**
9
+ * How many jobs this replica runs AT ONCE.
10
+ *
11
+ * The number that decides whether five hundred queued jobs take five minutes or an hour, and it is a deployment's
12
+ * to set because only it knows what its jobs do: flows that wait on somebody else's API are cheap to hold many of,
13
+ * flows that render or transform are not. Zero makes this replica a producer only — which is how a deployment
14
+ * dedicates a pod to running jobs and leaves the ones serving pages alone.
15
+ *
16
+ * It does not widen the run guards. A replica whose `concurrency.perSpace` is 10 will hold 10 runs for one space
17
+ * however many workers it has, and the rest go back to the queue and come round again.
18
+ */
19
+ workers?: number;
20
+ /** How often a worker with a free slot looks for work. Default 1s. */
21
+ pollMs?: number;
22
+ /**
23
+ * How long a claim is held before another worker may take the job over.
24
+ *
25
+ * The failover window, in other words: a replica that is killed has its jobs picked up this long afterwards. It
26
+ * is renewed every third of its length while a job runs, so a long flow keeps its claim and a dead process does
27
+ * not. Shorter is a faster recovery and a higher chance of taking a job away from a worker that was merely
28
+ * stalled; the single-flight key is what makes that safe rather than doubled.
29
+ */
30
+ leaseMs?: number;
31
+ /** First retry delay, doubling per attempt, and the ceiling it doubles up to. */
32
+ backoff?: {
33
+ baseMs?: number;
34
+ maxMs?: number;
35
+ };
36
+ /** Names this replica in the job history, so a bad one is identifiable. Defaults to the process and a random id. */
37
+ workerId?: string;
38
+ onError?: (error: unknown) => void;
39
+ };
40
+ export type JobWorker = {
41
+ /** One pass: claim what fits in the free slots and start it. Exposed for a deployment that drives its own loop. */
42
+ poll: () => Promise<number>;
43
+ /**
44
+ * Renews every claim this worker holds, and aborts the ones an operator asked to cancel.
45
+ *
46
+ * Exposed beside `poll` for the same reason: a deployment driving its own loop owns both halves, and a worker
47
+ * that polls without beating loses its jobs to the next replica halfway through them.
48
+ */
49
+ beat: () => Promise<void>;
50
+ start: () => void;
51
+ /**
52
+ * Stops claiming, then waits for every job this worker is running to FINISH — however long past its lease that is.
53
+ *
54
+ * The heartbeat keeps renewing their claims meanwhile, so no other replica takes a job over while this one is still
55
+ * running it, and an operator's cancel still reaches it. Each wait is bounded by the run's own timeout, which is
56
+ * the number an orchestrator's grace period has to cover: a pod killed before that loses the run to a lease that
57
+ * lapses, and another replica runs it again.
58
+ */
59
+ stop: () => Promise<void>;
60
+ inFlight: () => number;
61
+ };
62
+ export declare const createJobWorker: ({ queue, lookups, module, workers, pollMs, leaseMs, backoff, workerId, onError }: JobWorkerOptions) => JobWorker;