@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.
- package/CHANGELOG.md +257 -166
- package/README.md +420 -410
- package/package.json +61 -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 +142 -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 +81 -72
- package/src/diagnostics/redact.ts +55 -72
- package/src/diagnostics/snapshot.ts +100 -64
- package/src/engine/adapter.ts +172 -172
- package/src/engine/codec.ts +89 -89
- package/src/engine/notify-kit-engine.ts +715 -707
- package/src/index.ts +208 -208
- package/src/initialize.ts +247 -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 +150 -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 +294 -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 +101 -73
- package/src/state/mmkv-store.ts +92 -75
- package/src/state/permission-history.ts +41 -41
- package/src/state/record.ts +270 -263
- package/src/state/reset.ts +41 -41
- package/src/state/series-ledger.ts +91 -91
- package/src/state/store.ts +43 -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
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
+
}
|