@chipmobilesdk/rn-notification 0.1.2 → 0.2.1

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 (69) hide show
  1. package/CHANGELOG.md +96 -42
  2. package/README.md +399 -306
  3. package/package.json +2 -1
  4. package/skills/sdk-rn-notification/SKILL.md +68 -3
  5. package/src/channel/migrate.ts +86 -12
  6. package/src/channel/tone-map.ts +130 -90
  7. package/src/channel/validate-tone.ts +61 -0
  8. package/src/contract/channel.ts +188 -89
  9. package/src/contract/diagnostics.ts +102 -102
  10. package/src/contract/errors.ts +130 -123
  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 +60 -60
  15. package/src/contract/reconcile.ts +88 -88
  16. package/src/contract/recurrence.ts +123 -123
  17. package/src/diagnostics/burst.ts +101 -101
  18. package/src/diagnostics/events.ts +72 -72
  19. package/src/diagnostics/redact.ts +72 -72
  20. package/src/diagnostics/snapshot.ts +64 -64
  21. package/src/engine/adapter.ts +172 -79
  22. package/src/engine/notify-kit-engine.ts +761 -535
  23. package/src/index.ts +24 -0
  24. package/src/initialize.ts +174 -174
  25. package/src/interaction/index.ts +146 -146
  26. package/src/maintenance/cycle.ts +147 -147
  27. package/src/maintenance/permission-refresh.ts +41 -41
  28. package/src/maintenance/reanchor.ts +95 -95
  29. package/src/maintenance/toggle.ts +60 -60
  30. package/src/maintenance/verify.ts +83 -83
  31. package/src/maintenance/window-refresh.ts +61 -61
  32. package/src/permission/index.ts +183 -183
  33. package/src/permission/state.ts +77 -77
  34. package/src/push/envelope.ts +165 -165
  35. package/src/push/fcm-provider.ts +146 -146
  36. package/src/push/gate.ts +190 -190
  37. package/src/push/instructions.ts +109 -109
  38. package/src/push/provider-adapter.ts +54 -54
  39. package/src/push/signout.ts +65 -65
  40. package/src/reconcile/chunk.ts +53 -53
  41. package/src/reconcile/diff.ts +145 -145
  42. package/src/reconcile/duplicates.ts +73 -73
  43. package/src/reconcile/index.ts +14 -1
  44. package/src/reconcile/truncate.ts +56 -56
  45. package/src/recurrence/clock-guard.ts +71 -71
  46. package/src/recurrence/expand.ts +292 -292
  47. package/src/repeat/expiry.ts +55 -0
  48. package/src/repeat/index.ts +54 -0
  49. package/src/repeat/marker.ts +72 -0
  50. package/src/repeat/place.ts +73 -0
  51. package/src/schedule/anchor.ts +267 -267
  52. package/src/schedule/index.ts +184 -173
  53. package/src/schedule/partition.ts +85 -85
  54. package/src/schedule/resolve-moment.ts +80 -80
  55. package/src/schedule/validate-content.ts +111 -111
  56. package/src/schedule/validate-identifier.ts +126 -114
  57. package/src/state/caps.ts +71 -71
  58. package/src/state/interaction-buffer.ts +73 -73
  59. package/src/state/mmkv-store.ts +75 -71
  60. package/src/state/permission-history.ts +41 -41
  61. package/src/state/record.ts +258 -246
  62. package/src/state/reset.ts +41 -41
  63. package/src/state/series-ledger.ts +84 -84
  64. package/src/state/store.ts +40 -40
  65. package/src/testing/conformance/index.ts +248 -194
  66. package/src/testing/fake-engine.ts +469 -284
  67. package/src/testing/fake-provider.ts +129 -129
  68. package/src/testing/faults.ts +72 -72
  69. package/src/testing/measure.ts +337 -334
package/src/index.ts CHANGED
@@ -52,6 +52,7 @@ export type {
52
52
  // Channel
53
53
  ToneUrgency,
54
54
  ToneSound,
55
+ ToneRepeatAlert,
55
56
  ToneDeclaration,
56
57
  ChannelGroup,
57
58
  ChannelApplyResult,
@@ -80,6 +81,7 @@ export type {
80
81
  export {
81
82
  DEFAULT_ANCHORING_MODE,
82
83
  DEFAULT_LOOKAHEAD,
84
+ REPEAT_ALERT_BOUNDS,
83
85
  LOOKAHEAD_BOUNDS,
84
86
  INTERACTION_BUFFER_LIMITS,
85
87
  MAX_ROUTING_PAYLOAD_BYTES,
@@ -100,8 +102,11 @@ export type { DiagnosticCode } from './contract';
100
102
  */
101
103
  export type {
102
104
  NotificationEngineAdapter,
105
+ DeliveryEvent,
103
106
  EngineCapabilities,
104
107
  EngineScheduleRequest,
108
+ RepeatStop,
109
+ ToneDeclarationOutcome,
105
110
  } from './engine/adapter';
106
111
 
107
112
  /** The default engine. Consumers may substitute their own via the port above. */
@@ -128,6 +133,25 @@ export { occurrenceId } from './recurrence/expand';
128
133
  export { createChannels } from './channel/migrate';
129
134
  export type { Channels, ChannelDependencies } from './channel/migrate';
130
135
  export { channelIdFor, createToneResolver, parseChannelId, resolveTone } from './channel/tone-map';
136
+ export { validateToneDeclaration, validateToneDeclarations } from './channel/validate-tone';
137
+
138
+ /**
139
+ * Repeat expiry — what bounds an alert that repeats until it is handled.
140
+ *
141
+ * `registerBackgroundNotificationHandler` is the one piece a consumer wires up
142
+ * themselves, at module scope in `index.js`. An app that declares no repeating
143
+ * tone needs none of this.
144
+ */
145
+ export {
146
+ createRepeatExpiryHandler,
147
+ registerBackgroundNotificationHandler,
148
+ REPEAT_STOP_PREFIX,
149
+ } from './repeat';
150
+ export type {
151
+ BackgroundHandlerRegistration,
152
+ RepeatExpiryHandler,
153
+ RepeatExpiryDependencies,
154
+ } from './repeat';
131
155
 
132
156
  /** Interaction routing — one handler for foreground, background, and cold start. */
133
157
  export { createInteractions } from './interaction';
package/src/initialize.ts CHANGED
@@ -1,174 +1,174 @@
1
- /**
2
- * Package initialization.
3
- *
4
- * Composes the parts that must be in place before the first schedule call:
5
- * validate configuration, load persisted state, apply tone declarations, run
6
- * any pending channel migration, and start the maintenance registry.
7
- *
8
- * Budgeted at 100 ms of app startup, which is why state loading is synchronous
9
- * and tone declaration is the only platform round-trip.
10
- */
11
-
12
- import type { InitializeResult, NotificationConfig } from './contract/diagnostics';
13
- import { DEFAULT_LOOKAHEAD, LOOKAHEAD_BOUNDS, type LookaheadConfig } from './contract/recurrence';
14
- import { DIAGNOSTIC_CODES, NotificationError } from './contract/errors';
15
- import { DiagnosticEmitter } from './diagnostics/events';
16
- import { MaintenanceRegistry } from './maintenance/cycle';
17
- import { MaintenanceController } from './maintenance/toggle';
18
- import { StateManager } from './state/record';
19
- import type { StateStore } from './state/store';
20
- import type { NotificationEngineAdapter } from './engine/adapter';
21
-
22
- export interface RuntimeDependencies {
23
- readonly engine: NotificationEngineAdapter;
24
- readonly store: StateStore;
25
- readonly now?: () => number;
26
- }
27
-
28
- /** Everything assembled at initialization, shared by the operation modules. */
29
- export interface NotificationRuntime {
30
- readonly engine: NotificationEngineAdapter;
31
- readonly state: StateManager;
32
- readonly emitter: DiagnosticEmitter;
33
- readonly registry: MaintenanceRegistry;
34
- readonly maintenance: MaintenanceController;
35
- readonly lookahead: LookaheadConfig;
36
- readonly config: NotificationConfig;
37
- readonly now: () => number;
38
- }
39
-
40
- /**
41
- * Clamps the lookahead into its documented bounds.
42
- *
43
- * Bounded on adjustment because the platform ceiling is not negotiable: a
44
- * consumer asking for a five-year window would silently exhaust the 64-entry
45
- * iOS ceiling on the first frequent series.
46
- */
47
- export function resolveLookahead(partial: Partial<LookaheadConfig> | undefined): LookaheadConfig {
48
- const days = partial?.days ?? DEFAULT_LOOKAHEAD.days;
49
- const occurrencesPerSeries = partial?.occurrencesPerSeries ?? DEFAULT_LOOKAHEAD.occurrencesPerSeries;
50
-
51
- return {
52
- days: Math.min(Math.max(days, LOOKAHEAD_BOUNDS.minDays), LOOKAHEAD_BOUNDS.maxDays),
53
- occurrencesPerSeries: Math.min(
54
- Math.max(occurrencesPerSeries, LOOKAHEAD_BOUNDS.minOccurrences),
55
- LOOKAHEAD_BOUNDS.maxOccurrences,
56
- ),
57
- };
58
- }
59
-
60
- /** Fails before any platform call, so a bad configuration never half-applies. */
61
- export function validateConfig(config: NotificationConfig): void {
62
- if (config.tones.length === 0) {
63
- throw new NotificationError(
64
- DIAGNOSTIC_CODES.UNDECLARED_TONE,
65
- 'At least one tone must be declared before notifications can be scheduled.',
66
- );
67
- }
68
-
69
- const seen = new Set<string>();
70
- for (const tone of config.tones) {
71
- if (seen.has(tone.id)) {
72
- throw new NotificationError(
73
- DIAGNOSTIC_CODES.UNDECLARED_TONE,
74
- `Tone "${tone.id}" is declared more than once.`,
75
- { toneId: tone.id },
76
- );
77
- }
78
- seen.add(tone.id);
79
-
80
- if (!Number.isInteger(tone.version) || tone.version < 1) {
81
- throw new NotificationError(
82
- DIAGNOSTIC_CODES.CHANNEL_MIGRATION_REQUIRED,
83
- `Tone "${tone.id}" must carry an integer version of 1 or greater, because a channel configuration change is a migration rather than an assignment.`,
84
- { toneId: tone.id, version: tone.version },
85
- );
86
- }
87
- }
88
-
89
- const groupIds = new Set((config.channelGroups ?? []).map(group => group.id));
90
- for (const tone of config.tones) {
91
- if (tone.groupId !== undefined && !groupIds.has(tone.groupId)) {
92
- throw new NotificationError(
93
- DIAGNOSTIC_CODES.UNDECLARED_TONE,
94
- `Tone "${tone.id}" references undeclared channel group "${tone.groupId}".`,
95
- { toneId: tone.id, groupId: tone.groupId },
96
- );
97
- }
98
- }
99
- }
100
-
101
- export async function initialize(
102
- config: NotificationConfig,
103
- deps: RuntimeDependencies,
104
- ): Promise<{ runtime: NotificationRuntime; result: InitializeResult }> {
105
- const now = deps.now ?? (() => Date.now());
106
- const startedAt = now();
107
-
108
- validateConfig(config);
109
-
110
- const emitter = new DiagnosticEmitter({
111
- sink: config.eventSink,
112
- verbose: config.verboseDiagnostics,
113
- now,
114
- });
115
-
116
- const state = new StateManager({
117
- store: deps.store,
118
- now,
119
- emit: event => emitter.emit(event.code, describeStateEvent(event.code), event.context),
120
- });
121
-
122
- if (config.channelGroups !== undefined && config.channelGroups.length > 0) {
123
- await deps.engine.declareGroups(config.channelGroups);
124
- }
125
-
126
- const registry = new MaintenanceRegistry();
127
- const maintenance = new MaintenanceController({
128
- registry,
129
- now,
130
- // Owned identifiers come from the series ledger: those are the entries
131
- // automatic maintenance created and may therefore retire.
132
- ownedIds: () => new Set(state.read().seriesLedger.map(entry => entry.seriesId)),
133
- enabled: config.automaticMaintenance ?? true,
134
- });
135
-
136
- const runtime: NotificationRuntime = {
137
- engine: deps.engine,
138
- state,
139
- emitter,
140
- registry,
141
- maintenance,
142
- lookahead: resolveLookahead(config.lookahead),
143
- config,
144
- now,
145
- };
146
-
147
- return {
148
- runtime,
149
- result: {
150
- // Tone application and migration are performed by the channel module,
151
- // which registers itself during User Story 5.
152
- tonesApplied: 0,
153
- migrationsRun: 0,
154
- stateRebuilt: state.health().rebuilt,
155
- automaticMaintenance: maintenance.isEnabled,
156
- durationMs: now() - startedAt,
157
- },
158
- };
159
- }
160
-
161
- function describeStateEvent(code: string): string {
162
- switch (code) {
163
- case DIAGNOSTIC_CODES.STATE_CORRUPT_REBUILT:
164
- return 'Persisted state was unreadable and has been rebuilt. Deduplication history and buffered interactions from before the loss are gone; scheduled notifications are unaffected.';
165
- case DIAGNOSTIC_CODES.STATE_WRITE_FAILED:
166
- return 'Persisted state could not be written. The affected guarantee degrades to this session only.';
167
- case DIAGNOSTIC_CODES.STATE_CAP_EVICTED:
168
- return 'A persisted collection reached its cap and the oldest entries were evicted.';
169
- case DIAGNOSTIC_CODES.INTERACTION_BUFFER_EVICTED:
170
- return 'Buffered interactions were evicted at the cap or retention limit.';
171
- default:
172
- return 'Persisted state event.';
173
- }
174
- }
1
+ /**
2
+ * Package initialization.
3
+ *
4
+ * Composes the parts that must be in place before the first schedule call:
5
+ * validate configuration, load persisted state, apply tone declarations, run
6
+ * any pending channel migration, and start the maintenance registry.
7
+ *
8
+ * Budgeted at 100 ms of app startup, which is why state loading is synchronous
9
+ * and tone declaration is the only platform round-trip.
10
+ */
11
+
12
+ import type { InitializeResult, NotificationConfig } from './contract/diagnostics';
13
+ import { DEFAULT_LOOKAHEAD, LOOKAHEAD_BOUNDS, type LookaheadConfig } from './contract/recurrence';
14
+ import { DIAGNOSTIC_CODES, NotificationError } from './contract/errors';
15
+ import { DiagnosticEmitter } from './diagnostics/events';
16
+ import { MaintenanceRegistry } from './maintenance/cycle';
17
+ import { MaintenanceController } from './maintenance/toggle';
18
+ import { StateManager } from './state/record';
19
+ import type { StateStore } from './state/store';
20
+ import type { NotificationEngineAdapter } from './engine/adapter';
21
+
22
+ export interface RuntimeDependencies {
23
+ readonly engine: NotificationEngineAdapter;
24
+ readonly store: StateStore;
25
+ readonly now?: () => number;
26
+ }
27
+
28
+ /** Everything assembled at initialization, shared by the operation modules. */
29
+ export interface NotificationRuntime {
30
+ readonly engine: NotificationEngineAdapter;
31
+ readonly state: StateManager;
32
+ readonly emitter: DiagnosticEmitter;
33
+ readonly registry: MaintenanceRegistry;
34
+ readonly maintenance: MaintenanceController;
35
+ readonly lookahead: LookaheadConfig;
36
+ readonly config: NotificationConfig;
37
+ readonly now: () => number;
38
+ }
39
+
40
+ /**
41
+ * Clamps the lookahead into its documented bounds.
42
+ *
43
+ * Bounded on adjustment because the platform ceiling is not negotiable: a
44
+ * consumer asking for a five-year window would silently exhaust the 64-entry
45
+ * iOS ceiling on the first frequent series.
46
+ */
47
+ export function resolveLookahead(partial: Partial<LookaheadConfig> | undefined): LookaheadConfig {
48
+ const days = partial?.days ?? DEFAULT_LOOKAHEAD.days;
49
+ const occurrencesPerSeries = partial?.occurrencesPerSeries ?? DEFAULT_LOOKAHEAD.occurrencesPerSeries;
50
+
51
+ return {
52
+ days: Math.min(Math.max(days, LOOKAHEAD_BOUNDS.minDays), LOOKAHEAD_BOUNDS.maxDays),
53
+ occurrencesPerSeries: Math.min(
54
+ Math.max(occurrencesPerSeries, LOOKAHEAD_BOUNDS.minOccurrences),
55
+ LOOKAHEAD_BOUNDS.maxOccurrences,
56
+ ),
57
+ };
58
+ }
59
+
60
+ /** Fails before any platform call, so a bad configuration never half-applies. */
61
+ export function validateConfig(config: NotificationConfig): void {
62
+ if (config.tones.length === 0) {
63
+ throw new NotificationError(
64
+ DIAGNOSTIC_CODES.UNDECLARED_TONE,
65
+ 'At least one tone must be declared before notifications can be scheduled.',
66
+ );
67
+ }
68
+
69
+ const seen = new Set<string>();
70
+ for (const tone of config.tones) {
71
+ if (seen.has(tone.id)) {
72
+ throw new NotificationError(
73
+ DIAGNOSTIC_CODES.UNDECLARED_TONE,
74
+ `Tone "${tone.id}" is declared more than once.`,
75
+ { toneId: tone.id },
76
+ );
77
+ }
78
+ seen.add(tone.id);
79
+
80
+ if (!Number.isInteger(tone.version) || tone.version < 1) {
81
+ throw new NotificationError(
82
+ DIAGNOSTIC_CODES.CHANNEL_MIGRATION_REQUIRED,
83
+ `Tone "${tone.id}" must carry an integer version of 1 or greater, because a channel configuration change is a migration rather than an assignment.`,
84
+ { toneId: tone.id, version: tone.version },
85
+ );
86
+ }
87
+ }
88
+
89
+ const groupIds = new Set((config.channelGroups ?? []).map(group => group.id));
90
+ for (const tone of config.tones) {
91
+ if (tone.groupId !== undefined && !groupIds.has(tone.groupId)) {
92
+ throw new NotificationError(
93
+ DIAGNOSTIC_CODES.UNDECLARED_TONE,
94
+ `Tone "${tone.id}" references undeclared channel group "${tone.groupId}".`,
95
+ { toneId: tone.id, groupId: tone.groupId },
96
+ );
97
+ }
98
+ }
99
+ }
100
+
101
+ export async function initialize(
102
+ config: NotificationConfig,
103
+ deps: RuntimeDependencies,
104
+ ): Promise<{ runtime: NotificationRuntime; result: InitializeResult }> {
105
+ const now = deps.now ?? (() => Date.now());
106
+ const startedAt = now();
107
+
108
+ validateConfig(config);
109
+
110
+ const emitter = new DiagnosticEmitter({
111
+ sink: config.eventSink,
112
+ verbose: config.verboseDiagnostics,
113
+ now,
114
+ });
115
+
116
+ const state = new StateManager({
117
+ store: deps.store,
118
+ now,
119
+ emit: event => emitter.emit(event.code, describeStateEvent(event.code), event.context),
120
+ });
121
+
122
+ if (config.channelGroups !== undefined && config.channelGroups.length > 0) {
123
+ await deps.engine.declareGroups(config.channelGroups);
124
+ }
125
+
126
+ const registry = new MaintenanceRegistry();
127
+ const maintenance = new MaintenanceController({
128
+ registry,
129
+ now,
130
+ // Owned identifiers come from the series ledger: those are the entries
131
+ // automatic maintenance created and may therefore retire.
132
+ ownedIds: () => new Set(state.read().seriesLedger.map(entry => entry.seriesId)),
133
+ enabled: config.automaticMaintenance ?? true,
134
+ });
135
+
136
+ const runtime: NotificationRuntime = {
137
+ engine: deps.engine,
138
+ state,
139
+ emitter,
140
+ registry,
141
+ maintenance,
142
+ lookahead: resolveLookahead(config.lookahead),
143
+ config,
144
+ now,
145
+ };
146
+
147
+ return {
148
+ runtime,
149
+ result: {
150
+ // Tone application and migration are performed by the channel module,
151
+ // which registers itself during User Story 5.
152
+ tonesApplied: 0,
153
+ migrationsRun: 0,
154
+ stateRebuilt: state.health().rebuilt,
155
+ automaticMaintenance: maintenance.isEnabled,
156
+ durationMs: now() - startedAt,
157
+ },
158
+ };
159
+ }
160
+
161
+ function describeStateEvent(code: string): string {
162
+ switch (code) {
163
+ case DIAGNOSTIC_CODES.STATE_CORRUPT_REBUILT:
164
+ return 'Persisted state was unreadable and has been rebuilt. Deduplication history and buffered interactions from before the loss are gone; scheduled notifications are unaffected.';
165
+ case DIAGNOSTIC_CODES.STATE_WRITE_FAILED:
166
+ return 'Persisted state could not be written. The affected guarantee degrades to this session only.';
167
+ case DIAGNOSTIC_CODES.STATE_CAP_EVICTED:
168
+ return 'A persisted collection reached its cap and the oldest entries were evicted.';
169
+ case DIAGNOSTIC_CODES.INTERACTION_BUFFER_EVICTED:
170
+ return 'Buffered interactions were evicted at the cap or retention limit.';
171
+ default:
172
+ return 'Persisted state event.';
173
+ }
174
+ }