@connextar/house 0.9.0 → 0.11.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 (58) hide show
  1. package/README.md +461 -1
  2. package/dist/ai/config-index.d.ts +14 -0
  3. package/dist/ai/config-index.d.ts.map +1 -0
  4. package/dist/ai/config-index.js +14 -0
  5. package/dist/ai/config-index.js.map +1 -0
  6. package/dist/ai/images.d.ts +14 -0
  7. package/dist/ai/images.d.ts.map +1 -0
  8. package/dist/ai/images.js +10 -0
  9. package/dist/ai/images.js.map +1 -0
  10. package/dist/ai/index.d.ts +7 -3
  11. package/dist/ai/index.d.ts.map +1 -1
  12. package/dist/ai/index.js +7 -3
  13. package/dist/ai/index.js.map +1 -1
  14. package/dist/ai/provider.d.ts +1 -9
  15. package/dist/ai/provider.d.ts.map +1 -1
  16. package/dist/ai/provider.js +0 -5
  17. package/dist/ai/provider.js.map +1 -1
  18. package/dist/jobs/counts.d.ts +82 -0
  19. package/dist/jobs/counts.d.ts.map +1 -0
  20. package/dist/jobs/counts.js +112 -0
  21. package/dist/jobs/counts.js.map +1 -0
  22. package/dist/jobs/cron-auth.d.ts +44 -0
  23. package/dist/jobs/cron-auth.d.ts.map +1 -0
  24. package/dist/jobs/cron-auth.js +20 -0
  25. package/dist/jobs/cron-auth.js.map +1 -0
  26. package/dist/jobs/health.d.ts +157 -0
  27. package/dist/jobs/health.d.ts.map +1 -0
  28. package/dist/jobs/health.js +146 -0
  29. package/dist/jobs/health.js.map +1 -0
  30. package/dist/jobs/index.d.ts +26 -0
  31. package/dist/jobs/index.d.ts.map +1 -0
  32. package/dist/jobs/index.js +26 -0
  33. package/dist/jobs/index.js.map +1 -0
  34. package/dist/jobs/job-health-list.d.ts +66 -0
  35. package/dist/jobs/job-health-list.d.ts.map +1 -0
  36. package/dist/jobs/job-health-list.js +115 -0
  37. package/dist/jobs/job-health-list.js.map +1 -0
  38. package/dist/jobs/react-index.d.ts +19 -0
  39. package/dist/jobs/react-index.d.ts.map +1 -0
  40. package/dist/jobs/react-index.js +18 -0
  41. package/dist/jobs/react-index.js.map +1 -0
  42. package/dist/jobs/registry.d.ts +104 -0
  43. package/dist/jobs/registry.d.ts.map +1 -0
  44. package/dist/jobs/registry.js +72 -0
  45. package/dist/jobs/registry.js.map +1 -0
  46. package/dist/jobs/scheduler.d.ts +89 -0
  47. package/dist/jobs/scheduler.d.ts.map +1 -0
  48. package/dist/jobs/scheduler.js +282 -0
  49. package/dist/jobs/scheduler.js.map +1 -0
  50. package/dist/jobs/store.d.ts +94 -0
  51. package/dist/jobs/store.d.ts.map +1 -0
  52. package/dist/jobs/store.js +56 -0
  53. package/dist/jobs/store.js.map +1 -0
  54. package/dist/jobs/windows.d.ts +66 -0
  55. package/dist/jobs/windows.d.ts.map +1 -0
  56. package/dist/jobs/windows.js +130 -0
  57. package/dist/jobs/windows.js.map +1 -0
  58. package/package.json +13 -1
package/README.md CHANGED
@@ -60,6 +60,15 @@ 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.
71
+
63
72
  ## Modules
64
73
 
65
74
  ### `@connextar/house/errors`
@@ -712,11 +721,462 @@ rate was checked. **Where rates are kept is the app's decision**: AltEd and
712
721
  PharmaLine keep a dated table an operator can correct; a smaller app can price
713
722
  straight from the defaults. An unknown model is unpriced (`null`), never free.
714
723
 
724
+ **In the browser, import `@connextar/house/ai/config`.** `/ai` carries both
725
+ SDKs, which no client bundle should. `/ai/config` is everything that never calls
726
+ a provider — `PROVIDER_LABELS` and the other provider names, the job-table and
727
+ status types, `AI_IMAGE_TYPES` / `isAiImageType`, and the pricing arithmetic
728
+ and defaults — and imports no package at all (a test follows the emitted
729
+ imports to hold it to that). `/ai` re-exports all of it, so server code needs
730
+ only the one import:
731
+
732
+ ```tsx
733
+ "use client";
734
+ import { PROVIDER_LABELS, isAiImageType } from "@connextar/house/ai/config";
735
+ ```
736
+
715
737
  `@anthropic-ai/sdk`, `openai` and `zod` are optional peer dependencies — only an
716
- app that imports `/ai` needs them. The SDKs refuse to run in a browser-like
738
+ app that imports `/ai` needs them; `/ai/config` needs none. The SDKs refuse to run in a browser-like
717
739
  environment, so a Vitest suite that calls the harness needs
718
740
  `// @vitest-environment node` on the file.
719
741
 
742
+ ### `@connextar/house/jobs`, `/jobs/react`
743
+
744
+ Scheduled jobs: declared once, run once per window, switched off and on by
745
+ name, and honest about their health. `/jobs` is server code — the registry, the
746
+ dispatcher, Run now, the switch, health, the words for counts and the tick
747
+ route's secret check. `/jobs/react` is the health screen's list, and re-exports
748
+ the wording and types a client component needs, so nothing in the browser ever
749
+ imports `/jobs` (which reaches `node:crypto`; `src/jobs/entry-points.test.ts`
750
+ holds both sides of that line against the built output).
751
+
752
+ AltEd and Quickie each had this, from one seed. What each proved, and what each
753
+ got wrong:
754
+
755
+ | Concern | AltEd | Quickie | Here |
756
+ | ----------------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
757
+ | 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 |
758
+ | 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 |
759
+ | 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 |
760
+ | 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 |
761
+ | 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` |
762
+ | Health states | Paused · Never run · Errored · Stale · Healthy | Never run · Errored · Stale · Healthy | Paused · Never run · **Running** · Failed · Stale · Healthy — derived, never stored |
763
+ | 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" |
764
+ | 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 |
765
+ | 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 |
766
+ | 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` or `platform` scope, refused outside it, while paused, while running, and within a cool-down |
767
+ | 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 |
768
+ | 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 |
769
+ | `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 |
770
+ | 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 |
771
+ | 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 |
772
+
773
+ **Declaring the jobs** — once, beside the store adapter, server-only:
774
+
775
+ ```ts
776
+ // lib/jobs/index.ts
777
+ import "server-only";
778
+ import { createScheduler, daily, defineJob, defineJobs, hourly } from "@connextar/house/jobs";
779
+
780
+ import { jobStore } from "./store";
781
+
782
+ const HOUR = 60 * 60 * 1000;
783
+
784
+ export const jobs = defineJobs([
785
+ defineJob({
786
+ id: "billing-housekeeping", // stored: never rename one
787
+ label: "Billing & payment reminders",
788
+ cadence: "Daily",
789
+ window: daily(),
790
+ staleAfterMs: 26 * HOUR,
791
+ counts: {
792
+ chargesRaised: { label: { one: "charge raised", other: "charges raised" } },
793
+ sent: { label: { one: "family reminded", other: "families reminded" } },
794
+ skipped: { label: "already reminded this week" },
795
+ totalOutstanding: { label: "outstanding", kind: "money", currency: "GBP", unit: "major" },
796
+ },
797
+ run: async ({ now }) => {
798
+ const { charged } = await rollUpCharges(now);
799
+ return { chargesRaised: charged, ...(await sendPaymentReminders(now)) };
800
+ },
801
+ }),
802
+ defineJob({
803
+ id: "payment-reminders",
804
+ label: "Payment reminders",
805
+ cadence: "Daily, from 09:00",
806
+ window: daily({ from: 9 }),
807
+ staleAfterMs: 26 * HOUR,
808
+ runNow: { scope: "tenant" }, // an owner sweeps their own organisation, and nobody else's
809
+ counts: { remindersSent: { label: { one: "reminder sent", other: "reminders sent" } } },
810
+ // `tenantId` is null for the schedule's sweep and always set for an owner's Run now.
811
+ run: async ({ now, tenantId }) => {
812
+ const { remindersSent } = await sendPaymentReminders(now, { organizationId: tenantId ?? undefined });
813
+ return { remindersSent };
814
+ },
815
+ }),
816
+ defineJob({
817
+ id: "harvest",
818
+ label: "Harvest",
819
+ cadence: "Every 10 minutes",
820
+ schedule: "own", // its own route and clock; the tick never runs it
821
+ staleAfterMs: HOUR,
822
+ counts: { sites: { label: { one: "site read", other: "sites read" } } },
823
+ run: async () => ({ sites: (await runHarvest({ budgetMs: 150_000, maxSites: 4 })).sites.length }),
824
+ }),
825
+ ]);
826
+
827
+ export const scheduler = createScheduler({ jobs, store: jobStore });
828
+ ```
829
+
830
+ `defineJobs` checks the list when the module loads and throws every problem at
831
+ once (`jobDefinitionProblems` returns them as sentences, for a test). `defineJob`
832
+ holds `run` to its counts at compile time: every declared count must be
833
+ reported, and nothing undeclared may be. At run time an undeclared key is dropped
834
+ before it is stored, and one in an old row is not shown — there is no fallback
835
+ that could print a variable name.
836
+
837
+ **The routes** — each app answers in its own envelope; the module returns
838
+ decisions, not `Response`s:
839
+
840
+ ```ts
841
+ // app/api/cron/tick/route.ts — the one entry in vercel.json's crons
842
+ export async function GET(request: Request) {
843
+ const auth = checkCronRequest(request, { publicSecrets: ["dev-cron-secret"] });
844
+ if (!auth.ok) return apiError(auth.message, auth.status);
845
+ const results = await scheduler.tick();
846
+ return apiSuccess({ results }, summariseTick(results)); // "2 ran, 1 failed, 1 paused, 5 not due"
847
+ }
848
+
849
+ // app/api/cron/harvest/route.ts — a job on its own clock
850
+ const result = await scheduler.runOwn("harvest");
851
+
852
+ // app/api/cron/run/route.ts — Run now, behind the session
853
+ const outcome = await scheduler.runNow(body.job, {
854
+ actor: { id: user.id, name: user.name },
855
+ platform: user.platformAdmin,
856
+ tenantId: user.organizationId,
857
+ });
858
+ if (outcome.status === "refused") return apiError(outcome.message, refusalStatus(outcome.reason));
859
+ if (outcome.status === "failed") return apiError(outcome.message, 500);
860
+ return apiSuccess(outcome, outcome.message); // "Ran Payment reminders — 3 reminders sent."
861
+
862
+ // app/api/admin/cron/route.ts — the switch
863
+ const outcome = await scheduler.setPaused(body.job, body.action === "pause", {
864
+ actor: { id: admin.id, name: admin.name },
865
+ platform: true,
866
+ });
867
+ if (outcome.status === "refused") return apiError(outcome.message, refusalStatus(outcome.reason));
868
+ return apiSuccess(outcome, outcome.message);
869
+ ```
870
+
871
+ **The screen** — the server works out the view for whoever is looking; the list
872
+ draws it inside the app's own card:
873
+
874
+ ```tsx
875
+ // app/dashboard/settings/page.tsx (server)
876
+ const jobs = await scheduler.views({ platform: isAdmin, tenantId: user.organizationId });
877
+
878
+ // components/settings/automation-card.tsx
879
+ ("use client");
880
+ import { JobHealthList } from "@connextar/house/jobs/react";
881
+
882
+ <JobHealthList
883
+ jobs={jobs}
884
+ onSwitch={async (job, on) => {
885
+ await commitApi("/api/admin/cron", { job, action: on ? "resume" : "pause" });
886
+ }}
887
+ onRunNow={(job) => commitApi("/api/cron/run", { job })}
888
+ onSettled={router.refresh}
889
+ extra={(job) => (job.id === "harvest" ? <Link href={routes.harvest}>What it searches next</Link> : null)}
890
+ />;
891
+ ```
892
+
893
+ `onSwitch` and `onRunNow` throw to refuse — the ux layer's one failure
894
+ contract. The switch is `useOptimisticValue`: it moves on the click, the badge
895
+ moves with it (to the job's health as if on, which the view carries), and a
896
+ refusal puts both back and reports the reason through the
897
+ `ActionFeedbackProvider`. No other row is disabled while one saves. Run now is
898
+ `useKeyedAction`: that row's button is busy (`data-busy`), the route's message is
899
+ said, and the screen refreshes whether the run worked or not, since a failure is
900
+ on the ledger too. Dates are drawn in `timeZone` (default `Europe/London`),
901
+ never the runtime's own — the server draws the list in UTC and the browser
902
+ again in BST, and two times in one element is a hydration error. `classNames`
903
+ replaces any part's look.
904
+
905
+ The rules it keeps:
906
+
907
+ - **One run per job per window.** The window is `(now) => key | null`; the
908
+ store's unique index on `(job, windowKey)` is the idempotency. A second tick
909
+ in the window finds the key taken and says `already-ran`. `hourly`,
910
+ `everyHours`, `daily`, `weekly` and `monthly` produce the keys both apps
911
+ already store, so moving onto them runs nothing twice.
912
+ - **One failure stops nothing else.** A job that throws, a store that cannot
913
+ claim, a window function with a bug — each is that job's `failed` result and
914
+ its error on the ledger, and the next job runs. A run whose result could not
915
+ be written says so rather than calling the work failed.
916
+ - **A paused job's tick skips it and says so** ("switched off by AltEd Admin on
917
+ 29 Sept 2026; not started"). Its window is not claimed, so switched back on
918
+ inside the same window it still runs. If the switches cannot be read at all, no
919
+ switchable job starts: a switch nobody can read must not send a job out
920
+ spending. `pausedFromStored` reads a stored value AltEd's way — a row that
921
+ exists but does not parse is _paused_.
922
+ - **Switching takes platform authority** — it stops a job for every tenant — and
923
+ records who and when. `JobStore.writeSwitch` is where the audit entry goes,
924
+ in the same transaction as the switch. A switch to where it already is writes
925
+ nothing.
926
+ - **Health is derived, in this order:** paused · never run · running · failed ·
927
+ stale · healthy. A run past its timeout (default 15 minutes) and still
928
+ unfinished is failed, and says it never finished. Stale means no successful
929
+ run over every tenant for longer than the threshold — exactly at the threshold
930
+ is still healthy — and a job with `staleAfterMs: null` (one that runs only
931
+ when there is work) is never stale.
932
+ - **Run now is scoped and fenced.** `tenant` sweeps the caller's own tenant and
933
+ needs one; `platform` sweeps everything and needs platform authority. It is
934
+ refused while the job is off, while a run it would overlap is in flight (the
935
+ schedule's sweep covers the caller's tenant too), and within its cool-down
936
+ (default ten minutes, per job and scope) — a manual run sets `force`, so a
937
+ second press would otherwise send the whole batch again. `force` is for a
938
+ job's hour gates; a per-recipient throttle should stay on.
939
+ - **A tenant sees their own.** To a tenant viewer, a sweep over every tenant
940
+ shows whether it ran and when, but not its numbers (which are everyone's) or
941
+ its error (which may name another organisation's data). Their own Run now
942
+ shows in full. Other tenants' runs never appear, and none of them resets the
943
+ job's staleness.
944
+ - **Every count is declared.** A label (the words after the number; singular and
945
+ plural where they differ) and a kind: `count`, `money` (a currency _and_ a
946
+ unit — `major` for AltEd's pounds, `minor` for pence — because a default would
947
+ be out by a hundred for one of them), or `duration`. Money is formatted by
948
+ `Intl` in its currency, which also rounds away a summed decimal's float noise.
949
+ - **The tick's door** (`checkCronRequest`): an unset `CRON_SECRET` refuses with
950
+ 503; the secret is compared as SHA-256 digests with `timingSafeEqual`; the
951
+ Bearer scheme is required; `publicSecrets` (the committed development value)
952
+ are refused in production; `?secret=` is accepted only with
953
+ `allowQuery: true`, because an address lands in request logs.
954
+
955
+ **Storage.** The package owns no schema. The app implements `JobStore` — claim,
956
+ finish, latest run, read switches, write a switch — over its own tables. This is
957
+ the reference model; both apps' existing `cron_run` tables are it minus two
958
+ nullable columns, so every row they hold maps across as it is:
959
+
960
+ ```prisma
961
+ /// One row per job per window: the claim that makes a second tick skip, and the result.
962
+ model CronRun {
963
+ id String @id @default(cuid())
964
+ job String @db.VarChar(60)
965
+ /// The window, e.g. "2026-09-30", or "manual:tenant:<id>:<time>" for a Run now.
966
+ windowKey String @map("window_key") @db.VarChar(120)
967
+ /// Null: a run over every tenant. Otherwise the one tenant a Run now swept.
968
+ tenantId String? @map("tenant_id") @db.VarChar(64)
969
+ /// Who pressed Run now, by name. Null for the schedule.
970
+ triggeredBy String? @map("triggered_by") @db.VarChar(120)
971
+ startedAt DateTime @default(now()) @map("started_at")
972
+ finishedAt DateTime? @map("finished_at")
973
+ ok Boolean @default(false)
974
+ /// The declared counts, e.g. { "remindersSent": 3 }.
975
+ counts Json?
976
+ error String? @db.Text
977
+
978
+ @@unique([job, windowKey])
979
+ @@index([job, startedAt])
980
+ @@index([job, tenantId, startedAt])
981
+ @@map("cron_run")
982
+ }
983
+
984
+ /// A job switched off or on — the latest change. No row means on.
985
+ model CronSwitch {
986
+ job String @id @db.VarChar(60)
987
+ paused Boolean
988
+ switchedAt DateTime @map("switched_at")
989
+ switchedById String? @map("switched_by_id")
990
+ switchedByName String @map("switched_by_name") @db.VarChar(120)
991
+
992
+ @@map("cron_switch")
993
+ }
994
+ ```
995
+
996
+ For an app that already has `cron_run`:
997
+
998
+ ```sql
999
+ ALTER TABLE "cron_run" ADD COLUMN "tenant_id" VARCHAR(64), ADD COLUMN "triggered_by" VARCHAR(120);
1000
+ CREATE INDEX "cron_run_job_tenant_id_started_at_idx" ON "cron_run"("job", "tenant_id", "started_at");
1001
+ ```
1002
+
1003
+ ```ts
1004
+ // lib/jobs/store.ts
1005
+ import "server-only";
1006
+ import type { JobStore } from "@connextar/house/jobs";
1007
+
1008
+ import { Prisma } from "@/lib/db/generated/client";
1009
+ import { prisma } from "@lib/db/prisma";
1010
+
1011
+ export const jobStore: JobStore = {
1012
+ async claimRun({ job, windowKey, tenantId, by, startedAt }) {
1013
+ try {
1014
+ const row = await prisma.cronRun.create({ data: { job, windowKey, tenantId, triggeredBy: by, startedAt } });
1015
+ return row.id;
1016
+ } catch (error) {
1017
+ // The unique index refusing a second claim is the answer "already ran", not a failure.
1018
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") return null;
1019
+ throw error;
1020
+ }
1021
+ },
1022
+ async finishRun(id, { finishedAt, ok, counts, error }) {
1023
+ await prisma.cronRun.update({ where: { id }, data: { finishedAt, ok, error, ...(counts ? { counts } : {}) } });
1024
+ },
1025
+ async latestRun({ job, tenants, ok, manualOnly }) {
1026
+ const row = await prisma.cronRun.findFirst({
1027
+ where: {
1028
+ job,
1029
+ OR: tenants.map((tenantId) => ({ tenantId })), // `{ tenantId: null }` matches the whole-tenant runs
1030
+ ...(ok ? { ok: true } : {}),
1031
+ ...(manualOnly ? { windowKey: { startsWith: "manual:" } } : {}),
1032
+ },
1033
+ orderBy: { startedAt: "desc" },
1034
+ });
1035
+ return row && { ...row, by: row.triggeredBy };
1036
+ },
1037
+ async readSwitches() {
1038
+ const rows = await prisma.cronSwitch.findMany();
1039
+ return new Map(rows.map((r) => [r.job, { paused: r.paused, at: r.switchedAt, by: r.switchedByName }]));
1040
+ },
1041
+ async writeSwitch({ job, label, paused, at, by }) {
1042
+ await prisma.$transaction([
1043
+ prisma.cronSwitch.upsert({
1044
+ where: { job },
1045
+ create: { job, paused, switchedAt: at, switchedById: by.id, switchedByName: by.name },
1046
+ update: { paused, switchedAt: at, switchedById: by.id, switchedByName: by.name },
1047
+ }),
1048
+ prisma.auditLog.create({
1049
+ data: {
1050
+ actorId: by.id,
1051
+ actorName: by.name,
1052
+ action: paused ? "cron.paused" : "cron.resumed",
1053
+ entityType: "cron_job",
1054
+ entityId: job,
1055
+ summary: `${paused ? "Paused" : "Resumed"} ${label}`,
1056
+ },
1057
+ }),
1058
+ ]);
1059
+ },
1060
+ };
1061
+ ```
1062
+
1063
+ `createMemoryJobStore()` is the same contract in memory, unique index included,
1064
+ for the app's own tests of its binding. Prune the ledger in the app's
1065
+ housekeeping job like any other growing table, keeping more than the longest
1066
+ threshold — health reads the latest run and the latest success per job, so an
1067
+ older row is history, not state.
1068
+
1069
+ **What should not be a scheduled job.**
1070
+
1071
+ - **Anything a person is waiting for.** A report they asked for, a model call,
1072
+ an import step with its page open — do it in the request or straight after it.
1073
+ A tick is up to an hour away, and a spinner cannot wait that long.
1074
+ - **A reaction to an event.** A payment landing, a sign-up, a status change:
1075
+ handle it where it happens. A sweep that looks for "anything that changed" is
1076
+ a webhook written late and paid for on every tick.
1077
+ - **A job per record, or per tenant.** A reminder due seven days after an
1078
+ invoice is one daily sweep over "due today", not a job per invoice. The
1079
+ registry is a short list a person reads on one screen.
1080
+ - **Anything that must happen at a moment.** Windows are as coarse as the tick —
1081
+ AltEd's runs hourly, Quickie's every fifteen minutes. "At 09:00 sharp" is a
1082
+ queue with a due time, not a window.
1083
+ - **Work longer than one request.** A job that can outlive the platform's time
1084
+ limit is cut off and reads as never finished. Give it a budget and let the
1085
+ next window continue — AltEd's harvest stops starting new sites after two and
1086
+ a half minutes — or give it its own runner with `schedule: "own"`.
1087
+ - **Housekeeping a query already does.** An expired row the reads already ignore
1088
+ does not need deleting on a schedule unless something promises it will be
1089
+ gone — a retention period in a privacy policy does, and is then a job.
1090
+
1091
+ **Adopting it — AltEd.** Nothing changes in what runs or when:
1092
+
1093
+ 1. **Jobs.** Each `JobSpec` becomes a `defineJob`: `name` → `id` (unchanged, so
1094
+ the ledger and the `cron:<name>` switches still match), `when` → `cadence`,
1095
+ `maxAgeMs` → `staleAfterMs`, `attempt` + `windowKey` → one `window` with the
1096
+ same key: `daily()` for billing and housekeeping (the UTC date),
1097
+ `daily({ timeZone: "Europe/London" })` for the tracker and class sessions
1098
+ (`todayKey`), `daily({ from: 7, timeZone: "Europe/London" })` for the admin
1099
+ summary (its London date equals the UTC date from 07:00). The hourly
1100
+ moderation sweep's key is `2026-09-30-14` where `hourly()` gives
1101
+ `2026-09-30T14` — keep a one-line custom window, or accept one extra sweep in
1102
+ the cutover hour (it re-reads flagged messages, which is harmless). The weekly
1103
+ digest keeps ``(now) => (isMondayMorning(now) ? `digest-${todayKey(now)}` : null)``,
1104
+ or moves to `weekly({ from: 7, timeZone: "Europe/London" })` — which also
1105
+ catches up a missed Monday — cut over on any day but a Monday.
1106
+ 2. **Counts.** Declare every key each job already returns, under the same name,
1107
+ so every stored row reads: `totalOutstanding` as
1108
+ `{ kind: "money", currency: "GBP", unit: "major" }` (it is pounds, summed from
1109
+ the decimal column), the rest as counts with their words.
1110
+ 3. **Run now.** `scope: "global"` becomes `runNow: false` — what it effectively
1111
+ was, since `runJobNow` never had a caller. Where an administrator should be
1112
+ able to run one (the admin summary, class sessions, the tracker), give it
1113
+ `runNow: { scope: "platform" }` and add the run route; leave billing
1114
+ scheduled-only.
1115
+ 4. **Own schedules.** The harvest and the import runner become
1116
+ `schedule: "own"` and their routes call `scheduler.runOwn(...)` after the
1117
+ secret check — so both write runs and get health for the first time. The
1118
+ harvest takes `staleAfterMs: HOUR`; imports take `null` (idle is not stale),
1119
+ keep their cached nothing-to-do answer ahead of `runOwn`, and drop their own
1120
+ `isJobPaused("imports")`.
1121
+ 5. **Switches.** Keep `PlatformSetting`: `readSwitches` merges
1122
+ `readJobSettings()` with `readHarvestSetting()` under `harvest`, exactly as
1123
+ `jobSwitches()` does now, and `writeSwitch` calls
1124
+ `writeHarvestSetting`/`writeJobSetting` and `recordJobSwitched`. The
1125
+ round-23 rule is `pausedFromStored`.
1126
+ 6. **Ledger.** Run the two-column SQL above. No backfill: there are no Run now
1127
+ rows, because nothing ever called it
1128
+ (`SELECT count(*) FROM cron_run WHERE window_key LIKE 'manual:%'` should say
1129
+ 0 before and after).
1130
+ 7. **Routes and screen.** The tick, harvest and imports routes use
1131
+ `checkCronRequest` — `?secret=` now needs `allowQuery: true`, and Vercel's
1132
+ cron never sends it. `/api/admin/cron` calls `setPaused`. `AutomationCard`
1133
+ keeps its card and description and renders `JobHealthList` with
1134
+ `scheduler.views({ platform: true })`, the harvest link as `extra`.
1135
+ `lib/repos/cron.repo.ts`, `lib/server/cron-auth.ts` and the dispatcher half of
1136
+ `lib/services/cron.service.ts` go; `cron-switch.db.test.ts` stays as the test
1137
+ of the binding.
1138
+
1139
+ **Adopting it — Quickie.**
1140
+
1141
+ 1. **Jobs.** Every key it stores is one a helper makes, so the cutover runs
1142
+ nothing twice: `hourly()` for the follow-up digest and review requests;
1143
+ `daily({ from: 9 })`, `daily({ from: 17 })` and `daily({ from: 18 })` for
1144
+ payment reminders, booking reminders and budget alerts; `daily()` for bank
1145
+ consents and housekeeping; `everyHours(6)` for the bank feed;
1146
+ `(now) => (now.getUTCDay() === 1 ? hourly()(now) : null)` for the finance
1147
+ digest; `monthly()` for the monthly report, which now catches up a failed 1st
1148
+ on the 2nd. `scope: "tenant"` → `runNow: { scope: "tenant" }`; housekeeping
1149
+ → `runNow: false`.
1150
+ 2. **Counts.** Declare each key (`orgsEmailed`, `emailsSent`,
1151
+ `invoicesConsidered`, `remindersSent`, …) with its words; the Run now toast
1152
+ and the guide stop saying "3 remindersSent".
1153
+ 3. **Ledger.** Run the two-column SQL, then give the old Run now rows a tenant:
1154
+ `UPDATE "cron_run" SET "tenant_id" = 'unrecorded' WHERE "window_key" LIKE 'manual:%';`
1155
+ Every one was a single organisation's run, but which was never recorded.
1156
+ Marked so, none of them counts as a whole run for staleness and none appears
1157
+ on any owner's screen; nothing is deleted.
1158
+ 4. **Switches.** Add `CronSwitch`, and write the audit entry in `writeSwitch`
1159
+ with `organizationId: null` — a switch is a platform-level action, which
1160
+ `AuditLog` already allows. The switch belongs to platform operators
1161
+ (`User.platformAdmin`): an Automation page in the admin panel renders
1162
+ `scheduler.views({ platform: true })` with `onSwitch`.
1163
+ 5. **Owners.** Settings › Automation health renders
1164
+ `scheduler.views({ platform: false, tenantId: user.organizationId })` with
1165
+ `onRunNow` and no switch. `/api/cron/run` keeps its owner check and the bank
1166
+ feed's rate limit, then calls `runNow` with `platform: false` and the
1167
+ organisation. Owners now see their own runs in full and the platform's sweeps
1168
+ without other organisations' numbers or errors.
1169
+ 6. **Remove the three per-job routes** (`payment-reminders`, `finance-digest`,
1170
+ `follow-up-digest`). Nothing schedules them, and anyone holding the secret
1171
+ could run a sweep outside the ledger and its window — `?force=1` over every
1172
+ organisation, ignoring the hour and the throttle.
1173
+ 7. **The tick** calls `checkCronRequest(request, { publicSecrets: ["dev-cron-secret"] })`
1174
+ and `scheduler.tick()`. Payment reminders' Run now should stop passing
1175
+ `ignoreThrottle` — the cool-down stops a double press, but a throttle per
1176
+ invoice is what stops a customer being chased twice in a day.
1177
+ 8. **The guide** (`docs/guides/automation.md`): Errored is now Failed, a run in
1178
+ progress says Running, and the counts read as words.
1179
+
720
1180
  ### Styles — `@connextar/house/house.css`
721
1181
 
722
1182
  What the components need that utility classes cannot say: rich text, the editor's
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `@connextar/house/ai/config` — everything in `ai` that never calls a provider.
3
+ *
4
+ * Provider names, the job table, status shapes, image types and prices: what a
5
+ * settings screen, an upload form or a price table in the browser needs. Its own
6
+ * entry point because `@connextar/house/ai` carries both provider SDKs, which no
7
+ * client bundle should — so AltEd and PharmaLine each kept a copy of the labels
8
+ * and image types for their client components instead. `package-exports.test.ts`
9
+ * fails if anything reachable from here imports an SDK or Zod.
10
+ */
11
+ export { AI_PROVIDERS, PROVIDER_KEY_VARS, PROVIDER_LABELS, aiStatus, chooseProvider, configuredProviders, modelFor, publicAiStatus, resolveAi, type AiEnv, type AiModelSpec, type AiProviderName, type AiStatus, type AiTaskSpec, type AiTaskTable, type ProviderChoice, type PublicAiStatus, type ResolvedAi, } from "./config.js";
12
+ export { AI_IMAGE_TYPES, isAiImageType, type AiImage, type AiImageType } from "./images.js";
13
+ export { DEFAULT_PRICES, costUsd, findPrice, modelFamily, type ModelPrice, type PriceRates, type TokenCounts, } from "./pricing.js";
14
+ //# sourceMappingURL=config-index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config-index.d.ts","sourceRoot":"","sources":["../../src/ai/config-index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,eAAe,EACf,QAAQ,EACR,cAAc,EACd,mBAAmB,EACnB,QAAQ,EACR,cAAc,EACd,SAAS,EACT,KAAK,KAAK,EACV,KAAK,WAAW,EAChB,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,KAAK,cAAc,EACnB,KAAK,cAAc,EACnB,KAAK,UAAU,GAChB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,KAAK,OAAO,EAAE,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAC5F,OAAO,EACL,cAAc,EACd,OAAO,EACP,SAAS,EACT,WAAW,EACX,KAAK,UAAU,EACf,KAAK,UAAU,EACf,KAAK,WAAW,GACjB,MAAM,cAAc,CAAC"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `@connextar/house/ai/config` — everything in `ai` that never calls a provider.
3
+ *
4
+ * Provider names, the job table, status shapes, image types and prices: what a
5
+ * settings screen, an upload form or a price table in the browser needs. Its own
6
+ * entry point because `@connextar/house/ai` carries both provider SDKs, which no
7
+ * client bundle should — so AltEd and PharmaLine each kept a copy of the labels
8
+ * and image types for their client components instead. `package-exports.test.ts`
9
+ * fails if anything reachable from here imports an SDK or Zod.
10
+ */
11
+ export { AI_PROVIDERS, PROVIDER_KEY_VARS, PROVIDER_LABELS, aiStatus, chooseProvider, configuredProviders, modelFor, publicAiStatus, resolveAi, } from "./config.js";
12
+ export { AI_IMAGE_TYPES, isAiImageType } from "./images.js";
13
+ export { DEFAULT_PRICES, costUsd, findPrice, modelFamily, } from "./pricing.js";
14
+ //# sourceMappingURL=config-index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config-index.js","sourceRoot":"","sources":["../../src/ai/config-index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,eAAe,EACf,QAAQ,EACR,cAAc,EACd,mBAAmB,EACnB,QAAQ,EACR,cAAc,EACd,SAAS,GAUV,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,cAAc,EAAE,aAAa,EAAkC,MAAM,aAAa,CAAC;AAC5F,OAAO,EACL,cAAc,EACd,OAAO,EACP,SAAS,EACT,WAAW,GAIZ,MAAM,cAAc,CAAC"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The pictures a structured call can carry. Their own module, free of either SDK,
3
+ * so an upload form can check a file's type from `@connextar/house/ai/config`.
4
+ */
5
+ /** The image types both providers accept inline. */
6
+ export declare const AI_IMAGE_TYPES: readonly ["image/jpeg", "image/png", "image/gif", "image/webp"];
7
+ export type AiImageType = (typeof AI_IMAGE_TYPES)[number];
8
+ export declare function isAiImageType(value: string): value is AiImageType;
9
+ export interface AiImage {
10
+ mediaType: AiImageType;
11
+ /** Base64, no data-URL prefix. */
12
+ base64: string;
13
+ }
14
+ //# sourceMappingURL=images.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"images.d.ts","sourceRoot":"","sources":["../../src/ai/images.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,oDAAoD;AACpD,eAAO,MAAM,cAAc,iEAAkE,CAAC;AAC9F,MAAM,MAAM,WAAW,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,CAAC,CAAC;AAE1D,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,IAAI,WAAW,CAEjE;AAED,MAAM,WAAW,OAAO;IACtB,SAAS,EAAE,WAAW,CAAC;IACvB,kCAAkC;IAClC,MAAM,EAAE,MAAM,CAAC;CAChB"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The pictures a structured call can carry. Their own module, free of either SDK,
3
+ * so an upload form can check a file's type from `@connextar/house/ai/config`.
4
+ */
5
+ /** The image types both providers accept inline. */
6
+ export const AI_IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
7
+ export function isAiImageType(value) {
8
+ return AI_IMAGE_TYPES.includes(value);
9
+ }
10
+ //# sourceMappingURL=images.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"images.js","sourceRoot":"","sources":["../../src/ai/images.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,oDAAoD;AACpD,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,YAAY,EAAE,WAAW,EAAE,WAAW,EAAE,YAAY,CAAU,CAAC;AAG9F,MAAM,UAAU,aAAa,CAAC,KAAa;IACzC,OAAQ,cAAoC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;AAC/D,CAAC"}
@@ -1,4 +1,8 @@
1
- export { AI_PROVIDERS, PROVIDER_KEY_VARS, PROVIDER_LABELS, aiStatus, chooseProvider, configuredProviders, modelFor, publicAiStatus, resolveAi, type AiEnv, type AiModelSpec, type AiProviderName, type AiStatus, type AiTaskSpec, type AiTaskTable, type ProviderChoice, type PublicAiStatus, type ResolvedAi, } from "./config.js";
2
- export { AI_IMAGE_TYPES, AiServiceError, AiUnavailableError, AiUnusableAnswerError, OPENAI_MIN_OUTPUT_TOKENS, createAiHarness, isAiImageType, type AiCallResult, type AiHarness, type AiHarnessOptions, type AiImage, type AiImageType, type AiStructuredCall, type AiTextCall, } from "./provider.js";
3
- export { DEFAULT_PRICES, costUsd, findPrice, modelFamily, type ModelPrice, type PriceRates, type TokenCounts, } from "./pricing.js";
1
+ /**
2
+ * `@connextar/house/ai` — the harness, and everything it is configured with.
3
+ * Server-only in practice: it carries both provider SDKs. A client component
4
+ * imports `@connextar/house/ai/config` instead.
5
+ */
6
+ export * from "./config-index.js";
7
+ export { AiServiceError, AiUnavailableError, AiUnusableAnswerError, OPENAI_MIN_OUTPUT_TOKENS, createAiHarness, type AiCallResult, type AiHarness, type AiHarnessOptions, type AiStructuredCall, type AiTextCall, } from "./provider.js";
4
8
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ai/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,eAAe,EACf,QAAQ,EACR,cAAc,EACd,mBAAmB,EACnB,QAAQ,EACR,cAAc,EACd,SAAS,EACT,KAAK,KAAK,EACV,KAAK,WAAW,EAChB,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,KAAK,cAAc,EACnB,KAAK,cAAc,EACnB,KAAK,UAAU,GAChB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,cAAc,EACd,cAAc,EACd,kBAAkB,EAClB,qBAAqB,EACrB,wBAAwB,EACxB,eAAe,EACf,aAAa,EACb,KAAK,YAAY,EACjB,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,OAAO,EACZ,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,UAAU,GAChB,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,cAAc,EACd,OAAO,EACP,SAAS,EACT,WAAW,EACX,KAAK,UAAU,EACf,KAAK,UAAU,EACf,KAAK,WAAW,GACjB,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ai/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,cAAc,mBAAmB,CAAC;AAClC,OAAO,EACL,cAAc,EACd,kBAAkB,EAClB,qBAAqB,EACrB,wBAAwB,EACxB,eAAe,EACf,KAAK,YAAY,EACjB,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,gBAAgB,EACrB,KAAK,UAAU,GAChB,MAAM,eAAe,CAAC"}
package/dist/ai/index.js CHANGED
@@ -1,4 +1,8 @@
1
- export { AI_PROVIDERS, PROVIDER_KEY_VARS, PROVIDER_LABELS, aiStatus, chooseProvider, configuredProviders, modelFor, publicAiStatus, resolveAi, } from "./config.js";
2
- export { AI_IMAGE_TYPES, AiServiceError, AiUnavailableError, AiUnusableAnswerError, OPENAI_MIN_OUTPUT_TOKENS, createAiHarness, isAiImageType, } from "./provider.js";
3
- export { DEFAULT_PRICES, costUsd, findPrice, modelFamily, } from "./pricing.js";
1
+ /**
2
+ * `@connextar/house/ai` — the harness, and everything it is configured with.
3
+ * Server-only in practice: it carries both provider SDKs. A client component
4
+ * imports `@connextar/house/ai/config` instead.
5
+ */
6
+ export * from "./config-index.js";
7
+ export { AiServiceError, AiUnavailableError, AiUnusableAnswerError, OPENAI_MIN_OUTPUT_TOKENS, createAiHarness, } from "./provider.js";
4
8
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/ai/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,eAAe,EACf,QAAQ,EACR,cAAc,EACd,mBAAmB,EACnB,QAAQ,EACR,cAAc,EACd,SAAS,GAUV,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,cAAc,EACd,cAAc,EACd,kBAAkB,EAClB,qBAAqB,EACrB,wBAAwB,EACxB,eAAe,EACf,aAAa,GAQd,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,cAAc,EACd,OAAO,EACP,SAAS,EACT,WAAW,GAIZ,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/ai/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,cAAc,mBAAmB,CAAC;AAClC,OAAO,EACL,cAAc,EACd,kBAAkB,EAClB,qBAAqB,EACrB,wBAAwB,EACxB,eAAe,GAMhB,MAAM,eAAe,CAAC"}
@@ -1,5 +1,6 @@
1
1
  import type { z } from "zod";
2
2
  import { type AiEnv, type AiProviderName, type AiStatus, type AiTaskTable, type PublicAiStatus, type ResolvedAi } from "./config.js";
3
+ import type { AiImage } from "./images.js";
3
4
  /**
4
5
  * The one place either provider's SDK is called.
5
6
  *
@@ -66,15 +67,6 @@ export interface AiTextCall<Task extends string> {
66
67
  */
67
68
  timeoutMs?: number;
68
69
  }
69
- /** The image types both providers accept inline. */
70
- export declare const AI_IMAGE_TYPES: readonly ["image/jpeg", "image/png", "image/gif", "image/webp"];
71
- export type AiImageType = (typeof AI_IMAGE_TYPES)[number];
72
- export declare function isAiImageType(value: string): value is AiImageType;
73
- export interface AiImage {
74
- mediaType: AiImageType;
75
- /** Base64, no data-URL prefix. */
76
- base64: string;
77
- }
78
70
  export interface AiStructuredCall<Task extends string, T> extends AiTextCall<Task> {
79
71
  schema: z.ZodType<T>;
80
72
  /** A name for the schema. OpenAI requires one; Anthropic ignores it. */
@@ -1 +1 @@
1
- {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../../src/ai/provider.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../../src/ai/provider.ts"],"names":[],"mappings":"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"}