@chipmobilesdk/rn-notification 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +257 -166
  2. package/README.md +420 -410
  3. package/package.json +61 -75
  4. package/skills/sdk-rn-notification/SKILL.md +172 -173
  5. package/src/channel/migrate.ts +285 -289
  6. package/src/channel/tone-map.ts +130 -130
  7. package/src/channel/validate-tone.ts +61 -61
  8. package/src/contract/channel.ts +192 -188
  9. package/src/contract/diagnostics.ts +102 -102
  10. package/src/contract/errors.ts +142 -134
  11. package/src/contract/index.ts +10 -10
  12. package/src/contract/interaction.ts +62 -62
  13. package/src/contract/notification.ts +118 -118
  14. package/src/contract/permission.ts +55 -60
  15. package/src/contract/reconcile.ts +88 -88
  16. package/src/contract/recurrence.ts +129 -129
  17. package/src/diagnostics/burst.ts +101 -101
  18. package/src/diagnostics/events.ts +81 -72
  19. package/src/diagnostics/redact.ts +55 -72
  20. package/src/diagnostics/snapshot.ts +100 -64
  21. package/src/engine/adapter.ts +172 -172
  22. package/src/engine/codec.ts +89 -89
  23. package/src/engine/notify-kit-engine.ts +715 -707
  24. package/src/index.ts +208 -208
  25. package/src/initialize.ts +247 -236
  26. package/src/interaction/index.ts +146 -146
  27. package/src/maintenance/cycle.ts +150 -154
  28. package/src/maintenance/permission-refresh.ts +39 -41
  29. package/src/maintenance/reanchor.ts +101 -101
  30. package/src/maintenance/toggle.ts +66 -64
  31. package/src/maintenance/verify.ts +83 -83
  32. package/src/maintenance/window-refresh.ts +64 -64
  33. package/src/permission/index.ts +183 -183
  34. package/src/permission/state.ts +77 -77
  35. package/src/push/envelope.ts +165 -165
  36. package/src/push/fcm-provider.ts +150 -146
  37. package/src/push/gate.ts +190 -190
  38. package/src/push/index.ts +33 -29
  39. package/src/push/instructions.ts +114 -109
  40. package/src/push/provider-adapter.ts +54 -54
  41. package/src/push/signout.ts +65 -65
  42. package/src/reconcile/chunk.ts +53 -53
  43. package/src/reconcile/diff.ts +142 -145
  44. package/src/reconcile/duplicates.ts +73 -73
  45. package/src/reconcile/index.ts +180 -180
  46. package/src/reconcile/truncate.ts +56 -56
  47. package/src/recurrence/clock-guard.ts +73 -71
  48. package/src/recurrence/expand.ts +326 -319
  49. package/src/recurrence/index.ts +270 -259
  50. package/src/repeat/expiry.ts +53 -55
  51. package/src/repeat/index.ts +54 -54
  52. package/src/repeat/marker.ts +72 -72
  53. package/src/repeat/place.ts +73 -73
  54. package/src/schedule/anchor.ts +294 -267
  55. package/src/schedule/index.ts +174 -181
  56. package/src/schedule/partition.ts +88 -85
  57. package/src/schedule/resolve-moment.ts +80 -80
  58. package/src/schedule/validate-content.ts +111 -111
  59. package/src/schedule/validate-identifier.ts +126 -126
  60. package/src/schedule/writer.ts +55 -55
  61. package/src/state/caps.ts +71 -71
  62. package/src/state/dedup-ledger.ts +35 -35
  63. package/src/state/interaction-buffer.ts +101 -73
  64. package/src/state/mmkv-store.ts +92 -75
  65. package/src/state/permission-history.ts +41 -41
  66. package/src/state/record.ts +270 -263
  67. package/src/state/reset.ts +41 -41
  68. package/src/state/series-ledger.ts +91 -91
  69. package/src/state/store.ts +43 -40
  70. package/src/testing/conformance/index.ts +258 -248
  71. package/src/testing/fake-engine.ts +462 -468
  72. package/src/testing/fake-provider.ts +126 -129
  73. package/src/testing/faults.ts +72 -72
  74. package/src/testing/index.ts +23 -28
  75. package/src/testing/measure.ts +337 -337
@@ -1,41 +1,39 @@
1
- /**
2
- * The permission re-read maintenance step.
3
- *
4
- * The user can change permission in system settings while the app is
5
- * backgrounded, and the platform does not tell anyone. Re-reading on foreground
6
- * is the only way a consumer's interface avoids showing a stale answer.
7
- *
8
- * Runs last (order 30): permission state does not affect what the earlier steps
9
- * expand or re-anchor, and reading it after they have run means the report
10
- * reflects the device as the cycle leaves it.
11
- */
12
-
13
- import type { MaintenanceStep, MaintenanceContext, StepOutcome } from './cycle';
14
- import type { Permissions } from '../permission';
15
-
16
- export const PERMISSION_REFRESH_ORDER = 30;
17
-
18
- export interface PermissionRefreshDependencies {
19
- readonly permissions: Permissions;
20
- }
21
-
22
- export function createPermissionRefreshStep(
23
- deps: PermissionRefreshDependencies,
24
- ): MaintenanceStep {
25
- return {
26
- name: 'permission-refresh',
27
- order: PERMISSION_REFRESH_ORDER,
28
-
29
- async run(_context: MaintenanceContext): Promise<StepOutcome> {
30
- const { changed } = await deps.permissions.refresh();
31
-
32
- return {
33
- // Reading permission is not a platform mutation, so it never counts
34
- // toward the zero-operation guarantee.
35
- operationCount: 0,
36
- permissionChanged: changed,
37
- cancelledIds: [],
38
- };
39
- },
40
- };
41
- }
1
+ /**
2
+ * The permission re-read maintenance step.
3
+ *
4
+ * The user can change permission in system settings while the app is
5
+ * backgrounded, and the platform does not tell anyone. Re-reading on foreground
6
+ * is the only way a consumer's interface avoids showing a stale answer.
7
+ *
8
+ * Runs last (order 30): permission state does not affect what the earlier steps
9
+ * expand or re-anchor, and reading it after they have run means the report
10
+ * reflects the device as the cycle leaves it.
11
+ */
12
+
13
+ import type { MaintenanceStep, MaintenanceContext, StepOutcome } from './cycle';
14
+ import type { Permissions } from '../permission';
15
+
16
+ export const PERMISSION_REFRESH_ORDER = 30;
17
+
18
+ export interface PermissionRefreshDependencies {
19
+ readonly permissions: Permissions;
20
+ }
21
+
22
+ export function createPermissionRefreshStep(deps: PermissionRefreshDependencies): MaintenanceStep {
23
+ return {
24
+ name: 'permission-refresh',
25
+ order: PERMISSION_REFRESH_ORDER,
26
+
27
+ async run(_context: MaintenanceContext): Promise<StepOutcome> {
28
+ const { changed } = await deps.permissions.refresh();
29
+
30
+ return {
31
+ // Reading permission is not a platform mutation, so it never counts
32
+ // toward the zero-operation guarantee.
33
+ operationCount: 0,
34
+ permissionChanged: changed,
35
+ cancelledIds: [],
36
+ };
37
+ },
38
+ };
39
+ }
@@ -1,101 +1,101 @@
1
- /**
2
- * The re-anchoring maintenance step.
3
- *
4
- * Registers itself with the maintenance registry rather than being wired into
5
- * a fixed sequence, which is what keeps scheduling independent of
6
- * reconciliation, recurrence, and permissions despite all four contributing a
7
- * step.
8
- *
9
- * Runs early (order 10) because verification compares firing instants: if
10
- * verification ran first it would compare against instants re-anchoring is
11
- * about to rewrite, and report discrepancies that do not exist.
12
- */
13
-
14
- import type { MaintenanceStep, MaintenanceContext, StepOutcome } from './cycle';
15
- import type { NotificationEngineAdapter } from '../engine/adapter';
16
- import type { ResolvedTone } from '../contract/channel';
17
- import { planReAnchor, type TimeZoneReader } from '../schedule/anchor';
18
- import { createScheduleWriter } from '../schedule/writer';
19
-
20
- export interface ReAnchorStepDependencies {
21
- readonly engine: NotificationEngineAdapter;
22
- readonly timeZone: TimeZoneReader;
23
- readonly resolveTone: (toneId: string) => ResolvedTone | undefined;
24
- readonly canScheduleExactly: () => boolean;
25
- readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
26
- }
27
-
28
- export const RE_ANCHOR_ORDER = 10;
29
-
30
- export function createReAnchorStep(deps: ReAnchorStepDependencies): MaintenanceStep {
31
- // Through the shared writer so a repeating alert's stop marker moves with it;
32
- // written directly, the marker kept the old instant and cut the alert short
33
- // or let it run past its bound.
34
- const writer = createScheduleWriter(deps.engine);
35
-
36
- return {
37
- name: 'reanchor',
38
- order: RE_ANCHOR_ORDER,
39
-
40
- async run(_context: MaintenanceContext): Promise<StepOutcome> {
41
- const zone = deps.timeZone();
42
- const pending = await deps.engine.getPending();
43
- const plan = planReAnchor(pending, zone);
44
-
45
- // The no-change path costs one comparison per entry and issues zero
46
- // platform operations. That is what makes re-anchoring idempotent.
47
- if (plan.rewrites.length === 0) {
48
- return { operationCount: 0, reAnchored: 0, timeZoneChanged: false };
49
- }
50
-
51
- deps.emit?.(
52
- 'REANCHOR',
53
- `Device time zone changed from ${plan.fromZone ?? 'unknown'} to ${plan.toZone}; re-anchoring ${plan.rewrites.length} wall-clock notification(s).`,
54
- { from: plan.fromZone, to: plan.toZone, count: plan.rewrites.length },
55
- );
56
-
57
- let operationCount = 0;
58
-
59
- for (const { entry, resolvedAt } of plan.rewrites) {
60
- const tone = deps.resolveTone(entry.tone);
61
- if (tone === undefined) {
62
- // A tone that no longer exists means this entry belongs to a
63
- // configuration the app has since dropped. Leaving it alone is
64
- // correct: re-anchoring must never repair, only preserve.
65
- continue;
66
- }
67
-
68
- await writer.write({
69
- request: {
70
- id: entry.id,
71
- content: entry.content,
72
- tone: entry.tone,
73
- moment: { at: resolvedAt, anchoring: 'wallClock' },
74
- routing: entry.routing,
75
- actions: entry.actions,
76
- ownerTag: entry.ownerTag,
77
- groupTag: entry.groupTag,
78
- },
79
- resolvedAt,
80
- tone,
81
- // Re-anchored against the zone the device is in now — this is the
82
- // value that stops the entry being reported as stale next time.
83
- anchoringGeneration: zone,
84
- approximate: !deps.canScheduleExactly(),
85
- seriesId: entry.seriesId,
86
- pushCreated: entry.pushCreated,
87
- });
88
- operationCount += 1;
89
- }
90
-
91
- return {
92
- operationCount,
93
- reAnchored: operationCount,
94
- timeZoneChanged: true,
95
- // Re-anchoring replaces in place and cancels nothing, so it can never
96
- // touch a notification outside what it was handed.
97
- cancelledIds: [],
98
- };
99
- },
100
- };
101
- }
1
+ /**
2
+ * The re-anchoring maintenance step.
3
+ *
4
+ * Registers itself with the maintenance registry rather than being wired into
5
+ * a fixed sequence, which is what keeps scheduling independent of
6
+ * reconciliation, recurrence, and permissions despite all four contributing a
7
+ * step.
8
+ *
9
+ * Runs early (order 10) because verification compares firing instants: if
10
+ * verification ran first it would compare against instants re-anchoring is
11
+ * about to rewrite, and report discrepancies that do not exist.
12
+ */
13
+
14
+ import type { MaintenanceStep, MaintenanceContext, StepOutcome } from './cycle';
15
+ import type { NotificationEngineAdapter } from '../engine/adapter';
16
+ import type { ResolvedTone } from '../contract/channel';
17
+ import { planReAnchor, type TimeZoneReader } from '../schedule/anchor';
18
+ import { createScheduleWriter } from '../schedule/writer';
19
+
20
+ export interface ReAnchorStepDependencies {
21
+ readonly engine: NotificationEngineAdapter;
22
+ readonly timeZone: TimeZoneReader;
23
+ readonly resolveTone: (toneId: string) => ResolvedTone | undefined;
24
+ readonly canScheduleExactly: () => boolean;
25
+ readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
26
+ }
27
+
28
+ export const RE_ANCHOR_ORDER = 10;
29
+
30
+ export function createReAnchorStep(deps: ReAnchorStepDependencies): MaintenanceStep {
31
+ // Through the shared writer so a repeating alert's stop marker moves with it;
32
+ // written directly, the marker kept the old instant and cut the alert short
33
+ // or let it run past its bound.
34
+ const writer = createScheduleWriter(deps.engine);
35
+
36
+ return {
37
+ name: 'reanchor',
38
+ order: RE_ANCHOR_ORDER,
39
+
40
+ async run(_context: MaintenanceContext): Promise<StepOutcome> {
41
+ const zone = deps.timeZone();
42
+ const pending = await deps.engine.getPending();
43
+ const plan = planReAnchor(pending, zone);
44
+
45
+ // The no-change path costs one comparison per entry and issues zero
46
+ // platform operations. That is what makes re-anchoring idempotent.
47
+ if (plan.rewrites.length === 0) {
48
+ return { operationCount: 0, reAnchored: 0, timeZoneChanged: false };
49
+ }
50
+
51
+ deps.emit?.(
52
+ 'REANCHOR',
53
+ `Device time zone changed from ${plan.fromZone ?? 'unknown'} to ${plan.toZone}; re-anchoring ${plan.rewrites.length} wall-clock notification(s).`,
54
+ { from: plan.fromZone, to: plan.toZone, count: plan.rewrites.length },
55
+ );
56
+
57
+ let operationCount = 0;
58
+
59
+ for (const { entry, resolvedAt } of plan.rewrites) {
60
+ const tone = deps.resolveTone(entry.tone);
61
+ if (tone === undefined) {
62
+ // A tone that no longer exists means this entry belongs to a
63
+ // configuration the app has since dropped. Leaving it alone is
64
+ // correct: re-anchoring must never repair, only preserve.
65
+ continue;
66
+ }
67
+
68
+ await writer.write({
69
+ request: {
70
+ id: entry.id,
71
+ content: entry.content,
72
+ tone: entry.tone,
73
+ moment: { at: resolvedAt, anchoring: 'wallClock' },
74
+ routing: entry.routing,
75
+ actions: entry.actions,
76
+ ownerTag: entry.ownerTag,
77
+ groupTag: entry.groupTag,
78
+ },
79
+ resolvedAt,
80
+ tone,
81
+ // Re-anchored against the zone the device is in now — this is the
82
+ // value that stops the entry being reported as stale next time.
83
+ anchoringGeneration: zone,
84
+ approximate: !deps.canScheduleExactly(),
85
+ seriesId: entry.seriesId,
86
+ pushCreated: entry.pushCreated,
87
+ });
88
+ operationCount += 1;
89
+ }
90
+
91
+ return {
92
+ operationCount,
93
+ reAnchored: operationCount,
94
+ timeZoneChanged: true,
95
+ // Re-anchoring replaces in place and cancels nothing, so it can never
96
+ // touch a notification outside what it was handed.
97
+ cancelledIds: [],
98
+ };
99
+ },
100
+ };
101
+ }
@@ -1,64 +1,66 @@
1
- /**
2
- * The automatic-maintenance switch.
3
- *
4
- * A package that acts on its own must offer a way to stop it — and a consumer
5
- * that takes control must not lose a feature as the price. Disabling automatic
6
- * maintenance leaves every operation explicitly invocable through
7
- * `runMaintenance()`, producing identical device state.
8
- */
9
-
10
- import type { MaintenanceResult } from '../contract/reconcile';
11
- import type { MaintenanceContext, MaintenanceRegistry, MaintenanceTrigger } from './cycle';
12
-
13
- export interface MaintenanceControllerOptions {
14
- readonly registry: MaintenanceRegistry;
15
- readonly now: () => number;
16
- /**
17
- * Identifiers the package owns at the start of a cycle. May read the device,
18
- * since ownership is decided per pending occurrence, not per series.
19
- */
20
- readonly ownedIds: () => ReadonlySet<string> | Promise<ReadonlySet<string>>;
21
- readonly enabled?: boolean;
22
- }
23
-
24
- export class MaintenanceController {
25
- private enabled: boolean;
26
- private readonly registry: MaintenanceRegistry;
27
- private readonly now: () => number;
28
- private readonly ownedIds: () => ReadonlySet<string> | Promise<ReadonlySet<string>>;
29
-
30
- constructor(options: MaintenanceControllerOptions) {
31
- this.registry = options.registry;
32
- this.now = options.now;
33
- this.ownedIds = options.ownedIds;
34
- this.enabled = options.enabled ?? true;
35
- }
36
-
37
- get isEnabled(): boolean {
38
- return this.enabled;
39
- }
40
-
41
- setEnabled(enabled: boolean): void {
42
- this.enabled = enabled;
43
- }
44
-
45
- /** Called by lifecycle observers. A no-op when automatic maintenance is off. */
46
- async runAutomatic(trigger: Exclude<MaintenanceTrigger, 'explicit'>): Promise<MaintenanceResult | undefined> {
47
- if (!this.enabled) return undefined;
48
- return this.execute(trigger);
49
- }
50
-
51
- /** Always runs, regardless of the switch. This is what preserves capability parity. */
52
- async runExplicit(): Promise<MaintenanceResult> {
53
- return this.execute('explicit');
54
- }
55
-
56
- private async execute(trigger: MaintenanceTrigger): Promise<MaintenanceResult> {
57
- const context: MaintenanceContext = {
58
- trigger,
59
- now: this.now(),
60
- ownedIds: await this.ownedIds(),
61
- };
62
- return this.registry.run(context);
63
- }
64
- }
1
+ /**
2
+ * The automatic-maintenance switch.
3
+ *
4
+ * A package that acts on its own must offer a way to stop it — and a consumer
5
+ * that takes control must not lose a feature as the price. Disabling automatic
6
+ * maintenance leaves every operation explicitly invocable through
7
+ * `runMaintenance()`, producing identical device state.
8
+ */
9
+
10
+ import type { MaintenanceResult } from '../contract/reconcile';
11
+ import type { MaintenanceContext, MaintenanceRegistry, MaintenanceTrigger } from './cycle';
12
+
13
+ export interface MaintenanceControllerOptions {
14
+ readonly registry: MaintenanceRegistry;
15
+ readonly now: () => number;
16
+ /**
17
+ * Identifiers the package owns at the start of a cycle. May read the device,
18
+ * since ownership is decided per pending occurrence, not per series.
19
+ */
20
+ readonly ownedIds: () => ReadonlySet<string> | Promise<ReadonlySet<string>>;
21
+ readonly enabled?: boolean;
22
+ }
23
+
24
+ export class MaintenanceController {
25
+ private enabled: boolean;
26
+ private readonly registry: MaintenanceRegistry;
27
+ private readonly now: () => number;
28
+ private readonly ownedIds: () => ReadonlySet<string> | Promise<ReadonlySet<string>>;
29
+
30
+ constructor(options: MaintenanceControllerOptions) {
31
+ this.registry = options.registry;
32
+ this.now = options.now;
33
+ this.ownedIds = options.ownedIds;
34
+ this.enabled = options.enabled ?? true;
35
+ }
36
+
37
+ get isEnabled(): boolean {
38
+ return this.enabled;
39
+ }
40
+
41
+ setEnabled(enabled: boolean): void {
42
+ this.enabled = enabled;
43
+ }
44
+
45
+ /** Called by lifecycle observers. A no-op when automatic maintenance is off. */
46
+ async runAutomatic(
47
+ trigger: Exclude<MaintenanceTrigger, 'explicit'>,
48
+ ): Promise<MaintenanceResult | undefined> {
49
+ if (!this.enabled) return undefined;
50
+ return this.execute(trigger);
51
+ }
52
+
53
+ /** Always runs, regardless of the switch. This is what preserves capability parity. */
54
+ async runExplicit(): Promise<MaintenanceResult> {
55
+ return this.execute('explicit');
56
+ }
57
+
58
+ private async execute(trigger: MaintenanceTrigger): Promise<MaintenanceResult> {
59
+ const context: MaintenanceContext = {
60
+ trigger,
61
+ now: this.now(),
62
+ ownedIds: await this.ownedIds(),
63
+ };
64
+ return this.registry.run(context);
65
+ }
66
+ }
@@ -1,83 +1,83 @@
1
- /**
2
- * Pending-set verification.
3
- *
4
- * Engine boot restoration coverage varies by manufacturer, so after a restart
5
- * the device may hold fewer notifications than the package expects. This step
6
- * notices that.
7
- *
8
- * It **detects and reports only**. It never re-registers, cancels, or repairs
9
- * anything — the package deliberately does not persist a desired set, so it
10
- * cannot tell a restoration gap from an intentional consumer change. Guessing
11
- * would mean either resurrecting notifications the user cancelled or deleting
12
- * ones they still want. Repair is consumer-invoked reconciliation.
13
- *
14
- * Runs at order 20, after re-anchoring (order 10): re-anchoring rewrites firing
15
- * instants, and verifying first would compare against instants that are about
16
- * to change.
17
- */
18
-
19
- import type { MaintenanceStep, MaintenanceContext, StepOutcome } from './cycle';
20
- import type { NotificationEngineAdapter } from '../engine/adapter';
21
- import type { PendingDiscrepancy } from '../contract/reconcile';
22
- import { DIAGNOSTIC_CODES } from '../contract/errors';
23
-
24
- export const VERIFY_ORDER = 20;
25
-
26
- export interface VerifyStepDependencies {
27
- readonly engine: NotificationEngineAdapter;
28
- /**
29
- * Identifiers the package expects the device to hold — the union of series
30
- * occurrences and the last reconciliation's result. Absent expectations mean
31
- * nothing to verify, which is different from expecting nothing.
32
- */
33
- readonly expectedIds: () => ReadonlySet<string> | undefined;
34
- readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
35
- }
36
-
37
- export function createVerifyStep(deps: VerifyStepDependencies): MaintenanceStep {
38
- return {
39
- name: 'verify',
40
- order: VERIFY_ORDER,
41
-
42
- async run(context: MaintenanceContext): Promise<StepOutcome> {
43
- const expected = deps.expectedIds();
44
-
45
- // No recorded expectation is not the same as expecting an empty device.
46
- // Reporting every pending entry as "unrecognized" on a fresh install
47
- // would be noise, not a finding.
48
- if (expected === undefined) {
49
- return { operationCount: 0 };
50
- }
51
-
52
- const pending = await deps.engine.getPending();
53
- const actual = new Set(pending.map(entry => entry.id));
54
-
55
- const missing = [...expected].filter(id => !actual.has(id)).sort();
56
- const unrecognized = [...actual].filter(id => !expected.has(id)).sort();
57
-
58
- if (missing.length === 0 && unrecognized.length === 0) {
59
- return { operationCount: 0 };
60
- }
61
-
62
- const discrepancy: PendingDiscrepancy = {
63
- missing,
64
- unrecognized,
65
- detectedAt: context.now,
66
- };
67
-
68
- deps.emit?.(
69
- DIAGNOSTIC_CODES.PENDING_SET_DISCREPANCY,
70
- `Pending set differs from expectation: ${missing.length} expected but absent, ${unrecognized.length} present but unrecognized. ` +
71
- 'Reported only — run reconciliation to repair, since the package cannot distinguish a restoration gap from an intentional change.',
72
- { missing: missing.length, unrecognized: unrecognized.length },
73
- );
74
-
75
- return {
76
- operationCount: 0,
77
- discrepancy,
78
- // Verification repairs nothing, so it cancels nothing.
79
- cancelledIds: [],
80
- };
81
- },
82
- };
83
- }
1
+ /**
2
+ * Pending-set verification.
3
+ *
4
+ * Engine boot restoration coverage varies by manufacturer, so after a restart
5
+ * the device may hold fewer notifications than the package expects. This step
6
+ * notices that.
7
+ *
8
+ * It **detects and reports only**. It never re-registers, cancels, or repairs
9
+ * anything — the package deliberately does not persist a desired set, so it
10
+ * cannot tell a restoration gap from an intentional consumer change. Guessing
11
+ * would mean either resurrecting notifications the user cancelled or deleting
12
+ * ones they still want. Repair is consumer-invoked reconciliation.
13
+ *
14
+ * Runs at order 20, after re-anchoring (order 10): re-anchoring rewrites firing
15
+ * instants, and verifying first would compare against instants that are about
16
+ * to change.
17
+ */
18
+
19
+ import type { MaintenanceStep, MaintenanceContext, StepOutcome } from './cycle';
20
+ import type { NotificationEngineAdapter } from '../engine/adapter';
21
+ import type { PendingDiscrepancy } from '../contract/reconcile';
22
+ import { DIAGNOSTIC_CODES } from '../contract/errors';
23
+
24
+ export const VERIFY_ORDER = 20;
25
+
26
+ export interface VerifyStepDependencies {
27
+ readonly engine: NotificationEngineAdapter;
28
+ /**
29
+ * Identifiers the package expects the device to hold — the union of series
30
+ * occurrences and the last reconciliation's result. Absent expectations mean
31
+ * nothing to verify, which is different from expecting nothing.
32
+ */
33
+ readonly expectedIds: () => ReadonlySet<string> | undefined;
34
+ readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
35
+ }
36
+
37
+ export function createVerifyStep(deps: VerifyStepDependencies): MaintenanceStep {
38
+ return {
39
+ name: 'verify',
40
+ order: VERIFY_ORDER,
41
+
42
+ async run(context: MaintenanceContext): Promise<StepOutcome> {
43
+ const expected = deps.expectedIds();
44
+
45
+ // No recorded expectation is not the same as expecting an empty device.
46
+ // Reporting every pending entry as "unrecognized" on a fresh install
47
+ // would be noise, not a finding.
48
+ if (expected === undefined) {
49
+ return { operationCount: 0 };
50
+ }
51
+
52
+ const pending = await deps.engine.getPending();
53
+ const actual = new Set(pending.map(entry => entry.id));
54
+
55
+ const missing = [...expected].filter(id => !actual.has(id)).sort();
56
+ const unrecognized = [...actual].filter(id => !expected.has(id)).sort();
57
+
58
+ if (missing.length === 0 && unrecognized.length === 0) {
59
+ return { operationCount: 0 };
60
+ }
61
+
62
+ const discrepancy: PendingDiscrepancy = {
63
+ missing,
64
+ unrecognized,
65
+ detectedAt: context.now,
66
+ };
67
+
68
+ deps.emit?.(
69
+ DIAGNOSTIC_CODES.PENDING_SET_DISCREPANCY,
70
+ `Pending set differs from expectation: ${missing.length} expected but absent, ${unrecognized.length} present but unrecognized. ` +
71
+ 'Reported only — run reconciliation to repair, since the package cannot distinguish a restoration gap from an intentional change.',
72
+ { missing: missing.length, unrecognized: unrecognized.length },
73
+ );
74
+
75
+ return {
76
+ operationCount: 0,
77
+ discrepancy,
78
+ // Verification repairs nothing, so it cancels nothing.
79
+ cancelledIds: [],
80
+ };
81
+ },
82
+ };
83
+ }