@catalystiq/envoy-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # @catalystiq/envoy-sdk
2
+
3
+ Headless, bring-your-own-Postgres email SDK for Next.js (App Router). Drop multi-step,
4
+ time-based, **per-recipient AI-personalized** drip sequences and Resend-native broadcasts into
5
+ your own app — you own auth, the UI, and the database; the SDK owns the engine.
6
+
7
+ Built on [Resend](https://resend.com) (`resend@^6.14.0`) for transport, Topics, and Broadcasts,
8
+ and Claude Managed Agents for just-in-time personalization. Self-hosted, single-tenant.
9
+
10
+ > Status: early (`0.1.0`). The public surface may still shift.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm i @catalystiq/envoy-sdk resend
16
+ ```
17
+
18
+ Peers (optional, host-provided): `next >=14`, `react >=18`.
19
+
20
+ ## What it does
21
+
22
+ Two clearly separated send lanes:
23
+
24
+ - **Drip lane** — `defineSequence` + `enroll()` from your app events. Each step renders a Resend
25
+ Template and fills AI-written slots (subject/preheader/body) just-in-time via Claude, sent as an
26
+ individual transactional `emails.send`. Time-based waits, crash-safe agent resume, fail-soft.
27
+ - **Broadcast lane** — `defineBroadcastProgram` over Resend Segments + Topics. Content-gated,
28
+ send-once (external claim guard), with per-topic consent reconcile. Merge-vars only, no AI.
29
+
30
+ Plus: a mountable catch-all route handler (per-sub-path auth: your `authorize` + cron secret +
31
+ Svix webhook verify + signed unsubscribe + MCP), a dual-stream consent mirror that gates every
32
+ send, an RFC 8058 one-click unsubscribe landing, GDPR contact deletion, read-only React hooks,
33
+ and a retained MCP server so AI agents can operate the lifecycle.
34
+
35
+ ## Quick start
36
+
37
+ ```ts
38
+ // lib/envoy.ts (server-only)
39
+ import "server-only";
40
+ import { createEnvoy } from "@catalystiq/envoy-sdk";
41
+ import { pool } from "@/lib/db";
42
+
43
+ export const envoy = createEnvoy({
44
+ db: pool,
45
+ installNamespace: "myapp-prod",
46
+ resendApiKey: process.env.RESEND_API_KEY!,
47
+ webhookSecret: process.env.RESEND_WEBHOOK_SECRET!,
48
+ cronSecret: process.env.CRON_SECRET!,
49
+ unsubscribeSecret: process.env.ENVOY_UNSUBSCRIBE_SECRET!,
50
+ baseSegmentId: process.env.RESEND_BASE_SEGMENT_ID!,
51
+ streams: { digest: { default: "opt_out" }, alert: { default: "opt_in" } },
52
+ });
53
+ ```
54
+
55
+ ```ts
56
+ // app/api/envoy/[...envoy]/route.ts
57
+ import { envoy } from "@/lib/envoy";
58
+ const handler = envoy.routeHandler({ authorize: async (req) => /* your check */ true });
59
+ export const GET = handler;
60
+ export const POST = handler;
61
+ ```
62
+
63
+ Apply the SDK's migrations to your database (it ships `.sql` under `migrations/` and a `migrate`
64
+ helper), then `await envoy.enroll({ email, data }, "onboarding")` from your signup event.
65
+
66
+ ## Full integration guide
67
+
68
+ A complete, step-by-step host integration guide (auth model, the two crons, consent, webhooks,
69
+ GDPR, the delete-and-import adoption map, and the accepted compliance residuals) lives in the
70
+ repo at [`docs/sdk-agent-integration-guide.md`](https://github.com/getcatalystiq/envoy/blob/main/docs/sdk-agent-integration-guide.md).
71
+
72
+ ## License
73
+
74
+ MIT
@@ -0,0 +1,152 @@
1
+ declare const SDK_CLIENT_VERSION = "0.0.0";
2
+ /** Per-stream consent value. Mirrors the server `ConsentStatus`. */
3
+ type ClientConsentStatus = "opt_in" | "opt_out" | "unsubscribed";
4
+ /** `/read/program-state` — the broadcast cursor state for one (programKey, subjectKey). */
5
+ interface ProgramStateResponse {
6
+ /** High-water mark over the host's ordering column; null for a never-fired (program, subject). */
7
+ watermark: string | null;
8
+ /** Monotonic issue sequence (0 for never-seen). */
9
+ issueSeq: number;
10
+ /** ISO timestamp of the last real send; null when never fired. A stale value is a health signal. */
11
+ lastFiredAt: string | null;
12
+ /** Whether the host has paused this (program, subject). */
13
+ paused: boolean;
14
+ }
15
+ /** `/read/consent` — the consent mirror row for one (contact, topic). */
16
+ interface ConsentResponse {
17
+ /** Contact email (the host already authorized the viewer; the SDK does not redact in its own UI). */
18
+ contact: string;
19
+ /** Topic key the row is scoped to. */
20
+ topicKey: string;
21
+ /** Cached Resend Topic id (null until provisioned). */
22
+ topicId: string | null;
23
+ /** Digest-stream consent. */
24
+ digest: ClientConsentStatus;
25
+ /** Alert-stream consent. */
26
+ alert: ClientConsentStatus;
27
+ /** True when the mirror and Resend may have diverged (reconcile pending). */
28
+ dirty: boolean;
29
+ }
30
+ /** One broadcast issue in the `/read/broadcast-history` list. */
31
+ interface BroadcastHistoryItem {
32
+ /** Host-supplied broadcast key (one per issue). */
33
+ broadcastKey: string;
34
+ /** Resend broadcast id, once accepted + persisted; null in the crash gap. */
35
+ resendBroadcastId: string | null;
36
+ /** Host content item ids included in this issue. */
37
+ itemIds: string[];
38
+ /** ISO timestamp the issue was marked sent; null ⇒ unsent ⇒ resumable. */
39
+ sentAt: string | null;
40
+ /** ISO timestamp the claim row was created. */
41
+ createdAt: string;
42
+ }
43
+ /** `/read/broadcast-history` — most-recent-first list of broadcast issues for a program. */
44
+ interface BroadcastHistoryResponse {
45
+ items: BroadcastHistoryItem[];
46
+ }
47
+ /** `/read/analytics` — a minimal delivery/engagement contract (the richer model is deferred, see
48
+ * the plan's open question on analytics depth). The host's read endpoint owns the aggregation; the
49
+ * hook just surfaces whatever counters it returns plus an optional window echo. */
50
+ interface AnalyticsResponse {
51
+ /** Emails accepted by Resend in the window. */
52
+ sent: number;
53
+ /** Delivery webhooks observed. */
54
+ delivered: number;
55
+ /** Open events observed. */
56
+ opened: number;
57
+ /** Click events observed. */
58
+ clicked: number;
59
+ /** Bounces (hard + soft) observed. */
60
+ bounced: number;
61
+ /** Complaints / spam reports observed. */
62
+ complained: number;
63
+ /** Unsubscribes observed. */
64
+ unsubscribed: number;
65
+ /** Optional echo of the requested window (ISO dates), present when the host's endpoint sets it. */
66
+ window?: {
67
+ from: string | null;
68
+ to: string | null;
69
+ };
70
+ }
71
+ /** The state every read hook returns. `data` is null until the first successful load. */
72
+ interface ReadState<T> {
73
+ /** The last successfully fetched value, or null before the first success / after a reset. */
74
+ data: T | null;
75
+ /** A truthy error from the last attempt, or null on success. The message is host-controlled (the
76
+ * read endpoint's body) when the response was a non-2xx; a transport failure surfaces its own. */
77
+ error: Error | null;
78
+ /** True while a request is in flight (including the initial load and any `refetch`). */
79
+ loading: boolean;
80
+ /** Re-run the fetch, bypassing the cache (e.g. after the host mutated state via a server fn). */
81
+ refetch: () => void;
82
+ }
83
+ /** Options common to every hook. */
84
+ interface ReadHookOptions {
85
+ /**
86
+ * The mount base of the host's catch-all route (e.g. "/api/envoy"). The hook appends
87
+ * `/read/<resource>`. Required — the SDK is mount-agnostic and cannot guess the host's path.
88
+ */
89
+ basePath: string;
90
+ /**
91
+ * Skip fetching while false (e.g. gate a hook on a not-yet-known key, the React-Query `enabled`
92
+ * convention). Defaults to true.
93
+ */
94
+ enabled?: boolean;
95
+ }
96
+ /** Test/SSR seam: clear the shared read cache (used by tests; harmless in prod). */
97
+ declare function __clearReadCache(): void;
98
+ /** Args for {@link useProgramState}. */
99
+ interface UseProgramStateArgs extends ReadHookOptions {
100
+ /** Broadcast program key (a `defineBroadcastProgram` key). */
101
+ programKey: string;
102
+ /** Subject the watermark advances over (often "default" for a simple newsletter). */
103
+ subjectKey: string;
104
+ }
105
+ /**
106
+ * Read the broadcast cursor state for one (programKey, subjectKey) via `/read/program-state`. Use it
107
+ * on an admin screen to show the watermark / issue sequence / last-fired health timestamp / paused
108
+ * flag. Read-only — pause/advance happen through server fns.
109
+ */
110
+ declare function useProgramState(args: UseProgramStateArgs): ReadState<ProgramStateResponse>;
111
+ /** Args for {@link useConsent}. */
112
+ interface UseConsentArgs extends ReadHookOptions {
113
+ /** Contact email. */
114
+ email: string;
115
+ /** Topic key to read the mirror row for. */
116
+ topicKey: string;
117
+ }
118
+ /**
119
+ * Read the consent mirror row for one (contact, topic) via `/read/consent`. Surfaces the per-stream
120
+ * (digest/alert) statuses and the dirty flag so an admin can see what the gate will decide. The
121
+ * mirror is authoritative — this reflects exactly what a send checks.
122
+ */
123
+ declare function useConsent(args: UseConsentArgs): ReadState<ConsentResponse>;
124
+ /** Args for {@link useBroadcastHistory}. */
125
+ interface UseBroadcastHistoryArgs extends ReadHookOptions {
126
+ /** Broadcast program key to list issues for. */
127
+ programKey: string;
128
+ /** Max issues to fetch (the host endpoint caps/paginates; this is a hint). */
129
+ limit?: number;
130
+ }
131
+ /**
132
+ * Read the broadcast issue history for a program via `/read/broadcast-history` (most-recent-first).
133
+ * Each item is a claim row: key, Resend id, item ids, sent/created timestamps. Read-only.
134
+ */
135
+ declare function useBroadcastHistory(args: UseBroadcastHistoryArgs): ReadState<BroadcastHistoryResponse>;
136
+ /** Args for {@link useAnalytics}. */
137
+ interface UseAnalyticsArgs extends ReadHookOptions {
138
+ /** Optional ISO window start (inclusive). */
139
+ from?: string;
140
+ /** Optional ISO window end (inclusive). */
141
+ to?: string;
142
+ /** Optional stream filter ("digest" / "alert" / a custom stream name). */
143
+ stream?: string;
144
+ }
145
+ /**
146
+ * Read delivery/engagement counters via `/read/analytics`. The contract here is intentionally
147
+ * minimal (the richer analytics model is deferred); the host's endpoint owns the aggregation and
148
+ * may echo the requested window. Read-only.
149
+ */
150
+ declare function useAnalytics(args?: UseAnalyticsArgs): ReadState<AnalyticsResponse>;
151
+
152
+ export { type AnalyticsResponse, type BroadcastHistoryItem, type BroadcastHistoryResponse, type ClientConsentStatus, type ConsentResponse, type ProgramStateResponse, type ReadHookOptions, type ReadState, SDK_CLIENT_VERSION, type UseAnalyticsArgs, type UseBroadcastHistoryArgs, type UseConsentArgs, type UseProgramStateArgs, __clearReadCache, useAnalytics, useBroadcastHistory, useConsent, useProgramState };
@@ -0,0 +1,160 @@
1
+ "use client";
2
+ "use client";
3
+
4
+ // src/client/index.ts
5
+ import { useCallback, useEffect, useRef, useState } from "react";
6
+ var SDK_CLIENT_VERSION = "0.0.0";
7
+ var readCache = /* @__PURE__ */ new Map();
8
+ function __clearReadCache() {
9
+ readCache.clear();
10
+ }
11
+ function buildReadUrl(basePath, resource, params) {
12
+ const base = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
13
+ const search = new URLSearchParams();
14
+ for (const [key, raw] of Object.entries(params)) {
15
+ if (raw === void 0) continue;
16
+ search.set(key, String(raw));
17
+ }
18
+ const qs = search.toString();
19
+ return `${base}/read/${resource}${qs ? `?${qs}` : ""}`;
20
+ }
21
+ async function fetchRead(url, signal) {
22
+ let res;
23
+ try {
24
+ res = await fetch(url, {
25
+ method: "GET",
26
+ // The `/read` gate is the host's `authorize(req)` over the browser's existing session, so the
27
+ // request must carry credentials. Same-origin only — the SDK never reads cross-origin.
28
+ credentials: "same-origin",
29
+ headers: { accept: "application/json" },
30
+ signal
31
+ });
32
+ } catch (err) {
33
+ throw err instanceof Error ? err : new Error(String(err));
34
+ }
35
+ if (!res.ok) {
36
+ let detail = "";
37
+ try {
38
+ detail = (await res.text()).trim();
39
+ } catch {
40
+ detail = "";
41
+ }
42
+ throw new Error(detail.length > 0 ? detail : `Read request failed (${res.status}).`);
43
+ }
44
+ try {
45
+ return await res.json();
46
+ } catch {
47
+ throw new Error("Read response was not valid JSON.");
48
+ }
49
+ }
50
+ function useRead(url, enabled) {
51
+ const [data, setData] = useState(() => {
52
+ if (url === null) return null;
53
+ const cached = readCache.get(url);
54
+ return cached?.value ?? null;
55
+ });
56
+ const [error, setError] = useState(null);
57
+ const [loading, setLoading] = useState(false);
58
+ const [nonce, setNonce] = useState(0);
59
+ const mountedRef = useRef(true);
60
+ useEffect(() => {
61
+ mountedRef.current = true;
62
+ return () => {
63
+ mountedRef.current = false;
64
+ };
65
+ }, []);
66
+ const refetch = useCallback(() => {
67
+ if (url !== null) readCache.delete(url);
68
+ setNonce((n) => n + 1);
69
+ }, [url]);
70
+ useEffect(() => {
71
+ if (!enabled || url === null) {
72
+ setLoading(false);
73
+ return;
74
+ }
75
+ const controller = new AbortController();
76
+ let active = true;
77
+ const cached = readCache.get(url);
78
+ if (cached && cached.value !== void 0 && cached.error === void 0) {
79
+ setData(cached.value);
80
+ setError(null);
81
+ setLoading(false);
82
+ return () => {
83
+ active = false;
84
+ controller.abort();
85
+ };
86
+ }
87
+ setLoading(true);
88
+ setError(null);
89
+ let entry = readCache.get(url);
90
+ if (!entry || entry.promise === void 0) {
91
+ entry = entry ?? {};
92
+ entry.promise = fetchRead(url, controller.signal).then((value) => {
93
+ entry.value = value;
94
+ entry.error = void 0;
95
+ }).catch((err) => {
96
+ if (isAbortError(err)) return;
97
+ entry.error = err instanceof Error ? err : new Error(String(err));
98
+ entry.value = void 0;
99
+ }).finally(() => {
100
+ entry.promise = void 0;
101
+ });
102
+ readCache.set(url, entry);
103
+ }
104
+ entry.promise.then(() => {
105
+ if (!active || !mountedRef.current) return;
106
+ const settled = readCache.get(url);
107
+ if (settled?.error !== void 0) {
108
+ setError(settled.error);
109
+ setData(null);
110
+ } else if (settled?.value !== void 0) {
111
+ setData(settled.value);
112
+ setError(null);
113
+ }
114
+ setLoading(false);
115
+ }).catch(() => {
116
+ if (active && mountedRef.current) setLoading(false);
117
+ });
118
+ return () => {
119
+ active = false;
120
+ controller.abort();
121
+ };
122
+ }, [url, enabled, nonce]);
123
+ return { data, error, loading, refetch };
124
+ }
125
+ function isAbortError(err) {
126
+ return err instanceof Error && (err.name === "AbortError" || err.message.toLowerCase().includes("abort"));
127
+ }
128
+ function useProgramState(args) {
129
+ const { basePath, enabled = true, programKey, subjectKey } = args;
130
+ const ready = programKey.length > 0 && subjectKey.length > 0;
131
+ const url = ready ? buildReadUrl(basePath, "program-state", { programKey, subjectKey }) : null;
132
+ return useRead(url, enabled);
133
+ }
134
+ function useConsent(args) {
135
+ const { basePath, enabled = true, email, topicKey } = args;
136
+ const ready = email.length > 0 && topicKey.length > 0;
137
+ const url = ready ? buildReadUrl(basePath, "consent", { email, topicKey }) : null;
138
+ return useRead(url, enabled);
139
+ }
140
+ function useBroadcastHistory(args) {
141
+ const { basePath, enabled = true, programKey, limit } = args;
142
+ const ready = programKey.length > 0;
143
+ const url = ready ? buildReadUrl(basePath, "broadcast-history", { programKey, limit }) : null;
144
+ return useRead(url, enabled);
145
+ }
146
+ function useAnalytics(args = { basePath: "" }) {
147
+ const { basePath, enabled = true, from, to, stream } = args;
148
+ const ready = basePath.length > 0;
149
+ const url = ready ? buildReadUrl(basePath, "analytics", { from, to, stream }) : null;
150
+ return useRead(url, enabled);
151
+ }
152
+ export {
153
+ SDK_CLIENT_VERSION,
154
+ __clearReadCache,
155
+ useAnalytics,
156
+ useBroadcastHistory,
157
+ useConsent,
158
+ useProgramState
159
+ };
160
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/client/index.ts"],"sourcesContent":["\"use client\";\n\n// @catalystiq/envoy-sdk/client — React hooks entry (read-only state for host-built admin screens).\n//\n// U17 / origin R4. These are the ONLY hooks the SDK ships. They are deliberately read-only:\n// every mutation (enroll, consent.set, broadcast trigger, …) goes through the typed SERVER\n// functions on the `Envoy` handle (R3), never through a hook. A hook that could write would\n// duplicate the server-side consent gate and namespace boundary in the browser, where the host's\n// `authorize` has already run but the SDK's invariants are not enforceable — so writes stay server-\n// side and the client surface is pure reads.\n//\n// Each hook fetches one of the mounted route's `/read/*` endpoints (the route factory, U4, gates\n// `/read` with the host's `authorize(req)` — the SAME session cookie the browser already carries,\n// so these `fetch` calls inherit credentials with `credentials: \"same-origin\"`). The host mounts\n// the catch-all anywhere, so the hooks take a `basePath` (the mount base, e.g. \"/api/envoy\"); the\n// hook appends `/read/<resource>`. No server-only code is imported here — this module compiles into\n// the client bundle (tsup re-injects the \"use client\" banner since esbuild strips top-of-file\n// directives). The response shapes below are the WIRE shapes (JSON the server fns serialize); we\n// redefine them locally rather than import the server types so `./client` pulls in nothing from the\n// server entry.\n//\n// Fetch strategy is intentionally minimal (the unit calls for \"plain fetch + a tiny cache\"): a\n// process-level in-flight/result cache keyed by the request URL dedups concurrent identical reads\n// and lets a remount read the last value synchronously, with an explicit `refetch()` to invalidate.\n// No SWR/React-Query dependency — the SDK ships zero client runtime deps.\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nexport const SDK_CLIENT_VERSION = \"0.0.0\";\n\n// ---------------------------------------------------------------------------------------------\n// Wire shapes (the JSON the `/read/*` endpoints return). Mirrors of the server types, redefined\n// here so the client bundle imports nothing server-only.\n// ---------------------------------------------------------------------------------------------\n\n/** Per-stream consent value. Mirrors the server `ConsentStatus`. */\nexport type ClientConsentStatus = \"opt_in\" | \"opt_out\" | \"unsubscribed\";\n\n/** `/read/program-state` — the broadcast cursor state for one (programKey, subjectKey). */\nexport interface ProgramStateResponse {\n /** High-water mark over the host's ordering column; null for a never-fired (program, subject). */\n watermark: string | null;\n /** Monotonic issue sequence (0 for never-seen). */\n issueSeq: number;\n /** ISO timestamp of the last real send; null when never fired. A stale value is a health signal. */\n lastFiredAt: string | null;\n /** Whether the host has paused this (program, subject). */\n paused: boolean;\n}\n\n/** `/read/consent` — the consent mirror row for one (contact, topic). */\nexport interface ConsentResponse {\n /** Contact email (the host already authorized the viewer; the SDK does not redact in its own UI). */\n contact: string;\n /** Topic key the row is scoped to. */\n topicKey: string;\n /** Cached Resend Topic id (null until provisioned). */\n topicId: string | null;\n /** Digest-stream consent. */\n digest: ClientConsentStatus;\n /** Alert-stream consent. */\n alert: ClientConsentStatus;\n /** True when the mirror and Resend may have diverged (reconcile pending). */\n dirty: boolean;\n}\n\n/** One broadcast issue in the `/read/broadcast-history` list. */\nexport interface BroadcastHistoryItem {\n /** Host-supplied broadcast key (one per issue). */\n broadcastKey: string;\n /** Resend broadcast id, once accepted + persisted; null in the crash gap. */\n resendBroadcastId: string | null;\n /** Host content item ids included in this issue. */\n itemIds: string[];\n /** ISO timestamp the issue was marked sent; null ⇒ unsent ⇒ resumable. */\n sentAt: string | null;\n /** ISO timestamp the claim row was created. */\n createdAt: string;\n}\n\n/** `/read/broadcast-history` — most-recent-first list of broadcast issues for a program. */\nexport interface BroadcastHistoryResponse {\n items: BroadcastHistoryItem[];\n}\n\n/** `/read/analytics` — a minimal delivery/engagement contract (the richer model is deferred, see\n * the plan's open question on analytics depth). The host's read endpoint owns the aggregation; the\n * hook just surfaces whatever counters it returns plus an optional window echo. */\nexport interface AnalyticsResponse {\n /** Emails accepted by Resend in the window. */\n sent: number;\n /** Delivery webhooks observed. */\n delivered: number;\n /** Open events observed. */\n opened: number;\n /** Click events observed. */\n clicked: number;\n /** Bounces (hard + soft) observed. */\n bounced: number;\n /** Complaints / spam reports observed. */\n complained: number;\n /** Unsubscribes observed. */\n unsubscribed: number;\n /** Optional echo of the requested window (ISO dates), present when the host's endpoint sets it. */\n window?: { from: string | null; to: string | null };\n}\n\n// ---------------------------------------------------------------------------------------------\n// Shared hook machinery\n// ---------------------------------------------------------------------------------------------\n\n/** The state every read hook returns. `data` is null until the first successful load. */\nexport interface ReadState<T> {\n /** The last successfully fetched value, or null before the first success / after a reset. */\n data: T | null;\n /** A truthy error from the last attempt, or null on success. The message is host-controlled (the\n * read endpoint's body) when the response was a non-2xx; a transport failure surfaces its own. */\n error: Error | null;\n /** True while a request is in flight (including the initial load and any `refetch`). */\n loading: boolean;\n /** Re-run the fetch, bypassing the cache (e.g. after the host mutated state via a server fn). */\n refetch: () => void;\n}\n\n/** Options common to every hook. */\nexport interface ReadHookOptions {\n /**\n * The mount base of the host's catch-all route (e.g. \"/api/envoy\"). The hook appends\n * `/read/<resource>`. Required — the SDK is mount-agnostic and cannot guess the host's path.\n */\n basePath: string;\n /**\n * Skip fetching while false (e.g. gate a hook on a not-yet-known key, the React-Query `enabled`\n * convention). Defaults to true.\n */\n enabled?: boolean;\n}\n\ninterface CacheEntry<T> {\n /** Resolved value, if a fetch has completed for this URL. */\n value?: T;\n /** Error, if the last fetch for this URL failed. */\n error?: Error;\n /** In-flight promise, so concurrent hooks reading the same URL share one request. */\n promise?: Promise<void>;\n}\n\n// Module-level cache keyed by absolute request URL. Tiny by design — no eviction policy; an admin\n// surface reads a bounded set of keys, and `refetch` clears an entry explicitly. Kept off any React\n// context so multiple independent hook instances dedup naturally.\nconst readCache = new Map<string, CacheEntry<unknown>>();\n\n/** Test/SSR seam: clear the shared read cache (used by tests; harmless in prod). */\nexport function __clearReadCache(): void {\n readCache.clear();\n}\n\n/**\n * Build the absolute `/read/<resource>` URL. `basePath` is trimmed of a trailing slash; `params`\n * are appended as a query string (undefined values dropped). A leading-slash-less basePath is left\n * as-is (relative URLs are valid for same-origin `fetch`).\n */\nfunction buildReadUrl(\n basePath: string,\n resource: string,\n params: Record<string, string | number | undefined>\n): string {\n const base = basePath.endsWith(\"/\") ? basePath.slice(0, -1) : basePath;\n const search = new URLSearchParams();\n for (const [key, raw] of Object.entries(params)) {\n if (raw === undefined) continue;\n search.set(key, String(raw));\n }\n const qs = search.toString();\n return `${base}/read/${resource}${qs ? `?${qs}` : \"\"}`;\n}\n\n/**\n * Fetch one read URL, sharing in-flight requests via {@link readCache}. On a non-2xx the body text\n * becomes the error message (falling back to the status line) so a host-emitted error reaches the\n * hook's `error` state rather than a generic failure. A network throw surfaces verbatim.\n */\nasync function fetchRead<T>(url: string, signal: AbortSignal): Promise<T> {\n let res: Response;\n try {\n res = await fetch(url, {\n method: \"GET\",\n // The `/read` gate is the host's `authorize(req)` over the browser's existing session, so the\n // request must carry credentials. Same-origin only — the SDK never reads cross-origin.\n credentials: \"same-origin\",\n headers: { accept: \"application/json\" },\n signal,\n });\n } catch (err) {\n // Transport failure (offline, DNS, abort). An AbortError is rethrown so the caller can ignore it.\n throw err instanceof Error ? err : new Error(String(err));\n }\n\n if (!res.ok) {\n let detail = \"\";\n try {\n detail = (await res.text()).trim();\n } catch {\n detail = \"\";\n }\n throw new Error(detail.length > 0 ? detail : `Read request failed (${res.status}).`);\n }\n\n try {\n return (await res.json()) as T;\n } catch {\n throw new Error(\"Read response was not valid JSON.\");\n }\n}\n\n/**\n * The shared read hook. Fetches `url` (when `enabled`), exposing `{ data, error, loading, refetch }`.\n * A `null` url (a not-yet-resolvable key) holds the hook in an idle, non-loading state. Concurrent\n * instances of the same url share one request via the module cache; `refetch` drops the cache entry\n * and re-requests. An in-flight request is aborted on unmount / url change so a late resolve never\n * sets state on an unmounted component.\n */\nfunction useRead<T>(url: string | null, enabled: boolean): ReadState<T> {\n const [data, setData] = useState<T | null>(() => {\n if (url === null) return null;\n const cached = readCache.get(url);\n return (cached?.value as T | undefined) ?? null;\n });\n const [error, setError] = useState<Error | null>(null);\n const [loading, setLoading] = useState<boolean>(false);\n\n // Bump to force a cache-bypassing refetch.\n const [nonce, setNonce] = useState(0);\n\n // Track mount so an async resolve after unmount is a no-op (avoids the React \"set state on\n // unmounted component\" class of bug without leaning on a ref-to-isMounted hack per call).\n const mountedRef = useRef(true);\n useEffect(() => {\n mountedRef.current = true;\n return () => {\n mountedRef.current = false;\n };\n }, []);\n\n const refetch = useCallback(() => {\n if (url !== null) readCache.delete(url);\n setNonce((n) => n + 1);\n }, [url]);\n\n useEffect(() => {\n if (!enabled || url === null) {\n // Idle: not loading, no error, keep any prior data so a disable→enable flip doesn't flash.\n setLoading(false);\n return;\n }\n\n const controller = new AbortController();\n let active = true;\n\n const cached = readCache.get(url);\n if (cached && cached.value !== undefined && cached.error === undefined) {\n // Synchronous cache hit — surface immediately, no spinner.\n setData(cached.value as T);\n setError(null);\n setLoading(false);\n return () => {\n active = false;\n controller.abort();\n };\n }\n\n setLoading(true);\n setError(null);\n\n // Reuse an in-flight request for this url if one exists; otherwise start one and memoize it.\n let entry = readCache.get(url);\n if (!entry || entry.promise === undefined) {\n entry = entry ?? {};\n entry.promise = fetchRead<T>(url, controller.signal)\n .then((value) => {\n entry!.value = value;\n entry!.error = undefined;\n })\n .catch((err: unknown) => {\n // An abort is not a real error — leave the entry untouched so a later mount can retry.\n if (isAbortError(err)) return;\n entry!.error = err instanceof Error ? err : new Error(String(err));\n entry!.value = undefined;\n })\n .finally(() => {\n entry!.promise = undefined;\n });\n readCache.set(url, entry);\n }\n\n entry.promise\n .then(() => {\n if (!active || !mountedRef.current) return;\n const settled = readCache.get(url);\n if (settled?.error !== undefined) {\n setError(settled.error);\n setData(null);\n } else if (settled?.value !== undefined) {\n setData(settled.value as T);\n setError(null);\n }\n setLoading(false);\n })\n .catch(() => {\n // The shared promise never rejects (errors are captured onto the entry); this is a guard.\n if (active && mountedRef.current) setLoading(false);\n });\n\n return () => {\n active = false;\n controller.abort();\n };\n // `nonce` re-runs the effect after a `refetch` (the entry was just deleted, forcing a real fetch).\n }, [url, enabled, nonce]);\n\n return { data, error, loading, refetch };\n}\n\nfunction isAbortError(err: unknown): boolean {\n return (\n err instanceof Error && (err.name === \"AbortError\" || err.message.toLowerCase().includes(\"abort\"))\n );\n}\n\n// ---------------------------------------------------------------------------------------------\n// The four read hooks (R4)\n// ---------------------------------------------------------------------------------------------\n\n/** Args for {@link useProgramState}. */\nexport interface UseProgramStateArgs extends ReadHookOptions {\n /** Broadcast program key (a `defineBroadcastProgram` key). */\n programKey: string;\n /** Subject the watermark advances over (often \"default\" for a simple newsletter). */\n subjectKey: string;\n}\n\n/**\n * Read the broadcast cursor state for one (programKey, subjectKey) via `/read/program-state`. Use it\n * on an admin screen to show the watermark / issue sequence / last-fired health timestamp / paused\n * flag. Read-only — pause/advance happen through server fns.\n */\nexport function useProgramState(args: UseProgramStateArgs): ReadState<ProgramStateResponse> {\n const { basePath, enabled = true, programKey, subjectKey } = args;\n const ready = programKey.length > 0 && subjectKey.length > 0;\n const url = ready ? buildReadUrl(basePath, \"program-state\", { programKey, subjectKey }) : null;\n return useRead<ProgramStateResponse>(url, enabled);\n}\n\n/** Args for {@link useConsent}. */\nexport interface UseConsentArgs extends ReadHookOptions {\n /** Contact email. */\n email: string;\n /** Topic key to read the mirror row for. */\n topicKey: string;\n}\n\n/**\n * Read the consent mirror row for one (contact, topic) via `/read/consent`. Surfaces the per-stream\n * (digest/alert) statuses and the dirty flag so an admin can see what the gate will decide. The\n * mirror is authoritative — this reflects exactly what a send checks.\n */\nexport function useConsent(args: UseConsentArgs): ReadState<ConsentResponse> {\n const { basePath, enabled = true, email, topicKey } = args;\n const ready = email.length > 0 && topicKey.length > 0;\n const url = ready ? buildReadUrl(basePath, \"consent\", { email, topicKey }) : null;\n return useRead<ConsentResponse>(url, enabled);\n}\n\n/** Args for {@link useBroadcastHistory}. */\nexport interface UseBroadcastHistoryArgs extends ReadHookOptions {\n /** Broadcast program key to list issues for. */\n programKey: string;\n /** Max issues to fetch (the host endpoint caps/paginates; this is a hint). */\n limit?: number;\n}\n\n/**\n * Read the broadcast issue history for a program via `/read/broadcast-history` (most-recent-first).\n * Each item is a claim row: key, Resend id, item ids, sent/created timestamps. Read-only.\n */\nexport function useBroadcastHistory(\n args: UseBroadcastHistoryArgs\n): ReadState<BroadcastHistoryResponse> {\n const { basePath, enabled = true, programKey, limit } = args;\n const ready = programKey.length > 0;\n const url = ready ? buildReadUrl(basePath, \"broadcast-history\", { programKey, limit }) : null;\n return useRead<BroadcastHistoryResponse>(url, enabled);\n}\n\n/** Args for {@link useAnalytics}. */\nexport interface UseAnalyticsArgs extends ReadHookOptions {\n /** Optional ISO window start (inclusive). */\n from?: string;\n /** Optional ISO window end (inclusive). */\n to?: string;\n /** Optional stream filter (\"digest\" / \"alert\" / a custom stream name). */\n stream?: string;\n}\n\n/**\n * Read delivery/engagement counters via `/read/analytics`. The contract here is intentionally\n * minimal (the richer analytics model is deferred); the host's endpoint owns the aggregation and\n * may echo the requested window. Read-only.\n */\nexport function useAnalytics(args: UseAnalyticsArgs = { basePath: \"\" }): ReadState<AnalyticsResponse> {\n const { basePath, enabled = true, from, to, stream } = args;\n const ready = basePath.length > 0;\n const url = ready ? buildReadUrl(basePath, \"analytics\", { from, to, stream }) : null;\n return useRead<AnalyticsResponse>(url, enabled);\n}\n"],"mappings":";;;;AA0BA,SAAS,aAAa,WAAW,QAAQ,gBAAgB;AAElD,IAAM,qBAAqB;AA0HlC,IAAM,YAAY,oBAAI,IAAiC;AAGhD,SAAS,mBAAyB;AACvC,YAAU,MAAM;AAClB;AAOA,SAAS,aACP,UACA,UACA,QACQ;AACR,QAAM,OAAO,SAAS,SAAS,GAAG,IAAI,SAAS,MAAM,GAAG,EAAE,IAAI;AAC9D,QAAM,SAAS,IAAI,gBAAgB;AACnC,aAAW,CAAC,KAAK,GAAG,KAAK,OAAO,QAAQ,MAAM,GAAG;AAC/C,QAAI,QAAQ,OAAW;AACvB,WAAO,IAAI,KAAK,OAAO,GAAG,CAAC;AAAA,EAC7B;AACA,QAAM,KAAK,OAAO,SAAS;AAC3B,SAAO,GAAG,IAAI,SAAS,QAAQ,GAAG,KAAK,IAAI,EAAE,KAAK,EAAE;AACtD;AAOA,eAAe,UAAa,KAAa,QAAiC;AACxE,MAAI;AACJ,MAAI;AACF,UAAM,MAAM,MAAM,KAAK;AAAA,MACrB,QAAQ;AAAA;AAAA;AAAA,MAGR,aAAa;AAAA,MACb,SAAS,EAAE,QAAQ,mBAAmB;AAAA,MACtC;AAAA,IACF,CAAC;AAAA,EACH,SAAS,KAAK;AAEZ,UAAM,eAAe,QAAQ,MAAM,IAAI,MAAM,OAAO,GAAG,CAAC;AAAA,EAC1D;AAEA,MAAI,CAAC,IAAI,IAAI;AACX,QAAI,SAAS;AACb,QAAI;AACF,gBAAU,MAAM,IAAI,KAAK,GAAG,KAAK;AAAA,IACnC,QAAQ;AACN,eAAS;AAAA,IACX;AACA,UAAM,IAAI,MAAM,OAAO,SAAS,IAAI,SAAS,wBAAwB,IAAI,MAAM,IAAI;AAAA,EACrF;AAEA,MAAI;AACF,WAAQ,MAAM,IAAI,KAAK;AAAA,EACzB,QAAQ;AACN,UAAM,IAAI,MAAM,mCAAmC;AAAA,EACrD;AACF;AASA,SAAS,QAAW,KAAoB,SAAgC;AACtE,QAAM,CAAC,MAAM,OAAO,IAAI,SAAmB,MAAM;AAC/C,QAAI,QAAQ,KAAM,QAAO;AACzB,UAAM,SAAS,UAAU,IAAI,GAAG;AAChC,WAAQ,QAAQ,SAA2B;AAAA,EAC7C,CAAC;AACD,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAuB,IAAI;AACrD,QAAM,CAAC,SAAS,UAAU,IAAI,SAAkB,KAAK;AAGrD,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAS,CAAC;AAIpC,QAAM,aAAa,OAAO,IAAI;AAC9B,YAAU,MAAM;AACd,eAAW,UAAU;AACrB,WAAO,MAAM;AACX,iBAAW,UAAU;AAAA,IACvB;AAAA,EACF,GAAG,CAAC,CAAC;AAEL,QAAM,UAAU,YAAY,MAAM;AAChC,QAAI,QAAQ,KAAM,WAAU,OAAO,GAAG;AACtC,aAAS,CAAC,MAAM,IAAI,CAAC;AAAA,EACvB,GAAG,CAAC,GAAG,CAAC;AAER,YAAU,MAAM;AACd,QAAI,CAAC,WAAW,QAAQ,MAAM;AAE5B,iBAAW,KAAK;AAChB;AAAA,IACF;AAEA,UAAM,aAAa,IAAI,gBAAgB;AACvC,QAAI,SAAS;AAEb,UAAM,SAAS,UAAU,IAAI,GAAG;AAChC,QAAI,UAAU,OAAO,UAAU,UAAa,OAAO,UAAU,QAAW;AAEtE,cAAQ,OAAO,KAAU;AACzB,eAAS,IAAI;AACb,iBAAW,KAAK;AAChB,aAAO,MAAM;AACX,iBAAS;AACT,mBAAW,MAAM;AAAA,MACnB;AAAA,IACF;AAEA,eAAW,IAAI;AACf,aAAS,IAAI;AAGb,QAAI,QAAQ,UAAU,IAAI,GAAG;AAC7B,QAAI,CAAC,SAAS,MAAM,YAAY,QAAW;AACzC,cAAQ,SAAS,CAAC;AAClB,YAAM,UAAU,UAAa,KAAK,WAAW,MAAM,EAChD,KAAK,CAAC,UAAU;AACf,cAAO,QAAQ;AACf,cAAO,QAAQ;AAAA,MACjB,CAAC,EACA,MAAM,CAAC,QAAiB;AAEvB,YAAI,aAAa,GAAG,EAAG;AACvB,cAAO,QAAQ,eAAe,QAAQ,MAAM,IAAI,MAAM,OAAO,GAAG,CAAC;AACjE,cAAO,QAAQ;AAAA,MACjB,CAAC,EACA,QAAQ,MAAM;AACb,cAAO,UAAU;AAAA,MACnB,CAAC;AACH,gBAAU,IAAI,KAAK,KAAK;AAAA,IAC1B;AAEA,UAAM,QACH,KAAK,MAAM;AACV,UAAI,CAAC,UAAU,CAAC,WAAW,QAAS;AACpC,YAAM,UAAU,UAAU,IAAI,GAAG;AACjC,UAAI,SAAS,UAAU,QAAW;AAChC,iBAAS,QAAQ,KAAK;AACtB,gBAAQ,IAAI;AAAA,MACd,WAAW,SAAS,UAAU,QAAW;AACvC,gBAAQ,QAAQ,KAAU;AAC1B,iBAAS,IAAI;AAAA,MACf;AACA,iBAAW,KAAK;AAAA,IAClB,CAAC,EACA,MAAM,MAAM;AAEX,UAAI,UAAU,WAAW,QAAS,YAAW,KAAK;AAAA,IACpD,CAAC;AAEH,WAAO,MAAM;AACX,eAAS;AACT,iBAAW,MAAM;AAAA,IACnB;AAAA,EAEF,GAAG,CAAC,KAAK,SAAS,KAAK,CAAC;AAExB,SAAO,EAAE,MAAM,OAAO,SAAS,QAAQ;AACzC;AAEA,SAAS,aAAa,KAAuB;AAC3C,SACE,eAAe,UAAU,IAAI,SAAS,gBAAgB,IAAI,QAAQ,YAAY,EAAE,SAAS,OAAO;AAEpG;AAmBO,SAAS,gBAAgB,MAA4D;AAC1F,QAAM,EAAE,UAAU,UAAU,MAAM,YAAY,WAAW,IAAI;AAC7D,QAAM,QAAQ,WAAW,SAAS,KAAK,WAAW,SAAS;AAC3D,QAAM,MAAM,QAAQ,aAAa,UAAU,iBAAiB,EAAE,YAAY,WAAW,CAAC,IAAI;AAC1F,SAAO,QAA8B,KAAK,OAAO;AACnD;AAeO,SAAS,WAAW,MAAkD;AAC3E,QAAM,EAAE,UAAU,UAAU,MAAM,OAAO,SAAS,IAAI;AACtD,QAAM,QAAQ,MAAM,SAAS,KAAK,SAAS,SAAS;AACpD,QAAM,MAAM,QAAQ,aAAa,UAAU,WAAW,EAAE,OAAO,SAAS,CAAC,IAAI;AAC7E,SAAO,QAAyB,KAAK,OAAO;AAC9C;AAcO,SAAS,oBACd,MACqC;AACrC,QAAM,EAAE,UAAU,UAAU,MAAM,YAAY,MAAM,IAAI;AACxD,QAAM,QAAQ,WAAW,SAAS;AAClC,QAAM,MAAM,QAAQ,aAAa,UAAU,qBAAqB,EAAE,YAAY,MAAM,CAAC,IAAI;AACzF,SAAO,QAAkC,KAAK,OAAO;AACvD;AAiBO,SAAS,aAAa,OAAyB,EAAE,UAAU,GAAG,GAAiC;AACpG,QAAM,EAAE,UAAU,UAAU,MAAM,MAAM,IAAI,OAAO,IAAI;AACvD,QAAM,QAAQ,SAAS,SAAS;AAChC,QAAM,MAAM,QAAQ,aAAa,UAAU,aAAa,EAAE,MAAM,IAAI,OAAO,CAAC,IAAI;AAChF,SAAO,QAA2B,KAAK,OAAO;AAChD;","names":[]}