@marktiderman/genesis-native 0.1.0 → 0.3.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 (87) hide show
  1. package/README.md +354 -10
  2. package/dist/block-C5-noMhB.d.ts +223 -0
  3. package/dist/blocks-a1YQ1Elf.d.ts +1540 -0
  4. package/dist/chunk-65P7RU4V.js +49 -0
  5. package/dist/chunk-65P7RU4V.js.map +1 -0
  6. package/dist/chunk-6BT4PTGH.js +37 -0
  7. package/dist/chunk-6BT4PTGH.js.map +1 -0
  8. package/dist/chunk-75YP72G6.js +374 -0
  9. package/dist/chunk-75YP72G6.js.map +1 -0
  10. package/dist/chunk-NGYNYJPL.js +90 -0
  11. package/dist/chunk-NGYNYJPL.js.map +1 -0
  12. package/dist/chunk-VKTIHNAV.js +95 -0
  13. package/dist/chunk-VKTIHNAV.js.map +1 -0
  14. package/dist/{chunk-MMTPWUWI.js → chunk-VTJDIKFC.js} +9 -6
  15. package/dist/chunk-VTJDIKFC.js.map +1 -0
  16. package/dist/chunk-XE7LC7K6.js +29 -0
  17. package/dist/chunk-XE7LC7K6.js.map +1 -0
  18. package/dist/dev-warnings.d.ts +34 -0
  19. package/dist/dev-warnings.js +15 -0
  20. package/dist/dev-warnings.js.map +1 -0
  21. package/dist/devtools.d.ts +144 -0
  22. package/dist/devtools.js +390 -0
  23. package/dist/devtools.js.map +1 -0
  24. package/dist/index.d.ts +12 -368
  25. package/dist/index.js +88 -191
  26. package/dist/index.js.map +1 -1
  27. package/dist/observability.d.ts +170 -0
  28. package/dist/observability.js +123 -0
  29. package/dist/observability.js.map +1 -0
  30. package/dist/persist.d.ts +1 -1
  31. package/dist/persist.js +1 -1
  32. package/dist/push.d.ts +169 -0
  33. package/dist/push.js +996 -0
  34. package/dist/push.js.map +1 -0
  35. package/dist/{query-DhH3lLFa.d.ts → query-DUmVAfsF.d.ts} +11 -6
  36. package/dist/updates.d.ts +8 -1
  37. package/dist/updates.js +44 -69
  38. package/dist/updates.js.map +1 -1
  39. package/package.json +222 -5
  40. package/src/app.tsx +491 -0
  41. package/src/auth.tsx +176 -0
  42. package/src/blocks.tsx +144 -0
  43. package/src/boot.tsx +188 -0
  44. package/src/dev-warnings.ts +43 -0
  45. package/src/devtools/block.tsx +488 -0
  46. package/src/devtools/gestures.ts +107 -0
  47. package/src/devtools/index.ts +22 -0
  48. package/src/devtools/info.ts +45 -0
  49. package/src/devtools/slots.ts +21 -0
  50. package/src/errors.tsx +195 -0
  51. package/src/held-link.ts +90 -0
  52. package/src/hydration-gate.ts +88 -0
  53. package/src/index.ts +83 -0
  54. package/src/links.ts +140 -0
  55. package/src/nativewind.d.ts +41 -0
  56. package/src/observability/block.tsx +300 -0
  57. package/src/observability/events.ts +89 -0
  58. package/src/observability/index.ts +44 -0
  59. package/src/observability/scrub.ts +166 -0
  60. package/src/observability/sentry.ts +222 -0
  61. package/src/observability/sink.ts +147 -0
  62. package/src/persist.ts +83 -0
  63. package/src/push/backend.ts +129 -0
  64. package/src/push/block.tsx +545 -0
  65. package/src/push/index.ts +102 -0
  66. package/src/push/modules.ts +111 -0
  67. package/src/push/permission.ts +191 -0
  68. package/src/push/preferences.ts +136 -0
  69. package/src/push/quiet-hours.ts +71 -0
  70. package/src/push/registration.ts +189 -0
  71. package/src/push/routing.ts +27 -0
  72. package/src/push/screens.tsx +46 -0
  73. package/src/push/settings.tsx +378 -0
  74. package/src/query.ts +100 -0
  75. package/src/updates/block.tsx +442 -0
  76. package/src/updates/channels.ts +131 -0
  77. package/src/updates/checker.ts +190 -0
  78. package/src/updates/index.ts +87 -0
  79. package/src/updates/modules.ts +64 -0
  80. package/src/updates/policy.ts +314 -0
  81. package/src/updates/screens.tsx +159 -0
  82. package/src/updates/signature.ts +80 -0
  83. package/src/updates/store.ts +33 -0
  84. package/src/updates/version.ts +39 -0
  85. package/src/updates/whats-new.ts +122 -0
  86. package/dist/block-Dh5ny06W.d.ts +0 -535
  87. package/dist/chunk-MMTPWUWI.js.map +0 -1
@@ -0,0 +1,222 @@
1
+ /**
2
+ * `sentrySink(Sentry, options)`: Sentry through the observability seam.
3
+ * `@sentry/react-native` is an optional peer, passed in rather than
4
+ * imported (`import * as Sentry from "@sentry/react-native"`), so an app
5
+ * without it never bundles it.
6
+ *
7
+ * Calling it runs `Sentry.init` at once, so call it at module scope (where
8
+ * `createObservability` is built, before the root layout renders): errors
9
+ * thrown while the app's modules load and the shell's first render are then
10
+ * caught. A second call in the same process (Fast Refresh) reuses the client.
11
+ *
12
+ * What it sets up, on Expo SDK 58 with @sentry/react-native 8:
13
+ * - `expoRouterIntegration` (navigation spans, time to initial display);
14
+ * - release `<applicationId>@<version>+<build>` and dist `<build>` from the
15
+ * version signature (Sentry's own format, so release health lines up),
16
+ * with the update id, channel, runtime version and launch as tags;
17
+ * - the PII scrubber on every event, transaction, span, breadcrumb and log
18
+ * (`beforeSend`, `beforeSendTransaction`, `beforeSendSpan`,
19
+ * `beforeBreadcrumb`, `beforeSendLog`), and the user as an id only;
20
+ * - no default PII, screenshots, view hierarchy or accessibility labels, and
21
+ * touch breadcrumbs without the touched element's label (labels hold names);
22
+ * - fatal errors marked unhandled, so release health counts them as crashes.
23
+ *
24
+ * Native crashes (sentry-cocoa, sentry-android) are built on the device's
25
+ * native side and don't pass through these JavaScript hooks.
26
+ *
27
+ * Errors: the shell (`GenesisNativeApp`) reports every error it catches
28
+ * through `captureError`. Sentry's own global handler stays on for errors
29
+ * before the shell mounts; when both see one error object, Sentry's
30
+ * deduplication (`__sentry_captured__`) sends it once.
31
+ */
32
+ import type { ComponentType } from "react";
33
+ import type { VersionSignature } from "../updates/signature";
34
+ import { scrubEvent, scrubRecord, scrubString } from "./scrub";
35
+ import type { ObservabilitySink } from "./sink";
36
+
37
+ type Options = Record<string, unknown>;
38
+ type Hook<T> = (value: T, hint?: unknown) => T | null | PromiseLike<T | null>;
39
+
40
+ /**
41
+ * `@sentry/react-native`'s namespace: the calls this sink makes. Method
42
+ * syntax on purpose, so the real namespace (with wider option types) fits.
43
+ */
44
+ export interface SentryLike {
45
+ init(options: Options): unknown;
46
+ getClient(): unknown;
47
+ wrap<P extends Record<string, unknown>>(root: ComponentType<P>): ComponentType<P>;
48
+ captureException(error: unknown, context?: Options): string;
49
+ addBreadcrumb(breadcrumb: Options): void;
50
+ setUser(user: { id: string } | null): void;
51
+ setTag(key: string, value: string): void;
52
+ expoRouterIntegration?(options?: { enableTimeToInitialDisplay?: boolean }): unknown;
53
+ metrics?: {
54
+ distribution(name: string, value: number, options?: Options): void;
55
+ count(name: string, value?: number, options?: Options): void;
56
+ };
57
+ }
58
+
59
+ export interface SentrySinkOptions {
60
+ /** `process.env.EXPO_PUBLIC_SENTRY_DSN`. Empty or missing: Sentry is not started and the sink does nothing. */
61
+ dsn: string | undefined | null;
62
+ /** Default `development` in `__DEV__`, else from the signature's channel: `production`, `preview` (`preview` and `pr-*`), `development`, else `production`. */
63
+ environment?: string;
64
+ /** The version signature (`versionSignature(…)` from `/updates`, or the updates block's `signature`): release, dist and update tags. */
65
+ signature?: VersionSignature;
66
+ /** `Application.applicationId` (expo-application): the release's prefix. */
67
+ applicationId?: string | null;
68
+ /** Default 0.2 in production and 1 elsewhere. */
69
+ tracesSampleRate?: number;
70
+ /** Navigation spans and time to initial display through Expo Router. Default true. */
71
+ expoRouter?: boolean | { enableTimeToInitialDisplay?: boolean };
72
+ /** More integrations (replay, profiling). Masking is yours to set on them. */
73
+ integrations?: readonly unknown[];
74
+ /**
75
+ * Other `Sentry.init` options. The scrubbing hooks run first and yours
76
+ * after, on the scrubbed value; `sendDefaultPii` and the screenshot,
77
+ * view-hierarchy and accessibility options can be set here on purpose.
78
+ */
79
+ init?: Options;
80
+ /** False turns the scrubber off (not recommended; it is also the only thing reducing the user to an id). Default true. */
81
+ scrub?: boolean;
82
+ /** Called instead of `console.warn` when the DSN is missing outside development. */
83
+ onMissingDsn?: () => void;
84
+ }
85
+
86
+ declare const __DEV__: boolean | undefined;
87
+ const isDev = () => typeof __DEV__ !== "undefined" && __DEV__;
88
+
89
+ /** Sentry's release format: `<applicationId>@<version>+<build>`, with `dist` the build; `undefined` parts when unknown. */
90
+ export function sentryRelease(signature: VersionSignature | undefined, applicationId?: string | null): { release?: string; dist?: string } {
91
+ const version = signature?.version;
92
+ const build = signature?.build;
93
+ if (!version || !build || !applicationId) return {};
94
+ return { release: `${applicationId}@${version}+${build}`, dist: build };
95
+ }
96
+
97
+ /** The tags each event carries from the signature. Unknown values are written `unknown`, never left out. */
98
+ export function signatureTags(signature: VersionSignature | undefined): Record<string, string> {
99
+ if (!signature) return {};
100
+ return {
101
+ "update.launch": signature.launch,
102
+ "update.id": signature.launch === "update" ? (signature.updateId ?? "unknown") : signature.launch,
103
+ "update.channel": signature.requestedChannel ?? signature.channel ?? (signature.launch === "off" ? "none" : "unknown"),
104
+ "update.runtime": signature.runtimeVersion ?? "unknown",
105
+ ...(signature.emergency ? { "update.emergency": "true" } : {}),
106
+ };
107
+ }
108
+
109
+ /** One Sentry environment per kind of build, not one per channel (`pr-42` is `preview`). */
110
+ export function sentryEnvironment(channel: string | null | undefined): string {
111
+ if (!channel || channel === "production") return "production";
112
+ if (channel === "development") return "development";
113
+ if (channel === "preview" || /^pr-\d+$/.test(channel)) return "preview";
114
+ return "production";
115
+ }
116
+
117
+ /** A touch breadcrumb with the touched elements' names only: their labels (accessibility labels, test ids) can hold names. */
118
+ export function withoutTouchLabels<T extends { category?: unknown; message?: unknown; data?: unknown }>(crumb: T): T {
119
+ if (crumb.category !== "touch" || !crumb.data || typeof crumb.data !== "object") return crumb;
120
+ const data = crumb.data as { path?: unknown };
121
+ const path = Array.isArray(data.path) ? data.path.map((p) => (p && typeof p === "object" ? { name: (p as { name?: unknown }).name } : p)) : undefined;
122
+ const first = path?.[0] as { name?: unknown } | undefined;
123
+ return { ...crumb, message: `Touch event within element: ${typeof first?.name === "string" ? first.name : "unknown"}`, data: { ...data, ...(path ? { path } : {}) } };
124
+ }
125
+
126
+ function chain<T>(ours: (value: T) => T | null, theirs: unknown): Hook<T> {
127
+ return (value, hint) => {
128
+ const scrubbed = ours(value);
129
+ if (scrubbed === null) return null;
130
+ return typeof theirs === "function" ? (theirs as Hook<T>)(scrubbed, hint) : scrubbed;
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Start Sentry and return a sink for it. Without a DSN it starts nothing and
136
+ * returns a sink that does nothing (a warning outside development says so).
137
+ *
138
+ * @stability Beta
139
+ */
140
+ export function sentrySink(sentry: SentryLike, options: SentrySinkOptions): ObservabilitySink {
141
+ const { dsn, signature, applicationId, scrub = true } = options;
142
+ if (!dsn) {
143
+ if (!isDev()) (options.onMissingDsn ?? (() => console.warn("[observability] EXPO_PUBLIC_SENTRY_DSN is not set: errors are not reported to Sentry.")))();
144
+ return { name: "sentry (no DSN)" };
145
+ }
146
+ const environment = options.environment ?? (isDev() ? "development" : sentryEnvironment(signature?.requestedChannel ?? signature?.channel));
147
+ // Fast Refresh runs this module again: a second init would stack integrations on the live client.
148
+ if (!sentry.getClient()) {
149
+ const extra = options.init ?? {};
150
+ const router = options.expoRouter ?? true;
151
+ const integrations = [
152
+ ...(router && sentry.expoRouterIntegration
153
+ ? [sentry.expoRouterIntegration({ enableTimeToInitialDisplay: typeof router === "object" ? router.enableTimeToInitialDisplay : true })]
154
+ : []),
155
+ ...(options.integrations ?? []),
156
+ ...(Array.isArray(extra.integrations) ? extra.integrations : []),
157
+ ];
158
+ const keep = <T>(value: T) => value;
159
+ const span = (value: Options): Options => {
160
+ const scrubbed = scrubRecord(value);
161
+ // A span can't be dropped here: fail closed by sending it without its description and data.
162
+ return scrubbed ?? { ...value, description: undefined, data: {} };
163
+ };
164
+ sentry.init({
165
+ sendDefaultPii: false,
166
+ attachScreenshot: false,
167
+ attachViewHierarchy: false,
168
+ reportAccessibilityIdentifier: false,
169
+ tracesSampleRate: options.tracesSampleRate ?? (environment === "production" ? 0.2 : 1),
170
+ ...extra,
171
+ dsn,
172
+ environment,
173
+ ...sentryRelease(signature, applicationId),
174
+ integrations,
175
+ beforeSend: chain(scrub ? scrubEvent : keep, extra.beforeSend),
176
+ beforeSendTransaction: chain(scrub ? scrubEvent : keep, extra.beforeSendTransaction),
177
+ beforeBreadcrumb: chain<Options>((crumb) => (scrub ? scrubRecord(withoutTouchLabels(crumb)) : crumb), extra.beforeBreadcrumb),
178
+ beforeSendLog: chain(scrub ? scrubRecord : keep, extra.beforeSendLog),
179
+ beforeSendMetric: chain(scrub ? scrubRecord : keep, extra.beforeSendMetric),
180
+ beforeSendSpan: (value: Options) => {
181
+ const scrubbed = scrub ? span(value) : value;
182
+ return typeof extra.beforeSendSpan === "function" ? (extra.beforeSendSpan as (s: Options) => Options)(scrubbed) : scrubbed;
183
+ },
184
+ });
185
+ for (const [key, value] of Object.entries(signatureTags(signature))) sentry.setTag(key, value);
186
+ }
187
+
188
+ return {
189
+ name: "sentry",
190
+ // Sentry deduplicates by error object, and `beforeSend` scrubs: it always gets the original.
191
+ scrubsErrors: true,
192
+ captureError(error, context) {
193
+ sentry.captureException(error, {
194
+ // Unhandled when the app could not go on, so release health counts it as a crash.
195
+ mechanism: { handled: !context.fatal, type: `genesis.${context.source}` },
196
+ captureContext: {
197
+ level: context.fatal ? "fatal" : "error",
198
+ tags: { "genesis.source": context.source, ...(context.route ? { route: context.route } : {}) },
199
+ ...(context.componentStack ? { contexts: { react: { componentStack: scrub ? scrubString(context.componentStack) : context.componentStack } } } : {}),
200
+ },
201
+ });
202
+ },
203
+ breadcrumb(crumb) {
204
+ // Sentry's Expo Router integration records navigation itself.
205
+ if (crumb.category === "navigation" && (options.expoRouter ?? true)) return;
206
+ sentry.addBreadcrumb({
207
+ category: crumb.category,
208
+ message: crumb.message,
209
+ level: crumb.level ?? "info",
210
+ data: crumb.data,
211
+ ...(crumb.timestamp ? { timestamp: crumb.timestamp / 1000 } : {}),
212
+ });
213
+ },
214
+ setUser: (id) => sentry.setUser(id ? { id } : null),
215
+ event(name, props) {
216
+ sentry.addBreadcrumb({ category: "event", message: name, level: "info", data: props });
217
+ sentry.metrics?.count(name, 1, { attributes: props });
218
+ },
219
+ timing: (name, ms, tags) => sentry.metrics?.distribution(name, ms, { unit: "millisecond", attributes: tags }),
220
+ wrap: (root) => sentry.wrap(root),
221
+ };
222
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The vendor-neutral seam: a sink is where errors, breadcrumbs, the user id,
3
+ * analytics events and timings go. `createObservability` fans every call out
4
+ * to its sinks, scrubbed first; a sink that throws is reported to
5
+ * `onSinkError` and never takes the others (or the app) down with it.
6
+ *
7
+ * Shipped sinks: `sentrySink` (Sentry), `observeSink` (EAS Observe) and
8
+ * `consoleSink` (development). A vendor adapter (PostHog, Datadog…) is one
9
+ * object with the methods it supports.
10
+ */
11
+ import type { ComponentType } from "react";
12
+ import { scrubString, scrubValue } from "./scrub";
13
+ import type { EventValues } from "./events";
14
+
15
+ /** Where a caught error came from and whether the app could go on. */
16
+ export interface ErrorContext {
17
+ /** True when the app could not go on (a render error, a failed boot, a fatal global error). */
18
+ fatal: boolean;
19
+ /** `boundary`, `global`, `boot`, `rejection` (the shell's), or the app's own (`updates:policy`, `sync`). */
20
+ source: string;
21
+ componentStack?: string;
22
+ /** The route pattern on screen (`/(app)/settings`), when known. */
23
+ route?: string | null;
24
+ }
25
+
26
+ export type BreadcrumbLevel = "debug" | "info" | "warning" | "error";
27
+
28
+ /** One step that led up to an error: a navigation, a tap, a request. */
29
+ export interface Breadcrumb {
30
+ /** `navigation`, `ui`, `http`, `event`, or the app's own. */
31
+ category: string;
32
+ message?: string;
33
+ level?: BreadcrumbLevel;
34
+ data?: Readonly<Record<string, unknown>>;
35
+ /** Milliseconds since the epoch. Default now. */
36
+ timestamp?: number;
37
+ }
38
+
39
+ /** Low-cardinality labels on a timing (`route`, `step`), never ids or text someone typed. */
40
+ export type TimingTags = Readonly<Record<string, string>>;
41
+
42
+ /**
43
+ * One destination. Every method is optional: a sink takes what its vendor
44
+ * supports. Errors arrive as an `Error` whose message is already scrubbed,
45
+ * unless the sink sets `scrubsErrors` (it scrubs the whole event itself and
46
+ * needs the original object, as Sentry does to deduplicate).
47
+ *
48
+ * @stability Beta
49
+ */
50
+ export interface ObservabilitySink {
51
+ readonly name: string;
52
+ /** The sink scrubs error events itself; give it the original `Error`. */
53
+ readonly scrubsErrors?: boolean;
54
+ captureError?(error: Error, context: ErrorContext): void;
55
+ breadcrumb?(crumb: Breadcrumb): void;
56
+ /** The signed-in login's id, or `null` when signed out. Never an email or a name. */
57
+ setUser?(id: string | null): void;
58
+ /** An analytics event, its props checked against the schema. */
59
+ event?(name: string, props: EventValues): void;
60
+ timing?(name: string, ms: number, tags?: TimingTags): void;
61
+ /** Wraps the root layout (Sentry's `wrap`, EAS Observe's `ObserveRoot.wrap`). */
62
+ wrap?<P extends Record<string, unknown>>(root: ComponentType<P>): ComponentType<P>;
63
+ }
64
+
65
+ /** A copy of `error` whose message and stack have been scrubbed (for sinks that don't scrub themselves). */
66
+ export function scrubbedError(error: Error): Error {
67
+ const copy = new Error(scrubString(error.message));
68
+ copy.name = error.name;
69
+ if (error.stack) copy.stack = scrubString(error.stack);
70
+ return copy;
71
+ }
72
+
73
+ export interface ConsoleSinkOptions {
74
+ /** Default `console`. */
75
+ console?: Pick<Console, "log" | "warn" | "error">;
76
+ /** Also log breadcrumbs (noisy). Default false. */
77
+ breadcrumbs?: boolean;
78
+ }
79
+
80
+ /**
81
+ * Logs what would be sent, for development: fatal errors with `console.error`, others with `console.warn`,
82
+ * events and timings with `console.log`. Already scrubbed, so what you read
83
+ * is what a vendor sink would get.
84
+ *
85
+ * @stability Beta
86
+ */
87
+ export function consoleSink(options: ConsoleSinkOptions = {}): ObservabilitySink {
88
+ const out = options.console ?? console;
89
+ return {
90
+ name: "console",
91
+ // `warn` for what the app survives: `console.error` would raise a LogBox error for every one in development.
92
+ captureError: (error, context) => {
93
+ const line = `[observability] ${context.fatal ? "fatal " : ""}${context.source}: ${error.message}`;
94
+ if (context.fatal) out.error(line);
95
+ else out.warn(line);
96
+ },
97
+ breadcrumb: options.breadcrumbs ? (crumb) => out.log(`[observability] · ${crumb.category} ${crumb.message ?? ""}`) : undefined,
98
+ setUser: (id) => out.log(`[observability] user ${id ?? "(signed out)"}`),
99
+ event: (name, props) => out.log(`[observability] event ${name}`, props),
100
+ timing: (name, ms, tags) => out.log(`[observability] timing ${name} ${Math.round(ms)}ms`, tags ?? {}),
101
+ };
102
+ }
103
+
104
+ /**
105
+ * `expo-observe`'s `Observe` (SDK 56+), or a stand-in: the calls `observeSink`
106
+ * uses. Pass `import { Observe } from "expo-observe"`.
107
+ */
108
+ export interface ObserveLike {
109
+ logEvent(name: string, options?: { severity?: "debug" | "info" | "warn" | "error"; body?: string; attributes?: Record<string, unknown> }): void;
110
+ /** Error reporting, in preview on SDK 57+. */
111
+ reportError?(error: unknown): void;
112
+ }
113
+
114
+ export interface ObserveSinkOptions {
115
+ /** Report caught errors with `Observe.reportError` (in preview). Default false: Sentry is the error tracker. */
116
+ errors?: boolean;
117
+ }
118
+
119
+ /**
120
+ * EAS Observe. It measures launch (cold, warm, bundle load, first render),
121
+ * time to interactive and, with its Expo Router integration, per-route
122
+ * render times on its own; it has no API for a custom timing metric, so
123
+ * `timing` is logged as an event (`timing.<name>` with `duration_ms`) and
124
+ * shows on the Events page, not as a metric. Analytics events go to
125
+ * `Observe.logEvent` as they are.
126
+ *
127
+ * @stability Beta
128
+ */
129
+ export function observeSink(observe: ObserveLike, options: ObserveSinkOptions = {}): ObservabilitySink {
130
+ return {
131
+ name: "eas-observe",
132
+ event: (name, props) => observe.logEvent(name, { attributes: { ...props } }),
133
+ timing: (name, ms, tags) => observe.logEvent(`timing.${name}`, { severity: "info", attributes: { duration_ms: Math.round(ms), ...(tags ?? {}) } }),
134
+ captureError: options.errors && observe.reportError ? (error) => observe.reportError?.(error) : undefined,
135
+ };
136
+ }
137
+
138
+ /** Breadcrumb data scrubbed and made plain (for sinks and the devtools overlay). */
139
+ export function scrubBreadcrumb(crumb: Breadcrumb): Breadcrumb {
140
+ return {
141
+ category: scrubString(crumb.category),
142
+ level: crumb.level,
143
+ timestamp: crumb.timestamp,
144
+ ...(crumb.message !== undefined ? { message: scrubString(crumb.message) } : {}),
145
+ ...(crumb.data ? { data: scrubValue(crumb.data) as Record<string, unknown> } : {}),
146
+ };
147
+ }
package/src/persist.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `@marktiderman/genesis-native/persist`: save chosen queries to async storage
3
+ * (AsyncStorage, MMKV, …) with TanStack's own persister, so restore,
4
+ * throttling and max age are theirs.
5
+ *
6
+ * Install `@tanstack/query-persist-client-core` and
7
+ * `@tanstack/query-async-storage-persister` at the same version as your
8
+ * `@tanstack/react-query`: they pin `@tanstack/query-core` exactly, and two
9
+ * copies of it would not share a cache.
10
+ *
11
+ * <GenesisNativeApp persist={persistQueries({ storage: AsyncStorage, keys: ["clients"], buster: version })} … />
12
+ *
13
+ * @stability Beta
14
+ */
15
+ import { defaultShouldDehydrateQuery, type QueryClient } from "@tanstack/react-query";
16
+ import { persistQueryClientRestore, persistQueryClientSubscribe } from "@tanstack/query-persist-client-core";
17
+ import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
18
+ import { DAY_MS, type AsyncStorageLike, type QueryPersistence } from "./query";
19
+
20
+ export interface PersistQueriesOptions {
21
+ storage: AsyncStorageLike;
22
+ /**
23
+ * The query-key roots to persist: a query is saved when its key's first
24
+ * element is listed (`["clients", id]` is saved under `"clients"`). Nothing
25
+ * else is written to the phone.
26
+ */
27
+ keys: readonly string[];
28
+ /** Your app's version: a different value discards the saved cache. */
29
+ buster: string;
30
+ /** How old a saved cache may be when restored. Default 24 hours. */
31
+ maxAge?: number;
32
+ /** Storage key. Default `"genesis-query-cache"`. */
33
+ storageKey?: string;
34
+ /** Write at most this often. Default 1000 ms. */
35
+ throttleMs?: number;
36
+ }
37
+
38
+ export interface QueryPersistenceOptions extends PersistQueriesOptions {
39
+ queryClient: QueryClient;
40
+ }
41
+
42
+ /** Save the allowlisted queries to async storage and restore them, with a max age, an app-version buster and a user scope. */
43
+ export function createQueryPersistence({
44
+ queryClient,
45
+ storage,
46
+ keys,
47
+ buster,
48
+ maxAge = DAY_MS,
49
+ storageKey = "genesis-query-cache",
50
+ throttleMs = 1000,
51
+ }: QueryPersistenceOptions): QueryPersistence {
52
+ const persister = createAsyncStoragePersister({ storage, key: storageKey, throttleTime: throttleMs });
53
+ const allowed = new Set(keys);
54
+ const dehydrateOptions = {
55
+ shouldDehydrateQuery: (query: Parameters<typeof defaultShouldDehydrateQuery>[0]) =>
56
+ allowed.has(String(query.queryKey[0])) && defaultShouldDehydrateQuery(query),
57
+ // Mutations stay in memory: replaying a saved write is an outbox's job.
58
+ shouldDehydrateMutation: () => false,
59
+ };
60
+ // The scope rides in the buster: TanStack discards (and deletes) a saved
61
+ // cache whose buster differs, so another user's cache is never restored.
62
+ const scoped = (scope: string) => `${buster}|user:${scope}`;
63
+ return {
64
+ restore: async (scope) => {
65
+ try {
66
+ await persistQueryClientRestore({ queryClient, persister, buster: scoped(scope), maxAge });
67
+ } catch {
68
+ // An unreadable cache has already been deleted; start without it rather than fail the boot.
69
+ }
70
+ },
71
+ subscribe: (scope) => persistQueryClientSubscribe({ queryClient, persister, buster: scoped(scope), dehydrateOptions }),
72
+ clear: async () => {
73
+ await persister.removeClient();
74
+ },
75
+ };
76
+ }
77
+
78
+ /** `createQueryPersistence` for `GenesisNativeApp`'s `persist` prop, which supplies the client. */
79
+ export function persistQueries(options: PersistQueriesOptions): (queryClient: QueryClient) => QueryPersistence {
80
+ return (queryClient) => createQueryPersistence({ ...options, queryClient });
81
+ }
82
+
83
+ export type { QueryPersistence } from "./query";
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Where the push block keeps devices and preferences: migration 0005 of
3
+ * @marktiderman/genesis-supabase, through the app's Supabase client and row
4
+ * level security. `supabasePushBackend(supabase)` is the one to use; tests
5
+ * (and an app with its own server) pass their own `PushBackend`.
6
+ */
7
+
8
+ /** A person's notification preferences, as the screens edit them. */
9
+ export interface NotificationPreferences {
10
+ /** Category id → on or off. A category not listed has its default (`NotificationCategory.defaultOn`). */
11
+ categories: Record<string, boolean>;
12
+ quietHours: QuietHours;
13
+ /** Hide what a push says on the lock screen: the sender sends neutral text instead. */
14
+ hidePreviews: boolean;
15
+ }
16
+
17
+ export interface QuietHours {
18
+ enabled: boolean;
19
+ /** Local time, `"HH:MM"` (24-hour). */
20
+ start: string;
21
+ /** Local time, `"HH:MM"`; before `start` means the quiet runs past midnight. */
22
+ end: string;
23
+ /** IANA time zone (`"Europe/London"`) the times are read in; required while enabled. */
24
+ timeZone: string | null;
25
+ }
26
+
27
+ export const DEFAULT_PREFERENCES: NotificationPreferences = {
28
+ categories: {},
29
+ quietHours: { enabled: false, start: "22:00", end: "07:00", timeZone: null },
30
+ hidePreviews: false,
31
+ };
32
+
33
+ export interface PushBackend {
34
+ /** Keep this device's token against the signed-in person. */
35
+ register(token: string, platform: string, appVersion: string | null): Promise<void>;
36
+ /** Forget this device's token; works signed out (holding the token is the proof). */
37
+ forget(token: string): Promise<void>;
38
+ /** The signed-in person's preferences, or `null` before they have saved any. */
39
+ loadPreferences(userId: string): Promise<NotificationPreferences | null>;
40
+ savePreferences(userId: string, preferences: NotificationPreferences): Promise<void>;
41
+ }
42
+
43
+ type Result = { data: unknown; error: { message?: string; code?: string | null } | null };
44
+
45
+ /** The parts of a Supabase client the backend uses; a `SupabaseClient` fits, typed by your Database or not. */
46
+ export interface PushSupabaseLike {
47
+ // Loose on purpose (as deleteMyAccount's): a client typed by an app's Database narrows `rpc`'s
48
+ // and `from`'s arguments to its own functions and tables, which these names may not be in yet.
49
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
50
+ rpc: (fn: any, ...rest: any[]) => PromiseLike<Result>;
51
+ from(table: string): unknown;
52
+ }
53
+
54
+ interface PreferencesQuery {
55
+ select(columns: string): { eq(column: string, value: string): { maybeSingle(): PromiseLike<Result> } };
56
+ upsert(row: Record<string, unknown>, options: { onConflict: string }): { select(columns: string): PromiseLike<Result> };
57
+ }
58
+
59
+ /** A database refusal, with its SQLSTATE; the message is the database's own, never a token. */
60
+ export class PushBackendError extends Error {
61
+ constructor(
62
+ readonly operation: string,
63
+ readonly code: string | null,
64
+ ) {
65
+ super(`${operation} failed${code ? ` (${code})` : ""}`);
66
+ this.name = "PushBackendError";
67
+ }
68
+ }
69
+
70
+ const COLUMNS = "categories, quiet_hours_enabled, quiet_hours_start, quiet_hours_end, time_zone, hide_previews";
71
+
72
+ /** Postgres answers `time` as `"22:00:00"`. */
73
+ function clock(value: unknown, fallback: string): string {
74
+ const match = typeof value === "string" ? /^(\d{2}):(\d{2})/.exec(value) : null;
75
+ return match ? `${match[1]}:${match[2]}` : fallback;
76
+ }
77
+
78
+ function fromRow(row: Record<string, unknown>): NotificationPreferences {
79
+ const categories: Record<string, boolean> = {};
80
+ const raw = row.categories;
81
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
82
+ for (const [id, on] of Object.entries(raw as Record<string, unknown>)) if (typeof on === "boolean") categories[id] = on;
83
+ }
84
+ return {
85
+ categories,
86
+ quietHours: {
87
+ enabled: row.quiet_hours_enabled === true,
88
+ start: clock(row.quiet_hours_start, DEFAULT_PREFERENCES.quietHours.start),
89
+ end: clock(row.quiet_hours_end, DEFAULT_PREFERENCES.quietHours.end),
90
+ timeZone: typeof row.time_zone === "string" ? row.time_zone : null,
91
+ },
92
+ hidePreviews: row.hide_previews === true,
93
+ };
94
+ }
95
+
96
+ /** Migration 0005's functions and tables, under the signed-in person's row level security. */
97
+ export function supabasePushBackend(client: PushSupabaseLike): PushBackend {
98
+ const must = (operation: string, result: Result) => {
99
+ if (result.error) throw new PushBackendError(operation, result.error.code ?? null);
100
+ return result.data;
101
+ };
102
+ return {
103
+ async register(token, platform, appVersion) {
104
+ must("genesis_push_register", await client.rpc("genesis_push_register", { p_token: token, p_platform: platform, p_app_version: appVersion }));
105
+ },
106
+ async forget(token) {
107
+ must("genesis_push_forget", await client.rpc("genesis_push_forget", { p_token: token }));
108
+ },
109
+ async loadPreferences(userId) {
110
+ // Row level security already limits it to the caller; the filter says so twice.
111
+ const data = must("notification_preferences", await (client.from("notification_preferences") as PreferencesQuery).select(COLUMNS).eq("user_id", userId).maybeSingle());
112
+ return data && typeof data === "object" ? fromRow(data as Record<string, unknown>) : null;
113
+ },
114
+ async savePreferences(userId, p) {
115
+ const row = {
116
+ user_id: userId,
117
+ categories: p.categories,
118
+ quiet_hours_enabled: p.quietHours.enabled,
119
+ quiet_hours_start: p.quietHours.start,
120
+ quiet_hours_end: p.quietHours.end,
121
+ time_zone: p.quietHours.timeZone,
122
+ hide_previews: p.hidePreviews,
123
+ };
124
+ const data = must("notification_preferences", await (client.from("notification_preferences") as PreferencesQuery).upsert(row, { onConflict: "user_id" }).select("user_id"));
125
+ // Row level security turns a write it refuses into zero rows, not an error.
126
+ if (!Array.isArray(data) || data.length !== 1) throw new PushBackendError("notification_preferences", "no_rows");
127
+ },
128
+ };
129
+ }