@chipmobilesdk/rn-notification 0.1.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 (66) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +306 -0
  3. package/package.json +73 -0
  4. package/src/channel/migrate.ts +211 -0
  5. package/src/channel/tone-map.ts +90 -0
  6. package/src/contract/channel.ts +89 -0
  7. package/src/contract/diagnostics.ts +102 -0
  8. package/src/contract/errors.ts +123 -0
  9. package/src/contract/index.ts +10 -0
  10. package/src/contract/interaction.ts +62 -0
  11. package/src/contract/notification.ts +118 -0
  12. package/src/contract/permission.ts +60 -0
  13. package/src/contract/reconcile.ts +88 -0
  14. package/src/contract/recurrence.ts +123 -0
  15. package/src/diagnostics/burst.ts +101 -0
  16. package/src/diagnostics/events.ts +72 -0
  17. package/src/diagnostics/redact.ts +72 -0
  18. package/src/diagnostics/snapshot.ts +64 -0
  19. package/src/engine/adapter.ts +79 -0
  20. package/src/engine/notify-kit-engine.ts +535 -0
  21. package/src/index.ts +173 -0
  22. package/src/initialize.ts +174 -0
  23. package/src/interaction/index.ts +146 -0
  24. package/src/maintenance/cycle.ts +147 -0
  25. package/src/maintenance/permission-refresh.ts +41 -0
  26. package/src/maintenance/reanchor.ts +95 -0
  27. package/src/maintenance/toggle.ts +60 -0
  28. package/src/maintenance/verify.ts +83 -0
  29. package/src/maintenance/window-refresh.ts +61 -0
  30. package/src/permission/index.ts +183 -0
  31. package/src/permission/state.ts +77 -0
  32. package/src/push/envelope.ts +165 -0
  33. package/src/push/fcm-provider.ts +146 -0
  34. package/src/push/gate.ts +190 -0
  35. package/src/push/index.ts +29 -0
  36. package/src/push/instructions.ts +109 -0
  37. package/src/push/provider-adapter.ts +54 -0
  38. package/src/push/signout.ts +65 -0
  39. package/src/reconcile/chunk.ts +53 -0
  40. package/src/reconcile/diff.ts +145 -0
  41. package/src/reconcile/duplicates.ts +73 -0
  42. package/src/reconcile/index.ts +175 -0
  43. package/src/reconcile/truncate.ts +56 -0
  44. package/src/recurrence/clock-guard.ts +71 -0
  45. package/src/recurrence/expand.ts +292 -0
  46. package/src/recurrence/index.ts +177 -0
  47. package/src/schedule/anchor.ts +267 -0
  48. package/src/schedule/index.ts +173 -0
  49. package/src/schedule/partition.ts +85 -0
  50. package/src/schedule/resolve-moment.ts +80 -0
  51. package/src/schedule/validate-content.ts +111 -0
  52. package/src/schedule/validate-identifier.ts +114 -0
  53. package/src/state/caps.ts +71 -0
  54. package/src/state/dedup-ledger.ts +39 -0
  55. package/src/state/interaction-buffer.ts +73 -0
  56. package/src/state/mmkv-store.ts +71 -0
  57. package/src/state/permission-history.ts +41 -0
  58. package/src/state/record.ts +246 -0
  59. package/src/state/reset.ts +41 -0
  60. package/src/state/series-ledger.ts +84 -0
  61. package/src/state/store.ts +40 -0
  62. package/src/testing/conformance/index.ts +194 -0
  63. package/src/testing/fake-engine.ts +284 -0
  64. package/src/testing/fake-provider.ts +129 -0
  65. package/src/testing/faults.ts +72 -0
  66. package/src/testing/index.ts +26 -0
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Package initialization.
3
+ *
4
+ * Composes the parts that must be in place before the first schedule call:
5
+ * validate configuration, load persisted state, apply tone declarations, run
6
+ * any pending channel migration, and start the maintenance registry.
7
+ *
8
+ * Budgeted at 100 ms of app startup, which is why state loading is synchronous
9
+ * and tone declaration is the only platform round-trip.
10
+ */
11
+
12
+ import type { InitializeResult, NotificationConfig } from './contract/diagnostics';
13
+ import { DEFAULT_LOOKAHEAD, LOOKAHEAD_BOUNDS, type LookaheadConfig } from './contract/recurrence';
14
+ import { DIAGNOSTIC_CODES, NotificationError } from './contract/errors';
15
+ import { DiagnosticEmitter } from './diagnostics/events';
16
+ import { MaintenanceRegistry } from './maintenance/cycle';
17
+ import { MaintenanceController } from './maintenance/toggle';
18
+ import { StateManager } from './state/record';
19
+ import type { StateStore } from './state/store';
20
+ import type { NotificationEngineAdapter } from './engine/adapter';
21
+
22
+ export interface RuntimeDependencies {
23
+ readonly engine: NotificationEngineAdapter;
24
+ readonly store: StateStore;
25
+ readonly now?: () => number;
26
+ }
27
+
28
+ /** Everything assembled at initialization, shared by the operation modules. */
29
+ export interface NotificationRuntime {
30
+ readonly engine: NotificationEngineAdapter;
31
+ readonly state: StateManager;
32
+ readonly emitter: DiagnosticEmitter;
33
+ readonly registry: MaintenanceRegistry;
34
+ readonly maintenance: MaintenanceController;
35
+ readonly lookahead: LookaheadConfig;
36
+ readonly config: NotificationConfig;
37
+ readonly now: () => number;
38
+ }
39
+
40
+ /**
41
+ * Clamps the lookahead into its documented bounds.
42
+ *
43
+ * Bounded on adjustment because the platform ceiling is not negotiable: a
44
+ * consumer asking for a five-year window would silently exhaust the 64-entry
45
+ * iOS ceiling on the first frequent series.
46
+ */
47
+ export function resolveLookahead(partial: Partial<LookaheadConfig> | undefined): LookaheadConfig {
48
+ const days = partial?.days ?? DEFAULT_LOOKAHEAD.days;
49
+ const occurrencesPerSeries = partial?.occurrencesPerSeries ?? DEFAULT_LOOKAHEAD.occurrencesPerSeries;
50
+
51
+ return {
52
+ days: Math.min(Math.max(days, LOOKAHEAD_BOUNDS.minDays), LOOKAHEAD_BOUNDS.maxDays),
53
+ occurrencesPerSeries: Math.min(
54
+ Math.max(occurrencesPerSeries, LOOKAHEAD_BOUNDS.minOccurrences),
55
+ LOOKAHEAD_BOUNDS.maxOccurrences,
56
+ ),
57
+ };
58
+ }
59
+
60
+ /** Fails before any platform call, so a bad configuration never half-applies. */
61
+ export function validateConfig(config: NotificationConfig): void {
62
+ if (config.tones.length === 0) {
63
+ throw new NotificationError(
64
+ DIAGNOSTIC_CODES.UNDECLARED_TONE,
65
+ 'At least one tone must be declared before notifications can be scheduled.',
66
+ );
67
+ }
68
+
69
+ const seen = new Set<string>();
70
+ for (const tone of config.tones) {
71
+ if (seen.has(tone.id)) {
72
+ throw new NotificationError(
73
+ DIAGNOSTIC_CODES.UNDECLARED_TONE,
74
+ `Tone "${tone.id}" is declared more than once.`,
75
+ { toneId: tone.id },
76
+ );
77
+ }
78
+ seen.add(tone.id);
79
+
80
+ if (!Number.isInteger(tone.version) || tone.version < 1) {
81
+ throw new NotificationError(
82
+ DIAGNOSTIC_CODES.CHANNEL_MIGRATION_REQUIRED,
83
+ `Tone "${tone.id}" must carry an integer version of 1 or greater, because a channel configuration change is a migration rather than an assignment.`,
84
+ { toneId: tone.id, version: tone.version },
85
+ );
86
+ }
87
+ }
88
+
89
+ const groupIds = new Set((config.channelGroups ?? []).map(group => group.id));
90
+ for (const tone of config.tones) {
91
+ if (tone.groupId !== undefined && !groupIds.has(tone.groupId)) {
92
+ throw new NotificationError(
93
+ DIAGNOSTIC_CODES.UNDECLARED_TONE,
94
+ `Tone "${tone.id}" references undeclared channel group "${tone.groupId}".`,
95
+ { toneId: tone.id, groupId: tone.groupId },
96
+ );
97
+ }
98
+ }
99
+ }
100
+
101
+ export async function initialize(
102
+ config: NotificationConfig,
103
+ deps: RuntimeDependencies,
104
+ ): Promise<{ runtime: NotificationRuntime; result: InitializeResult }> {
105
+ const now = deps.now ?? (() => Date.now());
106
+ const startedAt = now();
107
+
108
+ validateConfig(config);
109
+
110
+ const emitter = new DiagnosticEmitter({
111
+ sink: config.eventSink,
112
+ verbose: config.verboseDiagnostics,
113
+ now,
114
+ });
115
+
116
+ const state = new StateManager({
117
+ store: deps.store,
118
+ now,
119
+ emit: event => emitter.emit(event.code, describeStateEvent(event.code), event.context),
120
+ });
121
+
122
+ if (config.channelGroups !== undefined && config.channelGroups.length > 0) {
123
+ await deps.engine.declareGroups(config.channelGroups);
124
+ }
125
+
126
+ const registry = new MaintenanceRegistry();
127
+ const maintenance = new MaintenanceController({
128
+ registry,
129
+ now,
130
+ // Owned identifiers come from the series ledger: those are the entries
131
+ // automatic maintenance created and may therefore retire.
132
+ ownedIds: () => new Set(state.read().seriesLedger.map(entry => entry.seriesId)),
133
+ enabled: config.automaticMaintenance ?? true,
134
+ });
135
+
136
+ const runtime: NotificationRuntime = {
137
+ engine: deps.engine,
138
+ state,
139
+ emitter,
140
+ registry,
141
+ maintenance,
142
+ lookahead: resolveLookahead(config.lookahead),
143
+ config,
144
+ now,
145
+ };
146
+
147
+ return {
148
+ runtime,
149
+ result: {
150
+ // Tone application and migration are performed by the channel module,
151
+ // which registers itself during User Story 5.
152
+ tonesApplied: 0,
153
+ migrationsRun: 0,
154
+ stateRebuilt: state.health().rebuilt,
155
+ automaticMaintenance: maintenance.isEnabled,
156
+ durationMs: now() - startedAt,
157
+ },
158
+ };
159
+ }
160
+
161
+ function describeStateEvent(code: string): string {
162
+ switch (code) {
163
+ case DIAGNOSTIC_CODES.STATE_CORRUPT_REBUILT:
164
+ return 'Persisted state was unreadable and has been rebuilt. Deduplication history and buffered interactions from before the loss are gone; scheduled notifications are unaffected.';
165
+ case DIAGNOSTIC_CODES.STATE_WRITE_FAILED:
166
+ return 'Persisted state could not be written. The affected guarantee degrades to this session only.';
167
+ case DIAGNOSTIC_CODES.STATE_CAP_EVICTED:
168
+ return 'A persisted collection reached its cap and the oldest entries were evicted.';
169
+ case DIAGNOSTIC_CODES.INTERACTION_BUFFER_EVICTED:
170
+ return 'Buffered interactions were evicted at the cap or retention limit.';
171
+ default:
172
+ return 'Persisted state event.';
173
+ }
174
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Interaction routing.
3
+ *
4
+ * One handler covers foreground, background, and cold start. The hard case is
5
+ * the last one: the tap happens before any JavaScript exists to receive it, so
6
+ * the event has to wait — durably — until a handler registers.
7
+ *
8
+ * Events are delivered in occurrence order and never dropped for want of a
9
+ * listener. After delivery the notification leaves the tray, because a
10
+ * notification the user has already acted on sitting there is noise.
11
+ */
12
+
13
+ import type {
14
+ InteractionEvent,
15
+ InteractionHandler,
16
+ TargetMissingSignal,
17
+ Unsubscribe,
18
+ } from '../contract/interaction';
19
+ import type { NotificationEngineAdapter } from '../engine/adapter';
20
+ import type { StateManager } from '../state/record';
21
+ import { DIAGNOSTIC_CODES } from '../contract/errors';
22
+ import { buffer, bufferedCount, drain } from '../state/interaction-buffer';
23
+
24
+ export interface InteractionDependencies {
25
+ readonly engine: NotificationEngineAdapter;
26
+ readonly state: StateManager;
27
+ readonly now: () => number;
28
+ readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
29
+ }
30
+
31
+ export function createInteractions(deps: InteractionDependencies) {
32
+ const emit = deps.emit ?? (() => {});
33
+ let handlers: InteractionHandler[] = [];
34
+ let engineSubscription: Unsubscribe | undefined;
35
+ /** Serializes delivery so rapid interactions arrive in order, never interleaved. */
36
+ let deliveryChain: Promise<void> = Promise.resolve();
37
+
38
+ const deliver = (event: InteractionEvent): void => {
39
+ deliveryChain = deliveryChain.then(async () => {
40
+ for (const handler of handlers) {
41
+ try {
42
+ await handler(event);
43
+ } catch {
44
+ // A consumer's handler failing must not stop the next event, or one
45
+ // bad tap would silently swallow every tap after it.
46
+ }
47
+ }
48
+
49
+ // Acted on, so it leaves the tray and stops making noise.
50
+ if (event.kind !== 'dismiss') {
51
+ await deps.engine.cancel(event.notificationId).catch(() => undefined);
52
+ }
53
+ });
54
+ };
55
+
56
+ const route = (event: InteractionEvent): void => {
57
+ if (handlers.length === 0) {
58
+ buffer(deps.state, event);
59
+ return;
60
+ }
61
+ deliver(event);
62
+ };
63
+
64
+ const ensureSubscribed = (): void => {
65
+ engineSubscription ??= deps.engine.subscribeToInteractions(route);
66
+ };
67
+
68
+ // Subscribing immediately rather than on first handler registration: an
69
+ // interaction arriving in the gap would otherwise be lost entirely, which is
70
+ // the exact failure the buffer exists to prevent.
71
+ ensureSubscribed();
72
+
73
+ return {
74
+ /**
75
+ * Registers a handler and immediately drains anything that was waiting.
76
+ *
77
+ * The drain is what makes a cold-start tap work: the event was persisted
78
+ * before this handler existed, possibly before this process existed.
79
+ */
80
+ onInteraction(handler: InteractionHandler): Unsubscribe {
81
+ handlers.push(handler);
82
+
83
+ for (const event of drain(deps.state)) {
84
+ deliver(event);
85
+ }
86
+
87
+ return () => {
88
+ handlers = handlers.filter(h => h !== handler);
89
+ };
90
+ },
91
+
92
+ /**
93
+ * Collects the interaction that launched the app, if any.
94
+ *
95
+ * Called once during startup. If no handler exists yet the event is
96
+ * buffered rather than discarded.
97
+ */
98
+ async collectInitial(): Promise<void> {
99
+ const initial = await deps.engine.takeInitialInteraction();
100
+ if (initial === undefined) return;
101
+ route(initial);
102
+ },
103
+
104
+ /**
105
+ * Signals that a routing target no longer exists.
106
+ *
107
+ * The consumer chooses its own fallback screen. No platform error is ever
108
+ * shown to the user for this — a notification pointing at deleted data is
109
+ * an ordinary condition, not a crash.
110
+ */
111
+ reportTargetMissing(signal: TargetMissingSignal): void {
112
+ emit(
113
+ DIAGNOSTIC_CODES.TARGET_MISSING,
114
+ 'The routing target no longer exists. The consumer chooses a fallback; no platform error is shown to the user.',
115
+ { notificationId: signal.notificationId },
116
+ );
117
+ },
118
+
119
+ /** Reports that a background action exceeded the platform's execution window. */
120
+ reportBackgroundTimeout(notificationId: string, windowMs: number): void {
121
+ emit(
122
+ DIAGNOSTIC_CODES.BACKGROUND_WINDOW_EXCEEDED,
123
+ `A background action exceeded the platform's ${windowMs} ms execution window and was cut short.`,
124
+ { notificationId, windowMs },
125
+ );
126
+ },
127
+
128
+ /** How many interactions are waiting for a handler. */
129
+ pendingCount(): number {
130
+ return bufferedCount(deps.state);
131
+ },
132
+
133
+ /** Settles outstanding deliveries. Exists for tests and orderly shutdown. */
134
+ async flush(): Promise<void> {
135
+ await deliveryChain;
136
+ },
137
+
138
+ dispose(): void {
139
+ engineSubscription?.();
140
+ engineSubscription = undefined;
141
+ handlers = [];
142
+ },
143
+ };
144
+ }
145
+
146
+ export type Interactions = ReturnType<typeof createInteractions>;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The automatic maintenance cycle.
3
+ *
4
+ * Covers exactly what only the package can know — window refresh, recurrence
5
+ * expansion, wall-clock re-anchoring, pending-set verification, permission
6
+ * re-reads. Full desired-set reconciliation is never automatic, because only
7
+ * the consumer knows what its data implies and a package that guessed would
8
+ * cancel notifications it does not understand.
9
+ *
10
+ * Steps register themselves here rather than being wired in a fixed order. That
11
+ * is what lets scheduling, reconciliation, recurrence, and permissions each
12
+ * contribute a step without importing one another.
13
+ *
14
+ * The rule that makes automatic work safe: a step may act only on notifications
15
+ * the package owns, and may never cancel anything else. It is enforced here
16
+ * rather than trusted to each step.
17
+ */
18
+
19
+ import type { MaintenanceResult, PendingDiscrepancy } from '../contract/reconcile';
20
+
21
+ export type MaintenanceTrigger =
22
+ | 'foreground'
23
+ | 'delivery'
24
+ | 'timeZoneChange'
25
+ | 'permissionChange'
26
+ | 'explicit';
27
+
28
+ export interface MaintenanceContext {
29
+ readonly trigger: MaintenanceTrigger;
30
+ readonly now: number;
31
+ /**
32
+ * Identifiers the package owns — series occurrences and entries it created.
33
+ * A step may cancel only within this set.
34
+ */
35
+ readonly ownedIds: ReadonlySet<string>;
36
+ }
37
+
38
+ export interface StepOutcome {
39
+ readonly occurrencesScheduled?: number;
40
+ readonly reAnchored?: number;
41
+ readonly windowRefreshed?: boolean;
42
+ readonly timeZoneChanged?: boolean;
43
+ readonly permissionChanged?: boolean;
44
+ readonly discrepancy?: PendingDiscrepancy;
45
+ /** Platform operations issued. Zero on a no-change pass. */
46
+ readonly operationCount?: number;
47
+ /** Identifiers this step cancelled, checked against `ownedIds`. */
48
+ readonly cancelledIds?: readonly string[];
49
+ }
50
+
51
+ export interface MaintenanceStep {
52
+ readonly name: string;
53
+ /** Lower runs first. Re-anchoring must precede verification, or verification sees stale instants. */
54
+ readonly order: number;
55
+ run(context: MaintenanceContext): Promise<StepOutcome>;
56
+ }
57
+
58
+ export class MaintenanceScopeViolation extends Error {
59
+ readonly step: string;
60
+ readonly offendingIds: readonly string[];
61
+
62
+ constructor(step: string, offendingIds: readonly string[]) {
63
+ super(
64
+ `Maintenance step "${step}" attempted to cancel ${offendingIds.length} notification(s) it does not own. ` +
65
+ 'Automatic maintenance may never cancel a directly-scheduled notification.',
66
+ );
67
+ this.name = 'MaintenanceScopeViolation';
68
+ this.step = step;
69
+ this.offendingIds = offendingIds;
70
+ Object.setPrototypeOf(this, MaintenanceScopeViolation.prototype);
71
+ }
72
+ }
73
+
74
+ export class MaintenanceRegistry {
75
+ private steps: MaintenanceStep[] = [];
76
+ private running = false;
77
+
78
+ register(step: MaintenanceStep): () => void {
79
+ this.steps.push(step);
80
+ this.steps.sort((a, b) => a.order - b.order);
81
+ return () => {
82
+ this.steps = this.steps.filter(s => s !== step);
83
+ };
84
+ }
85
+
86
+ get registered(): readonly string[] {
87
+ return this.steps.map(s => s.name);
88
+ }
89
+
90
+ /**
91
+ * Runs every registered step in order.
92
+ *
93
+ * Overlapping cycles are collapsed rather than queued: a second foreground
94
+ * event arriving mid-cycle wants the same work the running cycle is already
95
+ * doing.
96
+ */
97
+ async run(context: MaintenanceContext): Promise<MaintenanceResult> {
98
+ const started = context.now;
99
+
100
+ if (this.running) {
101
+ return {
102
+ ranAt: started,
103
+ windowRefreshed: false,
104
+ occurrencesScheduled: 0,
105
+ reAnchored: 0,
106
+ timeZoneChanged: false,
107
+ permissionChanged: false,
108
+ operationCount: 0,
109
+ durationMs: 0,
110
+ };
111
+ }
112
+
113
+ this.running = true;
114
+ const aggregate = {
115
+ windowRefreshed: false,
116
+ occurrencesScheduled: 0,
117
+ reAnchored: 0,
118
+ timeZoneChanged: false,
119
+ permissionChanged: false,
120
+ operationCount: 0,
121
+ };
122
+ let discrepancy: PendingDiscrepancy | undefined;
123
+
124
+ try {
125
+ for (const step of this.steps) {
126
+ const outcome = await step.run(context);
127
+
128
+ const offending = (outcome.cancelledIds ?? []).filter(id => !context.ownedIds.has(id));
129
+ if (offending.length > 0) {
130
+ throw new MaintenanceScopeViolation(step.name, offending);
131
+ }
132
+
133
+ aggregate.windowRefreshed ||= outcome.windowRefreshed ?? false;
134
+ aggregate.occurrencesScheduled += outcome.occurrencesScheduled ?? 0;
135
+ aggregate.reAnchored += outcome.reAnchored ?? 0;
136
+ aggregate.timeZoneChanged ||= outcome.timeZoneChanged ?? false;
137
+ aggregate.permissionChanged ||= outcome.permissionChanged ?? false;
138
+ aggregate.operationCount += outcome.operationCount ?? 0;
139
+ discrepancy ??= outcome.discrepancy;
140
+ }
141
+ } finally {
142
+ this.running = false;
143
+ }
144
+
145
+ return { ranAt: started, ...aggregate, discrepancy, durationMs: 0 };
146
+ }
147
+ }
@@ -0,0 +1,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(
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
+ }
@@ -0,0 +1,95 @@
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
+
19
+ export interface ReAnchorStepDependencies {
20
+ readonly engine: NotificationEngineAdapter;
21
+ readonly timeZone: TimeZoneReader;
22
+ readonly resolveTone: (toneId: string) => ResolvedTone | undefined;
23
+ readonly canScheduleExactly: () => boolean;
24
+ readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
25
+ }
26
+
27
+ export const RE_ANCHOR_ORDER = 10;
28
+
29
+ export function createReAnchorStep(deps: ReAnchorStepDependencies): MaintenanceStep {
30
+ return {
31
+ name: 'reanchor',
32
+ order: RE_ANCHOR_ORDER,
33
+
34
+ async run(_context: MaintenanceContext): Promise<StepOutcome> {
35
+ const zone = deps.timeZone();
36
+ const pending = await deps.engine.getPending();
37
+ const plan = planReAnchor(pending, zone);
38
+
39
+ // The no-change path costs one comparison per entry and issues zero
40
+ // platform operations. That is what makes re-anchoring idempotent.
41
+ if (plan.rewrites.length === 0) {
42
+ return { operationCount: 0, reAnchored: 0, timeZoneChanged: false };
43
+ }
44
+
45
+ deps.emit?.(
46
+ 'REANCHOR',
47
+ `Device time zone changed from ${plan.fromZone ?? 'unknown'} to ${plan.toZone}; re-anchoring ${plan.rewrites.length} wall-clock notification(s).`,
48
+ { from: plan.fromZone, to: plan.toZone, count: plan.rewrites.length },
49
+ );
50
+
51
+ let operationCount = 0;
52
+
53
+ for (const { entry, resolvedAt } of plan.rewrites) {
54
+ const tone = deps.resolveTone(entry.tone);
55
+ if (tone === undefined) {
56
+ // A tone that no longer exists means this entry belongs to a
57
+ // configuration the app has since dropped. Leaving it alone is
58
+ // correct: re-anchoring must never repair, only preserve.
59
+ continue;
60
+ }
61
+
62
+ await deps.engine.schedule({
63
+ request: {
64
+ id: entry.id,
65
+ content: entry.content,
66
+ tone: entry.tone,
67
+ moment: { at: resolvedAt, anchoring: 'wallClock' },
68
+ routing: entry.routing,
69
+ actions: entry.actions,
70
+ ownerTag: entry.ownerTag,
71
+ groupTag: entry.groupTag,
72
+ },
73
+ resolvedAt,
74
+ tone,
75
+ // Re-anchored against the zone the device is in now — this is the
76
+ // value that stops the entry being reported as stale next time.
77
+ anchoringGeneration: zone,
78
+ approximate: !deps.canScheduleExactly(),
79
+ seriesId: entry.seriesId,
80
+ pushCreated: entry.pushCreated,
81
+ });
82
+ operationCount += 1;
83
+ }
84
+
85
+ return {
86
+ operationCount,
87
+ reAnchored: operationCount,
88
+ timeZoneChanged: true,
89
+ // Re-anchoring replaces in place and cancels nothing, so it can never
90
+ // touch a notification outside what it was handed.
91
+ cancelledIds: [],
92
+ };
93
+ },
94
+ };
95
+ }
@@ -0,0 +1,60 @@
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
+ readonly ownedIds: () => ReadonlySet<string>;
17
+ readonly enabled?: boolean;
18
+ }
19
+
20
+ export class MaintenanceController {
21
+ private enabled: boolean;
22
+ private readonly registry: MaintenanceRegistry;
23
+ private readonly now: () => number;
24
+ private readonly ownedIds: () => ReadonlySet<string>;
25
+
26
+ constructor(options: MaintenanceControllerOptions) {
27
+ this.registry = options.registry;
28
+ this.now = options.now;
29
+ this.ownedIds = options.ownedIds;
30
+ this.enabled = options.enabled ?? true;
31
+ }
32
+
33
+ get isEnabled(): boolean {
34
+ return this.enabled;
35
+ }
36
+
37
+ setEnabled(enabled: boolean): void {
38
+ this.enabled = enabled;
39
+ }
40
+
41
+ /** Called by lifecycle observers. A no-op when automatic maintenance is off. */
42
+ async runAutomatic(trigger: Exclude<MaintenanceTrigger, 'explicit'>): Promise<MaintenanceResult | undefined> {
43
+ if (!this.enabled) return undefined;
44
+ return this.execute(trigger);
45
+ }
46
+
47
+ /** Always runs, regardless of the switch. This is what preserves capability parity. */
48
+ async runExplicit(): Promise<MaintenanceResult> {
49
+ return this.execute('explicit');
50
+ }
51
+
52
+ private async execute(trigger: MaintenanceTrigger): Promise<MaintenanceResult> {
53
+ const context: MaintenanceContext = {
54
+ trigger,
55
+ now: this.now(),
56
+ ownedIds: this.ownedIds(),
57
+ };
58
+ return this.registry.run(context);
59
+ }
60
+ }