@chipmobilesdk/rn-notification 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.
Files changed (66) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +306 -0
  3. package/package.json +73 -0
  4. package/src/channel/migrate.ts +211 -0
  5. package/src/channel/tone-map.ts +90 -0
  6. package/src/contract/channel.ts +89 -0
  7. package/src/contract/diagnostics.ts +102 -0
  8. package/src/contract/errors.ts +123 -0
  9. package/src/contract/index.ts +10 -0
  10. package/src/contract/interaction.ts +62 -0
  11. package/src/contract/notification.ts +118 -0
  12. package/src/contract/permission.ts +60 -0
  13. package/src/contract/reconcile.ts +88 -0
  14. package/src/contract/recurrence.ts +123 -0
  15. package/src/diagnostics/burst.ts +101 -0
  16. package/src/diagnostics/events.ts +72 -0
  17. package/src/diagnostics/redact.ts +72 -0
  18. package/src/diagnostics/snapshot.ts +64 -0
  19. package/src/engine/adapter.ts +79 -0
  20. package/src/engine/notify-kit-engine.ts +535 -0
  21. package/src/index.ts +173 -0
  22. package/src/initialize.ts +174 -0
  23. package/src/interaction/index.ts +146 -0
  24. package/src/maintenance/cycle.ts +147 -0
  25. package/src/maintenance/permission-refresh.ts +41 -0
  26. package/src/maintenance/reanchor.ts +95 -0
  27. package/src/maintenance/toggle.ts +60 -0
  28. package/src/maintenance/verify.ts +83 -0
  29. package/src/maintenance/window-refresh.ts +61 -0
  30. package/src/permission/index.ts +183 -0
  31. package/src/permission/state.ts +77 -0
  32. package/src/push/envelope.ts +165 -0
  33. package/src/push/fcm-provider.ts +146 -0
  34. package/src/push/gate.ts +190 -0
  35. package/src/push/index.ts +29 -0
  36. package/src/push/instructions.ts +109 -0
  37. package/src/push/provider-adapter.ts +54 -0
  38. package/src/push/signout.ts +65 -0
  39. package/src/reconcile/chunk.ts +53 -0
  40. package/src/reconcile/diff.ts +145 -0
  41. package/src/reconcile/duplicates.ts +73 -0
  42. package/src/reconcile/index.ts +175 -0
  43. package/src/reconcile/truncate.ts +56 -0
  44. package/src/recurrence/clock-guard.ts +71 -0
  45. package/src/recurrence/expand.ts +292 -0
  46. package/src/recurrence/index.ts +177 -0
  47. package/src/schedule/anchor.ts +267 -0
  48. package/src/schedule/index.ts +173 -0
  49. package/src/schedule/partition.ts +85 -0
  50. package/src/schedule/resolve-moment.ts +80 -0
  51. package/src/schedule/validate-content.ts +111 -0
  52. package/src/schedule/validate-identifier.ts +114 -0
  53. package/src/state/caps.ts +71 -0
  54. package/src/state/dedup-ledger.ts +39 -0
  55. package/src/state/interaction-buffer.ts +73 -0
  56. package/src/state/mmkv-store.ts +71 -0
  57. package/src/state/permission-history.ts +41 -0
  58. package/src/state/record.ts +246 -0
  59. package/src/state/reset.ts +41 -0
  60. package/src/state/series-ledger.ts +84 -0
  61. package/src/state/store.ts +40 -0
  62. package/src/testing/conformance/index.ts +194 -0
  63. package/src/testing/fake-engine.ts +284 -0
  64. package/src/testing/fake-provider.ts +129 -0
  65. package/src/testing/faults.ts +72 -0
  66. package/src/testing/index.ts +26 -0
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The Firebase Cloud Messaging provider.
3
+ *
4
+ * Every `@react-native-firebase/messaging` import is confined to this file, so
5
+ * the rest of the package — and every consuming app — stays free of provider
6
+ * types. Substituting a provider is an adapter swap, not a rewrite.
7
+ *
8
+ * The module is required **lazily**. That matters more here than for the
9
+ * engine: messaging is an *optional* peer dependency, and an app that never
10
+ * enables push must not fail at import time for a package it deliberately did
11
+ * not install.
12
+ */
13
+
14
+ import type {
15
+ PushAvailability,
16
+ PushProviderAdapter,
17
+ } from './provider-adapter';
18
+ import type { Unsubscribe } from '../contract/interaction';
19
+
20
+ /** Minimal shape used from the SDK, so its full surface never leaks into typing. */
21
+ interface MessagingModule {
22
+ (): MessagingInstance;
23
+ AuthorizationStatus?: Record<string, number>;
24
+ }
25
+
26
+ interface MessagingInstance {
27
+ getToken(): Promise<string>;
28
+ deleteToken(): Promise<void>;
29
+ onTokenRefresh(handler: (token: string) => void): () => void;
30
+ onMessage(handler: (message: unknown) => Promise<void> | void): () => void;
31
+ setBackgroundMessageHandler(handler: (message: unknown) => Promise<void>): void;
32
+ getInitialNotification(): Promise<unknown | null>;
33
+ hasPermission?(): Promise<number>;
34
+ isDeviceRegisteredForRemoteMessages?: boolean;
35
+ }
36
+
37
+ function loadMessaging(): MessagingModule | undefined {
38
+ try {
39
+ const module = require('@react-native-firebase/messaging') as { default: MessagingModule };
40
+ return module.default;
41
+ } catch {
42
+ // Absent optional peer, or missing native module. Reported as
43
+ // unavailability through the normal path rather than thrown at import.
44
+ return undefined;
45
+ }
46
+ }
47
+
48
+ export interface FcmProviderOptions {
49
+ /** Injected in tests; production resolves the real module. */
50
+ readonly module?: MessagingModule;
51
+ /**
52
+ * Registers the background message handler.
53
+ *
54
+ * Left to the consumer by default because the SDK requires it to be
55
+ * registered at module scope in `index.js`, outside React's lifecycle — a
56
+ * constraint the package documents rather than works around.
57
+ */
58
+ readonly onBackgroundMessage?: (message: unknown) => Promise<void>;
59
+ }
60
+
61
+ export function createFcmProvider(options: FcmProviderOptions = {}): PushProviderAdapter {
62
+ let cached: MessagingModule | undefined = options.module;
63
+ let resolved = options.module !== undefined;
64
+
65
+ const messaging = (): MessagingInstance | undefined => {
66
+ if (!resolved) {
67
+ cached = loadMessaging();
68
+ resolved = true;
69
+ }
70
+ return cached?.();
71
+ };
72
+
73
+ if (options.onBackgroundMessage !== undefined) {
74
+ messaging()?.setBackgroundMessageHandler(options.onBackgroundMessage);
75
+ }
76
+
77
+ return {
78
+ name: '@react-native-firebase/messaging',
79
+
80
+ async getAvailability(): Promise<PushAvailability> {
81
+ const instance = messaging();
82
+
83
+ // A device without Google Play Services, or an app that never installed
84
+ // the optional peer. Either way the local path is unaffected.
85
+ if (instance === undefined) return 'unavailable';
86
+
87
+ try {
88
+ await instance.getToken();
89
+ return 'available';
90
+ } catch {
91
+ // Registration can fail for reasons the SDK does not distinguish, so
92
+ // `unknown` is reported rather than a guess at which one.
93
+ return 'unknown';
94
+ }
95
+ },
96
+
97
+ async getToken(): Promise<string> {
98
+ const instance = messaging();
99
+ if (instance === undefined) {
100
+ throw new Error('Push provider is unavailable on this device.');
101
+ }
102
+ // Resolves only once a genuine token exists; the gate never presents an
103
+ // empty value as if it were one.
104
+ return instance.getToken();
105
+ },
106
+
107
+ onTokenChange(handler: (token: string) => void): Unsubscribe {
108
+ const instance = messaging();
109
+ if (instance === undefined) return () => undefined;
110
+ return instance.onTokenRefresh(handler);
111
+ },
112
+
113
+ async invalidateToken(): Promise<boolean> {
114
+ const instance = messaging();
115
+ if (instance === undefined) return false;
116
+
117
+ try {
118
+ await instance.deleteToken();
119
+ return true;
120
+ } catch {
121
+ // Reported as a failed invalidation rather than assumed successful —
122
+ // the device may keep receiving the previous account's messages.
123
+ return false;
124
+ }
125
+ },
126
+
127
+ subscribeToMessages(handler: (raw: unknown) => void): Unsubscribe {
128
+ const instance = messaging();
129
+ if (instance === undefined) return () => undefined;
130
+
131
+ // Foreground only. Background delivery arrives through the handler the
132
+ // consumer registers at module scope, which the README documents.
133
+ return instance.onMessage(message => {
134
+ handler(message);
135
+ });
136
+ },
137
+
138
+ async takeInitialMessage(): Promise<unknown | undefined> {
139
+ const instance = messaging();
140
+ if (instance === undefined) return undefined;
141
+
142
+ const message = await instance.getInitialNotification();
143
+ return message ?? undefined;
144
+ },
145
+ };
146
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * The push gate.
3
+ *
4
+ * Everything a consumer sees about push comes through here, in one normalized
5
+ * shape with zero provider types. Substituting a provider is therefore an
6
+ * adapter swap rather than a consumer rewrite.
7
+ *
8
+ * Two behaviours are deliberate and easy to get wrong. A push arriving in the
9
+ * **foreground is never auto-displayed** — presentation is the app's decision,
10
+ * and a library that decided for it would fight every consumer's design. And a
11
+ * malformed payload is **dropped safely**, because a broken message from a
12
+ * server must not be able to crash an installed app.
13
+ */
14
+
15
+ import type { PushEnvelope, PushProviderAdapter, PushAvailability } from './provider-adapter';
16
+ import type { StateManager } from '../state/record';
17
+ import type { Unsubscribe } from '../contract/interaction';
18
+ import { DIAGNOSTIC_CODES, isNotificationError } from '../contract/errors';
19
+ import { normalizeEnvelope, IMPLEMENTED_PAYLOAD_VERSION } from './envelope';
20
+ import { recordSeen } from '../state/dedup-ledger';
21
+
22
+ export type PushHandler = (envelope: PushEnvelope) => void | Promise<void>;
23
+
24
+ export interface PushGateDependencies {
25
+ readonly provider: PushProviderAdapter;
26
+ readonly state: StateManager;
27
+ readonly now: () => number;
28
+ readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
29
+ }
30
+
31
+ export type UndeliverableCause =
32
+ | 'missingPermission'
33
+ | 'expiredToken'
34
+ | 'platformRestriction'
35
+ | 'malformedPayload'
36
+ | 'suspectedRateLimit';
37
+
38
+ export function createPushGate(deps: PushGateDependencies) {
39
+ const emit = deps.emit ?? (() => {});
40
+ let handlers: PushHandler[] = [];
41
+ let subscription: Unsubscribe | undefined;
42
+ let tokenSubscription: Unsubscribe | undefined;
43
+ let deliveryChain: Promise<void> = Promise.resolve();
44
+
45
+ const dispatch = (envelope: PushEnvelope): void => {
46
+ deliveryChain = deliveryChain.then(async () => {
47
+ for (const handler of handlers) {
48
+ try {
49
+ await handler(envelope);
50
+ } catch {
51
+ // A consumer's handler failing must not stop the next payload.
52
+ }
53
+ }
54
+ });
55
+ };
56
+
57
+ const receive = (raw: unknown): PushEnvelope | undefined => {
58
+ let normalized;
59
+ try {
60
+ normalized = normalizeEnvelope(raw, deps.now());
61
+ } catch (error) {
62
+ // Dropped safely and recorded. A broken message must never crash the app
63
+ // or corrupt the local schedule.
64
+ emit(
65
+ DIAGNOSTIC_CODES.PAYLOAD_MALFORMED,
66
+ isNotificationError(error)
67
+ ? error.message
68
+ : 'Push payload could not be interpreted and was discarded.',
69
+ isNotificationError(error) ? error.context : undefined,
70
+ );
71
+ return undefined;
72
+ }
73
+
74
+ const { envelope, versionSkew } = normalized;
75
+
76
+ if (versionSkew) {
77
+ emit(
78
+ DIAGNOSTIC_CODES.PAYLOAD_VERSION_SKEW,
79
+ `Push payload declares contract version ${envelope.version}; this build implements ${IMPLEMENTED_PAYLOAD_VERSION}. Processed best-effort — known fields honoured, unknown fields ignored.`,
80
+ { received: envelope.version, implemented: IMPLEMENTED_PAYLOAD_VERSION },
81
+ );
82
+ }
83
+
84
+ // Deduplication survives process death, which is the case that matters:
85
+ // a redelivery usually arrives in a fresh process.
86
+ if (recordSeen(deps.state, envelope.dedupId, deps.now(), envelope.ownerTag)) {
87
+ emit(
88
+ DIAGNOSTIC_CODES.PAYLOAD_DUPLICATE,
89
+ 'Push payload was already handled and was suppressed.',
90
+ { dedupId: envelope.dedupId },
91
+ );
92
+ return undefined;
93
+ }
94
+
95
+ dispatch(envelope);
96
+ return envelope;
97
+ };
98
+
99
+ return {
100
+ /** Reports whether platform push services exist, so degradation is controlled. */
101
+ async getAvailability(): Promise<PushAvailability> {
102
+ const availability = await deps.provider.getAvailability();
103
+
104
+ if (availability === 'unavailable') {
105
+ emit(
106
+ DIAGNOSTIC_CODES.PUSH_UNAVAILABLE,
107
+ 'Platform push services are unavailable on this device. The push path is disabled; the entire local notification path is unaffected.',
108
+ );
109
+ }
110
+ return availability;
111
+ },
112
+
113
+ /** Waits for a genuine token. Never returns a placeholder presented as one. */
114
+ async getToken(): Promise<string> {
115
+ const token = await deps.provider.getToken();
116
+
117
+ if (typeof token !== 'string' || token.length === 0) {
118
+ emit(
119
+ DIAGNOSTIC_CODES.TOKEN_UNAVAILABLE,
120
+ 'The provider has no registration token yet. An empty value is never returned as if it were one.',
121
+ );
122
+ throw new Error('Push token is not yet available.');
123
+ }
124
+ // The token value itself is never logged.
125
+ return token;
126
+ },
127
+
128
+ onTokenChange(handler: (token: string) => void): Unsubscribe {
129
+ tokenSubscription = deps.provider.onTokenChange(handler);
130
+ return tokenSubscription;
131
+ },
132
+
133
+ async invalidateToken(): Promise<boolean> {
134
+ const ok = await deps.provider.invalidateToken();
135
+
136
+ if (!ok) {
137
+ emit(
138
+ DIAGNOSTIC_CODES.TOKEN_INVALIDATION_FAILED,
139
+ 'Token invalidation did not succeed. The device may keep receiving messages for the previous account.',
140
+ );
141
+ }
142
+ return ok;
143
+ },
144
+
145
+ /**
146
+ * Subscribes to inbound payloads.
147
+ *
148
+ * Nothing is displayed automatically — the consumer decides whether and how
149
+ * to present a foreground push.
150
+ */
151
+ onPushReceived(handler: PushHandler): Unsubscribe {
152
+ handlers.push(handler);
153
+ subscription ??= deps.provider.subscribeToMessages(receive);
154
+
155
+ return () => {
156
+ handlers = handlers.filter(h => h !== handler);
157
+ };
158
+ },
159
+
160
+ /** Processes the payload that launched the app, if any. */
161
+ async collectInitial(): Promise<PushEnvelope | undefined> {
162
+ const raw = await deps.provider.takeInitialMessage();
163
+ return raw === undefined ? undefined : receive(raw);
164
+ },
165
+
166
+ /** Reports a specific cause rather than leaving a non-delivery unexplained. */
167
+ reportUndeliverable(cause: UndeliverableCause, context?: Record<string, unknown>): void {
168
+ emit(
169
+ DIAGNOSTIC_CODES.PUSH_UNDELIVERABLE,
170
+ `A push could not be delivered or displayed: ${cause}.`,
171
+ { cause, ...context },
172
+ );
173
+ },
174
+
175
+ /** Exposed for tests and orderly shutdown. */
176
+ async flush(): Promise<void> {
177
+ await deliveryChain;
178
+ },
179
+
180
+ dispose(): void {
181
+ subscription?.();
182
+ tokenSubscription?.();
183
+ subscription = undefined;
184
+ tokenSubscription = undefined;
185
+ handlers = [];
186
+ },
187
+ };
188
+ }
189
+
190
+ export type PushGate = ReturnType<typeof createPushGate>;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Push surface: `@chipmobilesdk/rn-notification/push`
3
+ *
4
+ * Separate from the default entrypoint so an app that never imports this ships
5
+ * zero provider code, zero push permissions, and zero entitlements — and takes
6
+ * on none of the provider's data-collection declarations.
7
+ */
8
+
9
+ export { createPushGate } from './gate';
10
+ export type { PushGate, PushGateDependencies, PushHandler, UndeliverableCause } from './gate';
11
+
12
+ export { createFcmProvider } from './fcm-provider';
13
+ export type { FcmProviderOptions } from './fcm-provider';
14
+
15
+ export { createSignOut } from './signout';
16
+ export type { SignOut, SignOutDependencies, SignOutResult } from './signout';
17
+
18
+ export { createInstructionHandler } from './instructions';
19
+ export type { InstructionHandler, InstructionDependencies, InstructionOutcome } from './instructions';
20
+
21
+ export { normalizeEnvelope, IMPLEMENTED_PAYLOAD_VERSION } from './envelope';
22
+ export type { NormalizeResult } from './envelope';
23
+
24
+ export type {
25
+ PushProviderAdapter,
26
+ PushEnvelope,
27
+ PushConfig,
28
+ PushAvailability,
29
+ } from './provider-adapter';
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Push-driven local scheduling.
3
+ *
4
+ * Where the two paths meet. A server can schedule or cancel a local
5
+ * notification, and it gets **no privileged path**: an instruction arriving by
6
+ * push passes exactly the same identifier validation, moment validation, tone
7
+ * resolution, and permission rules as a request from app code.
8
+ *
9
+ * That symmetry is the point. A server that could bypass validation would be
10
+ * able to put the device into a state the app itself could not produce, and no
11
+ * amount of local correctness would survive it.
12
+ */
13
+
14
+ import type { PushEnvelope } from './provider-adapter';
15
+ import type { Scheduler } from '../schedule';
16
+ import type { NotificationEngineAdapter } from '../engine/adapter';
17
+ import { DIAGNOSTIC_CODES, isNotificationError } from '../contract/errors';
18
+
19
+ export interface InstructionDependencies {
20
+ readonly scheduler: Scheduler;
21
+ readonly engine: NotificationEngineAdapter;
22
+ readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
23
+ }
24
+
25
+ export type InstructionOutcome =
26
+ | { readonly kind: 'scheduled'; readonly id: string; readonly replaced: boolean }
27
+ | { readonly kind: 'cancelled'; readonly id: string }
28
+ | { readonly kind: 'suppressed'; readonly id: string; readonly reason: string }
29
+ | { readonly kind: 'rejected'; readonly id: string; readonly code: string; readonly reason: string }
30
+ | { readonly kind: 'ignored'; readonly id: string };
31
+
32
+ export function createInstructionHandler(deps: InstructionDependencies) {
33
+ const emit = deps.emit ?? (() => {});
34
+
35
+ /**
36
+ * Whether a local schedule already covers this event.
37
+ *
38
+ * Local wins: it is the entry the device can present at the right moment with
39
+ * no network. A server that genuinely wants to override sends an explicit
40
+ * cancel followed by a schedule, which is unambiguous in a way "last write
41
+ * wins" is not.
42
+ */
43
+ const localAlreadyCovers = async (id: string): Promise<boolean> => {
44
+ const pending = await deps.engine.getPending();
45
+ const existing = pending.find(entry => entry.id === id);
46
+ return existing !== undefined && !existing.pushCreated;
47
+ };
48
+
49
+ return {
50
+ async handle(envelope: PushEnvelope): Promise<InstructionOutcome> {
51
+ if (envelope.kind !== 'schedule' && envelope.kind !== 'cancel') {
52
+ return { kind: 'ignored', id: envelope.id };
53
+ }
54
+
55
+ try {
56
+ if (envelope.kind === 'cancel') {
57
+ await deps.scheduler.cancel(envelope.id);
58
+ return { kind: 'cancelled', id: envelope.id };
59
+ }
60
+
61
+ if (envelope.moment === undefined || envelope.content === undefined) {
62
+ throw Object.assign(
63
+ new Error('A schedule instruction must carry both a moment and content.'),
64
+ { code: DIAGNOSTIC_CODES.PAYLOAD_MALFORMED },
65
+ );
66
+ }
67
+
68
+ if (await localAlreadyCovers(envelope.id)) {
69
+ emit(
70
+ DIAGNOSTIC_CODES.PAYLOAD_DUPLICATE,
71
+ 'A local schedule already covers this event; the push instruction was suppressed so the user sees exactly one notification.',
72
+ { id: envelope.id },
73
+ );
74
+ return {
75
+ kind: 'suppressed',
76
+ id: envelope.id,
77
+ reason: 'A local schedule already covers this event.',
78
+ };
79
+ }
80
+
81
+ const result = await deps.scheduler.schedule(
82
+ {
83
+ id: envelope.id,
84
+ content: envelope.content,
85
+ tone: envelope.tone ?? '',
86
+ moment: envelope.moment,
87
+ routing: envelope.routing,
88
+ ownerTag: envelope.ownerTag,
89
+ },
90
+ { pushCreated: true },
91
+ );
92
+
93
+ return { kind: 'scheduled', id: envelope.id, replaced: result.replaced };
94
+ } catch (error) {
95
+ // Rejected under exactly the rules an app-initiated request would face,
96
+ // and recorded rather than swallowed.
97
+ const code = isNotificationError(error)
98
+ ? error.code
99
+ : ((error as { code?: string }).code ?? 'UNKNOWN');
100
+ const reason = error instanceof Error ? error.message : String(error);
101
+
102
+ emit(code, `Push instruction rejected: ${reason}`, { id: envelope.id });
103
+ return { kind: 'rejected', id: envelope.id, code, reason };
104
+ }
105
+ },
106
+ };
107
+ }
108
+
109
+ export type InstructionHandler = ReturnType<typeof createInstructionHandler>;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The port every push provider satisfies.
3
+ *
4
+ * No provider type appears in any signature, which is what makes swapping the
5
+ * provider cost the consumer nothing.
6
+ */
7
+
8
+ import type { RoutingPayload, NotificationContent } from '../contract/notification';
9
+ import type { Unsubscribe } from '../contract/interaction';
10
+
11
+ /** The single normalized shape every provider's payload is reduced to. */
12
+ export interface PushEnvelope {
13
+ /** Contract version carried by the payload. See contracts/push-payload.md. */
14
+ readonly version: number;
15
+ readonly kind: 'display' | 'data' | 'schedule' | 'cancel';
16
+ readonly id: string;
17
+ readonly ownerTag?: string;
18
+ readonly content?: NotificationContent;
19
+ readonly tone?: string;
20
+ readonly moment?: { readonly at: number; readonly anchoring?: 'wallClock' | 'absolute' };
21
+ readonly routing?: RoutingPayload;
22
+ /** De-duplication key, falling back to `id` when absent. */
23
+ readonly dedupId: string;
24
+ readonly receivedAt: number;
25
+ /**
26
+ * True when the payload declared a version this build does not implement and
27
+ * was processed best-effort, honouring known fields and ignoring the rest.
28
+ */
29
+ readonly versionSkew: boolean;
30
+ }
31
+
32
+ export interface PushConfig {
33
+ /** Called when the provider reports a new or refreshed registration token. */
34
+ readonly onTokenChange?: (token: string) => void;
35
+ }
36
+
37
+ export type PushAvailability = 'available' | 'unavailable' | 'unknown';
38
+
39
+ export interface PushProviderAdapter {
40
+ readonly name: string;
41
+
42
+ /** Reports whether platform push services exist, so degradation is controlled rather than opaque. */
43
+ getAvailability(): Promise<PushAvailability>;
44
+
45
+ /** Waits for a genuine token. Never returns a placeholder presented as a token. */
46
+ getToken(): Promise<string>;
47
+ onTokenChange(handler: (token: string) => void): Unsubscribe;
48
+ invalidateToken(): Promise<boolean>;
49
+
50
+ /** Raw provider payloads, normalized by the gate rather than by the adapter. */
51
+ subscribeToMessages(handler: (raw: unknown) => void): Unsubscribe;
52
+ /** A payload that launched the app, consumed exactly once. */
53
+ takeInitialMessage(): Promise<unknown | undefined>;
54
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Sign-out cleanup.
3
+ *
4
+ * Three things must happen together, and the order matters: invalidate the
5
+ * token first so no further messages arrive for the account being left, then
6
+ * cancel its notifications, then clear its persisted state.
7
+ *
8
+ * Deliberately **scoped to one owner tag**. On a shared device, signing one
9
+ * account out must not clear another's deduplication history or cancel their
10
+ * reminders — and must not discard the channel migration ledger, which
11
+ * describes the device rather than any account.
12
+ */
13
+
14
+ import type { PushGate } from './gate';
15
+ import type { Scheduler } from '../schedule';
16
+ import type { StateManager } from '../state/record';
17
+ import { clearOwner } from '../state/reset';
18
+
19
+ export interface SignOutDependencies {
20
+ readonly scheduler: Scheduler;
21
+ readonly state: StateManager;
22
+ /** Absent for a local-only app, which still needs the rest of the cleanup. */
23
+ readonly push?: PushGate;
24
+ readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
25
+ }
26
+
27
+ export interface SignOutResult {
28
+ readonly ownerTag: string;
29
+ readonly tokenInvalidated: boolean;
30
+ readonly cancelledIds: readonly string[];
31
+ readonly clearedCollections: readonly string[];
32
+ }
33
+
34
+ export function createSignOut(deps: SignOutDependencies) {
35
+ return {
36
+ async signOut(ownerTag: string): Promise<SignOutResult> {
37
+ // Token first: stop the inbound flow before removing what would have
38
+ // handled it, so nothing arrives for an account that is already gone.
39
+ let tokenInvalidated = false;
40
+ if (deps.push !== undefined) {
41
+ try {
42
+ tokenInvalidated = await deps.push.invalidateToken();
43
+ } catch {
44
+ // A failed invalidation must not prevent local cleanup — leaving a
45
+ // signed-out account's notifications on the device is the worse of
46
+ // the two outcomes.
47
+ tokenInvalidated = false;
48
+ }
49
+ }
50
+
51
+ const cancelledIds = await deps.scheduler.cancelByOwner(ownerTag);
52
+ const { clearedCollections } = clearOwner(deps.state, ownerTag);
53
+
54
+ deps.emit?.(
55
+ 'SIGN_OUT',
56
+ `Signed out ${ownerTag}: token ${tokenInvalidated ? 'invalidated' : 'not invalidated'}, ${cancelledIds.length} notification(s) cancelled, per-owner state cleared.`,
57
+ { ownerTag, tokenInvalidated, cancelled: cancelledIds.length },
58
+ );
59
+
60
+ return { ownerTag, tokenInvalidated, cancelledIds, clearedCollections };
61
+ },
62
+ };
63
+ }
64
+
65
+ export type SignOut = ReturnType<typeof createSignOut>;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Chunked application.
3
+ *
4
+ * A 500-entry reconciliation must not drop frames on a list screen, so work is
5
+ * applied in slices with a yield between them. No single slice may occupy the
6
+ * interface thread for more than 16 ms, which is what holds 60 fps while
7
+ * reconciliation is in flight.
8
+ */
9
+
10
+ export const DEFAULT_CHUNK_SIZE = 25;
11
+
12
+ /** Yields to the event loop so the interface thread can render between slices. */
13
+ export function yieldToEventLoop(): Promise<void> {
14
+ return new Promise(resolve => setTimeout(resolve, 0));
15
+ }
16
+
17
+ export interface ChunkOptions {
18
+ readonly size?: number;
19
+ readonly onYield?: () => Promise<void>;
20
+ }
21
+
22
+ /**
23
+ * Applies `work` to every item, yielding between chunks.
24
+ *
25
+ * Sequential within a chunk rather than parallel: these calls bridge to native,
26
+ * and a burst of concurrent scheduling is where duplicate-delivery races come
27
+ * from.
28
+ */
29
+ export async function applyInChunks<T>(
30
+ items: readonly T[],
31
+ work: (item: T) => Promise<void>,
32
+ options: ChunkOptions = {},
33
+ ): Promise<number> {
34
+ const size = Math.max(1, options.size ?? DEFAULT_CHUNK_SIZE);
35
+ const yieldFn = options.onYield ?? yieldToEventLoop;
36
+ let applied = 0;
37
+
38
+ for (let start = 0; start < items.length; start += size) {
39
+ const chunk = items.slice(start, start + size);
40
+
41
+ for (const item of chunk) {
42
+ await work(item);
43
+ applied += 1;
44
+ }
45
+
46
+ // No yield after the final chunk — the caller is about to resume anyway.
47
+ if (start + size < items.length) {
48
+ await yieldFn();
49
+ }
50
+ }
51
+
52
+ return applied;
53
+ }