@marktiderman/genesis-native 0.2.0 → 0.4.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 (98) hide show
  1. package/README.md +470 -10
  2. package/dist/block-Onqx7NiK.d.ts +223 -0
  3. package/dist/blocks-C2RDpvaN.d.ts +1595 -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-ONAXQHNG.js +23 -0
  13. package/dist/chunk-ONAXQHNG.js.map +1 -0
  14. package/dist/chunk-VKTIHNAV.js +95 -0
  15. package/dist/chunk-VKTIHNAV.js.map +1 -0
  16. package/dist/{chunk-MMTPWUWI.js → chunk-VTJDIKFC.js} +9 -6
  17. package/dist/chunk-VTJDIKFC.js.map +1 -0
  18. package/dist/chunk-XE7LC7K6.js +29 -0
  19. package/dist/chunk-XE7LC7K6.js.map +1 -0
  20. package/dist/dev-warnings.d.ts +34 -0
  21. package/dist/dev-warnings.js +15 -0
  22. package/dist/dev-warnings.js.map +1 -0
  23. package/dist/devtools.d.ts +144 -0
  24. package/dist/devtools.js +390 -0
  25. package/dist/devtools.js.map +1 -0
  26. package/dist/index.d.ts +12 -368
  27. package/dist/index.js +88 -191
  28. package/dist/index.js.map +1 -1
  29. package/dist/observability.d.ts +170 -0
  30. package/dist/observability.js +123 -0
  31. package/dist/observability.js.map +1 -0
  32. package/dist/persist.d.ts +1 -1
  33. package/dist/persist.js +1 -1
  34. package/dist/push-payload.d.ts +43 -0
  35. package/dist/push-payload.js +3 -0
  36. package/dist/push-payload.js.map +1 -0
  37. package/dist/push.d.ts +187 -0
  38. package/dist/push.js +1069 -0
  39. package/dist/push.js.map +1 -0
  40. package/dist/{query-DhH3lLFa.d.ts → query-DUmVAfsF.d.ts} +11 -6
  41. package/dist/rich-push-plugin.d.ts +74 -0
  42. package/dist/rich-push-plugin.js +209 -0
  43. package/dist/rich-push-plugin.js.map +1 -0
  44. package/dist/updates.d.ts +8 -1
  45. package/dist/updates.js +44 -69
  46. package/dist/updates.js.map +1 -1
  47. package/ios/notification-service/NotificationService.swift +309 -0
  48. package/package.json +238 -5
  49. package/src/app.tsx +491 -0
  50. package/src/auth.tsx +176 -0
  51. package/src/blocks.tsx +144 -0
  52. package/src/boot.tsx +188 -0
  53. package/src/dev-warnings.ts +43 -0
  54. package/src/devtools/block.tsx +488 -0
  55. package/src/devtools/gestures.ts +107 -0
  56. package/src/devtools/index.ts +22 -0
  57. package/src/devtools/info.ts +45 -0
  58. package/src/devtools/slots.ts +21 -0
  59. package/src/errors.tsx +195 -0
  60. package/src/held-link.ts +90 -0
  61. package/src/hydration-gate.ts +88 -0
  62. package/src/index.ts +83 -0
  63. package/src/links.ts +140 -0
  64. package/src/nativewind.d.ts +41 -0
  65. package/src/observability/block.tsx +300 -0
  66. package/src/observability/events.ts +89 -0
  67. package/src/observability/index.ts +44 -0
  68. package/src/observability/scrub.ts +166 -0
  69. package/src/observability/sentry.ts +222 -0
  70. package/src/observability/sink.ts +147 -0
  71. package/src/persist.ts +83 -0
  72. package/src/plugin/rich-push.ts +312 -0
  73. package/src/push/backend.ts +137 -0
  74. package/src/push/block.tsx +604 -0
  75. package/src/push/index.ts +105 -0
  76. package/src/push/modules.ts +123 -0
  77. package/src/push/payload.ts +61 -0
  78. package/src/push/permission.ts +191 -0
  79. package/src/push/preferences.ts +136 -0
  80. package/src/push/quiet-hours.ts +71 -0
  81. package/src/push/registration.ts +225 -0
  82. package/src/push/routing.ts +57 -0
  83. package/src/push/screens.tsx +46 -0
  84. package/src/push/settings.tsx +378 -0
  85. package/src/query.ts +100 -0
  86. package/src/updates/block.tsx +442 -0
  87. package/src/updates/channels.ts +131 -0
  88. package/src/updates/checker.ts +190 -0
  89. package/src/updates/index.ts +87 -0
  90. package/src/updates/modules.ts +64 -0
  91. package/src/updates/policy.ts +314 -0
  92. package/src/updates/screens.tsx +159 -0
  93. package/src/updates/signature.ts +80 -0
  94. package/src/updates/store.ts +33 -0
  95. package/src/updates/version.ts +39 -0
  96. package/src/updates/whats-new.ts +122 -0
  97. package/dist/block-Dh5ny06W.d.ts +0 -535
  98. 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";