@chipmobilesdk/rn-notification 0.1.0 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-notification",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "main": "src/index.ts",
@@ -38,6 +38,7 @@
38
38
  "src/schedule",
39
39
  "src/state",
40
40
  "src/testing",
41
+ "skills",
41
42
  "README.md",
42
43
  "CHANGELOG.md"
43
44
  ],
@@ -0,0 +1,102 @@
1
+ ---
2
+ name: sdk-rn-notification
3
+ description: "Local and push notifications via @chipmobilesdk/rn-notification: scheduling, identifiers, wall-clock vs absolute anchoring, idempotent reconciliation, recurrence windows, permissions, Android channel migration, tap routing, FCM. Use when scheduling a reminder, handling a notification tap, requesting notification permission, changing a channel, or debugging a notification that did not arrive."
4
+ metadata:
5
+ package: "@chipmobilesdk/rn-notification"
6
+ packageVersion: "0.1.1"
7
+ user-invocable: true
8
+ disable-model-invocation: false
9
+ ---
10
+
11
+ # @chipmobilesdk/rn-notification
12
+
13
+ Scheduled local notifications with stable identity and idempotent reconciliation,
14
+ provider-agnostic push, permissions, declarative channels and tones, and tap routing that
15
+ survives a cold start.
16
+
17
+ ## Use this when
18
+
19
+ - Scheduling, replacing, or cancelling a notification
20
+ - Reacting to a tap, an action button, or a dismissal
21
+ - Requesting notification or exact-alarm permission
22
+ - Declaring or changing a tone/channel, or enabling push
23
+ - Debugging a notification that did not arrive, or arrived twice
24
+
25
+ **Not** for in-app banners or an inbox (this package renders nothing), for storing app
26
+ data (`@chipmobilesdk/rn-local-db`), or for translating text — resolve strings yourself.
27
+
28
+ ## Read before writing any code
29
+
30
+ Three things cause most of the bugs, and none is visible in the type signatures:
31
+
32
+ 1. **Identifiers are yours and must be derived from data.** `crypto.randomUUID()` means
33
+ you can never replace or cancel what you scheduled. Nothing is normalized, so
34
+ `Task:1` and `task:1` are two different notifications.
35
+ 2. **Wall-clock is the default anchoring mode** — an 08:00 reminder stays 08:00 after the
36
+ user changes time zone. One case re-anchoring cannot fix: read
37
+ `### What re-anchoring cannot fix` before promising exact behaviour to anyone.
38
+ 3. **Reconciliation is never automatic.** The package maintains its own recurrence windows
39
+ and re-anchoring, but never invents a desired set. Data changed and you did not call
40
+ `reconcile()` means the device is stale, by design.
41
+
42
+ Read `## Choosing identifiers`, `## Choosing an anchoring mode`, and
43
+ `## Maintenance versus reconciliation` before designing anything.
44
+
45
+ **A refusal must never break the user's work.** Permission denial is a degradation, not a
46
+ failure: the caller's operation still succeeds and the lost capability is reported
47
+ separately. Without exact-alarm capability notifications are still scheduled, flagged
48
+ `mayBeDelayed`. Never gate a user action on a permission answer.
49
+
50
+ ## Android: two manifest entries you must add yourself
51
+
52
+ Consumer-owned; this package cannot ship either.
53
+
54
+ - **`SCHEDULE_EXACT_ALARM` arrives whether you want it or not** — the engine declares it
55
+ and manifest merging is transitive. It is store-restricted and needs a Play Console
56
+ declaration. Remove it with `tools:node="remove"` unless you need precise timing.
57
+ - **Backup exclusion** for the state record — a deduplication ledger restored onto another
58
+ device claims payloads were already delivered, silently suppressing real ones.
59
+
60
+ See `### Android` and `### Backup exclusion`.
61
+
62
+ ## Public API
63
+
64
+ Three entrypoints. The **root** exports one factory per concern (`createScheduler`,
65
+ `createReconciler`, `createRecurrence`, `createPermissions`, `createChannels`,
66
+ `createInteractions`, `createSnapshot`), the maintenance steps and their registry, plus
67
+ `resolveTone`, `resetState`, `clearOwner`, `isNonFailure` and `DIAGNOSTIC_CODES`.
68
+ **`/push`** carries the push gate, FCM provider, instruction handler and sign-out —
69
+ **import it only if you enable push**, because an app that never touches this subpath ships
70
+ no provider code, no push permissions, and no entitlements. **`/testing`** carries the fake
71
+ engine and provider, the conformance suite and the fault injector.
72
+
73
+ Enumerate exact names from `node_modules/@chipmobilesdk/rn-notification/src/index.ts`
74
+ rather than guessing.
75
+
76
+ ## Where the detail lives
77
+
78
+ `node_modules/@chipmobilesdk/rn-notification/README.md`:
79
+
80
+ | Need | Section |
81
+ |---|---|
82
+ | Install, peers, enabling push | `## Installation`, `### Enabling push (optional)` |
83
+ | Native setup | `### iOS`, `### Android`, `### Backup exclusion` |
84
+ | Naming notifications | `## Choosing identifiers` |
85
+ | Time zones and travel | `## Choosing an anchoring mode` |
86
+ | Automatic vs your responsibility | `## Maintenance versus reconciliation` |
87
+ | Converging the device to your data | `## Reconciliation`, `## Recurrence` |
88
+ | Permission timing and recovery | `## Permissions` |
89
+ | Taps, actions, cold start | `## Interaction` |
90
+ | Push and the backend contract | `## Push` |
91
+ | On-device storage and privacy | `## What is persisted`, `## Data behaviour …` |
92
+ | Limits and timing budgets | `## Budgets` |
93
+ | Error codes | `## Diagnostic identifiers` |
94
+
95
+ ## Debugging "it did not arrive"
96
+
97
+ Capture a state snapshot (`createSnapshot`) before guessing — permissions, applied channel
98
+ versions, pending set, last maintenance cycle, discrepancies and state health, in one
99
+ place. Some codes are **reported outcomes, not failures** (`TRUNCATED_OVER_CEILING`,
100
+ `EXACT_ALARM_UNAVAILABLE`, `PENDING_SET_DISCREPANCY`, `BURST_THRESHOLD_EXCEEDED`,
101
+ `PAYLOAD_VERSION_SKEW`); use `isNonFailure(code)` rather than treating every code as an
102
+ error.
package/src/index.ts CHANGED
@@ -171,3 +171,14 @@ export type { StateStore } from './state/store';
171
171
  export { MAX_IDENTIFIER_LENGTH } from './schedule/validate-identifier';
172
172
  export { CONTENT_LIMITS } from './schedule/validate-content';
173
173
  export { PAST_TOLERANCE_MS } from './schedule/resolve-moment';
174
+
175
+ /**
176
+ * Reads the platform time zone, which anchoring depends on.
177
+ *
178
+ * Exported because every consumer needs it and pure JavaScript cannot provide
179
+ * it reliably — `Intl.DateTimeFormat().resolvedOptions().timeZone` does not
180
+ * update when the device zone changes. Leaving consumers to write this
181
+ * themselves is how a subtly wrong version ends up in each of them.
182
+ */
183
+ export { createPlatformTimeZoneReader, isValidTimeZone } from './schedule/anchor';
184
+ export type { TimeZoneReader } from './schedule/anchor';
@@ -1,39 +1,35 @@
1
- /**
2
- * The push deduplication ledger.
3
- *
4
- * Providers redeliver. The redelivery typically arrives in a **fresh process**,
5
- * which is precisely why this is persisted rather than held in memory — an
6
- * in-memory set would forget exactly when it matters.
7
- *
8
- * Bounded by cap and retention. A payload redelivered after its entry has aged
9
- * out is treated as new: that is the documented bound of the guarantee, not a
10
- * defect.
11
- */
12
-
13
- import type { StateManager } from './record';
14
-
15
- export function isDuplicate(state: StateManager, dedupId: string): boolean {
16
- return state.read().dedupLedger.some(entry => entry.dedupId === dedupId);
17
- }
18
-
19
- /**
20
- * Records a payload as seen.
21
- *
22
- * Returns true when it was already known, so a caller can branch on the same
23
- * read that records it rather than checking twice.
24
- */
25
- export function recordSeen(
26
- state: StateManager,
27
- dedupId: string,
28
- now: number,
29
- ownerTag?: string,
30
- ): boolean {
31
- if (isDuplicate(state, dedupId)) return true;
32
-
33
- state.update('dedupLedger', current => [...current, { at: now, dedupId, ownerTag }]);
34
- return false;
35
- }
36
-
37
- export function dedupCount(state: StateManager): number {
38
- return state.read().dedupLedger.length;
39
- }
1
+ /**
2
+ * The push deduplication ledger.
3
+ *
4
+ * Providers redeliver. The redelivery typically arrives in a **fresh process**,
5
+ * which is precisely why this is persisted rather than held in memory — an
6
+ * in-memory set would forget exactly when it matters.
7
+ *
8
+ * Bounded by cap and retention. A payload redelivered after its entry has aged
9
+ * out is treated as new: that is the documented bound of the guarantee, not a
10
+ * defect.
11
+ */
12
+
13
+ import type { StateManager } from './record';
14
+
15
+ export function isDuplicate(state: StateManager, dedupId: string): boolean {
16
+ return state.read().dedupLedger.some(entry => entry.dedupId === dedupId);
17
+ }
18
+
19
+ /**
20
+ * Records a payload as seen.
21
+ *
22
+ * Returns true when it was already known, so a caller can branch on the same
23
+ * read that records it rather than checking twice.
24
+ */
25
+ export function recordSeen(
26
+ state: StateManager,
27
+ dedupId: string,
28
+ now: number,
29
+ ownerTag?: string,
30
+ ): boolean {
31
+ if (isDuplicate(state, dedupId)) return true;
32
+
33
+ state.update('dedupLedger', current => [...current, { at: now, dedupId, ownerTag }]);
34
+ return false;
35
+ }
@@ -11,6 +11,8 @@ export { createFakeEngine } from './fake-engine';
11
11
  export { createFakeProvider } from './fake-provider';
12
12
  export { createFaultInjector } from './faults';
13
13
  export { runConformanceSuite } from './conformance';
14
+ export { runMeasurementHarness, PERF_BUDGETS } from './measure';
15
+ export type { MeasurementReport, Measurement, BudgetName, MeasureOptions } from './measure';
14
16
  export type { ConformanceReport, ConformanceCheck, ConformanceOptions } from './conformance';
15
17
  export type { FaultInjector, FaultKind, FaultRecord, Disposable } from './faults';
16
18
  export type { FakeProvider, FakeProviderOptions, FakeProviderControls } from './fake-provider';
@@ -0,0 +1,334 @@
1
+ /**
2
+ * The PERF-001 measurement harness.
3
+ *
4
+ * Every budget in the specification, measured against the real code paths and
5
+ * reported as pass or fail with the number that produced the verdict. Written
6
+ * as a plain async function rather than a Jest suite so the same harness runs
7
+ * in continuous integration and from the demo app on a physical device — which
8
+ * matters, because PERF-003 forbids emulator numbers standing in as evidence.
9
+ *
10
+ * Five budgets are deliberately absent and reported as such: delivery accuracy,
11
+ * cold-start latency, startup delta and frame rate cannot be measured
12
+ * in-process. The first needs the OS to actually fire an alarm, the second a
13
+ * real process death. Pretending to measure them here would be worse than
14
+ * leaving the gap visible.
15
+ */
16
+
17
+ import type { NotificationEngineAdapter } from '../engine/adapter';
18
+ import type { NotificationRequest } from '../contract/notification';
19
+ import type { ResolvedTone } from '../contract/channel';
20
+ import type { RecurrenceRule } from '../contract/recurrence';
21
+ import { createScheduler } from '../schedule';
22
+ import { createReconciler } from '../reconcile';
23
+ import { createRecurrence } from '../recurrence';
24
+ import { createPermissions } from '../permission';
25
+ import { createReAnchorStep } from '../maintenance/reanchor';
26
+ import { MaintenanceRegistry } from '../maintenance/cycle';
27
+ import { StateManager } from '../state/record';
28
+ import { createMemoryStore } from '../state/store';
29
+ import { planReAnchor } from '../schedule/anchor';
30
+ import { recordSeen } from '../state/dedup-ledger';
31
+
32
+ /** Every budget PERF-001 states, in milliseconds unless the name says otherwise. */
33
+ export const PERF_BUDGETS = {
34
+ scheduleOne: 50,
35
+ readPending100: 200,
36
+ reconcileNoChange100: 150,
37
+ reconcile500With100Changes: 1_500,
38
+ maintenanceIdleCycle: 300,
39
+ windowRefresh32Series: 500,
40
+ reAnchor100: 1_000,
41
+ reAnchorNoChangeCheck: 20,
42
+ permissionRead: 50,
43
+ dedupCheck: 20,
44
+ interfaceThreadUnit: 16,
45
+ stateFootprintBytes: 1_048_576,
46
+ } as const;
47
+
48
+ export type BudgetName = keyof typeof PERF_BUDGETS;
49
+
50
+ export interface Measurement {
51
+ readonly name: BudgetName;
52
+ readonly label: string;
53
+ /** Milliseconds, or bytes for the footprint budget. */
54
+ readonly measured: number;
55
+ readonly budget: number;
56
+ readonly unit: 'ms' | 'bytes';
57
+ readonly passed: boolean;
58
+ /** Present where the budget also demands zero platform operations. */
59
+ readonly operationCount?: number;
60
+ }
61
+
62
+ export interface MeasurementReport {
63
+ readonly measurements: readonly Measurement[];
64
+ readonly passed: number;
65
+ readonly failed: number;
66
+ /** Budgets this harness structurally cannot measure. Never silently omitted. */
67
+ readonly requiresDevice: readonly string[];
68
+ readonly ranAt: number;
69
+ }
70
+
71
+ const TONE: ResolvedTone = {
72
+ toneId: 'perf',
73
+ version: 1,
74
+ android: {
75
+ channelId: 'perf.v1',
76
+ importance: 'default',
77
+ vibration: false,
78
+ badge: false,
79
+ bypassDnd: false,
80
+ },
81
+ ios: { categoryId: 'perf', interruptionLevel: 'active', badge: false },
82
+ };
83
+
84
+ export interface MeasureOptions {
85
+ /** Defaults to the real clock, which is what a device run needs. */
86
+ readonly clock?: () => number;
87
+ /** Identifier prefix, so a device run cannot collide with real notifications. */
88
+ readonly prefix?: string;
89
+ }
90
+
91
+ export async function runMeasurementHarness(
92
+ engine: NotificationEngineAdapter,
93
+ options: MeasureOptions = {},
94
+ ): Promise<MeasurementReport> {
95
+ const clock = options.clock ?? (() => Date.now());
96
+ const prefix = options.prefix ?? 'perf';
97
+ const measurements: Measurement[] = [];
98
+
99
+ const now = () => Date.now();
100
+ const zone = 'UTC';
101
+ const state = new StateManager({ store: createMemoryStore(), now });
102
+ const resolveTone = () => TONE;
103
+
104
+ const record = (
105
+ name: BudgetName,
106
+ label: string,
107
+ measured: number,
108
+ operationCount?: number,
109
+ ): void => {
110
+ const budget = PERF_BUDGETS[name];
111
+ // A budget that also demands zero operations fails on either count.
112
+ const opsOk = operationCount === undefined || operationCount === 0;
113
+ measurements.push({
114
+ name,
115
+ label,
116
+ measured,
117
+ budget,
118
+ unit: name === 'stateFootprintBytes' ? 'bytes' : 'ms',
119
+ passed: measured <= budget && opsOk,
120
+ operationCount,
121
+ });
122
+ };
123
+
124
+ /** Median of three, so one scheduler hiccup does not decide a verdict. */
125
+ const timeOf = async (run: () => Promise<unknown>): Promise<number> => {
126
+ const samples: number[] = [];
127
+ for (let i = 0; i < 3; i += 1) {
128
+ const started = clock();
129
+ await run();
130
+ samples.push(clock() - started);
131
+ }
132
+ return samples.sort((a, b) => a - b)[1];
133
+ };
134
+
135
+ const request = (id: string, at: number): NotificationRequest => ({
136
+ id: `${prefix}:${id}`,
137
+ content: { title: id, body: 'Measurement harness entry' },
138
+ tone: 'perf',
139
+ moment: { at },
140
+ groupTag: prefix,
141
+ });
142
+
143
+ const scheduler = createScheduler({
144
+ engine,
145
+ resolveTone,
146
+ timeZone: () => zone,
147
+ now,
148
+ canScheduleExactly: () => true,
149
+ });
150
+
151
+ await engine.cancelAll();
152
+
153
+ // --- Scheduling -----------------------------------------------------------
154
+
155
+ record(
156
+ 'scheduleOne',
157
+ 'Single schedule operation',
158
+ await timeOf(() => scheduler.schedule(request('single', now() + 3_600_000))),
159
+ );
160
+
161
+ for (let i = 0; i < 100; i += 1) {
162
+ await scheduler.schedule(request(`bulk:${i}`, now() + (i + 1) * 60_000));
163
+ }
164
+
165
+ record('readPending100', 'Read pending set of 100', await timeOf(() => scheduler.getPending()));
166
+
167
+ // --- Reconciliation -------------------------------------------------------
168
+
169
+ const reconciler = createReconciler({
170
+ engine,
171
+ resolveTone,
172
+ timeZone: () => zone,
173
+ now,
174
+ canScheduleExactly: () => true,
175
+ });
176
+
177
+ const hundred = Array.from({ length: 100 }, (_, i) =>
178
+ request(`recon:${i}`, now() + (i + 1) * 60_000),
179
+ );
180
+ await reconciler.reconcile({ domain: prefix, desired: hundred });
181
+
182
+ let noChangeOps = 0;
183
+ const noChangeMs = await timeOf(async () => {
184
+ const result = await reconciler.reconcile({ domain: prefix, desired: hundred });
185
+ noChangeOps = result.operationCount;
186
+ });
187
+ record('reconcileNoChange100', 'No-change reconcile over 100', noChangeMs, noChangeOps);
188
+
189
+ const fiveHundred = Array.from({ length: 500 }, (_, i) =>
190
+ request(`recon:${i}`, now() + (i + 1) * 60_000),
191
+ );
192
+ await reconciler.reconcile({ domain: prefix, desired: fiveHundred });
193
+
194
+ const changed = fiveHundred.map((entry, i) =>
195
+ i < 100 ? { ...entry, content: { title: `changed-${i}`, body: entry.content.body } } : entry,
196
+ );
197
+ record(
198
+ 'reconcile500With100Changes',
199
+ '500-entry reconcile with 100 changes',
200
+ await timeOf(() => reconciler.reconcile({ domain: prefix, desired: changed })),
201
+ );
202
+
203
+ // --- Re-anchoring ---------------------------------------------------------
204
+
205
+ const pending = await engine.getPending();
206
+ record(
207
+ 'reAnchorNoChangeCheck',
208
+ 'Re-anchor check with no zone change',
209
+ await timeOf(async () => planReAnchor(pending, zone)),
210
+ planReAnchor(pending, zone).rewrites.length,
211
+ );
212
+
213
+ const context = { trigger: 'foreground' as const, now: now(), ownedIds: new Set<string>() };
214
+ const travelled = createReAnchorStep({
215
+ engine,
216
+ timeZone: () => 'Asia/Tokyo',
217
+ resolveTone,
218
+ canScheduleExactly: () => true,
219
+ });
220
+ record('reAnchor100', 'Re-anchor after a zone change', await timeOf(() => travelled.run(context)));
221
+
222
+ // --- Maintenance ----------------------------------------------------------
223
+
224
+ const registry = new MaintenanceRegistry();
225
+ registry.register(
226
+ createReAnchorStep({
227
+ engine,
228
+ timeZone: () => zone,
229
+ resolveTone,
230
+ canScheduleExactly: () => true,
231
+ }),
232
+ );
233
+
234
+ let idleOps = 0;
235
+ const idleMs = await timeOf(async () => {
236
+ const result = await registry.run({ ...context, now: now() });
237
+ idleOps = result.operationCount;
238
+ });
239
+ record('maintenanceIdleCycle', 'Idle maintenance cycle', idleMs, idleOps);
240
+
241
+ // --- Recurrence -----------------------------------------------------------
242
+
243
+ const recurrence = createRecurrence({
244
+ engine,
245
+ state,
246
+ resolveTone,
247
+ timeZone: () => zone,
248
+ now,
249
+ lookahead: { days: 60, occurrencesPerSeries: 32 },
250
+ canScheduleExactly: () => true,
251
+ });
252
+
253
+ const series: RecurrenceRule[] = Array.from({ length: 32 }, (_, i) => ({
254
+ id: `${prefix}:series:${i}`,
255
+ pattern: { kind: 'daily', hour: i % 24, minute: 0 },
256
+ end: { kind: 'count', count: 2 },
257
+ template: {
258
+ content: { title: `Series ${i}`, body: 'Measurement harness series' },
259
+ tone: 'perf',
260
+ groupTag: prefix,
261
+ },
262
+ }));
263
+
264
+ for (const rule of series) {
265
+ await recurrence.registerSeries(rule);
266
+ }
267
+
268
+ record(
269
+ 'windowRefresh32Series',
270
+ 'Window refresh across 32 series',
271
+ await timeOf(async () => {
272
+ for (const rule of series) {
273
+ await recurrence.refreshSeries(rule);
274
+ }
275
+ }),
276
+ );
277
+
278
+ // --- Permissions ----------------------------------------------------------
279
+
280
+ const permissions = createPermissions({
281
+ engine,
282
+ state,
283
+ platform: { os: 'android', apiLevel: 34 },
284
+ now,
285
+ });
286
+ record(
287
+ 'permissionRead',
288
+ 'Read all permission states',
289
+ await timeOf(() => permissions.getPermissions()),
290
+ );
291
+
292
+ // --- Deduplication and footprint -----------------------------------------
293
+
294
+ for (let i = 0; i < 400; i += 1) {
295
+ recordSeen(state, `${prefix}:dedup:${i}`, now());
296
+ }
297
+ record(
298
+ 'dedupCheck',
299
+ 'Deduplication ledger check',
300
+ await timeOf(async () => recordSeen(state, `${prefix}:dedup:probe`, now())),
301
+ );
302
+
303
+ record(
304
+ 'stateFootprintBytes',
305
+ 'Persisted state on disk at caps',
306
+ state.health().approximateBytes,
307
+ );
308
+
309
+ // The interface-thread ceiling is a property of one chunked unit of work, not
310
+ // of a whole pass, so it is measured per entry rather than per reconcile.
311
+ const chunkMs = await timeOf(() =>
312
+ reconciler.reconcile({ domain: prefix, desired: hundred.slice(0, 25) }),
313
+ );
314
+ record('interfaceThreadUnit', 'Single chunked unit of reconcile work', chunkMs / 25);
315
+
316
+ await engine.cancelAll();
317
+
318
+ const passed = measurements.filter(measurement => measurement.passed).length;
319
+
320
+ return {
321
+ measurements,
322
+ passed,
323
+ failed: measurements.length - passed,
324
+ // Stated rather than silently omitted — these need real hardware.
325
+ requiresDevice: [
326
+ 'Delivery accuracy, exact mode (60 s) — needs the OS to fire a real alarm',
327
+ 'Delivery accuracy, approximate mode (15 min) — needs a device under no vendor restriction',
328
+ 'Cold-start tap to handler (500 ms) — needs a real process death and relaunch',
329
+ 'App startup delta (100 ms) — needs a real cold start',
330
+ 'Sustained frame rate during reconcile (55 fps) — needs a rendering device',
331
+ ],
332
+ ranAt: now(),
333
+ };
334
+ }