@chipmobilesdk/rn-notification 0.1.1 → 0.2.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 (71) hide show
  1. package/CHANGELOG.md +80 -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 +35 -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/dedup-ledger.ts +35 -39
  59. package/src/state/interaction-buffer.ts +73 -73
  60. package/src/state/mmkv-store.ts +71 -71
  61. package/src/state/permission-history.ts +41 -41
  62. package/src/state/record.ts +258 -246
  63. package/src/state/reset.ts +41 -41
  64. package/src/state/series-ledger.ts +84 -84
  65. package/src/state/store.ts +40 -40
  66. package/src/testing/conformance/index.ts +248 -194
  67. package/src/testing/fake-engine.ts +469 -284
  68. package/src/testing/fake-provider.ts +129 -129
  69. package/src/testing/faults.ts +72 -72
  70. package/src/testing/index.ts +2 -0
  71. package/src/testing/measure.ts +337 -0
@@ -1,101 +1,101 @@
1
- /**
2
- * Burst detection.
3
- *
4
- * Both platforms apply undocumented delivery rate limits and will silently drop
5
- * or delay past them — which is exactly the invisible failure this package
6
- * exists to eliminate. So volume is watched and reported.
7
- *
8
- * It is **not** acted upon. Nothing is throttled, delayed, coalesced, or
9
- * dropped: deciding which of a user's notifications to suppress is a product
10
- * decision the package has no basis to make. Noticing that something abnormal
11
- * is happening is one it can.
12
- *
13
- * Counters are in memory only. A burst is a live condition, and persisting it
14
- * would expand the state contract for no diagnostic gain.
15
- */
16
-
17
- import type { BurstThreshold } from '../contract/diagnostics';
18
- import { DEFAULT_BURST_THRESHOLD } from '../contract/diagnostics';
19
- import { DIAGNOSTIC_CODES } from '../contract/errors';
20
-
21
- const MINUTE_MS = 60_000;
22
- const HOUR_MS = 60 * MINUTE_MS;
23
-
24
- export interface BurstDetectorDependencies {
25
- readonly threshold?: BurstThreshold;
26
- readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
27
- }
28
-
29
- export interface BurstStatus {
30
- readonly ownerTag: string;
31
- readonly lastMinute: number;
32
- readonly lastHour: number;
33
- readonly exceeded: boolean;
34
- }
35
-
36
- export function createBurstDetector(deps: BurstDetectorDependencies = {}) {
37
- const threshold = deps.threshold ?? DEFAULT_BURST_THRESHOLD;
38
- const emit = deps.emit ?? (() => {});
39
- /** Per owner tag, because one busy account should not implicate another. */
40
- const timestamps = new Map<string, number[]>();
41
- /** Prevents one sustained burst producing a diagnostic per notification. */
42
- const reportedAt = new Map<string, number>();
43
-
44
- const prune = (tag: string, now: number): number[] => {
45
- const kept = (timestamps.get(tag) ?? []).filter(at => now - at <= HOUR_MS);
46
- timestamps.set(tag, kept);
47
- return kept;
48
- };
49
-
50
- return {
51
- /**
52
- * Records one presented notification and reports if the threshold is crossed.
53
- *
54
- * Always returns after recording — the caller delivers regardless of what
55
- * this says.
56
- */
57
- record(now: number, ownerTag = '*'): BurstStatus {
58
- const kept = prune(ownerTag, now);
59
- kept.push(now);
60
-
61
- const lastMinute = kept.filter(at => now - at <= MINUTE_MS).length;
62
- const lastHour = kept.length;
63
- const exceeded = lastMinute > threshold.perMinute || lastHour > threshold.perHour;
64
-
65
- if (exceeded) {
66
- const last = reportedAt.get(ownerTag) ?? 0;
67
- // One report per minute per tag; a sustained burst is one condition,
68
- // not a hundred.
69
- if (now - last >= MINUTE_MS) {
70
- reportedAt.set(ownerTag, now);
71
- emit(
72
- DIAGNOSTIC_CODES.BURST_THRESHOLD_EXCEEDED,
73
- `Abnormal notification volume: ${lastMinute} in the last minute and ${lastHour} in the last hour (thresholds ${threshold.perMinute}/minute, ${threshold.perHour}/hour). Every notification was still delivered — the package throttles nothing.`,
74
- { ownerTag, lastMinute, lastHour, threshold },
75
- );
76
- }
77
- }
78
-
79
- return { ownerTag, lastMinute, lastHour, exceeded };
80
- },
81
-
82
- status(now: number, ownerTag = '*'): BurstStatus {
83
- const kept = prune(ownerTag, now);
84
- const lastMinute = kept.filter(at => now - at <= MINUTE_MS).length;
85
-
86
- return {
87
- ownerTag,
88
- lastMinute,
89
- lastHour: kept.length,
90
- exceeded: lastMinute > threshold.perMinute || kept.length > threshold.perHour,
91
- };
92
- },
93
-
94
- reset(): void {
95
- timestamps.clear();
96
- reportedAt.clear();
97
- },
98
- };
99
- }
100
-
101
- export type BurstDetector = ReturnType<typeof createBurstDetector>;
1
+ /**
2
+ * Burst detection.
3
+ *
4
+ * Both platforms apply undocumented delivery rate limits and will silently drop
5
+ * or delay past them — which is exactly the invisible failure this package
6
+ * exists to eliminate. So volume is watched and reported.
7
+ *
8
+ * It is **not** acted upon. Nothing is throttled, delayed, coalesced, or
9
+ * dropped: deciding which of a user's notifications to suppress is a product
10
+ * decision the package has no basis to make. Noticing that something abnormal
11
+ * is happening is one it can.
12
+ *
13
+ * Counters are in memory only. A burst is a live condition, and persisting it
14
+ * would expand the state contract for no diagnostic gain.
15
+ */
16
+
17
+ import type { BurstThreshold } from '../contract/diagnostics';
18
+ import { DEFAULT_BURST_THRESHOLD } from '../contract/diagnostics';
19
+ import { DIAGNOSTIC_CODES } from '../contract/errors';
20
+
21
+ const MINUTE_MS = 60_000;
22
+ const HOUR_MS = 60 * MINUTE_MS;
23
+
24
+ export interface BurstDetectorDependencies {
25
+ readonly threshold?: BurstThreshold;
26
+ readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
27
+ }
28
+
29
+ export interface BurstStatus {
30
+ readonly ownerTag: string;
31
+ readonly lastMinute: number;
32
+ readonly lastHour: number;
33
+ readonly exceeded: boolean;
34
+ }
35
+
36
+ export function createBurstDetector(deps: BurstDetectorDependencies = {}) {
37
+ const threshold = deps.threshold ?? DEFAULT_BURST_THRESHOLD;
38
+ const emit = deps.emit ?? (() => {});
39
+ /** Per owner tag, because one busy account should not implicate another. */
40
+ const timestamps = new Map<string, number[]>();
41
+ /** Prevents one sustained burst producing a diagnostic per notification. */
42
+ const reportedAt = new Map<string, number>();
43
+
44
+ const prune = (tag: string, now: number): number[] => {
45
+ const kept = (timestamps.get(tag) ?? []).filter(at => now - at <= HOUR_MS);
46
+ timestamps.set(tag, kept);
47
+ return kept;
48
+ };
49
+
50
+ return {
51
+ /**
52
+ * Records one presented notification and reports if the threshold is crossed.
53
+ *
54
+ * Always returns after recording — the caller delivers regardless of what
55
+ * this says.
56
+ */
57
+ record(now: number, ownerTag = '*'): BurstStatus {
58
+ const kept = prune(ownerTag, now);
59
+ kept.push(now);
60
+
61
+ const lastMinute = kept.filter(at => now - at <= MINUTE_MS).length;
62
+ const lastHour = kept.length;
63
+ const exceeded = lastMinute > threshold.perMinute || lastHour > threshold.perHour;
64
+
65
+ if (exceeded) {
66
+ const last = reportedAt.get(ownerTag) ?? 0;
67
+ // One report per minute per tag; a sustained burst is one condition,
68
+ // not a hundred.
69
+ if (now - last >= MINUTE_MS) {
70
+ reportedAt.set(ownerTag, now);
71
+ emit(
72
+ DIAGNOSTIC_CODES.BURST_THRESHOLD_EXCEEDED,
73
+ `Abnormal notification volume: ${lastMinute} in the last minute and ${lastHour} in the last hour (thresholds ${threshold.perMinute}/minute, ${threshold.perHour}/hour). Every notification was still delivered — the package throttles nothing.`,
74
+ { ownerTag, lastMinute, lastHour, threshold },
75
+ );
76
+ }
77
+ }
78
+
79
+ return { ownerTag, lastMinute, lastHour, exceeded };
80
+ },
81
+
82
+ status(now: number, ownerTag = '*'): BurstStatus {
83
+ const kept = prune(ownerTag, now);
84
+ const lastMinute = kept.filter(at => now - at <= MINUTE_MS).length;
85
+
86
+ return {
87
+ ownerTag,
88
+ lastMinute,
89
+ lastHour: kept.length,
90
+ exceeded: lastMinute > threshold.perMinute || kept.length > threshold.perHour,
91
+ };
92
+ },
93
+
94
+ reset(): void {
95
+ timestamps.clear();
96
+ reportedAt.clear();
97
+ },
98
+ };
99
+ }
100
+
101
+ export type BurstDetector = ReturnType<typeof createBurstDetector>;
@@ -1,72 +1,72 @@
1
- /**
2
- * The structured event stream.
3
- *
4
- * With no sink attached the package emits nothing and buffers nothing. A
5
- * library that writes to the console in production has decided something for
6
- * its consumer; this one routes events wherever the app already sends its logs,
7
- * or stays silent.
8
- */
9
-
10
- import type { DiagnosticEvent, DiagnosticSeverity, EventSink } from '../contract/diagnostics';
11
- import type { DiagnosticCode } from '../contract/errors';
12
- import { isNonFailure } from '../contract/errors';
13
- import { redactContext } from './redact';
14
-
15
- export interface EmitterOptions {
16
- readonly sink?: EventSink;
17
- readonly verbose?: boolean;
18
- readonly now?: () => number;
19
- }
20
-
21
- export class DiagnosticEmitter {
22
- private sink?: EventSink;
23
- private verbose: boolean;
24
- private readonly now: () => number;
25
-
26
- constructor(options: EmitterOptions = {}) {
27
- this.sink = options.sink;
28
- this.verbose = options.verbose ?? false;
29
- this.now = options.now ?? (() => Date.now());
30
- }
31
-
32
- setSink(sink: EventSink | undefined): void {
33
- this.sink = sink;
34
- }
35
-
36
- setVerbose(verbose: boolean): void {
37
- this.verbose = verbose;
38
- }
39
-
40
- get hasSink(): boolean {
41
- return this.sink !== undefined;
42
- }
43
-
44
- emit(
45
- code: DiagnosticCode,
46
- message: string,
47
- context?: Readonly<Record<string, unknown>>,
48
- severity?: DiagnosticSeverity,
49
- ): void {
50
- // No sink means no work at all — not a buffer that grows until one appears.
51
- if (this.sink === undefined) return;
52
-
53
- const event: DiagnosticEvent = {
54
- code,
55
- severity: severity ?? (isNonFailure(code) ? 'info' : 'error'),
56
- message,
57
- at: this.now(),
58
- context: redactContext(context, this.verbose),
59
- };
60
-
61
- try {
62
- this.sink(event);
63
- } catch {
64
- // A failing sink is the consumer's problem and must never become ours.
65
- }
66
- }
67
-
68
- /** Lifecycle transitions that are not failures: scheduled, delivered, reconciled. */
69
- info(code: DiagnosticCode, message: string, context?: Readonly<Record<string, unknown>>): void {
70
- this.emit(code, message, context, 'info');
71
- }
72
- }
1
+ /**
2
+ * The structured event stream.
3
+ *
4
+ * With no sink attached the package emits nothing and buffers nothing. A
5
+ * library that writes to the console in production has decided something for
6
+ * its consumer; this one routes events wherever the app already sends its logs,
7
+ * or stays silent.
8
+ */
9
+
10
+ import type { DiagnosticEvent, DiagnosticSeverity, EventSink } from '../contract/diagnostics';
11
+ import type { DiagnosticCode } from '../contract/errors';
12
+ import { isNonFailure } from '../contract/errors';
13
+ import { redactContext } from './redact';
14
+
15
+ export interface EmitterOptions {
16
+ readonly sink?: EventSink;
17
+ readonly verbose?: boolean;
18
+ readonly now?: () => number;
19
+ }
20
+
21
+ export class DiagnosticEmitter {
22
+ private sink?: EventSink;
23
+ private verbose: boolean;
24
+ private readonly now: () => number;
25
+
26
+ constructor(options: EmitterOptions = {}) {
27
+ this.sink = options.sink;
28
+ this.verbose = options.verbose ?? false;
29
+ this.now = options.now ?? (() => Date.now());
30
+ }
31
+
32
+ setSink(sink: EventSink | undefined): void {
33
+ this.sink = sink;
34
+ }
35
+
36
+ setVerbose(verbose: boolean): void {
37
+ this.verbose = verbose;
38
+ }
39
+
40
+ get hasSink(): boolean {
41
+ return this.sink !== undefined;
42
+ }
43
+
44
+ emit(
45
+ code: DiagnosticCode,
46
+ message: string,
47
+ context?: Readonly<Record<string, unknown>>,
48
+ severity?: DiagnosticSeverity,
49
+ ): void {
50
+ // No sink means no work at all — not a buffer that grows until one appears.
51
+ if (this.sink === undefined) return;
52
+
53
+ const event: DiagnosticEvent = {
54
+ code,
55
+ severity: severity ?? (isNonFailure(code) ? 'info' : 'error'),
56
+ message,
57
+ at: this.now(),
58
+ context: redactContext(context, this.verbose),
59
+ };
60
+
61
+ try {
62
+ this.sink(event);
63
+ } catch {
64
+ // A failing sink is the consumer's problem and must never become ours.
65
+ }
66
+ }
67
+
68
+ /** Lifecycle transitions that are not failures: scheduled, delivered, reconciled. */
69
+ info(code: DiagnosticCode, message: string, context?: Readonly<Record<string, unknown>>): void {
70
+ this.emit(code, message, context, 'info');
71
+ }
72
+ }
@@ -1,72 +1,72 @@
1
- /**
2
- * Content redaction for diagnostics.
3
- *
4
- * A notification library that logs notification bodies has turned a diagnostic
5
- * aid into a privacy incident. Nothing content-bearing reaches an event unless
6
- * verbose diagnostics is explicitly enabled — and that setting is documented as
7
- * unsuitable for production.
8
- *
9
- * Values are replaced by their *shape* — a length, a type, a character position
10
- * — which is what actually helps someone debug a validation failure anyway.
11
- */
12
-
13
- /** Keys whose values are never emitted at any verbosity below verbose. */
14
- const REDACTED_KEYS = new Set([
15
- 'title',
16
- 'body',
17
- 'subtitle',
18
- 'content',
19
- 'routing',
20
- 'routingPayload',
21
- 'payload',
22
- 'token',
23
- 'pushToken',
24
- 'inputText',
25
- 'text',
26
- 'data',
27
- ]);
28
-
29
- const REDACTED = '[redacted]';
30
-
31
- function describe(value: unknown): string {
32
- if (value === null) return '[null]';
33
- if (value === undefined) return '[undefined]';
34
- if (typeof value === 'string') return `[string:${value.length}]`;
35
- if (typeof value === 'number' || typeof value === 'boolean') return REDACTED;
36
- if (Array.isArray(value)) return `[array:${value.length}]`;
37
- if (typeof value === 'object') return `[object:${Object.keys(value).length}]`;
38
- return REDACTED;
39
- }
40
-
41
- /**
42
- * Strips content values from a diagnostic context.
43
- *
44
- * Recurses so a nested `{ request: { content: { body } } }` is caught — a
45
- * top-level-only scrub is the kind of redaction that leaks the first time
46
- * anyone nests a payload.
47
- */
48
- export function redactContext(
49
- context: Readonly<Record<string, unknown>> | undefined,
50
- verbose: boolean,
51
- ): Readonly<Record<string, unknown>> | undefined {
52
- if (context === undefined) return undefined;
53
- if (verbose) return context;
54
-
55
- const walk = (value: unknown, depth: number): unknown => {
56
- if (depth > 6 || value === null || typeof value !== 'object') return value;
57
- if (Array.isArray(value)) return value.map(item => walk(item, depth + 1));
58
-
59
- const output: Record<string, unknown> = {};
60
- for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
61
- output[key] = REDACTED_KEYS.has(key) ? describe(inner) : walk(inner, depth + 1);
62
- }
63
- return output;
64
- };
65
-
66
- return walk(context, 0) as Readonly<Record<string, unknown>>;
67
- }
68
-
69
- /** True when a key would be redacted. Exported so tests can assert the policy directly. */
70
- export function isRedactedKey(key: string): boolean {
71
- return REDACTED_KEYS.has(key);
72
- }
1
+ /**
2
+ * Content redaction for diagnostics.
3
+ *
4
+ * A notification library that logs notification bodies has turned a diagnostic
5
+ * aid into a privacy incident. Nothing content-bearing reaches an event unless
6
+ * verbose diagnostics is explicitly enabled — and that setting is documented as
7
+ * unsuitable for production.
8
+ *
9
+ * Values are replaced by their *shape* — a length, a type, a character position
10
+ * — which is what actually helps someone debug a validation failure anyway.
11
+ */
12
+
13
+ /** Keys whose values are never emitted at any verbosity below verbose. */
14
+ const REDACTED_KEYS = new Set([
15
+ 'title',
16
+ 'body',
17
+ 'subtitle',
18
+ 'content',
19
+ 'routing',
20
+ 'routingPayload',
21
+ 'payload',
22
+ 'token',
23
+ 'pushToken',
24
+ 'inputText',
25
+ 'text',
26
+ 'data',
27
+ ]);
28
+
29
+ const REDACTED = '[redacted]';
30
+
31
+ function describe(value: unknown): string {
32
+ if (value === null) return '[null]';
33
+ if (value === undefined) return '[undefined]';
34
+ if (typeof value === 'string') return `[string:${value.length}]`;
35
+ if (typeof value === 'number' || typeof value === 'boolean') return REDACTED;
36
+ if (Array.isArray(value)) return `[array:${value.length}]`;
37
+ if (typeof value === 'object') return `[object:${Object.keys(value).length}]`;
38
+ return REDACTED;
39
+ }
40
+
41
+ /**
42
+ * Strips content values from a diagnostic context.
43
+ *
44
+ * Recurses so a nested `{ request: { content: { body } } }` is caught — a
45
+ * top-level-only scrub is the kind of redaction that leaks the first time
46
+ * anyone nests a payload.
47
+ */
48
+ export function redactContext(
49
+ context: Readonly<Record<string, unknown>> | undefined,
50
+ verbose: boolean,
51
+ ): Readonly<Record<string, unknown>> | undefined {
52
+ if (context === undefined) return undefined;
53
+ if (verbose) return context;
54
+
55
+ const walk = (value: unknown, depth: number): unknown => {
56
+ if (depth > 6 || value === null || typeof value !== 'object') return value;
57
+ if (Array.isArray(value)) return value.map(item => walk(item, depth + 1));
58
+
59
+ const output: Record<string, unknown> = {};
60
+ for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
61
+ output[key] = REDACTED_KEYS.has(key) ? describe(inner) : walk(inner, depth + 1);
62
+ }
63
+ return output;
64
+ };
65
+
66
+ return walk(context, 0) as Readonly<Record<string, unknown>>;
67
+ }
68
+
69
+ /** True when a key would be redacted. Exported so tests can assert the policy directly. */
70
+ export function isRedactedKey(key: string): boolean {
71
+ return REDACTED_KEYS.has(key);
72
+ }
@@ -1,64 +1,64 @@
1
- /**
2
- * The state snapshot.
3
- *
4
- * The readable answer to "why didn't it arrive?" — assembled in one place so
5
- * that question can be answered without attaching a native debugger.
6
- *
7
- * The push token appears as **presence only**. Its value is never exposed here
8
- * or anywhere else, because a snapshot is the thing most likely to be pasted
9
- * into a bug report.
10
- */
11
-
12
- import type { StateSnapshot } from '../contract/diagnostics';
13
- import type { PendingEntry } from '../contract/notification';
14
- import type { PermissionReport, RestrictionReport } from '../contract/permission';
15
- import type { MaintenanceResult, PendingDiscrepancy, ReconcileResult } from '../contract/reconcile';
16
- import type { NotificationEngineAdapter } from '../engine/adapter';
17
- import type { StateManager } from '../state/record';
18
-
19
- export interface SnapshotDependencies {
20
- readonly engine: NotificationEngineAdapter;
21
- readonly state: StateManager;
22
- readonly now: () => number;
23
- readonly permissions: () => Promise<PermissionReport>;
24
- readonly restrictions: () => Promise<RestrictionReport>;
25
- readonly appliedTones: () => readonly { readonly toneId: string; readonly appliedVersion: number }[];
26
- readonly lastReconcile: () => ReconcileResult | undefined;
27
- readonly lastMaintenance: () => MaintenanceResult | undefined;
28
- /** Presence only — never the value. */
29
- readonly hasPushToken: () => boolean;
30
- }
31
-
32
- export function createSnapshot(deps: SnapshotDependencies) {
33
- return {
34
- async capture(): Promise<StateSnapshot> {
35
- const [permissions, restrictions, pending] = await Promise.all([
36
- deps.permissions(),
37
- deps.restrictions(),
38
- deps.engine.getPending(),
39
- ]);
40
-
41
- const maintenance = deps.lastMaintenance();
42
- const discrepancy: PendingDiscrepancy | undefined = maintenance?.discrepancy;
43
-
44
- return {
45
- permissions,
46
- restrictions,
47
- tones: deps.appliedTones().map(tone => ({
48
- id: tone.toneId,
49
- appliedVersion: tone.appliedVersion,
50
- })),
51
- pendingCount: pending.length,
52
- pending: pending as readonly PendingEntry[],
53
- lastReconcile: deps.lastReconcile(),
54
- lastMaintenance: maintenance,
55
- discrepancy,
56
- stateHealth: deps.state.health(),
57
- pushTokenPresent: deps.hasPushToken(),
58
- capturedAt: deps.now(),
59
- };
60
- },
61
- };
62
- }
63
-
64
- export type Snapshot = ReturnType<typeof createSnapshot>;
1
+ /**
2
+ * The state snapshot.
3
+ *
4
+ * The readable answer to "why didn't it arrive?" — assembled in one place so
5
+ * that question can be answered without attaching a native debugger.
6
+ *
7
+ * The push token appears as **presence only**. Its value is never exposed here
8
+ * or anywhere else, because a snapshot is the thing most likely to be pasted
9
+ * into a bug report.
10
+ */
11
+
12
+ import type { StateSnapshot } from '../contract/diagnostics';
13
+ import type { PendingEntry } from '../contract/notification';
14
+ import type { PermissionReport, RestrictionReport } from '../contract/permission';
15
+ import type { MaintenanceResult, PendingDiscrepancy, ReconcileResult } from '../contract/reconcile';
16
+ import type { NotificationEngineAdapter } from '../engine/adapter';
17
+ import type { StateManager } from '../state/record';
18
+
19
+ export interface SnapshotDependencies {
20
+ readonly engine: NotificationEngineAdapter;
21
+ readonly state: StateManager;
22
+ readonly now: () => number;
23
+ readonly permissions: () => Promise<PermissionReport>;
24
+ readonly restrictions: () => Promise<RestrictionReport>;
25
+ readonly appliedTones: () => readonly { readonly toneId: string; readonly appliedVersion: number }[];
26
+ readonly lastReconcile: () => ReconcileResult | undefined;
27
+ readonly lastMaintenance: () => MaintenanceResult | undefined;
28
+ /** Presence only — never the value. */
29
+ readonly hasPushToken: () => boolean;
30
+ }
31
+
32
+ export function createSnapshot(deps: SnapshotDependencies) {
33
+ return {
34
+ async capture(): Promise<StateSnapshot> {
35
+ const [permissions, restrictions, pending] = await Promise.all([
36
+ deps.permissions(),
37
+ deps.restrictions(),
38
+ deps.engine.getPending(),
39
+ ]);
40
+
41
+ const maintenance = deps.lastMaintenance();
42
+ const discrepancy: PendingDiscrepancy | undefined = maintenance?.discrepancy;
43
+
44
+ return {
45
+ permissions,
46
+ restrictions,
47
+ tones: deps.appliedTones().map(tone => ({
48
+ id: tone.toneId,
49
+ appliedVersion: tone.appliedVersion,
50
+ })),
51
+ pendingCount: pending.length,
52
+ pending: pending as readonly PendingEntry[],
53
+ lastReconcile: deps.lastReconcile(),
54
+ lastMaintenance: maintenance,
55
+ discrepancy,
56
+ stateHealth: deps.state.health(),
57
+ pushTokenPresent: deps.hasPushToken(),
58
+ capturedAt: deps.now(),
59
+ };
60
+ },
61
+ };
62
+ }
63
+
64
+ export type Snapshot = ReturnType<typeof createSnapshot>;