@chipmobilesdk/rn-notification 0.4.0 → 0.4.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 (75) hide show
  1. package/CHANGELOG.md +174 -166
  2. package/README.md +414 -410
  3. package/package.json +58 -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 +134 -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 +72 -72
  19. package/src/diagnostics/redact.ts +72 -72
  20. package/src/diagnostics/snapshot.ts +67 -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 +706 -707
  24. package/src/index.ts +208 -208
  25. package/src/initialize.ts +240 -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 +143 -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 +278 -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 +73 -73
  64. package/src/state/mmkv-store.ts +75 -75
  65. package/src/state/permission-history.ts +41 -41
  66. package/src/state/record.ts +266 -263
  67. package/src/state/reset.ts +41 -41
  68. package/src/state/series-ledger.ts +91 -91
  69. package/src/state/store.ts +40 -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,172 +1,172 @@
1
- /**
2
- * The port every local notification engine satisfies.
3
- *
4
- * No engine type appears in any signature here. That is what makes the engine
5
- * replaceable — and it has already been exercised once, when the archived
6
- * Notifee was swapped for its maintained fork with no consumer impact.
7
- */
8
-
9
- import type { PendingEntry, NotificationRequest } from '../contract/notification';
10
- import type { PermissionKind, PermissionState, RestrictionReport } from '../contract/permission';
11
- import type { ResolvedTone, ChannelGroup } from '../contract/channel';
12
- import type { InteractionEvent, Unsubscribe } from '../contract/interaction';
13
-
14
- /** A request whose anchoring has already been resolved to a firing instant. */
15
- export interface EngineScheduleRequest {
16
- readonly request: NotificationRequest;
17
- /** The absolute instant the platform should fire, after anchoring resolution. */
18
- readonly resolvedAt: number;
19
- /** Resolved platform channel and category for the request's tone. */
20
- readonly tone: ResolvedTone;
21
- /**
22
- * The IANA time zone this moment was anchored against.
23
- *
24
- * Carried explicitly because the adapter has to round-trip it through the
25
- * platform data bag — `getPending` must report it (FR-010) so a consumer can
26
- * detect the one case re-anchoring cannot fix (FR-007c). Absent for
27
- * absolute-instant moments, which are never anchored to a zone at all.
28
- */
29
- readonly anchoringGeneration?: string;
30
- /** True when exact-alarm capability is unavailable and approximate mode applies. */
31
- readonly approximate: boolean;
32
- readonly seriesId?: string;
33
- readonly pushCreated?: boolean;
34
- /**
35
- * Marks this request as a package-owned stop marker rather than a consumer
36
- * notification.
37
- *
38
- * Carried here rather than smuggled through `ownerTag` or `routing` so it can
39
- * never collide with a value a consumer owns, and so the adapter can keep
40
- * markers out of `getPending` without inspecting consumer data.
41
- */
42
- readonly repeatStop?: RepeatStop;
43
- }
44
-
45
- export interface EngineCapabilities {
46
- /** iOS holds only the 64 soonest pending notifications and silently drops the rest. */
47
- readonly pendingCeiling: number;
48
- readonly supportsExactAlarms: boolean;
49
- readonly supportsChannels: boolean;
50
- readonly supportsTextInputActions: boolean;
51
- readonly supportsDoNotDisturbBypass: boolean;
52
- /**
53
- * Whether a notification's alert sound can be made to repeat until handled.
54
- *
55
- * Decides two things: whether a refused repeat is reported to the caller, and
56
- * whether a stop marker is placed at all. Where this is false no marker is
57
- * created, so the platform's pending ceiling is untouched by the feature.
58
- */
59
- readonly supportsRepeatingAlert: boolean;
60
- /** Milliseconds a background action or silent payload may run for. */
61
- readonly backgroundWindowMs: number;
62
- }
63
-
64
- /**
65
- * What applying a tone declaration actually did.
66
- *
67
- * A boolean cannot carry this: a caller has to tell a label correction from a
68
- * newly created channel from a no-op, and reporting a refresh as a creation
69
- * would make every apply look like a write.
70
- */
71
- export type ToneDeclarationOutcome = 'created' | 'labelsRefreshed' | 'unchanged';
72
-
73
- /**
74
- * A package-owned artifact scheduled to end a repeating alert.
75
- *
76
- * Never a consumer notification. It exists because no platform offers "stop the
77
- * sound at time T but keep the notification", so something has to run at T.
78
- */
79
- export interface RepeatStop {
80
- readonly markerId: string;
81
- /** The consumer notification whose alert this stops. */
82
- readonly targetId: string;
83
- readonly dueAt: number;
84
- }
85
-
86
- /** A notification arriving, as opposed to a user acting on one. */
87
- export interface DeliveryEvent {
88
- readonly notificationId: string;
89
- readonly occurredAt: number;
90
- /** Present when this delivery is a package-owned stop marker rather than a real notification. */
91
- readonly repeatStop?: RepeatStop;
92
- }
93
-
94
- export interface NotificationEngineAdapter {
95
- readonly name: string;
96
-
97
- getCapabilities(): Promise<EngineCapabilities>;
98
-
99
- /** Scheduling an existing identifier replaces it; exactly one entry results. */
100
- schedule(request: EngineScheduleRequest): Promise<void>;
101
-
102
- cancel(id: string): Promise<void>;
103
- cancelMany(ids: readonly string[]): Promise<void>;
104
- cancelAll(): Promise<void>;
105
-
106
- /** Full detail, so callers can diff rather than merely count. */
107
- getPending(): Promise<readonly PendingEntry[]>;
108
-
109
- /**
110
- * Applies a channel or category declaration.
111
- *
112
- * Must return `labelsRefreshed` only when a label actually changed. Returning
113
- * it unconditionally would turn every apply into a write and quietly break
114
- * the zero-operation guarantee reconciliation idempotence rests on.
115
- */
116
- declareTone(tone: ResolvedTone): Promise<ToneDeclarationOutcome>;
117
- /** Removes a retired channel. Returns true when one was actually removed. */
118
- retireTone(channelId: string): Promise<boolean>;
119
- declareGroups(groups: readonly ChannelGroup[]): Promise<void>;
120
- /** Channel ids the platform currently holds, which re-declaration cannot reveal. */
121
- listDeclaredChannels(): Promise<readonly string[]>;
122
-
123
- getPermission(kind: PermissionKind): Promise<PermissionState>;
124
- requestPermission(kind: PermissionKind): Promise<PermissionState>;
125
- openSettings(kind: PermissionKind): Promise<void>;
126
- getRestrictions(): Promise<RestrictionReport>;
127
-
128
- /** Interactions arriving before a handler registers are the caller's problem to buffer, not the engine's. */
129
- subscribeToInteractions(handler: (event: InteractionEvent) => void): Unsubscribe;
130
- /** Any interaction that launched the app, consumed exactly once. */
131
- takeInitialInteraction(): Promise<InteractionEvent | undefined>;
132
-
133
- /**
134
- * Stops a delivered notification's repeating alert without removing it.
135
- *
136
- * Re-posts the notification under the same identifier with the alert
137
- * suppressed. The same identifier means the same tray slot, so it replaces in
138
- * place: the sound stops and the notification stays. Cancelling instead would
139
- * be worse than never alerting at all — the user comes home to a silent phone
140
- * and no idea what they missed.
141
- *
142
- * Returns false when the notification is no longer displayed, which is the
143
- * ordinary case once the user has handled it.
144
- */
145
- silenceDelivered(id: string): Promise<boolean>;
146
-
147
- /** Package-owned stop markers currently scheduled. Never consumer notifications. */
148
- listRepeatStops(): Promise<readonly RepeatStop[]>;
149
-
150
- /**
151
- * Deliveries, as distinct from interactions.
152
- *
153
- * `subscribeToInteractions` deliberately discards delivery events, because
154
- * reporting an arrival as a tap would cancel a notification the user never
155
- * touched. Repeat expiry needs exactly the events that filter throws away, so
156
- * it gets its own subscription rather than loosening that one.
157
- */
158
- subscribeToDeliveries(handler: (event: DeliveryEvent) => void): Unsubscribe;
159
-
160
- /**
161
- * Registers the handler that runs when the app process is not alive.
162
- *
163
- * Must be called at module scope, outside the React tree, because that is
164
- * where the engine registers its headless task. The package cannot do this on
165
- * a consumer's behalf, which is why it is a documented setup step rather than
166
- * an internal detail — and why its absence is reported rather than guessed at.
167
- */
168
- registerBackgroundDeliveryHandler(handler: (event: DeliveryEvent) => Promise<void>): void;
169
-
170
- /** Whether a background delivery handler has been registered in this process. */
171
- hasBackgroundDeliveryHandler(): boolean;
172
- }
1
+ /**
2
+ * The port every local notification engine satisfies.
3
+ *
4
+ * No engine type appears in any signature here. That is what makes the engine
5
+ * replaceable — and it has already been exercised once, when the archived
6
+ * Notifee was swapped for its maintained fork with no consumer impact.
7
+ */
8
+
9
+ import type { PendingEntry, NotificationRequest } from '../contract/notification';
10
+ import type { PermissionKind, PermissionState, RestrictionReport } from '../contract/permission';
11
+ import type { ResolvedTone, ChannelGroup } from '../contract/channel';
12
+ import type { InteractionEvent, Unsubscribe } from '../contract/interaction';
13
+
14
+ /** A request whose anchoring has already been resolved to a firing instant. */
15
+ export interface EngineScheduleRequest {
16
+ readonly request: NotificationRequest;
17
+ /** The absolute instant the platform should fire, after anchoring resolution. */
18
+ readonly resolvedAt: number;
19
+ /** Resolved platform channel and category for the request's tone. */
20
+ readonly tone: ResolvedTone;
21
+ /**
22
+ * The IANA time zone this moment was anchored against.
23
+ *
24
+ * Carried explicitly because the adapter has to round-trip it through the
25
+ * platform data bag — `getPending` must report it (FR-010) so a consumer can
26
+ * detect the one case re-anchoring cannot fix (FR-007c). Absent for
27
+ * absolute-instant moments, which are never anchored to a zone at all.
28
+ */
29
+ readonly anchoringGeneration?: string;
30
+ /** True when exact-alarm capability is unavailable and approximate mode applies. */
31
+ readonly approximate: boolean;
32
+ readonly seriesId?: string;
33
+ readonly pushCreated?: boolean;
34
+ /**
35
+ * Marks this request as a package-owned stop marker rather than a consumer
36
+ * notification.
37
+ *
38
+ * Carried here rather than smuggled through `ownerTag` or `routing` so it can
39
+ * never collide with a value a consumer owns, and so the adapter can keep
40
+ * markers out of `getPending` without inspecting consumer data.
41
+ */
42
+ readonly repeatStop?: RepeatStop;
43
+ }
44
+
45
+ export interface EngineCapabilities {
46
+ /** iOS holds only the 64 soonest pending notifications and silently drops the rest. */
47
+ readonly pendingCeiling: number;
48
+ readonly supportsExactAlarms: boolean;
49
+ readonly supportsChannels: boolean;
50
+ readonly supportsTextInputActions: boolean;
51
+ readonly supportsDoNotDisturbBypass: boolean;
52
+ /**
53
+ * Whether a notification's alert sound can be made to repeat until handled.
54
+ *
55
+ * Decides two things: whether a refused repeat is reported to the caller, and
56
+ * whether a stop marker is placed at all. Where this is false no marker is
57
+ * created, so the platform's pending ceiling is untouched by the feature.
58
+ */
59
+ readonly supportsRepeatingAlert: boolean;
60
+ /** Milliseconds a background action or silent payload may run for. */
61
+ readonly backgroundWindowMs: number;
62
+ }
63
+
64
+ /**
65
+ * What applying a tone declaration actually did.
66
+ *
67
+ * A boolean cannot carry this: a caller has to tell a label correction from a
68
+ * newly created channel from a no-op, and reporting a refresh as a creation
69
+ * would make every apply look like a write.
70
+ */
71
+ export type ToneDeclarationOutcome = 'created' | 'labelsRefreshed' | 'unchanged';
72
+
73
+ /**
74
+ * A package-owned artifact scheduled to end a repeating alert.
75
+ *
76
+ * Never a consumer notification. It exists because no platform offers "stop the
77
+ * sound at time T but keep the notification", so something has to run at T.
78
+ */
79
+ export interface RepeatStop {
80
+ readonly markerId: string;
81
+ /** The consumer notification whose alert this stops. */
82
+ readonly targetId: string;
83
+ readonly dueAt: number;
84
+ }
85
+
86
+ /** A notification arriving, as opposed to a user acting on one. */
87
+ export interface DeliveryEvent {
88
+ readonly notificationId: string;
89
+ readonly occurredAt: number;
90
+ /** Present when this delivery is a package-owned stop marker rather than a real notification. */
91
+ readonly repeatStop?: RepeatStop;
92
+ }
93
+
94
+ export interface NotificationEngineAdapter {
95
+ readonly name: string;
96
+
97
+ getCapabilities(): Promise<EngineCapabilities>;
98
+
99
+ /** Scheduling an existing identifier replaces it; exactly one entry results. */
100
+ schedule(request: EngineScheduleRequest): Promise<void>;
101
+
102
+ cancel(id: string): Promise<void>;
103
+ cancelMany(ids: readonly string[]): Promise<void>;
104
+ cancelAll(): Promise<void>;
105
+
106
+ /** Full detail, so callers can diff rather than merely count. */
107
+ getPending(): Promise<readonly PendingEntry[]>;
108
+
109
+ /**
110
+ * Applies a channel or category declaration.
111
+ *
112
+ * Must return `labelsRefreshed` only when a label actually changed. Returning
113
+ * it unconditionally would turn every apply into a write and quietly break
114
+ * the zero-operation guarantee reconciliation idempotence rests on.
115
+ */
116
+ declareTone(tone: ResolvedTone): Promise<ToneDeclarationOutcome>;
117
+ /** Removes a retired channel. Returns true when one was actually removed. */
118
+ retireTone(channelId: string): Promise<boolean>;
119
+ declareGroups(groups: readonly ChannelGroup[]): Promise<void>;
120
+ /** Channel ids the platform currently holds, which re-declaration cannot reveal. */
121
+ listDeclaredChannels(): Promise<readonly string[]>;
122
+
123
+ getPermission(kind: PermissionKind): Promise<PermissionState>;
124
+ requestPermission(kind: PermissionKind): Promise<PermissionState>;
125
+ openSettings(kind: PermissionKind): Promise<void>;
126
+ getRestrictions(): Promise<RestrictionReport>;
127
+
128
+ /** Interactions arriving before a handler registers are the caller's problem to buffer, not the engine's. */
129
+ subscribeToInteractions(handler: (event: InteractionEvent) => void): Unsubscribe;
130
+ /** Any interaction that launched the app, consumed exactly once. */
131
+ takeInitialInteraction(): Promise<InteractionEvent | undefined>;
132
+
133
+ /**
134
+ * Stops a delivered notification's repeating alert without removing it.
135
+ *
136
+ * Re-posts the notification under the same identifier with the alert
137
+ * suppressed. The same identifier means the same tray slot, so it replaces in
138
+ * place: the sound stops and the notification stays. Cancelling instead would
139
+ * be worse than never alerting at all — the user comes home to a silent phone
140
+ * and no idea what they missed.
141
+ *
142
+ * Returns false when the notification is no longer displayed, which is the
143
+ * ordinary case once the user has handled it.
144
+ */
145
+ silenceDelivered(id: string): Promise<boolean>;
146
+
147
+ /** Package-owned stop markers currently scheduled. Never consumer notifications. */
148
+ listRepeatStops(): Promise<readonly RepeatStop[]>;
149
+
150
+ /**
151
+ * Deliveries, as distinct from interactions.
152
+ *
153
+ * `subscribeToInteractions` deliberately discards delivery events, because
154
+ * reporting an arrival as a tap would cancel a notification the user never
155
+ * touched. Repeat expiry needs exactly the events that filter throws away, so
156
+ * it gets its own subscription rather than loosening that one.
157
+ */
158
+ subscribeToDeliveries(handler: (event: DeliveryEvent) => void): Unsubscribe;
159
+
160
+ /**
161
+ * Registers the handler that runs when the app process is not alive.
162
+ *
163
+ * Must be called at module scope, outside the React tree, because that is
164
+ * where the engine registers its headless task. The package cannot do this on
165
+ * a consumer's behalf, which is why it is a documented setup step rather than
166
+ * an internal detail — and why its absence is reported rather than guessed at.
167
+ */
168
+ registerBackgroundDeliveryHandler(handler: (event: DeliveryEvent) => Promise<void>): void;
169
+
170
+ /** Whether a background delivery handler has been registered in this process. */
171
+ hasBackgroundDeliveryHandler(): boolean;
172
+ }
@@ -1,89 +1,89 @@
1
- /**
2
- * The metadata codec for a notification's platform data bag.
3
- *
4
- * Anchoring, series origin, tags, routing and actions have no engine
5
- * equivalent, and `getPending` must return them (FR-010) or a consumer cannot
6
- * diff against intent. They travel as strings in the free-form data bag, which
7
- * is the one field both platforms carry through unchanged.
8
- *
9
- * Shared by the real engine and the fake. When the fake stored requests
10
- * verbatim it could not lose a field the real engine lost — `actions` never
11
- * came back from a device, so every reconcile replaced every notification that
12
- * had them, and the fake-backed tests never saw it.
13
- */
14
-
15
- import type { EngineScheduleRequest } from './adapter';
16
- import type { Action, AnchoringMode, PendingEntry, RoutingPayload } from '../contract/notification';
17
-
18
- /** Everything `getPending` reports that only the data bag can carry. */
19
- export type PackedMetadata = Pick<
20
- PendingEntry,
21
- | 'anchoring'
22
- | 'anchoringGeneration'
23
- | 'tone'
24
- | 'routing'
25
- | 'actions'
26
- | 'ownerTag'
27
- | 'groupTag'
28
- | 'seriesId'
29
- | 'mayBeDelayed'
30
- | 'pushCreated'
31
- >;
32
-
33
- export function packData(request: EngineScheduleRequest): Record<string, string> {
34
- return {
35
- __cms_anchoring: request.request.moment.anchoring ?? 'wallClock',
36
- // The IANA zone, not the tone. Storing the wrong value here does not merely
37
- // mislabel an entry: the re-anchoring pass feeds it straight to
38
- // Intl.DateTimeFormat, which throws a hard Hermes error on an invalid zone.
39
- ...(request.anchoringGeneration !== undefined
40
- ? { __cms_generation: request.anchoringGeneration }
41
- : {}),
42
- __cms_tone: request.request.tone,
43
- __cms_approximate: String(request.approximate),
44
- __cms_pushCreated: String(request.pushCreated ?? false),
45
- ...(request.seriesId !== undefined ? { __cms_series: request.seriesId } : {}),
46
- ...(request.request.ownerTag !== undefined ? { __cms_owner: request.request.ownerTag } : {}),
47
- ...(request.request.groupTag !== undefined ? { __cms_group: request.request.groupTag } : {}),
48
- ...(request.request.routing !== undefined
49
- ? { __cms_routing: JSON.stringify(request.request.routing) }
50
- : {}),
51
- ...(request.request.actions !== undefined
52
- ? { __cms_actions: JSON.stringify(request.request.actions) }
53
- : {}),
54
- // A stop marker's whole payload. It travels here rather than in `ownerTag`
55
- // or `routing` so it can never collide with a value a consumer owns.
56
- ...(request.repeatStop !== undefined
57
- ? {
58
- __cms_stop_target: request.repeatStop.targetId,
59
- __cms_stop_due: String(request.repeatStop.dueAt),
60
- }
61
- : {}),
62
- };
63
- }
64
-
65
- /** A JSON field we cannot parse is reported as absent: the notification is still valid. */
66
- function parseJson<T>(raw: string | undefined): T | undefined {
67
- if (typeof raw !== 'string') return undefined;
68
- try {
69
- return JSON.parse(raw) as T;
70
- } catch {
71
- return undefined;
72
- }
73
- }
74
-
75
- export function unpackData(data: Record<string, string>): PackedMetadata {
76
- const anchoring: AnchoringMode = data.__cms_anchoring === 'absolute' ? 'absolute' : 'wallClock';
77
- return {
78
- anchoring,
79
- anchoringGeneration: anchoring === 'absolute' ? undefined : data.__cms_generation,
80
- tone: data.__cms_tone ?? '',
81
- routing: parseJson<RoutingPayload>(data.__cms_routing),
82
- actions: parseJson<readonly Action[]>(data.__cms_actions),
83
- ownerTag: data.__cms_owner,
84
- groupTag: data.__cms_group,
85
- seriesId: data.__cms_series,
86
- mayBeDelayed: data.__cms_approximate === 'true',
87
- pushCreated: data.__cms_pushCreated === 'true',
88
- };
89
- }
1
+ /**
2
+ * The metadata codec for a notification's platform data bag.
3
+ *
4
+ * Anchoring, series origin, tags, routing and actions have no engine
5
+ * equivalent, and `getPending` must return them (FR-010) or a consumer cannot
6
+ * diff against intent. They travel as strings in the free-form data bag, which
7
+ * is the one field both platforms carry through unchanged.
8
+ *
9
+ * Shared by the real engine and the fake. When the fake stored requests
10
+ * verbatim it could not lose a field the real engine lost — `actions` never
11
+ * came back from a device, so every reconcile replaced every notification that
12
+ * had them, and the fake-backed tests never saw it.
13
+ */
14
+
15
+ import type { EngineScheduleRequest } from './adapter';
16
+ import type { Action, AnchoringMode, PendingEntry, RoutingPayload } from '../contract/notification';
17
+
18
+ /** Everything `getPending` reports that only the data bag can carry. */
19
+ export type PackedMetadata = Pick<
20
+ PendingEntry,
21
+ | 'anchoring'
22
+ | 'anchoringGeneration'
23
+ | 'tone'
24
+ | 'routing'
25
+ | 'actions'
26
+ | 'ownerTag'
27
+ | 'groupTag'
28
+ | 'seriesId'
29
+ | 'mayBeDelayed'
30
+ | 'pushCreated'
31
+ >;
32
+
33
+ export function packData(request: EngineScheduleRequest): Record<string, string> {
34
+ return {
35
+ __cms_anchoring: request.request.moment.anchoring ?? 'wallClock',
36
+ // The IANA zone, not the tone. Storing the wrong value here does not merely
37
+ // mislabel an entry: the re-anchoring pass feeds it straight to
38
+ // Intl.DateTimeFormat, which throws a hard Hermes error on an invalid zone.
39
+ ...(request.anchoringGeneration !== undefined
40
+ ? { __cms_generation: request.anchoringGeneration }
41
+ : {}),
42
+ __cms_tone: request.request.tone,
43
+ __cms_approximate: String(request.approximate),
44
+ __cms_pushCreated: String(request.pushCreated ?? false),
45
+ ...(request.seriesId !== undefined ? { __cms_series: request.seriesId } : {}),
46
+ ...(request.request.ownerTag !== undefined ? { __cms_owner: request.request.ownerTag } : {}),
47
+ ...(request.request.groupTag !== undefined ? { __cms_group: request.request.groupTag } : {}),
48
+ ...(request.request.routing !== undefined
49
+ ? { __cms_routing: JSON.stringify(request.request.routing) }
50
+ : {}),
51
+ ...(request.request.actions !== undefined
52
+ ? { __cms_actions: JSON.stringify(request.request.actions) }
53
+ : {}),
54
+ // A stop marker's whole payload. It travels here rather than in `ownerTag`
55
+ // or `routing` so it can never collide with a value a consumer owns.
56
+ ...(request.repeatStop !== undefined
57
+ ? {
58
+ __cms_stop_target: request.repeatStop.targetId,
59
+ __cms_stop_due: String(request.repeatStop.dueAt),
60
+ }
61
+ : {}),
62
+ };
63
+ }
64
+
65
+ /** A JSON field we cannot parse is reported as absent: the notification is still valid. */
66
+ function parseJson<T>(raw: string | undefined): T | undefined {
67
+ if (typeof raw !== 'string') return undefined;
68
+ try {
69
+ return JSON.parse(raw) as T;
70
+ } catch {
71
+ return undefined;
72
+ }
73
+ }
74
+
75
+ export function unpackData(data: Record<string, string>): PackedMetadata {
76
+ const anchoring: AnchoringMode = data.__cms_anchoring === 'absolute' ? 'absolute' : 'wallClock';
77
+ return {
78
+ anchoring,
79
+ anchoringGeneration: anchoring === 'absolute' ? undefined : data.__cms_generation,
80
+ tone: data.__cms_tone ?? '',
81
+ routing: parseJson<RoutingPayload>(data.__cms_routing),
82
+ actions: parseJson<readonly Action[]>(data.__cms_actions),
83
+ ownerTag: data.__cms_owner,
84
+ groupTag: data.__cms_group,
85
+ seriesId: data.__cms_series,
86
+ mayBeDelayed: data.__cms_approximate === 'true',
87
+ pushCreated: data.__cms_pushCreated === 'true',
88
+ };
89
+ }