@chipmobilesdk/rn-notification 0.4.0 → 0.5.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 (75) hide show
  1. package/CHANGELOG.md +257 -166
  2. package/README.md +420 -410
  3. package/package.json +61 -75
  4. package/skills/sdk-rn-notification/SKILL.md +172 -173
  5. package/src/channel/migrate.ts +285 -289
  6. package/src/channel/tone-map.ts +130 -130
  7. package/src/channel/validate-tone.ts +61 -61
  8. package/src/contract/channel.ts +192 -188
  9. package/src/contract/diagnostics.ts +102 -102
  10. package/src/contract/errors.ts +142 -134
  11. package/src/contract/index.ts +10 -10
  12. package/src/contract/interaction.ts +62 -62
  13. package/src/contract/notification.ts +118 -118
  14. package/src/contract/permission.ts +55 -60
  15. package/src/contract/reconcile.ts +88 -88
  16. package/src/contract/recurrence.ts +129 -129
  17. package/src/diagnostics/burst.ts +101 -101
  18. package/src/diagnostics/events.ts +81 -72
  19. package/src/diagnostics/redact.ts +55 -72
  20. package/src/diagnostics/snapshot.ts +100 -64
  21. package/src/engine/adapter.ts +172 -172
  22. package/src/engine/codec.ts +89 -89
  23. package/src/engine/notify-kit-engine.ts +715 -707
  24. package/src/index.ts +208 -208
  25. package/src/initialize.ts +247 -236
  26. package/src/interaction/index.ts +146 -146
  27. package/src/maintenance/cycle.ts +150 -154
  28. package/src/maintenance/permission-refresh.ts +39 -41
  29. package/src/maintenance/reanchor.ts +101 -101
  30. package/src/maintenance/toggle.ts +66 -64
  31. package/src/maintenance/verify.ts +83 -83
  32. package/src/maintenance/window-refresh.ts +64 -64
  33. package/src/permission/index.ts +183 -183
  34. package/src/permission/state.ts +77 -77
  35. package/src/push/envelope.ts +165 -165
  36. package/src/push/fcm-provider.ts +150 -146
  37. package/src/push/gate.ts +190 -190
  38. package/src/push/index.ts +33 -29
  39. package/src/push/instructions.ts +114 -109
  40. package/src/push/provider-adapter.ts +54 -54
  41. package/src/push/signout.ts +65 -65
  42. package/src/reconcile/chunk.ts +53 -53
  43. package/src/reconcile/diff.ts +142 -145
  44. package/src/reconcile/duplicates.ts +73 -73
  45. package/src/reconcile/index.ts +180 -180
  46. package/src/reconcile/truncate.ts +56 -56
  47. package/src/recurrence/clock-guard.ts +73 -71
  48. package/src/recurrence/expand.ts +326 -319
  49. package/src/recurrence/index.ts +270 -259
  50. package/src/repeat/expiry.ts +53 -55
  51. package/src/repeat/index.ts +54 -54
  52. package/src/repeat/marker.ts +72 -72
  53. package/src/repeat/place.ts +73 -73
  54. package/src/schedule/anchor.ts +294 -267
  55. package/src/schedule/index.ts +174 -181
  56. package/src/schedule/partition.ts +88 -85
  57. package/src/schedule/resolve-moment.ts +80 -80
  58. package/src/schedule/validate-content.ts +111 -111
  59. package/src/schedule/validate-identifier.ts +126 -126
  60. package/src/schedule/writer.ts +55 -55
  61. package/src/state/caps.ts +71 -71
  62. package/src/state/dedup-ledger.ts +35 -35
  63. package/src/state/interaction-buffer.ts +101 -73
  64. package/src/state/mmkv-store.ts +92 -75
  65. package/src/state/permission-history.ts +41 -41
  66. package/src/state/record.ts +270 -263
  67. package/src/state/reset.ts +41 -41
  68. package/src/state/series-ledger.ts +91 -91
  69. package/src/state/store.ts +43 -40
  70. package/src/testing/conformance/index.ts +258 -248
  71. package/src/testing/fake-engine.ts +462 -468
  72. package/src/testing/fake-provider.ts +126 -129
  73. package/src/testing/faults.ts +72 -72
  74. package/src/testing/index.ts +23 -28
  75. package/src/testing/measure.ts +337 -337
@@ -1,55 +1,55 @@
1
- /**
2
- * The one door every consumer notification goes through to reach the engine.
3
- *
4
- * Scheduler, reconciler, recurrence, re-anchoring and channel re-homing all
5
- * write notifications. Each used to call `engine.schedule` itself, and the
6
- * guarantees attached to a write — a repeating alert gets its stop marker, and
7
- * that marker moves when the notification moves — existed only on the paths
8
- * that remembered them. A recurring alarm never stopped, and a re-anchored one
9
- * stopped at the old instant.
10
- *
11
- * Validation stays with each caller, because each has its own failure policy:
12
- * the scheduler throws, reconciliation rejects one entry and carries on, and
13
- * recurrence validates its template once at registration.
14
- */
15
-
16
- import type { NotificationEngineAdapter, EngineScheduleRequest } from '../engine/adapter';
17
- import { createStopPlacement } from '../repeat/place';
18
-
19
- export interface ScheduleWriter {
20
- /** Writes one notification and brings its stop marker in line with it. */
21
- write(input: EngineScheduleRequest): Promise<void>;
22
- /** Cancels one notification and the marker guarding it. */
23
- cancel(id: string): Promise<void>;
24
- /** Drops the marker for a notification already cancelled some other way. */
25
- releaseStop(targetId: string): Promise<void>;
26
- /**
27
- * Platform slots consumer notifications can actually use: stop markers hold
28
- * real capacity, so the usable ceiling is the platform's minus theirs.
29
- */
30
- capacity(): Promise<number>;
31
- }
32
-
33
- export function createScheduleWriter(engine: NotificationEngineAdapter): ScheduleWriter {
34
- const stops = createStopPlacement(engine);
35
-
36
- return {
37
- async write(input) {
38
- await engine.schedule(input);
39
- await stops.sync(input.request.id, input.tone, input.resolvedAt);
40
- },
41
-
42
- async cancel(id) {
43
- await engine.cancel(id);
44
- await stops.release(id);
45
- },
46
-
47
- releaseStop: targetId => stops.release(targetId),
48
-
49
- async capacity() {
50
- const capabilities = await engine.getCapabilities();
51
- const markerCost = (await engine.listRepeatStops()).length;
52
- return Math.max(0, capabilities.pendingCeiling - markerCost);
53
- },
54
- };
55
- }
1
+ /**
2
+ * The one door every consumer notification goes through to reach the engine.
3
+ *
4
+ * Scheduler, reconciler, recurrence, re-anchoring and channel re-homing all
5
+ * write notifications. Each used to call `engine.schedule` itself, and the
6
+ * guarantees attached to a write — a repeating alert gets its stop marker, and
7
+ * that marker moves when the notification moves — existed only on the paths
8
+ * that remembered them. A recurring alarm never stopped, and a re-anchored one
9
+ * stopped at the old instant.
10
+ *
11
+ * Validation stays with each caller, because each has its own failure policy:
12
+ * the scheduler throws, reconciliation rejects one entry and carries on, and
13
+ * recurrence validates its template once at registration.
14
+ */
15
+
16
+ import type { NotificationEngineAdapter, EngineScheduleRequest } from '../engine/adapter';
17
+ import { createStopPlacement } from '../repeat/place';
18
+
19
+ export interface ScheduleWriter {
20
+ /** Writes one notification and brings its stop marker in line with it. */
21
+ write(input: EngineScheduleRequest): Promise<void>;
22
+ /** Cancels one notification and the marker guarding it. */
23
+ cancel(id: string): Promise<void>;
24
+ /** Drops the marker for a notification already cancelled some other way. */
25
+ releaseStop(targetId: string): Promise<void>;
26
+ /**
27
+ * Platform slots consumer notifications can actually use: stop markers hold
28
+ * real capacity, so the usable ceiling is the platform's minus theirs.
29
+ */
30
+ capacity(): Promise<number>;
31
+ }
32
+
33
+ export function createScheduleWriter(engine: NotificationEngineAdapter): ScheduleWriter {
34
+ const stops = createStopPlacement(engine);
35
+
36
+ return {
37
+ async write(input) {
38
+ await engine.schedule(input);
39
+ await stops.sync(input.request.id, input.tone, input.resolvedAt);
40
+ },
41
+
42
+ async cancel(id) {
43
+ await engine.cancel(id);
44
+ await stops.release(id);
45
+ },
46
+
47
+ releaseStop: targetId => stops.release(targetId),
48
+
49
+ async capacity() {
50
+ const capabilities = await engine.getCapabilities();
51
+ const markerCost = (await engine.listRepeatStops()).length;
52
+ return Math.max(0, capabilities.pendingCeiling - markerCost);
53
+ },
54
+ };
55
+ }
package/src/state/caps.ts CHANGED
@@ -1,71 +1,71 @@
1
- /**
2
- * Caps and eviction for the persisted collections.
3
- *
4
- * No collection may grow without bound. Eviction is oldest-first and always
5
- * observable — a silent eviction would turn a bounded guarantee into an
6
- * unexplained one.
7
- */
8
-
9
- /** Every persisted collection with a size or age bound. */
10
- export const COLLECTION_CAPS = {
11
- dedupLedger: { maxEntries: 500, retentionMs: 24 * 60 * 60 * 1000 },
12
- interactionBuffer: { maxEntries: 50, retentionMs: 7 * 24 * 60 * 60 * 1000 },
13
- migrationLedger: { maxEntries: 20, retentionMs: Number.POSITIVE_INFINITY },
14
- seriesLedger: { maxEntries: 32, retentionMs: Number.POSITIVE_INFINITY },
15
- permissionHistory: { maxEntries: 64, retentionMs: Number.POSITIVE_INFINITY },
16
- } as const;
17
-
18
- export type CollectionName = keyof typeof COLLECTION_CAPS;
19
-
20
- /** Anything stored with a timestamp, which is everything that can age out. */
21
- export interface Timestamped {
22
- readonly at: number;
23
- }
24
-
25
- export interface EvictionOutcome<T> {
26
- readonly kept: readonly T[];
27
- readonly evicted: readonly T[];
28
- readonly reason: 'none' | 'cap' | 'retention' | 'cap+retention';
29
- }
30
-
31
- /**
32
- * Applies retention first, then the size cap.
33
- *
34
- * Order matters: expiring stale entries before trimming means a burst of new
35
- * entries does not evict fresh ones while stale ones sit protected by
36
- * recency ordering.
37
- */
38
- export function evict<T extends Timestamped>(
39
- entries: readonly T[],
40
- collection: CollectionName,
41
- now: number,
42
- ): EvictionOutcome<T> {
43
- const { maxEntries, retentionMs } = COLLECTION_CAPS[collection];
44
-
45
- const expired: T[] = [];
46
- const live: T[] = [];
47
- for (const entry of entries) {
48
- if (Number.isFinite(retentionMs) && now - entry.at > retentionMs) {
49
- expired.push(entry);
50
- } else {
51
- live.push(entry);
52
- }
53
- }
54
-
55
- // Oldest first, so the tail is what we keep when trimming.
56
- live.sort((a, b) => a.at - b.at);
57
-
58
- const overflow = live.length > maxEntries ? live.slice(0, live.length - maxEntries) : [];
59
- const kept = live.length > maxEntries ? live.slice(live.length - maxEntries) : live;
60
-
61
- let reason: EvictionOutcome<T>['reason'] = 'none';
62
- if (expired.length > 0 && overflow.length > 0) {
63
- reason = 'cap+retention';
64
- } else if (overflow.length > 0) {
65
- reason = 'cap';
66
- } else if (expired.length > 0) {
67
- reason = 'retention';
68
- }
69
-
70
- return { kept, evicted: [...expired, ...overflow], reason };
71
- }
1
+ /**
2
+ * Caps and eviction for the persisted collections.
3
+ *
4
+ * No collection may grow without bound. Eviction is oldest-first and always
5
+ * observable — a silent eviction would turn a bounded guarantee into an
6
+ * unexplained one.
7
+ */
8
+
9
+ /** Every persisted collection with a size or age bound. */
10
+ export const COLLECTION_CAPS = {
11
+ dedupLedger: { maxEntries: 500, retentionMs: 24 * 60 * 60 * 1000 },
12
+ interactionBuffer: { maxEntries: 50, retentionMs: 7 * 24 * 60 * 60 * 1000 },
13
+ migrationLedger: { maxEntries: 20, retentionMs: Number.POSITIVE_INFINITY },
14
+ seriesLedger: { maxEntries: 32, retentionMs: Number.POSITIVE_INFINITY },
15
+ permissionHistory: { maxEntries: 64, retentionMs: Number.POSITIVE_INFINITY },
16
+ } as const;
17
+
18
+ export type CollectionName = keyof typeof COLLECTION_CAPS;
19
+
20
+ /** Anything stored with a timestamp, which is everything that can age out. */
21
+ export interface Timestamped {
22
+ readonly at: number;
23
+ }
24
+
25
+ export interface EvictionOutcome<T> {
26
+ readonly kept: readonly T[];
27
+ readonly evicted: readonly T[];
28
+ readonly reason: 'none' | 'cap' | 'retention' | 'cap+retention';
29
+ }
30
+
31
+ /**
32
+ * Applies retention first, then the size cap.
33
+ *
34
+ * Order matters: expiring stale entries before trimming means a burst of new
35
+ * entries does not evict fresh ones while stale ones sit protected by
36
+ * recency ordering.
37
+ */
38
+ export function evict<T extends Timestamped>(
39
+ entries: readonly T[],
40
+ collection: CollectionName,
41
+ now: number,
42
+ ): EvictionOutcome<T> {
43
+ const { maxEntries, retentionMs } = COLLECTION_CAPS[collection];
44
+
45
+ const expired: T[] = [];
46
+ const live: T[] = [];
47
+ for (const entry of entries) {
48
+ if (Number.isFinite(retentionMs) && now - entry.at > retentionMs) {
49
+ expired.push(entry);
50
+ } else {
51
+ live.push(entry);
52
+ }
53
+ }
54
+
55
+ // Oldest first, so the tail is what we keep when trimming.
56
+ live.sort((a, b) => a.at - b.at);
57
+
58
+ const overflow = live.length > maxEntries ? live.slice(0, live.length - maxEntries) : [];
59
+ const kept = live.length > maxEntries ? live.slice(live.length - maxEntries) : live;
60
+
61
+ let reason: EvictionOutcome<T>['reason'] = 'none';
62
+ if (expired.length > 0 && overflow.length > 0) {
63
+ reason = 'cap+retention';
64
+ } else if (overflow.length > 0) {
65
+ reason = 'cap';
66
+ } else if (expired.length > 0) {
67
+ reason = 'retention';
68
+ }
69
+
70
+ return { kept, evicted: [...expired, ...overflow], reason };
71
+ }
@@ -1,35 +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
- }
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
+ }
@@ -1,73 +1,101 @@
1
- /**
2
- * The durable interaction buffer.
3
- *
4
- * A cold-start tap arrives before any handler exists. Holding it in memory
5
- * would be enough if the process always survived — but the process is exactly
6
- * what does not survive a cold start, so the buffer is persisted.
7
- *
8
- * This is the one content-bearing thing the package stores: an interaction
9
- * cannot be delivered without its routing payload. It is evicted the moment it
10
- * is delivered, and time-limited regardless, so an event nobody ever collects
11
- * cannot sit on disk forever.
12
- */
13
-
14
- import type { StateManager, BufferedInteraction } from './record';
15
- import type { InteractionEvent } from '../contract/interaction';
16
-
17
- /** Identity for a buffered event, derived so re-buffering cannot duplicate. */
18
- function bufferedId(event: InteractionEvent): string {
19
- return `${event.kind}:${event.notificationId}:${event.actionId ?? ''}:${event.occurredAt}`;
20
- }
21
-
22
- export function buffer(state: StateManager, event: InteractionEvent): void {
23
- const id = bufferedId(event);
24
-
25
- state.update('interactionBuffer', current => {
26
- if (current.some(entry => entry.id === id)) return current;
27
-
28
- const entry: BufferedInteraction = {
29
- at: event.occurredAt,
30
- id,
31
- kind: event.kind,
32
- notificationId: event.notificationId,
33
- actionId: event.actionId,
34
- inputText: event.inputText,
35
- routing: event.routing,
36
- ownerTag: event.ownerTag,
37
- };
38
- return [...current, entry];
39
- });
40
- }
41
-
42
- /**
43
- * Takes every buffered event in occurrence order and clears them.
44
- *
45
- * Draining and clearing together is deliberate: delivering an event twice is
46
- * worse than the alternative, because a consumer that acts on an interaction
47
- * (marking a task done, sending a reply) would act twice.
48
- */
49
- export function drain(state: StateManager): readonly InteractionEvent[] {
50
- const buffered = [...state.read().interactionBuffer].sort((a, b) => a.at - b.at);
51
-
52
- if (buffered.length === 0) return [];
53
-
54
- state.update('interactionBuffer', () => []);
55
-
56
- return buffered.map(entry => ({
57
- kind: entry.kind,
58
- notificationId: entry.notificationId,
59
- actionId: entry.actionId,
60
- inputText: entry.inputText,
61
- routing: entry.routing,
62
- ownerTag: entry.ownerTag,
63
- occurredAt: entry.at,
64
- // The state it arrived in is gone; what matters to a consumer is that it
65
- // waited, which is what `wasBuffered` says.
66
- appState: 'coldStart' as const,
67
- wasBuffered: true,
68
- }));
69
- }
70
-
71
- export function bufferedCount(state: StateManager): number {
72
- return state.read().interactionBuffer.length;
73
- }
1
+ /**
2
+ * The durable interaction buffer.
3
+ *
4
+ * A cold-start tap arrives before any handler exists. Holding it in memory
5
+ * would be enough if the process always survived — but the process is exactly
6
+ * what does not survive a cold start, so the buffer is persisted.
7
+ *
8
+ * This is the one content-bearing thing the package stores: an interaction
9
+ * cannot be delivered without its routing payload. It is evicted the moment it
10
+ * is delivered, and time-limited regardless, so an event nobody ever collects
11
+ * cannot sit on disk forever.
12
+ *
13
+ * Text the user typed into a reply action is never written to disk (NOT-14): it
14
+ * is free text, and the store is not encrypted. It is held in memory beside the
15
+ * buffer, so a handler registered later in the same process still receives it;
16
+ * if the process dies first, the event is delivered without `inputText`.
17
+ */
18
+
19
+ import type { StateManager, BufferedInteraction } from './record';
20
+ import type { InteractionEvent } from '../contract/interaction';
21
+
22
+ /** Typed replies, in memory only, per state manager and buffered event id. */
23
+ const typedReplies = new WeakMap<StateManager, Map<string, string>>();
24
+
25
+ function repliesOf(state: StateManager): Map<string, string> {
26
+ let replies = typedReplies.get(state);
27
+ if (!replies) {
28
+ replies = new Map();
29
+ typedReplies.set(state, replies);
30
+ }
31
+ return replies;
32
+ }
33
+
34
+ /** Identity for a buffered event, derived so re-buffering cannot duplicate. */
35
+ function bufferedId(event: InteractionEvent): string {
36
+ return `${event.kind}:${event.notificationId}:${event.actionId ?? ''}:${event.occurredAt}`;
37
+ }
38
+
39
+ export function buffer(state: StateManager, event: InteractionEvent): void {
40
+ const id = bufferedId(event);
41
+ if (event.inputText !== undefined) {
42
+ repliesOf(state).set(id, event.inputText);
43
+ }
44
+
45
+ state.update('interactionBuffer', current => {
46
+ if (current.some(entry => entry.id === id)) return current;
47
+
48
+ const entry: BufferedInteraction = {
49
+ at: event.occurredAt,
50
+ id,
51
+ kind: event.kind,
52
+ notificationId: event.notificationId,
53
+ actionId: event.actionId,
54
+ routing: event.routing,
55
+ ownerTag: event.ownerTag,
56
+ };
57
+ return [...current, entry];
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Takes every buffered event in occurrence order and clears them.
63
+ *
64
+ * Draining and clearing together is deliberate: delivering an event twice is
65
+ * worse than the alternative, because a consumer that acts on an interaction
66
+ * (marking a task done, sending a reply) would act twice.
67
+ */
68
+ export function drain(state: StateManager): readonly InteractionEvent[] {
69
+ const buffered = [...state.read().interactionBuffer].sort((a, b) => a.at - b.at);
70
+
71
+ if (buffered.length === 0) return [];
72
+
73
+ state.update('interactionBuffer', () => []);
74
+ const replies = repliesOf(state);
75
+
76
+ return buffered.map(entry => ({
77
+ kind: entry.kind,
78
+ notificationId: entry.notificationId,
79
+ actionId: entry.actionId,
80
+ // A record written before 0.5 may still carry the text on disk; it is
81
+ // delivered once, and the buffer was just cleared.
82
+ inputText: takeReply(replies, entry.id) ?? entry.inputText,
83
+ routing: entry.routing,
84
+ ownerTag: entry.ownerTag,
85
+ occurredAt: entry.at,
86
+ // The state it arrived in is gone; what matters to a consumer is that it
87
+ // waited, which is what `wasBuffered` says.
88
+ appState: 'coldStart' as const,
89
+ wasBuffered: true,
90
+ }));
91
+ }
92
+
93
+ export function bufferedCount(state: StateManager): number {
94
+ return state.read().interactionBuffer.length;
95
+ }
96
+
97
+ function takeReply(replies: Map<string, string>, id: string): string | undefined {
98
+ const text = replies.get(id);
99
+ replies.delete(id);
100
+ return text;
101
+ }