@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
@@ -1,54 +1,54 @@
1
- /**
2
- * The port every push provider satisfies.
3
- *
4
- * No provider type appears in any signature, which is what makes swapping the
5
- * provider cost the consumer nothing.
6
- */
7
-
8
- import type { RoutingPayload, NotificationContent } from '../contract/notification';
9
- import type { Unsubscribe } from '../contract/interaction';
10
-
11
- /** The single normalized shape every provider's payload is reduced to. */
12
- export interface PushEnvelope {
13
- /** Contract version carried by the payload. See contracts/push-payload.md. */
14
- readonly version: number;
15
- readonly kind: 'display' | 'data' | 'schedule' | 'cancel';
16
- readonly id: string;
17
- readonly ownerTag?: string;
18
- readonly content?: NotificationContent;
19
- readonly tone?: string;
20
- readonly moment?: { readonly at: number; readonly anchoring?: 'wallClock' | 'absolute' };
21
- readonly routing?: RoutingPayload;
22
- /** De-duplication key, falling back to `id` when absent. */
23
- readonly dedupId: string;
24
- readonly receivedAt: number;
25
- /**
26
- * True when the payload declared a version this build does not implement and
27
- * was processed best-effort, honouring known fields and ignoring the rest.
28
- */
29
- readonly versionSkew: boolean;
30
- }
31
-
32
- export interface PushConfig {
33
- /** Called when the provider reports a new or refreshed registration token. */
34
- readonly onTokenChange?: (token: string) => void;
35
- }
36
-
37
- export type PushAvailability = 'available' | 'unavailable' | 'unknown';
38
-
39
- export interface PushProviderAdapter {
40
- readonly name: string;
41
-
42
- /** Reports whether platform push services exist, so degradation is controlled rather than opaque. */
43
- getAvailability(): Promise<PushAvailability>;
44
-
45
- /** Waits for a genuine token. Never returns a placeholder presented as a token. */
46
- getToken(): Promise<string>;
47
- onTokenChange(handler: (token: string) => void): Unsubscribe;
48
- invalidateToken(): Promise<boolean>;
49
-
50
- /** Raw provider payloads, normalized by the gate rather than by the adapter. */
51
- subscribeToMessages(handler: (raw: unknown) => void): Unsubscribe;
52
- /** A payload that launched the app, consumed exactly once. */
53
- takeInitialMessage(): Promise<unknown | undefined>;
54
- }
1
+ /**
2
+ * The port every push provider satisfies.
3
+ *
4
+ * No provider type appears in any signature, which is what makes swapping the
5
+ * provider cost the consumer nothing.
6
+ */
7
+
8
+ import type { RoutingPayload, NotificationContent } from '../contract/notification';
9
+ import type { Unsubscribe } from '../contract/interaction';
10
+
11
+ /** The single normalized shape every provider's payload is reduced to. */
12
+ export interface PushEnvelope {
13
+ /** Contract version carried by the payload. See contracts/push-payload.md. */
14
+ readonly version: number;
15
+ readonly kind: 'display' | 'data' | 'schedule' | 'cancel';
16
+ readonly id: string;
17
+ readonly ownerTag?: string;
18
+ readonly content?: NotificationContent;
19
+ readonly tone?: string;
20
+ readonly moment?: { readonly at: number; readonly anchoring?: 'wallClock' | 'absolute' };
21
+ readonly routing?: RoutingPayload;
22
+ /** De-duplication key, falling back to `id` when absent. */
23
+ readonly dedupId: string;
24
+ readonly receivedAt: number;
25
+ /**
26
+ * True when the payload declared a version this build does not implement and
27
+ * was processed best-effort, honouring known fields and ignoring the rest.
28
+ */
29
+ readonly versionSkew: boolean;
30
+ }
31
+
32
+ export interface PushConfig {
33
+ /** Called when the provider reports a new or refreshed registration token. */
34
+ readonly onTokenChange?: (token: string) => void;
35
+ }
36
+
37
+ export type PushAvailability = 'available' | 'unavailable' | 'unknown';
38
+
39
+ export interface PushProviderAdapter {
40
+ readonly name: string;
41
+
42
+ /** Reports whether platform push services exist, so degradation is controlled rather than opaque. */
43
+ getAvailability(): Promise<PushAvailability>;
44
+
45
+ /** Waits for a genuine token. Never returns a placeholder presented as a token. */
46
+ getToken(): Promise<string>;
47
+ onTokenChange(handler: (token: string) => void): Unsubscribe;
48
+ invalidateToken(): Promise<boolean>;
49
+
50
+ /** Raw provider payloads, normalized by the gate rather than by the adapter. */
51
+ subscribeToMessages(handler: (raw: unknown) => void): Unsubscribe;
52
+ /** A payload that launched the app, consumed exactly once. */
53
+ takeInitialMessage(): Promise<unknown | undefined>;
54
+ }
@@ -1,65 +1,65 @@
1
- /**
2
- * Sign-out cleanup.
3
- *
4
- * Three things must happen together, and the order matters: invalidate the
5
- * token first so no further messages arrive for the account being left, then
6
- * cancel its notifications, then clear its persisted state.
7
- *
8
- * Deliberately **scoped to one owner tag**. On a shared device, signing one
9
- * account out must not clear another's deduplication history or cancel their
10
- * reminders — and must not discard the channel migration ledger, which
11
- * describes the device rather than any account.
12
- */
13
-
14
- import type { PushGate } from './gate';
15
- import type { Scheduler } from '../schedule';
16
- import type { StateManager } from '../state/record';
17
- import { clearOwner } from '../state/reset';
18
-
19
- export interface SignOutDependencies {
20
- readonly scheduler: Scheduler;
21
- readonly state: StateManager;
22
- /** Absent for a local-only app, which still needs the rest of the cleanup. */
23
- readonly push?: PushGate;
24
- readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
25
- }
26
-
27
- export interface SignOutResult {
28
- readonly ownerTag: string;
29
- readonly tokenInvalidated: boolean;
30
- readonly cancelledIds: readonly string[];
31
- readonly clearedCollections: readonly string[];
32
- }
33
-
34
- export function createSignOut(deps: SignOutDependencies) {
35
- return {
36
- async signOut(ownerTag: string): Promise<SignOutResult> {
37
- // Token first: stop the inbound flow before removing what would have
38
- // handled it, so nothing arrives for an account that is already gone.
39
- let tokenInvalidated = false;
40
- if (deps.push !== undefined) {
41
- try {
42
- tokenInvalidated = await deps.push.invalidateToken();
43
- } catch {
44
- // A failed invalidation must not prevent local cleanup — leaving a
45
- // signed-out account's notifications on the device is the worse of
46
- // the two outcomes.
47
- tokenInvalidated = false;
48
- }
49
- }
50
-
51
- const cancelledIds = await deps.scheduler.cancelByOwner(ownerTag);
52
- const { clearedCollections } = clearOwner(deps.state, ownerTag);
53
-
54
- deps.emit?.(
55
- 'SIGN_OUT',
56
- `Signed out ${ownerTag}: token ${tokenInvalidated ? 'invalidated' : 'not invalidated'}, ${cancelledIds.length} notification(s) cancelled, per-owner state cleared.`,
57
- { ownerTag, tokenInvalidated, cancelled: cancelledIds.length },
58
- );
59
-
60
- return { ownerTag, tokenInvalidated, cancelledIds, clearedCollections };
61
- },
62
- };
63
- }
64
-
65
- export type SignOut = ReturnType<typeof createSignOut>;
1
+ /**
2
+ * Sign-out cleanup.
3
+ *
4
+ * Three things must happen together, and the order matters: invalidate the
5
+ * token first so no further messages arrive for the account being left, then
6
+ * cancel its notifications, then clear its persisted state.
7
+ *
8
+ * Deliberately **scoped to one owner tag**. On a shared device, signing one
9
+ * account out must not clear another's deduplication history or cancel their
10
+ * reminders — and must not discard the channel migration ledger, which
11
+ * describes the device rather than any account.
12
+ */
13
+
14
+ import type { PushGate } from './gate';
15
+ import type { Scheduler } from '../schedule';
16
+ import type { StateManager } from '../state/record';
17
+ import { clearOwner } from '../state/reset';
18
+
19
+ export interface SignOutDependencies {
20
+ readonly scheduler: Scheduler;
21
+ readonly state: StateManager;
22
+ /** Absent for a local-only app, which still needs the rest of the cleanup. */
23
+ readonly push?: PushGate;
24
+ readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
25
+ }
26
+
27
+ export interface SignOutResult {
28
+ readonly ownerTag: string;
29
+ readonly tokenInvalidated: boolean;
30
+ readonly cancelledIds: readonly string[];
31
+ readonly clearedCollections: readonly string[];
32
+ }
33
+
34
+ export function createSignOut(deps: SignOutDependencies) {
35
+ return {
36
+ async signOut(ownerTag: string): Promise<SignOutResult> {
37
+ // Token first: stop the inbound flow before removing what would have
38
+ // handled it, so nothing arrives for an account that is already gone.
39
+ let tokenInvalidated = false;
40
+ if (deps.push !== undefined) {
41
+ try {
42
+ tokenInvalidated = await deps.push.invalidateToken();
43
+ } catch {
44
+ // A failed invalidation must not prevent local cleanup — leaving a
45
+ // signed-out account's notifications on the device is the worse of
46
+ // the two outcomes.
47
+ tokenInvalidated = false;
48
+ }
49
+ }
50
+
51
+ const cancelledIds = await deps.scheduler.cancelByOwner(ownerTag);
52
+ const { clearedCollections } = clearOwner(deps.state, ownerTag);
53
+
54
+ deps.emit?.(
55
+ 'SIGN_OUT',
56
+ `Signed out ${ownerTag}: token ${tokenInvalidated ? 'invalidated' : 'not invalidated'}, ${cancelledIds.length} notification(s) cancelled, per-owner state cleared.`,
57
+ { ownerTag, tokenInvalidated, cancelled: cancelledIds.length },
58
+ );
59
+
60
+ return { ownerTag, tokenInvalidated, cancelledIds, clearedCollections };
61
+ },
62
+ };
63
+ }
64
+
65
+ export type SignOut = ReturnType<typeof createSignOut>;
@@ -1,53 +1,53 @@
1
- /**
2
- * Chunked application.
3
- *
4
- * A 500-entry reconciliation must not drop frames on a list screen, so work is
5
- * applied in slices with a yield between them. No single slice may occupy the
6
- * interface thread for more than 16 ms, which is what holds 60 fps while
7
- * reconciliation is in flight.
8
- */
9
-
10
- export const DEFAULT_CHUNK_SIZE = 25;
11
-
12
- /** Yields to the event loop so the interface thread can render between slices. */
13
- export function yieldToEventLoop(): Promise<void> {
14
- return new Promise(resolve => setTimeout(resolve, 0));
15
- }
16
-
17
- export interface ChunkOptions {
18
- readonly size?: number;
19
- readonly onYield?: () => Promise<void>;
20
- }
21
-
22
- /**
23
- * Applies `work` to every item, yielding between chunks.
24
- *
25
- * Sequential within a chunk rather than parallel: these calls bridge to native,
26
- * and a burst of concurrent scheduling is where duplicate-delivery races come
27
- * from.
28
- */
29
- export async function applyInChunks<T>(
30
- items: readonly T[],
31
- work: (item: T) => Promise<void>,
32
- options: ChunkOptions = {},
33
- ): Promise<number> {
34
- const size = Math.max(1, options.size ?? DEFAULT_CHUNK_SIZE);
35
- const yieldFn = options.onYield ?? yieldToEventLoop;
36
- let applied = 0;
37
-
38
- for (let start = 0; start < items.length; start += size) {
39
- const chunk = items.slice(start, start + size);
40
-
41
- for (const item of chunk) {
42
- await work(item);
43
- applied += 1;
44
- }
45
-
46
- // No yield after the final chunk — the caller is about to resume anyway.
47
- if (start + size < items.length) {
48
- await yieldFn();
49
- }
50
- }
51
-
52
- return applied;
53
- }
1
+ /**
2
+ * Chunked application.
3
+ *
4
+ * A 500-entry reconciliation must not drop frames on a list screen, so work is
5
+ * applied in slices with a yield between them. No single slice may occupy the
6
+ * interface thread for more than 16 ms, which is what holds 60 fps while
7
+ * reconciliation is in flight.
8
+ */
9
+
10
+ export const DEFAULT_CHUNK_SIZE = 25;
11
+
12
+ /** Yields to the event loop so the interface thread can render between slices. */
13
+ export function yieldToEventLoop(): Promise<void> {
14
+ return new Promise(resolve => setTimeout(resolve, 0));
15
+ }
16
+
17
+ export interface ChunkOptions {
18
+ readonly size?: number;
19
+ readonly onYield?: () => Promise<void>;
20
+ }
21
+
22
+ /**
23
+ * Applies `work` to every item, yielding between chunks.
24
+ *
25
+ * Sequential within a chunk rather than parallel: these calls bridge to native,
26
+ * and a burst of concurrent scheduling is where duplicate-delivery races come
27
+ * from.
28
+ */
29
+ export async function applyInChunks<T>(
30
+ items: readonly T[],
31
+ work: (item: T) => Promise<void>,
32
+ options: ChunkOptions = {},
33
+ ): Promise<number> {
34
+ const size = Math.max(1, options.size ?? DEFAULT_CHUNK_SIZE);
35
+ const yieldFn = options.onYield ?? yieldToEventLoop;
36
+ let applied = 0;
37
+
38
+ for (let start = 0; start < items.length; start += size) {
39
+ const chunk = items.slice(start, start + size);
40
+
41
+ for (const item of chunk) {
42
+ await work(item);
43
+ applied += 1;
44
+ }
45
+
46
+ // No yield after the final chunk — the caller is about to resume anyway.
47
+ if (start + size < items.length) {
48
+ await yieldFn();
49
+ }
50
+ }
51
+
52
+ return applied;
53
+ }
@@ -1,145 +1,145 @@
1
- /**
2
- * Desired-versus-device diffing.
3
- *
4
- * The package's central promise lives here: reconciling twice with unchanged
5
- * input must produce **zero** platform operations on the second run. That is
6
- * only true if "unchanged" is decided by comparing everything that would make
7
- * the delivered notification different — miss a field and the second run
8
- * rewrites entries needlessly; include a volatile one and it never settles.
9
- *
10
- * Anchoring mode is part of the comparison key deliberately. A mode change on
11
- * the same identifier must force a replacement rather than read as unchanged,
12
- * because the two modes resolve to different instants the moment a zone
13
- * changes.
14
- */
15
-
16
- import type { NotificationRequest, PendingEntry } from '../contract/notification';
17
-
18
- export type OperationKind = 'create' | 'replace' | 'cancel';
19
-
20
- export interface DiffOperation {
21
- readonly kind: OperationKind;
22
- readonly id: string;
23
- /** Present for create and replace. */
24
- readonly request?: NotificationRequest;
25
- /** The instant the entry should fire, after anchoring resolution. */
26
- readonly resolvedAt?: number;
27
- }
28
-
29
- export interface DiffResult {
30
- readonly operations: readonly DiffOperation[];
31
- readonly unchanged: readonly string[];
32
- }
33
-
34
- /** Stable serialization of the fields that make a delivered notification different. */
35
- function fingerprint(
36
- request: NotificationRequest,
37
- resolvedAt: number,
38
- ): string {
39
- return JSON.stringify([
40
- resolvedAt,
41
- request.moment.anchoring ?? 'wallClock',
42
- request.content.title,
43
- request.content.body,
44
- request.content.subtitle ?? null,
45
- request.tone,
46
- request.ownerTag ?? null,
47
- request.groupTag ?? null,
48
- // Key order is not stable across constructions, so sort before comparing —
49
- // otherwise two structurally identical payloads could compare unequal and
50
- // the second pass would rewrite every entry carrying routing data.
51
- request.routing === undefined ? null : stableStringify(request.routing),
52
- request.actions === undefined
53
- ? null
54
- : request.actions.map(a => [a.id, a.title, a.mode, a.input?.placeholder ?? null]),
55
- ]);
56
- }
57
-
58
- function entryFingerprint(entry: PendingEntry): string {
59
- return JSON.stringify([
60
- entry.resolvedAt,
61
- entry.anchoring,
62
- entry.content.title,
63
- entry.content.body,
64
- entry.content.subtitle ?? null,
65
- entry.tone,
66
- entry.ownerTag ?? null,
67
- entry.groupTag ?? null,
68
- entry.routing === undefined ? null : stableStringify(entry.routing),
69
- entry.actions === undefined
70
- ? null
71
- : entry.actions.map(a => [a.id, a.title, a.mode, a.input?.placeholder ?? null]),
72
- ]);
73
- }
74
-
75
- /** Key-order-independent JSON, so structurally equal payloads compare equal. */
76
- function stableStringify(value: unknown): string {
77
- if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
78
- if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
79
-
80
- const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) =>
81
- a < b ? -1 : a > b ? 1 : 0,
82
- );
83
- return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}`;
84
- }
85
-
86
- export interface DiffInput {
87
- readonly desired: readonly NotificationRequest[];
88
- /** Resolved firing instant per desired identifier, after anchoring. */
89
- readonly resolvedAt: ReadonlyMap<string, number>;
90
- /** What the device currently holds, already narrowed to the caller's domain. */
91
- readonly device: readonly PendingEntry[];
92
- /**
93
- * Identifiers that were rejected before diffing and must be left alone.
94
- *
95
- * Without this they would look like strays — present on the device, absent
96
- * from the desired set — and be cancelled. But a rejection is not a
97
- * cancellation: the package could not decide what the caller meant, so
98
- * destroying the existing notification would be the worst available answer.
99
- */
100
- readonly protectedIds?: ReadonlySet<string>;
101
- }
102
-
103
- /**
104
- * Computes the minimal operation set.
105
- *
106
- * Cancels are emitted before creates so an interrupted pass leaves a
107
- * recoverable gap rather than a duplicate: a missing notification is fixed by
108
- * the next reconcile, whereas two notifications for one subject is a defect the
109
- * user sees.
110
- */
111
- export function diff(input: DiffInput): DiffResult {
112
- const deviceById = new Map(input.device.map(entry => [entry.id, entry]));
113
- const desiredIds = new Set(input.desired.map(request => request.id));
114
-
115
- const cancels: DiffOperation[] = [];
116
- const writes: DiffOperation[] = [];
117
- const unchanged: string[] = [];
118
-
119
- const protectedIds = input.protectedIds ?? new Set<string>();
120
-
121
- for (const entry of input.device) {
122
- if (!desiredIds.has(entry.id) && !protectedIds.has(entry.id)) {
123
- cancels.push({ kind: 'cancel', id: entry.id });
124
- }
125
- }
126
-
127
- for (const request of input.desired) {
128
- const resolvedAt = input.resolvedAt.get(request.id) ?? request.moment.at;
129
- const existing = deviceById.get(request.id);
130
-
131
- if (existing === undefined) {
132
- writes.push({ kind: 'create', id: request.id, request, resolvedAt });
133
- continue;
134
- }
135
-
136
- if (fingerprint(request, resolvedAt) === entryFingerprint(existing)) {
137
- unchanged.push(request.id);
138
- continue;
139
- }
140
-
141
- writes.push({ kind: 'replace', id: request.id, request, resolvedAt });
142
- }
143
-
144
- return { operations: [...cancels, ...writes], unchanged };
145
- }
1
+ /**
2
+ * Desired-versus-device diffing.
3
+ *
4
+ * The package's central promise lives here: reconciling twice with unchanged
5
+ * input must produce **zero** platform operations on the second run. That is
6
+ * only true if "unchanged" is decided by comparing everything that would make
7
+ * the delivered notification different — miss a field and the second run
8
+ * rewrites entries needlessly; include a volatile one and it never settles.
9
+ *
10
+ * Anchoring mode is part of the comparison key deliberately. A mode change on
11
+ * the same identifier must force a replacement rather than read as unchanged,
12
+ * because the two modes resolve to different instants the moment a zone
13
+ * changes.
14
+ */
15
+
16
+ import type { NotificationRequest, PendingEntry } from '../contract/notification';
17
+
18
+ export type OperationKind = 'create' | 'replace' | 'cancel';
19
+
20
+ export interface DiffOperation {
21
+ readonly kind: OperationKind;
22
+ readonly id: string;
23
+ /** Present for create and replace. */
24
+ readonly request?: NotificationRequest;
25
+ /** The instant the entry should fire, after anchoring resolution. */
26
+ readonly resolvedAt?: number;
27
+ }
28
+
29
+ export interface DiffResult {
30
+ readonly operations: readonly DiffOperation[];
31
+ readonly unchanged: readonly string[];
32
+ }
33
+
34
+ /** Stable serialization of the fields that make a delivered notification different. */
35
+ function fingerprint(
36
+ request: NotificationRequest,
37
+ resolvedAt: number,
38
+ ): string {
39
+ return JSON.stringify([
40
+ resolvedAt,
41
+ request.moment.anchoring ?? 'wallClock',
42
+ request.content.title,
43
+ request.content.body,
44
+ request.content.subtitle ?? null,
45
+ request.tone,
46
+ request.ownerTag ?? null,
47
+ request.groupTag ?? null,
48
+ // Key order is not stable across constructions, so sort before comparing —
49
+ // otherwise two structurally identical payloads could compare unequal and
50
+ // the second pass would rewrite every entry carrying routing data.
51
+ request.routing === undefined ? null : stableStringify(request.routing),
52
+ request.actions === undefined
53
+ ? null
54
+ : request.actions.map(a => [a.id, a.title, a.mode, a.input?.placeholder ?? null]),
55
+ ]);
56
+ }
57
+
58
+ function entryFingerprint(entry: PendingEntry): string {
59
+ return JSON.stringify([
60
+ entry.resolvedAt,
61
+ entry.anchoring,
62
+ entry.content.title,
63
+ entry.content.body,
64
+ entry.content.subtitle ?? null,
65
+ entry.tone,
66
+ entry.ownerTag ?? null,
67
+ entry.groupTag ?? null,
68
+ entry.routing === undefined ? null : stableStringify(entry.routing),
69
+ entry.actions === undefined
70
+ ? null
71
+ : entry.actions.map(a => [a.id, a.title, a.mode, a.input?.placeholder ?? null]),
72
+ ]);
73
+ }
74
+
75
+ /** Key-order-independent JSON, so structurally equal payloads compare equal. */
76
+ function stableStringify(value: unknown): string {
77
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
78
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
79
+
80
+ const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) =>
81
+ a < b ? -1 : a > b ? 1 : 0,
82
+ );
83
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}`;
84
+ }
85
+
86
+ export interface DiffInput {
87
+ readonly desired: readonly NotificationRequest[];
88
+ /** Resolved firing instant per desired identifier, after anchoring. */
89
+ readonly resolvedAt: ReadonlyMap<string, number>;
90
+ /** What the device currently holds, already narrowed to the caller's domain. */
91
+ readonly device: readonly PendingEntry[];
92
+ /**
93
+ * Identifiers that were rejected before diffing and must be left alone.
94
+ *
95
+ * Without this they would look like strays — present on the device, absent
96
+ * from the desired set — and be cancelled. But a rejection is not a
97
+ * cancellation: the package could not decide what the caller meant, so
98
+ * destroying the existing notification would be the worst available answer.
99
+ */
100
+ readonly protectedIds?: ReadonlySet<string>;
101
+ }
102
+
103
+ /**
104
+ * Computes the minimal operation set.
105
+ *
106
+ * Cancels are emitted before creates so an interrupted pass leaves a
107
+ * recoverable gap rather than a duplicate: a missing notification is fixed by
108
+ * the next reconcile, whereas two notifications for one subject is a defect the
109
+ * user sees.
110
+ */
111
+ export function diff(input: DiffInput): DiffResult {
112
+ const deviceById = new Map(input.device.map(entry => [entry.id, entry]));
113
+ const desiredIds = new Set(input.desired.map(request => request.id));
114
+
115
+ const cancels: DiffOperation[] = [];
116
+ const writes: DiffOperation[] = [];
117
+ const unchanged: string[] = [];
118
+
119
+ const protectedIds = input.protectedIds ?? new Set<string>();
120
+
121
+ for (const entry of input.device) {
122
+ if (!desiredIds.has(entry.id) && !protectedIds.has(entry.id)) {
123
+ cancels.push({ kind: 'cancel', id: entry.id });
124
+ }
125
+ }
126
+
127
+ for (const request of input.desired) {
128
+ const resolvedAt = input.resolvedAt.get(request.id) ?? request.moment.at;
129
+ const existing = deviceById.get(request.id);
130
+
131
+ if (existing === undefined) {
132
+ writes.push({ kind: 'create', id: request.id, request, resolvedAt });
133
+ continue;
134
+ }
135
+
136
+ if (fingerprint(request, resolvedAt) === entryFingerprint(existing)) {
137
+ unchanged.push(request.id);
138
+ continue;
139
+ }
140
+
141
+ writes.push({ kind: 'replace', id: request.id, request, resolvedAt });
142
+ }
143
+
144
+ return { operations: [...cancels, ...writes], unchanged };
145
+ }