@connextar/house 0.10.0 → 0.12.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.
Files changed (46) hide show
  1. package/README.md +590 -0
  2. package/dist/jobs/counts.d.ts +82 -0
  3. package/dist/jobs/counts.d.ts.map +1 -0
  4. package/dist/jobs/counts.js +112 -0
  5. package/dist/jobs/counts.js.map +1 -0
  6. package/dist/jobs/cron-auth.d.ts +44 -0
  7. package/dist/jobs/cron-auth.d.ts.map +1 -0
  8. package/dist/jobs/cron-auth.js +20 -0
  9. package/dist/jobs/cron-auth.js.map +1 -0
  10. package/dist/jobs/health.d.ts +170 -0
  11. package/dist/jobs/health.d.ts.map +1 -0
  12. package/dist/jobs/health.js +149 -0
  13. package/dist/jobs/health.js.map +1 -0
  14. package/dist/jobs/index.d.ts +27 -0
  15. package/dist/jobs/index.d.ts.map +1 -0
  16. package/dist/jobs/index.js +27 -0
  17. package/dist/jobs/index.js.map +1 -0
  18. package/dist/jobs/job-health-list.d.ts +78 -0
  19. package/dist/jobs/job-health-list.d.ts.map +1 -0
  20. package/dist/jobs/job-health-list.js +119 -0
  21. package/dist/jobs/job-health-list.js.map +1 -0
  22. package/dist/jobs/prune.d.ts +45 -0
  23. package/dist/jobs/prune.d.ts.map +1 -0
  24. package/dist/jobs/prune.js +58 -0
  25. package/dist/jobs/prune.js.map +1 -0
  26. package/dist/jobs/react-index.d.ts +19 -0
  27. package/dist/jobs/react-index.d.ts.map +1 -0
  28. package/dist/jobs/react-index.js +18 -0
  29. package/dist/jobs/react-index.js.map +1 -0
  30. package/dist/jobs/registry.d.ts +119 -0
  31. package/dist/jobs/registry.d.ts.map +1 -0
  32. package/dist/jobs/registry.js +87 -0
  33. package/dist/jobs/registry.js.map +1 -0
  34. package/dist/jobs/scheduler.d.ts +100 -0
  35. package/dist/jobs/scheduler.d.ts.map +1 -0
  36. package/dist/jobs/scheduler.js +297 -0
  37. package/dist/jobs/scheduler.js.map +1 -0
  38. package/dist/jobs/store.d.ts +119 -0
  39. package/dist/jobs/store.d.ts.map +1 -0
  40. package/dist/jobs/store.js +87 -0
  41. package/dist/jobs/store.js.map +1 -0
  42. package/dist/jobs/windows.d.ts +66 -0
  43. package/dist/jobs/windows.d.ts.map +1 -0
  44. package/dist/jobs/windows.js +130 -0
  45. package/dist/jobs/windows.js.map +1 -0
  46. package/package.json +9 -1
package/README.md CHANGED
@@ -60,6 +60,18 @@ is one right answer to that — which is exactly the shape of thing that belongs
60
60
  here and nowhere else. The module is the union of the five, not a copy of the
61
61
  best one.
62
62
 
63
+ `jobs`, in 0.11.0, is the same case with two copies. AltEd and Quickie grew
64
+ their scheduled jobs from one seed and each proved something the other lacked —
65
+ AltEd a switch with a name and a date against it, Quickie a Run now fenced to the
66
+ caller's own organisation — and both printed a job's result as its variable
67
+ names, so production read "0 chargesRaised · 0 totalOutstanding", with an amount
68
+ of money shown as a number of things. The module is their union, with that
69
+ made impossible rather than fixed: a count reaches the screen only through a
70
+ declaration that says its words and its kind. 0.12.0 lets a job offer Run now
71
+ to owners and operators both — Quickie's operators could not re-run a failed
72
+ overnight sweep of a per-organisation job — and prunes the ledger without ever
73
+ deleting a row health is read from.
74
+
63
75
  ## Modules
64
76
 
65
77
  ### `@connextar/house/errors`
@@ -730,6 +742,584 @@ app that imports `/ai` needs them; `/ai/config` needs none. The SDKs refuse to r
730
742
  environment, so a Vitest suite that calls the harness needs
731
743
  `// @vitest-environment node` on the file.
732
744
 
745
+ ### `@connextar/house/jobs`, `/jobs/react`
746
+
747
+ Scheduled jobs: declared once, run once per window, switched off and on by
748
+ name, and honest about their health. `/jobs` is server code — the registry, the
749
+ dispatcher, Run now, the switch, health, the words for counts and the tick
750
+ route's secret check. `/jobs/react` is the health screen's list, and re-exports
751
+ the wording and types a client component needs, so nothing in the browser ever
752
+ imports `/jobs` (which reaches `node:crypto`; `src/jobs/entry-points.test.ts`
753
+ holds both sides of that line against the built output).
754
+
755
+ AltEd and Quickie each had this, from one seed. What each proved, and what each
756
+ got wrong:
757
+
758
+ | Concern | AltEd | Quickie | Here |
759
+ | ----------------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
760
+ | Registry | `JobSpec`: name, label, `when` in words, scope, `maxAgeMs`, `attempt` + `windowKey`, `run` | The same, without `when` | `defineJob`: id, label, cadence, one `window`, threshold, timeout, switchable, Run now rule, counts |
761
+ | Window and idempotency | Unique `(job, windowKey)` claim; tracker keyed on the UK date after a twice-sent day | The same claim; hour, day, six-hour and month keys | The same claim, one function instead of two; calendar helpers take a time zone and keep both apps' keys |
762
+ | Missed window | Weekly digest only on Monday — a failed Monday loses the week | Monthly report only on the 1st — a failed 1st loses the month | `weekly` and `monthly` stay open (after `from` each day) until the period ends |
763
+ | One job failing | Isolated — but a failed claim or ledger write threw out of the loop | The same | Isolated, store failures included; each job's result says what happened |
764
+ | Ledger | `CronRun`: job, window, start, finish, ok, counts, error | Identical table | The same eight columns plus `tenant_id` and `triggered_by`; the app owns it behind `JobStore` |
765
+ | Health states | Paused · Never run · Errored · Stale · Healthy | Never run · Errored · Stale · Healthy | Paused · Never run · **Running** · Failed · Stale · Healthy — derived, never stored |
766
+ | A run in progress | Shown as **Errored** (`ok` is false until it finishes); one cut off stays so, with no reason | The same | Running inside its timeout; past it, Failed — "never finished, probably cut off" |
767
+ | Staleness | Last OK older than `maxAgeMs` (strictly) | The same — and a tenant's Run now reset it for everyone | Last successful _whole_ run older than the threshold; healthy exactly at it |
768
+ | Pause / resume | Per job, who and when, audited; harvest's switch shared with its own screen | None | Per job, who and when; platform authority; audit in the adapter's transaction; tick says it skipped |
769
+ | Run now | `runJobNow` exists but has no caller, and every job is `global`, so it would refuse them all | Owner-only, scoped to the caller's organisation; no cool-down, and `force` skips reminder throttles | `tenant`, `platform` or both; refused outside it, while paused, while running, in its cool-down |
770
+ | Jobs on their own clock | Harvest (10 min) and imports (1 min): switchable, but record no runs, so no health | — | `schedule: "own"` + `runOwn`: the switch and the ledger, with or without a window |
771
+ | Errors | Message stored; shown to administrators | Message stored; shown to **every** organisation's owner, whoever's data it came from | Message stored; a tenant viewer sees whether a sweep failed, not its words or its numbers |
772
+ | `CRON_SECRET` | Bearer or `?secret=`; `timingSafeEqual` after a length check | Bearer (prefix optional) or `?secret=`; SHA-256 digests; dev secret refused in production | Bearer required; digests; dev secret refused; `?secret=` only when a route asks |
773
+ | Counts | `Record<string, number>` printed as keys; `totalOutstanding` is pounds shown as a count | The same, in the card and the Run now toast ("3 remindersSent") | Declared: words (singular and plural) and a kind — count, money (currency and unit) or duration |
774
+ | Legacy routes | — | Three per-job routes still live, bypassing the ledger; `?force=1` sweeps every tenant | One tick route; nothing else runs a tick job |
775
+
776
+ **Declaring the jobs** — once, beside the store adapter, server-only:
777
+
778
+ ```ts
779
+ // lib/jobs/index.ts
780
+ import "server-only";
781
+ import { createScheduler, daily, defineJob, defineJobs, hourly } from "@connextar/house/jobs";
782
+
783
+ import { jobStore } from "./store";
784
+
785
+ const HOUR = 60 * 60 * 1000;
786
+
787
+ export const jobs = defineJobs([
788
+ defineJob({
789
+ id: "billing-housekeeping", // stored: never rename one
790
+ label: "Billing & payment reminders",
791
+ cadence: "Daily",
792
+ window: daily(),
793
+ staleAfterMs: 26 * HOUR,
794
+ counts: {
795
+ chargesRaised: { label: { one: "charge raised", other: "charges raised" } },
796
+ sent: { label: { one: "family reminded", other: "families reminded" } },
797
+ skipped: { label: "already reminded this week" },
798
+ totalOutstanding: { label: "outstanding", kind: "money", currency: "GBP", unit: "major" },
799
+ },
800
+ run: async ({ now }) => {
801
+ const { charged } = await rollUpCharges(now);
802
+ return { chargesRaised: charged, ...(await sendPaymentReminders(now)) };
803
+ },
804
+ }),
805
+ defineJob({
806
+ id: "payment-reminders",
807
+ label: "Payment reminders",
808
+ cadence: "Daily, from 09:00",
809
+ window: daily({ from: 9 }),
810
+ staleAfterMs: 26 * HOUR,
811
+ // An owner sweeps their own organisation, and nobody else's; an operator re-runs the sweep over all of them.
812
+ // One scope — `{ scope: "tenant" }` — is declared as before.
813
+ runNow: { scope: ["tenant", "platform"] },
814
+ counts: { remindersSent: { label: { one: "reminder sent", other: "reminders sent" } } },
815
+ // `tenantId` is null for the schedule's sweep and a platform Run now, and always set for an owner's.
816
+ run: async ({ now, tenantId }) => {
817
+ const { remindersSent } = await sendPaymentReminders(now, { organizationId: tenantId ?? undefined });
818
+ return { remindersSent };
819
+ },
820
+ }),
821
+ defineJob({
822
+ id: "harvest",
823
+ label: "Harvest",
824
+ cadence: "Every 10 minutes",
825
+ schedule: "own", // its own route and clock; the tick never runs it
826
+ staleAfterMs: HOUR,
827
+ counts: { sites: { label: { one: "site read", other: "sites read" } } },
828
+ run: async () => ({ sites: (await runHarvest({ budgetMs: 150_000, maxSites: 4 })).sites.length }),
829
+ }),
830
+ ]);
831
+
832
+ export const scheduler = createScheduler({ jobs, store: jobStore });
833
+ ```
834
+
835
+ `defineJobs` checks the list when the module loads and throws every problem at
836
+ once (`jobDefinitionProblems` returns them as sentences, for a test). `defineJob`
837
+ holds `run` to its counts at compile time: every declared count must be
838
+ reported, and nothing undeclared may be. At run time an undeclared key is dropped
839
+ before it is stored, and one in an old row is not shown — there is no fallback
840
+ that could print a variable name.
841
+
842
+ **The routes** — each app answers in its own envelope; the module returns
843
+ decisions, not `Response`s:
844
+
845
+ ```ts
846
+ // app/api/cron/tick/route.ts — the one entry in vercel.json's crons
847
+ export async function GET(request: Request) {
848
+ const auth = checkCronRequest(request, { publicSecrets: ["dev-cron-secret"] });
849
+ if (!auth.ok) return apiError(auth.message, auth.status);
850
+ const results = await scheduler.tick();
851
+ return apiSuccess({ results }, summariseTick(results)); // "2 ran, 1 failed, 1 paused, 5 not due"
852
+ }
853
+
854
+ // app/api/cron/harvest/route.ts — a job on its own clock
855
+ const result = await scheduler.runOwn("harvest");
856
+
857
+ // app/api/cron/run/route.ts — Run now, behind the session
858
+ const outcome = await scheduler.runNow(body.job, {
859
+ actor: { id: user.id, name: user.name },
860
+ platform: user.platformAdmin,
861
+ tenantId: user.organizationId,
862
+ scope: body.scope, // the button pressed, on a job that offers both; optional
863
+ });
864
+ if (outcome.status === "refused") return apiError(outcome.message, refusalStatus(outcome.reason));
865
+ if (outcome.status === "failed") return apiError(outcome.message, 500);
866
+ return apiSuccess(outcome, outcome.message); // "Ran Payment reminders — 3 reminders sent."
867
+
868
+ // app/api/admin/cron/route.ts — the switch
869
+ const outcome = await scheduler.setPaused(body.job, body.action === "pause", {
870
+ actor: { id: admin.id, name: admin.name },
871
+ platform: true,
872
+ });
873
+ if (outcome.status === "refused") return apiError(outcome.message, refusalStatus(outcome.reason));
874
+ return apiSuccess(outcome, outcome.message);
875
+ ```
876
+
877
+ **The screen** — the server works out the view for whoever is looking; the list
878
+ draws it inside the app's own card:
879
+
880
+ ```tsx
881
+ // app/dashboard/settings/page.tsx (server)
882
+ const jobs = await scheduler.views({ platform: isAdmin, tenantId: user.organizationId });
883
+
884
+ // components/settings/automation-card.tsx
885
+ ("use client");
886
+ import { JobHealthList } from "@connextar/house/jobs/react";
887
+
888
+ <JobHealthList
889
+ jobs={jobs}
890
+ onSwitch={async (job, on) => {
891
+ await commitApi("/api/admin/cron", { job, action: on ? "resume" : "pause" });
892
+ }}
893
+ onRunNow={(job, scope) => commitApi("/api/cron/run", { job, scope })}
894
+ runNowWords={{ tenant: "your organisation", platform: "every organisation" }}
895
+ onSettled={router.refresh}
896
+ extra={(job) => (job.id === "harvest" ? <Link href={routes.harvest}>What it searches next</Link> : null)}
897
+ />;
898
+ ```
899
+
900
+ `onSwitch` and `onRunNow` throw to refuse — the ux layer's one failure
901
+ contract. The switch is `useOptimisticValue`: it moves on the click, the badge
902
+ moves with it (to the job's health as if on, which the view carries), and a
903
+ refusal puts both back and reports the reason through the
904
+ `ActionFeedbackProvider`. No other row is disabled while one saves. Run now is
905
+ `useKeyedAction`: that row's button is busy (`data-busy`), the route's message is
906
+ said, and the screen refreshes whether the run worked or not, since a failure is
907
+ on the ledger too. A row draws one button per scope the view offers
908
+ (`runNowScopes`) and hands `onRunNow` the one pressed; nearly every viewer holds
909
+ one — an operator the platform run, an owner their own — and sees "Run now". A
910
+ viewer who holds both sees two, "Run for every account" and "Run for your
911
+ account", in the app's words through `runNowWords`. Dates are drawn in
912
+ `timeZone` (default `Europe/London`), never the runtime's own — the server
913
+ draws the list in UTC and the browser again in BST, and two times in one
914
+ element is a hydration error. `classNames` replaces any part's look.
915
+
916
+ The rules it keeps:
917
+
918
+ - **One run per job per window.** The window is `(now) => key | null`; the
919
+ store's unique index on `(job, windowKey)` is the idempotency. A second tick
920
+ in the window finds the key taken and says `already-ran`. `hourly`,
921
+ `everyHours`, `daily`, `weekly` and `monthly` produce the keys both apps
922
+ already store, so moving onto them runs nothing twice.
923
+ - **One failure stops nothing else.** A job that throws, a store that cannot
924
+ claim, a window function with a bug — each is that job's `failed` result and
925
+ its error on the ledger, and the next job runs. A run whose result could not
926
+ be written says so rather than calling the work failed.
927
+ - **A paused job's tick skips it and says so** ("switched off by AltEd Admin on
928
+ 29 Sept 2026; not started"). Its window is not claimed, so switched back on
929
+ inside the same window it still runs. If the switches cannot be read at all, no
930
+ switchable job starts: a switch nobody can read must not send a job out
931
+ spending. `pausedFromStored` reads a stored value AltEd's way — a row that
932
+ exists but does not parse is _paused_.
933
+ - **Switching takes platform authority** — it stops a job for every tenant — and
934
+ records who and when. `JobStore.writeSwitch` is where the audit entry goes,
935
+ in the same transaction as the switch. A switch to where it already is writes
936
+ nothing.
937
+ - **Health is derived, in this order:** paused · never run · running · failed ·
938
+ stale · healthy. A run past its timeout (default 15 minutes) and still
939
+ unfinished is failed, and says it never finished. Stale means no successful
940
+ run over every tenant for longer than the threshold — exactly at the threshold
941
+ is still healthy — and a job with `staleAfterMs: null` (one that runs only
942
+ when there is work) is never stale.
943
+ - **Run now is scoped and fenced.** `tenant` sweeps the caller's own tenant and
944
+ needs one; `platform` sweeps everything and needs platform authority. It is
945
+ refused while the job is off, while a run it would overlap is in flight (the
946
+ schedule's sweep covers the caller's tenant too), and within its cool-down
947
+ (default ten minutes, per job and scope, and per tenant for tenant runs) — a
948
+ manual run sets `force`, so a second press would otherwise send the whole
949
+ batch again. `force` is for a job's hour gates; a per-recipient throttle
950
+ should stay on.
951
+ - **A job may offer both** — `runNow: { scope: ["tenant", "platform"] }` —
952
+ and a single scope is declared as it always was. The caller's `scope` says
953
+ which was pressed; left out, it is the caller's tenant when they have one and
954
+ every tenant when they do not, and it never grants anything `platform` and
955
+ `tenantId` do not. The scopes are fenced from each other: separate
956
+ cool-downs, and neither starts while the other is in flight — a tenant run
957
+ waits for a platform run, and a platform run waits for any tenant's
958
+ (`JobStore.latestUnfinishedRun`, which `createScheduler` requires the moment
959
+ one job offers both). A platform Run now is a run over every tenant, so it
960
+ resets the job's health for everyone, as the schedule's does; a tenant's never
961
+ does. `outcome.scope` says which ran, for the app's audit entry.
962
+ - **A tenant sees their own.** To a tenant viewer, a sweep over every tenant
963
+ shows whether it ran and when, but not its numbers (which are everyone's) or
964
+ its error (which may name another organisation's data). Their own Run now
965
+ shows in full. Other tenants' runs never appear, and none of them resets the
966
+ job's staleness.
967
+ - **Every count is declared.** A label (the words after the number; singular and
968
+ plural where they differ) and a kind: `count`, `money` (a currency _and_ a
969
+ unit — `major` for AltEd's pounds, `minor` for pence — because a default would
970
+ be out by a hundred for one of them), or `duration`. Money is formatted by
971
+ `Intl` in its currency, which also rounds away a summed decimal's float noise.
972
+ - **The tick's door** (`checkCronRequest`): an unset `CRON_SECRET` refuses with
973
+ 503; the secret is compared as SHA-256 digests with `timingSafeEqual`; the
974
+ Bearer scheme is required; `publicSecrets` (the committed development value)
975
+ are refused in production; `?secret=` is accepted only with
976
+ `allowQuery: true`, because an address lands in request logs.
977
+
978
+ **Storage.** The package owns no schema. The app implements `JobStore` — claim,
979
+ finish, latest run, read switches, write a switch, and, since 0.12.0, the latest
980
+ unfinished run over any tenant (for a job with both Run now scopes) and pruning
981
+ — over its own tables. The two new methods are optional in the type, so an
982
+ 0.11.0 adapter still compiles; `createScheduler` and `pruneJobRuns` say by name
983
+ what is missing when a feature needs one. This is
984
+ the reference model; both apps' existing `cron_run` tables are it minus two
985
+ nullable columns, so every row they hold maps across as it is:
986
+
987
+ ```prisma
988
+ /// One row per job per window: the claim that makes a second tick skip, and the result.
989
+ model CronRun {
990
+ id String @id @default(cuid())
991
+ job String @db.VarChar(60)
992
+ /// The window, e.g. "2026-09-30", or "manual:tenant:<id>:<time>" for a Run now.
993
+ windowKey String @map("window_key") @db.VarChar(120)
994
+ /// Null: a run over every tenant. Otherwise the one tenant a Run now swept.
995
+ tenantId String? @map("tenant_id") @db.VarChar(64)
996
+ /// Who pressed Run now, by name. Null for the schedule.
997
+ triggeredBy String? @map("triggered_by") @db.VarChar(120)
998
+ startedAt DateTime @default(now()) @map("started_at")
999
+ finishedAt DateTime? @map("finished_at")
1000
+ ok Boolean @default(false)
1001
+ /// The declared counts, e.g. { "remindersSent": 3 }.
1002
+ counts Json?
1003
+ error String? @db.Text
1004
+
1005
+ @@unique([job, windowKey])
1006
+ @@index([job, startedAt])
1007
+ @@index([job, tenantId, startedAt])
1008
+ @@map("cron_run")
1009
+ }
1010
+
1011
+ /// A job switched off or on — the latest change. No row means on.
1012
+ model CronSwitch {
1013
+ job String @id @db.VarChar(60)
1014
+ paused Boolean
1015
+ switchedAt DateTime @map("switched_at")
1016
+ switchedById String? @map("switched_by_id")
1017
+ switchedByName String @map("switched_by_name") @db.VarChar(120)
1018
+
1019
+ @@map("cron_switch")
1020
+ }
1021
+ ```
1022
+
1023
+ For an app that already has `cron_run`:
1024
+
1025
+ ```sql
1026
+ ALTER TABLE "cron_run" ADD COLUMN "tenant_id" VARCHAR(64), ADD COLUMN "triggered_by" VARCHAR(120);
1027
+ CREATE INDEX "cron_run_job_tenant_id_started_at_idx" ON "cron_run"("job", "tenant_id", "started_at");
1028
+ ```
1029
+
1030
+ ```ts
1031
+ // lib/jobs/store.ts
1032
+ import "server-only";
1033
+ import type { JobStore } from "@connextar/house/jobs";
1034
+
1035
+ import { Prisma } from "@/lib/db/generated/client";
1036
+ import { prisma } from "@lib/db/prisma";
1037
+
1038
+ export const jobStore: JobStore = {
1039
+ async claimRun({ job, windowKey, tenantId, by, startedAt }) {
1040
+ try {
1041
+ const row = await prisma.cronRun.create({ data: { job, windowKey, tenantId, triggeredBy: by, startedAt } });
1042
+ return row.id;
1043
+ } catch (error) {
1044
+ // The unique index refusing a second claim is the answer "already ran", not a failure.
1045
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") return null;
1046
+ throw error;
1047
+ }
1048
+ },
1049
+ async finishRun(id, { finishedAt, ok, counts, error }) {
1050
+ await prisma.cronRun.update({ where: { id }, data: { finishedAt, ok, error, ...(counts ? { counts } : {}) } });
1051
+ },
1052
+ async latestRun({ job, tenants, ok, manualOnly }) {
1053
+ const row = await prisma.cronRun.findFirst({
1054
+ where: {
1055
+ job,
1056
+ OR: tenants.map((tenantId) => ({ tenantId })), // `{ tenantId: null }` matches the whole-tenant runs
1057
+ ...(ok ? { ok: true } : {}),
1058
+ ...(manualOnly ? { windowKey: { startsWith: "manual:" } } : {}),
1059
+ },
1060
+ // `id` breaks a tie the way `pruneRuns` does, so the row read as latest is the row kept as latest.
1061
+ orderBy: [{ startedAt: "desc" }, { id: "desc" }],
1062
+ });
1063
+ return row && { ...row, by: row.triggeredBy };
1064
+ },
1065
+ // Since 0.12.0 — needed once a job's Run now offers both scopes.
1066
+ async latestUnfinishedRun(job) {
1067
+ const row = await prisma.cronRun.findFirst({
1068
+ where: { job, finishedAt: null }, // any tenant: a platform run must wait for every tenant's
1069
+ orderBy: [{ startedAt: "desc" }, { id: "desc" }],
1070
+ });
1071
+ return row && { ...row, by: row.triggeredBy };
1072
+ },
1073
+ // Since 0.12.0 — `pruneJobRuns` calls it from the housekeeping job. One statement, at most `limit` rows, oldest
1074
+ // first. `PARTITION BY` groups every NULL tenant together, which is exactly "the runs over every tenant".
1075
+ async pruneRuns({ startedBefore, limit }) {
1076
+ return prisma.$executeRaw`
1077
+ DELETE FROM "cron_run" WHERE "id" IN (
1078
+ SELECT "id" FROM (
1079
+ SELECT "id", "ok", "started_at", "finished_at",
1080
+ ROW_NUMBER() OVER (PARTITION BY "job", "tenant_id" ORDER BY "started_at" DESC, "id" DESC) AS "nth",
1081
+ ROW_NUMBER() OVER (PARTITION BY "job", "tenant_id", "ok" ORDER BY "started_at" DESC, "id" DESC) AS "nth_of_kind"
1082
+ FROM "cron_run"
1083
+ ) AS "ranked"
1084
+ WHERE "started_at" < ${startedBefore}
1085
+ AND "finished_at" IS NOT NULL -- never a run still under way
1086
+ AND "nth" > 1 -- never the latest run of a job for its tenant
1087
+ AND NOT ("ok" AND "nth_of_kind" = 1) -- nor its latest success
1088
+ ORDER BY "started_at", "id"
1089
+ LIMIT ${limit}
1090
+ )`;
1091
+ },
1092
+ async readSwitches() {
1093
+ const rows = await prisma.cronSwitch.findMany();
1094
+ return new Map(rows.map((r) => [r.job, { paused: r.paused, at: r.switchedAt, by: r.switchedByName }]));
1095
+ },
1096
+ async writeSwitch({ job, label, paused, at, by }) {
1097
+ await prisma.$transaction([
1098
+ prisma.cronSwitch.upsert({
1099
+ where: { job },
1100
+ create: { job, paused, switchedAt: at, switchedById: by.id, switchedByName: by.name },
1101
+ update: { paused, switchedAt: at, switchedById: by.id, switchedByName: by.name },
1102
+ }),
1103
+ prisma.auditLog.create({
1104
+ data: {
1105
+ actorId: by.id,
1106
+ actorName: by.name,
1107
+ action: paused ? "cron.paused" : "cron.resumed",
1108
+ entityType: "cron_job",
1109
+ entityId: job,
1110
+ summary: `${paused ? "Paused" : "Resumed"} ${label}`,
1111
+ },
1112
+ }),
1113
+ ]);
1114
+ },
1115
+ };
1116
+ ```
1117
+
1118
+ `createMemoryJobStore()` is the same contract in memory, unique index and
1119
+ pruning rules included, for the app's own tests of its binding.
1120
+
1121
+ **Pruning the ledger.** Every tick and every own-clock call writes a row —
1122
+ AltEd about 150 a day — and a Run now row keeps the name of whoever pressed it.
1123
+ `pruneJobRuns(store, { now, retentionDays, batchLimit })` deletes the old ones
1124
+ through `JobStore.pruneRuns`, from the app's **own housekeeping job**, and
1125
+ reports what it removed as a declared count:
1126
+
1127
+ ```ts
1128
+ import { JOB_RUN_PRUNE_COUNTS, pruneJobRuns } from "@connextar/house/jobs";
1129
+
1130
+ defineJob({
1131
+ id: "rate-limit-prune",
1132
+ label: "Housekeeping",
1133
+ // …
1134
+ counts: { windowsPruned: { label: "…" }, ...JOB_RUN_PRUNE_COUNTS }, // "12 old job runs removed"
1135
+ run: async ({ now }) => ({ windowsPruned: await pruneRateLimits(), ...(await pruneJobRuns(jobStore, { now })) }),
1136
+ });
1137
+ ```
1138
+
1139
+ - **Retention** is 90 days by default, in days (at least 1), so a number cannot
1140
+ be read as milliseconds and empty the ledger. A run exactly at the boundary is
1141
+ kept.
1142
+ - **Always kept, however old:** per job, the latest run and the latest
1143
+ successful run — once for the runs over every tenant (the schedule's and
1144
+ platform Run nows), and once for each tenant's own. Health, the Last run and
1145
+ Last success lines and an owner's own view are read from exactly these, so a
1146
+ monthly job that last ran 120 days ago still says so rather than "Never run",
1147
+ and a job whose last run failed still shows when it last worked.
1148
+ - **Never a run still under way** — an unfinished row is never deleted. (A run
1149
+ cut off by the platform stays as the evidence; there are few.)
1150
+ - **Bounded:** at most `batchLimit` rows a call (default 1,000), oldest first, in
1151
+ one statement, so a housekeeping run never holds the table. What is left goes
1152
+ the next day; calling it again straight away deletes only that. A long
1153
+ history is caught up a batch a day — or raise `batchLimit` for the first run.
1154
+
1155
+ Choosing the retention: keep it longer than the longest window (a monthly job's
1156
+ 33 days), so the ledger holds a whole period of every job to investigate; and
1157
+ where the privacy policy names a period for operator activity, use that — the
1158
+ `triggered_by` names are why it matters. The rows always kept outlive it, name
1159
+ included; a quiet job's single last run is the price of its health staying
1160
+ true.
1161
+
1162
+ **What should not be a scheduled job.**
1163
+
1164
+ - **Anything a person is waiting for.** A report they asked for, a model call,
1165
+ an import step with its page open — do it in the request or straight after it.
1166
+ A tick is up to an hour away, and a spinner cannot wait that long.
1167
+ - **A reaction to an event.** A payment landing, a sign-up, a status change:
1168
+ handle it where it happens. A sweep that looks for "anything that changed" is
1169
+ a webhook written late and paid for on every tick.
1170
+ - **A job per record, or per tenant.** A reminder due seven days after an
1171
+ invoice is one daily sweep over "due today", not a job per invoice. The
1172
+ registry is a short list a person reads on one screen.
1173
+ - **Anything that must happen at a moment.** Windows are as coarse as the tick —
1174
+ AltEd's runs hourly, Quickie's every fifteen minutes. "At 09:00 sharp" is a
1175
+ queue with a due time, not a window.
1176
+ - **Work longer than one request.** A job that can outlive the platform's time
1177
+ limit is cut off and reads as never finished. Give it a budget and let the
1178
+ next window continue — AltEd's harvest stops starting new sites after two and
1179
+ a half minutes — or give it its own runner with `schedule: "own"`.
1180
+ - **Housekeeping a query already does.** An expired row the reads already ignore
1181
+ does not need deleting on a schedule unless something promises it will be
1182
+ gone — a retention period in a privacy policy does, and is then a job.
1183
+
1184
+ **Adopting it — AltEd.** Nothing changes in what runs or when:
1185
+
1186
+ 1. **Jobs.** Each `JobSpec` becomes a `defineJob`: `name` → `id` (unchanged, so
1187
+ the ledger and the `cron:<name>` switches still match), `when` → `cadence`,
1188
+ `maxAgeMs` → `staleAfterMs`, `attempt` + `windowKey` → one `window` with the
1189
+ same key: `daily()` for billing and housekeeping (the UTC date),
1190
+ `daily({ timeZone: "Europe/London" })` for the tracker and class sessions
1191
+ (`todayKey`), `daily({ from: 7, timeZone: "Europe/London" })` for the admin
1192
+ summary (its London date equals the UTC date from 07:00). The hourly
1193
+ moderation sweep's key is `2026-09-30-14` where `hourly()` gives
1194
+ `2026-09-30T14` — keep a one-line custom window, or accept one extra sweep in
1195
+ the cutover hour (it re-reads flagged messages, which is harmless). The weekly
1196
+ digest keeps ``(now) => (isMondayMorning(now) ? `digest-${todayKey(now)}` : null)``,
1197
+ or moves to `weekly({ from: 7, timeZone: "Europe/London" })` — which also
1198
+ catches up a missed Monday — cut over on any day but a Monday.
1199
+ 2. **Counts.** Declare every key each job already returns, under the same name,
1200
+ so every stored row reads: `totalOutstanding` as
1201
+ `{ kind: "money", currency: "GBP", unit: "major" }` (it is pounds, summed from
1202
+ the decimal column), the rest as counts with their words.
1203
+ 3. **Run now.** `scope: "global"` becomes `runNow: false` — what it effectively
1204
+ was, since `runJobNow` never had a caller. Where an administrator should be
1205
+ able to run one (the admin summary, class sessions, the tracker), give it
1206
+ `runNow: { scope: "platform" }` and add the run route; leave billing
1207
+ scheduled-only.
1208
+ 4. **Own schedules.** The harvest and the import runner become
1209
+ `schedule: "own"` and their routes call `scheduler.runOwn(...)` after the
1210
+ secret check — so both write runs and get health for the first time. The
1211
+ harvest takes `staleAfterMs: HOUR`; imports take `null` (idle is not stale),
1212
+ keep their cached nothing-to-do answer ahead of `runOwn`, and drop their own
1213
+ `isJobPaused("imports")`.
1214
+ 5. **Switches.** Keep `PlatformSetting`: `readSwitches` merges
1215
+ `readJobSettings()` with `readHarvestSetting()` under `harvest`, exactly as
1216
+ `jobSwitches()` does now, and `writeSwitch` calls
1217
+ `writeHarvestSetting`/`writeJobSetting` and `recordJobSwitched`. The
1218
+ round-23 rule is `pausedFromStored`.
1219
+ 6. **Ledger.** Run the two-column SQL above. No backfill: there are no Run now
1220
+ rows, because nothing ever called it
1221
+ (`SELECT count(*) FROM cron_run WHERE window_key LIKE 'manual:%'` should say
1222
+ 0 before and after).
1223
+ 7. **Routes and screen.** The tick, harvest and imports routes use
1224
+ `checkCronRequest` — `?secret=` now needs `allowQuery: true`, and Vercel's
1225
+ cron never sends it. `/api/admin/cron` calls `setPaused`. `AutomationCard`
1226
+ keeps its card and description and renders `JobHealthList` with
1227
+ `scheduler.views({ platform: true })`, the harvest link as `extra`.
1228
+ `lib/repos/cron.repo.ts`, `lib/server/cron-auth.ts` and the dispatcher half of
1229
+ `lib/services/cron.service.ts` go; `cron-switch.db.test.ts` stays as the test
1230
+ of the binding.
1231
+
1232
+ **Adopting it — Quickie.**
1233
+
1234
+ 1. **Jobs.** Every key it stores is one a helper makes, so the cutover runs
1235
+ nothing twice: `hourly()` for the follow-up digest and review requests;
1236
+ `daily({ from: 9 })`, `daily({ from: 17 })` and `daily({ from: 18 })` for
1237
+ payment reminders, booking reminders and budget alerts; `daily()` for bank
1238
+ consents and housekeeping; `everyHours(6)` for the bank feed;
1239
+ `(now) => (now.getUTCDay() === 1 ? hourly()(now) : null)` for the finance
1240
+ digest; `monthly()` for the monthly report, which now catches up a failed 1st
1241
+ on the 2nd. `scope: "tenant"` → `runNow: { scope: "tenant" }`; housekeeping
1242
+ → `runNow: false`.
1243
+ 2. **Counts.** Declare each key (`orgsEmailed`, `emailsSent`,
1244
+ `invoicesConsidered`, `remindersSent`, …) with its words; the Run now toast
1245
+ and the guide stop saying "3 remindersSent".
1246
+ 3. **Ledger.** Run the two-column SQL, then give the old Run now rows a tenant:
1247
+ `UPDATE "cron_run" SET "tenant_id" = 'unrecorded' WHERE "window_key" LIKE 'manual:%';`
1248
+ Every one was a single organisation's run, but which was never recorded.
1249
+ Marked so, none of them counts as a whole run for staleness and none appears
1250
+ on any owner's screen; nothing is deleted.
1251
+ 4. **Switches.** Add `CronSwitch`, and write the audit entry in `writeSwitch`
1252
+ with `organizationId: null` — a switch is a platform-level action, which
1253
+ `AuditLog` already allows. The switch belongs to platform operators
1254
+ (`User.platformAdmin`): an Automation page in the admin panel renders
1255
+ `scheduler.views({ platform: true })` with `onSwitch`.
1256
+ 5. **Owners.** Settings › Automation health renders
1257
+ `scheduler.views({ platform: false, tenantId: user.organizationId })` with
1258
+ `onRunNow` and no switch. `/api/cron/run` keeps its owner check and the bank
1259
+ feed's rate limit, then calls `runNow` with `platform: false` and the
1260
+ organisation. Owners now see their own runs in full and the platform's sweeps
1261
+ without other organisations' numbers or errors.
1262
+ 6. **Remove the three per-job routes** (`payment-reminders`, `finance-digest`,
1263
+ `follow-up-digest`). Nothing schedules them, and anyone holding the secret
1264
+ could run a sweep outside the ledger and its window — `?force=1` over every
1265
+ organisation, ignoring the hour and the throttle.
1266
+ 7. **The tick** calls `checkCronRequest(request, { publicSecrets: ["dev-cron-secret"] })`
1267
+ and `scheduler.tick()`. Payment reminders' Run now should stop passing
1268
+ `ignoreThrottle` — the cool-down stops a double press, but a throttle per
1269
+ invoice is what stops a customer being chased twice in a day.
1270
+ 8. **The guide** (`docs/guides/automation.md`): Errored is now Failed, a run in
1271
+ progress says Running, and the counts read as words.
1272
+
1273
+ **Upgrading to 0.12.0 — both Run now scopes, and pruning.** A job declared
1274
+ with one scope behaves exactly as in 0.11.0, and an 0.11.0 adapter compiles
1275
+ unchanged. Each app then adds pruning to its housekeeping, and Quickie makes
1276
+ its per-organisation jobs that are safe to re-sweep dual.
1277
+
1278
+ - **AltEd** — no dual jobs: it is single-tenant, and every job that offers Run
1279
+ now already sweeps everything (`platform`). In `lib/jobs/store.ts` add
1280
+ `pruneRuns` from the adapter above (`latestUnfinishedRun` is not needed
1281
+ until a job offers both), and add `{ id: "desc" }` to `latestRun`'s
1282
+ `orderBy`. In `lib/jobs/index.ts`, Housekeeping (`rate-limit-prune`) takes
1283
+ `...JOB_RUN_PRUNE_COUNTS` in its counts and
1284
+ `...(await pruneJobRuns(jobStore, { now }))` in its result. The harvest's
1285
+ 144 rows a day are most of the ledger; whatever history has built up beyond
1286
+ 90 days is caught up at a thousand rows a day. Test the SQL in
1287
+ `store.db.test.ts` with runs dated before 2000 under the test's own job ids
1288
+ and `startedBefore` in 2000, so no real row is old enough to go.
1289
+ - **Quickie** — make these dual, `runNow: { scope: ["tenant", "platform"] }`,
1290
+ because a second sweep sends nobody anything twice: **payment reminders**
1291
+ (the per-invoice throttle, which Run now does not lift), **booking
1292
+ reminders** (a booking's `REMINDER` message), **review requests** (`PENDING`
1293
+ becomes `SENT`), **bank consent check** (`expiryNoticeAt`) and **budget
1294
+ alerts** (`BudgetAlert`, one per budget, category and month). Keep these
1295
+ tenant-only: the **follow-up** and **weekly finance digests** —
1296
+ `ignoreHour: force` would email every opted-in organisation at once, and
1297
+ nothing stops a second digest the same day; the **monthly report** — nothing
1298
+ records that an organisation's report went, so re-running a sweep that failed
1299
+ half-way emails the first half again (make it dual once it does); and the
1300
+ **bank feed sync** — `force` lifts each connection's six-hour hold, so a
1301
+ platform run would call every bank for every organisation, and the next
1302
+ window recovers anyway (if it is ever made dual, pass
1303
+ `force && tenantId !== null`). In `lib/repos/cron.repo.ts` add
1304
+ `latestUnfinishedRun` — `createScheduler` refuses a dual job without it — and
1305
+ `pruneRuns`. No route changes: `/api/admin/cron` already calls `runNow` with
1306
+ `platform: true, tenantId: null`, which on a dual job is the platform run,
1307
+ and `/api/cron/run` with `platform: false` and the organisation, which is the
1308
+ tenant run; the admin panel now shows Run now on the dual jobs, and owners see
1309
+ what they saw. Reword the admin route's and panel's "only for the jobs that
1310
+ sweep shared tables", and the automation guide, to say an operator can re-run
1311
+ a failed sweep. Housekeeping (`rate-limit-prune`, `lib/services/cron.service.ts`)
1312
+ prunes through `cronJobStore`. The old `unrecorded` Run now rows age out like
1313
+ any tenant's, the latest per job kept; no screen reads them.
1314
+ - **senco** — no dual jobs: retention and housekeeping are sweeps over every
1315
+ school, and a school's Run now is refused on purpose (`lib/jobs/index.ts`).
1316
+ In `lib/jobs/store.ts` add `pruneRuns` and `{ id: "desc" }` in `latestRun`'s
1317
+ `orderBy`; Housekeeping (`rate-limit-prune`, `lib/jobs/index.ts`) takes the
1318
+ counts and `pruneJobRuns(jobStore, { now })`. `CronSwitchChange` is the
1319
+ switches' audit record, not ledger, and is not pruned. Retention enforcement
1320
+ is a promise to schools about how long evidence is kept; say the ledger's 90
1321
+ days in the same place.
1322
+
733
1323
  ### Styles — `@connextar/house/house.css`
734
1324
 
735
1325
  What the components need that utility classes cannot say: rich text, the editor's