@chipmobilesdk/rn-notification 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +174 -166
- package/README.md +414 -410
- package/package.json +58 -75
- package/skills/sdk-rn-notification/SKILL.md +172 -173
- package/src/channel/migrate.ts +285 -289
- package/src/channel/tone-map.ts +130 -130
- package/src/channel/validate-tone.ts +61 -61
- package/src/contract/channel.ts +192 -188
- package/src/contract/diagnostics.ts +102 -102
- package/src/contract/errors.ts +134 -134
- package/src/contract/index.ts +10 -10
- package/src/contract/interaction.ts +62 -62
- package/src/contract/notification.ts +118 -118
- package/src/contract/permission.ts +55 -60
- package/src/contract/reconcile.ts +88 -88
- package/src/contract/recurrence.ts +129 -129
- package/src/diagnostics/burst.ts +101 -101
- package/src/diagnostics/events.ts +72 -72
- package/src/diagnostics/redact.ts +72 -72
- package/src/diagnostics/snapshot.ts +67 -64
- package/src/engine/adapter.ts +172 -172
- package/src/engine/codec.ts +89 -89
- package/src/engine/notify-kit-engine.ts +706 -707
- package/src/index.ts +208 -208
- package/src/initialize.ts +240 -236
- package/src/interaction/index.ts +146 -146
- package/src/maintenance/cycle.ts +150 -154
- package/src/maintenance/permission-refresh.ts +39 -41
- package/src/maintenance/reanchor.ts +101 -101
- package/src/maintenance/toggle.ts +66 -64
- package/src/maintenance/verify.ts +83 -83
- package/src/maintenance/window-refresh.ts +64 -64
- package/src/permission/index.ts +183 -183
- package/src/permission/state.ts +77 -77
- package/src/push/envelope.ts +165 -165
- package/src/push/fcm-provider.ts +143 -146
- package/src/push/gate.ts +190 -190
- package/src/push/index.ts +33 -29
- package/src/push/instructions.ts +114 -109
- package/src/push/provider-adapter.ts +54 -54
- package/src/push/signout.ts +65 -65
- package/src/reconcile/chunk.ts +53 -53
- package/src/reconcile/diff.ts +142 -145
- package/src/reconcile/duplicates.ts +73 -73
- package/src/reconcile/index.ts +180 -180
- package/src/reconcile/truncate.ts +56 -56
- package/src/recurrence/clock-guard.ts +73 -71
- package/src/recurrence/expand.ts +326 -319
- package/src/recurrence/index.ts +270 -259
- package/src/repeat/expiry.ts +53 -55
- package/src/repeat/index.ts +54 -54
- package/src/repeat/marker.ts +72 -72
- package/src/repeat/place.ts +73 -73
- package/src/schedule/anchor.ts +278 -267
- package/src/schedule/index.ts +174 -181
- package/src/schedule/partition.ts +88 -85
- package/src/schedule/resolve-moment.ts +80 -80
- package/src/schedule/validate-content.ts +111 -111
- package/src/schedule/validate-identifier.ts +126 -126
- package/src/schedule/writer.ts +55 -55
- package/src/state/caps.ts +71 -71
- package/src/state/dedup-ledger.ts +35 -35
- package/src/state/interaction-buffer.ts +73 -73
- package/src/state/mmkv-store.ts +75 -75
- package/src/state/permission-history.ts +41 -41
- package/src/state/record.ts +266 -263
- package/src/state/reset.ts +41 -41
- package/src/state/series-ledger.ts +91 -91
- package/src/state/store.ts +40 -40
- package/src/testing/conformance/index.ts +258 -248
- package/src/testing/fake-engine.ts +462 -468
- package/src/testing/fake-provider.ts +126 -129
- package/src/testing/faults.ts +72 -72
- package/src/testing/index.ts +23 -28
- package/src/testing/measure.ts +337 -337
package/src/interaction/index.ts
CHANGED
|
@@ -1,146 +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>;
|
|
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>;
|
package/src/maintenance/cycle.ts
CHANGED
|
@@ -1,154 +1,150 @@
|
|
|
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
|
-
| '
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
readonly
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
readonly
|
|
40
|
-
readonly
|
|
41
|
-
|
|
42
|
-
readonly
|
|
43
|
-
|
|
44
|
-
readonly
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
readonly
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
);
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
this.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
aggregate.
|
|
144
|
-
aggregate.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return { ranAt: started, ...aggregate, discrepancy, durationMs: this.clock() - clockStart };
|
|
153
|
-
}
|
|
154
|
-
}
|
|
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' | 'delivery' | 'timeZoneChange' | 'permissionChange' | 'explicit';
|
|
23
|
+
|
|
24
|
+
export interface MaintenanceContext {
|
|
25
|
+
readonly trigger: MaintenanceTrigger;
|
|
26
|
+
readonly now: number;
|
|
27
|
+
/**
|
|
28
|
+
* Identifiers the package owns — series occurrences and entries it created.
|
|
29
|
+
* A step may cancel only within this set.
|
|
30
|
+
*/
|
|
31
|
+
readonly ownedIds: ReadonlySet<string>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface StepOutcome {
|
|
35
|
+
readonly occurrencesScheduled?: number;
|
|
36
|
+
readonly reAnchored?: number;
|
|
37
|
+
readonly windowRefreshed?: boolean;
|
|
38
|
+
readonly timeZoneChanged?: boolean;
|
|
39
|
+
readonly permissionChanged?: boolean;
|
|
40
|
+
readonly discrepancy?: PendingDiscrepancy;
|
|
41
|
+
/** Platform operations issued. Zero on a no-change pass. */
|
|
42
|
+
readonly operationCount?: number;
|
|
43
|
+
/** Identifiers this step cancelled, checked against `ownedIds`. */
|
|
44
|
+
readonly cancelledIds?: readonly string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface MaintenanceStep {
|
|
48
|
+
readonly name: string;
|
|
49
|
+
/** Lower runs first. Re-anchoring must precede verification, or verification sees stale instants. */
|
|
50
|
+
readonly order: number;
|
|
51
|
+
run(context: MaintenanceContext): Promise<StepOutcome>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class MaintenanceScopeViolation extends Error {
|
|
55
|
+
readonly step: string;
|
|
56
|
+
readonly offendingIds: readonly string[];
|
|
57
|
+
|
|
58
|
+
constructor(step: string, offendingIds: readonly string[]) {
|
|
59
|
+
super(
|
|
60
|
+
`Maintenance step "${step}" attempted to cancel ${offendingIds.length} notification(s) it does not own. ` +
|
|
61
|
+
'Automatic maintenance may never cancel a directly-scheduled notification.',
|
|
62
|
+
);
|
|
63
|
+
this.name = 'MaintenanceScopeViolation';
|
|
64
|
+
this.step = step;
|
|
65
|
+
this.offendingIds = offendingIds;
|
|
66
|
+
Object.setPrototypeOf(this, MaintenanceScopeViolation.prototype);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface MaintenanceRegistryOptions {
|
|
71
|
+
/** Measures `durationMs`. `context.now` is the cycle's logical time and does not move. */
|
|
72
|
+
readonly clock?: () => number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class MaintenanceRegistry {
|
|
76
|
+
private steps: MaintenanceStep[] = [];
|
|
77
|
+
private inFlight: Promise<MaintenanceResult> | null = null;
|
|
78
|
+
private readonly clock: () => number;
|
|
79
|
+
|
|
80
|
+
constructor(options: MaintenanceRegistryOptions = {}) {
|
|
81
|
+
this.clock = options.clock ?? (() => Date.now());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
register(step: MaintenanceStep): () => void {
|
|
85
|
+
this.steps.push(step);
|
|
86
|
+
this.steps.sort((a, b) => a.order - b.order);
|
|
87
|
+
return () => {
|
|
88
|
+
this.steps = this.steps.filter(s => s !== step);
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
get registered(): readonly string[] {
|
|
93
|
+
return this.steps.map(s => s.name);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Runs every registered step in order.
|
|
98
|
+
*
|
|
99
|
+
* Overlapping cycles are joined rather than queued or dropped: a second
|
|
100
|
+
* caller arriving mid-cycle wants the work the running cycle is already
|
|
101
|
+
* doing, so it waits for that cycle and gets its result. It used to get an
|
|
102
|
+
* empty result at once. Since `initialize` started running a cycle on every
|
|
103
|
+
* foreground, an app that ran maintenance and then reconciled on the same
|
|
104
|
+
* event got that empty result and reconciled while the window refresh was
|
|
105
|
+
* still writing: a reminder the reconcile cancelled could be scheduled again.
|
|
106
|
+
*/
|
|
107
|
+
run(context: MaintenanceContext): Promise<MaintenanceResult> {
|
|
108
|
+
if (this.inFlight) {
|
|
109
|
+
return this.inFlight;
|
|
110
|
+
}
|
|
111
|
+
const cycle = this.runSteps(context).finally(() => {
|
|
112
|
+
if (this.inFlight === cycle) this.inFlight = null;
|
|
113
|
+
});
|
|
114
|
+
this.inFlight = cycle;
|
|
115
|
+
return cycle;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async runSteps(context: MaintenanceContext): Promise<MaintenanceResult> {
|
|
119
|
+
const started = context.now;
|
|
120
|
+
const clockStart = this.clock();
|
|
121
|
+
const aggregate = {
|
|
122
|
+
windowRefreshed: false,
|
|
123
|
+
occurrencesScheduled: 0,
|
|
124
|
+
reAnchored: 0,
|
|
125
|
+
timeZoneChanged: false,
|
|
126
|
+
permissionChanged: false,
|
|
127
|
+
operationCount: 0,
|
|
128
|
+
};
|
|
129
|
+
let discrepancy: PendingDiscrepancy | undefined;
|
|
130
|
+
|
|
131
|
+
for (const step of this.steps) {
|
|
132
|
+
const outcome = await step.run(context);
|
|
133
|
+
|
|
134
|
+
const offending = (outcome.cancelledIds ?? []).filter(id => !context.ownedIds.has(id));
|
|
135
|
+
if (offending.length > 0) {
|
|
136
|
+
throw new MaintenanceScopeViolation(step.name, offending);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
aggregate.windowRefreshed ||= outcome.windowRefreshed ?? false;
|
|
140
|
+
aggregate.occurrencesScheduled += outcome.occurrencesScheduled ?? 0;
|
|
141
|
+
aggregate.reAnchored += outcome.reAnchored ?? 0;
|
|
142
|
+
aggregate.timeZoneChanged ||= outcome.timeZoneChanged ?? false;
|
|
143
|
+
aggregate.permissionChanged ||= outcome.permissionChanged ?? false;
|
|
144
|
+
aggregate.operationCount += outcome.operationCount ?? 0;
|
|
145
|
+
discrepancy ??= outcome.discrepancy;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { ranAt: started, ...aggregate, discrepancy, durationMs: this.clock() - clockStart };
|
|
149
|
+
}
|
|
150
|
+
}
|