@chipmobilesdk/rn-notification 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +80 -42
- package/README.md +399 -306
- package/package.json +2 -1
- package/skills/sdk-rn-notification/SKILL.md +68 -3
- package/src/channel/migrate.ts +86 -12
- package/src/channel/tone-map.ts +130 -90
- package/src/channel/validate-tone.ts +61 -0
- package/src/contract/channel.ts +188 -89
- package/src/contract/diagnostics.ts +102 -102
- package/src/contract/errors.ts +130 -123
- 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 +60 -60
- package/src/contract/reconcile.ts +88 -88
- package/src/contract/recurrence.ts +123 -123
- 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 +64 -64
- package/src/engine/adapter.ts +172 -79
- package/src/engine/notify-kit-engine.ts +761 -535
- package/src/index.ts +24 -0
- package/src/initialize.ts +174 -174
- package/src/interaction/index.ts +146 -146
- package/src/maintenance/cycle.ts +147 -147
- package/src/maintenance/permission-refresh.ts +41 -41
- package/src/maintenance/reanchor.ts +95 -95
- package/src/maintenance/toggle.ts +60 -60
- package/src/maintenance/verify.ts +83 -83
- package/src/maintenance/window-refresh.ts +61 -61
- 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 +146 -146
- package/src/push/gate.ts +190 -190
- package/src/push/instructions.ts +109 -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 +145 -145
- package/src/reconcile/duplicates.ts +73 -73
- package/src/reconcile/index.ts +14 -1
- package/src/reconcile/truncate.ts +56 -56
- package/src/recurrence/clock-guard.ts +71 -71
- package/src/recurrence/expand.ts +292 -292
- package/src/repeat/expiry.ts +55 -0
- package/src/repeat/index.ts +54 -0
- package/src/repeat/marker.ts +72 -0
- package/src/repeat/place.ts +73 -0
- package/src/schedule/anchor.ts +267 -267
- package/src/schedule/index.ts +184 -173
- package/src/schedule/partition.ts +85 -85
- package/src/schedule/resolve-moment.ts +80 -80
- package/src/schedule/validate-content.ts +111 -111
- package/src/schedule/validate-identifier.ts +126 -114
- package/src/state/caps.ts +71 -71
- package/src/state/interaction-buffer.ts +73 -73
- package/src/state/mmkv-store.ts +71 -71
- package/src/state/permission-history.ts +41 -41
- package/src/state/record.ts +258 -246
- package/src/state/reset.ts +41 -41
- package/src/state/series-ledger.ts +84 -84
- package/src/state/store.ts +40 -40
- package/src/testing/conformance/index.ts +248 -194
- package/src/testing/fake-engine.ts +469 -284
- package/src/testing/fake-provider.ts +129 -129
- package/src/testing/faults.ts +72 -72
- package/src/testing/measure.ts +336 -333
|
@@ -1,95 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,60 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,61 +1,61 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The window-refresh maintenance step.
|
|
3
|
-
*
|
|
4
|
-
* Runs at order 15 — after re-anchoring (10) so it expands against corrected
|
|
5
|
-
* instants, before verification (20) so verification sees the refreshed set
|
|
6
|
-
* rather than reporting occurrences the refresh was about to add.
|
|
7
|
-
*
|
|
8
|
-
* This step is the reason automatic maintenance exists at all: only the package
|
|
9
|
-
* knows when a series' horizon has moved, and it can act on that without ever
|
|
10
|
-
* needing a desired set from the consumer.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import type { MaintenanceStep, MaintenanceContext, StepOutcome } from './cycle';
|
|
14
|
-
import type { RecurrenceRule } from '../contract/recurrence';
|
|
15
|
-
import type { Recurrence } from '../recurrence';
|
|
16
|
-
|
|
17
|
-
export const WINDOW_REFRESH_ORDER = 15;
|
|
18
|
-
|
|
19
|
-
export interface WindowRefreshDependencies {
|
|
20
|
-
readonly recurrence: Recurrence;
|
|
21
|
-
/**
|
|
22
|
-
* Rules the consumer has registered, supplied as a lookup rather than stored.
|
|
23
|
-
*
|
|
24
|
-
* The ledger records what the package expanded; the rule itself belongs to
|
|
25
|
-
* the consumer, because persisting a rule would mean persisting its content
|
|
26
|
-
* template — and FR-096a forbids the package holding notification content.
|
|
27
|
-
*/
|
|
28
|
-
readonly registeredRules: () => readonly RecurrenceRule[];
|
|
29
|
-
readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function createWindowRefreshStep(deps: WindowRefreshDependencies): MaintenanceStep {
|
|
33
|
-
return {
|
|
34
|
-
name: 'window-refresh',
|
|
35
|
-
order: WINDOW_REFRESH_ORDER,
|
|
36
|
-
|
|
37
|
-
async run(_context: MaintenanceContext): Promise<StepOutcome> {
|
|
38
|
-
const rules = deps.registeredRules();
|
|
39
|
-
if (rules.length === 0) {
|
|
40
|
-
return { operationCount: 0, windowRefreshed: false };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
let scheduled = 0;
|
|
44
|
-
|
|
45
|
-
for (const rule of rules) {
|
|
46
|
-
const result = await deps.recurrence.refreshSeries(rule);
|
|
47
|
-
scheduled += result.scheduled;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return {
|
|
51
|
-
operationCount: scheduled,
|
|
52
|
-
occurrencesScheduled: scheduled,
|
|
53
|
-
// A refresh that found nothing to add still ran; reporting it as
|
|
54
|
-
// refreshed distinguishes "nothing due" from "step never executed".
|
|
55
|
-
windowRefreshed: true,
|
|
56
|
-
// Refresh only ever adds occurrences within series the package owns.
|
|
57
|
-
cancelledIds: [],
|
|
58
|
-
};
|
|
59
|
-
},
|
|
60
|
-
};
|
|
61
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The window-refresh maintenance step.
|
|
3
|
+
*
|
|
4
|
+
* Runs at order 15 — after re-anchoring (10) so it expands against corrected
|
|
5
|
+
* instants, before verification (20) so verification sees the refreshed set
|
|
6
|
+
* rather than reporting occurrences the refresh was about to add.
|
|
7
|
+
*
|
|
8
|
+
* This step is the reason automatic maintenance exists at all: only the package
|
|
9
|
+
* knows when a series' horizon has moved, and it can act on that without ever
|
|
10
|
+
* needing a desired set from the consumer.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { MaintenanceStep, MaintenanceContext, StepOutcome } from './cycle';
|
|
14
|
+
import type { RecurrenceRule } from '../contract/recurrence';
|
|
15
|
+
import type { Recurrence } from '../recurrence';
|
|
16
|
+
|
|
17
|
+
export const WINDOW_REFRESH_ORDER = 15;
|
|
18
|
+
|
|
19
|
+
export interface WindowRefreshDependencies {
|
|
20
|
+
readonly recurrence: Recurrence;
|
|
21
|
+
/**
|
|
22
|
+
* Rules the consumer has registered, supplied as a lookup rather than stored.
|
|
23
|
+
*
|
|
24
|
+
* The ledger records what the package expanded; the rule itself belongs to
|
|
25
|
+
* the consumer, because persisting a rule would mean persisting its content
|
|
26
|
+
* template — and FR-096a forbids the package holding notification content.
|
|
27
|
+
*/
|
|
28
|
+
readonly registeredRules: () => readonly RecurrenceRule[];
|
|
29
|
+
readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createWindowRefreshStep(deps: WindowRefreshDependencies): MaintenanceStep {
|
|
33
|
+
return {
|
|
34
|
+
name: 'window-refresh',
|
|
35
|
+
order: WINDOW_REFRESH_ORDER,
|
|
36
|
+
|
|
37
|
+
async run(_context: MaintenanceContext): Promise<StepOutcome> {
|
|
38
|
+
const rules = deps.registeredRules();
|
|
39
|
+
if (rules.length === 0) {
|
|
40
|
+
return { operationCount: 0, windowRefreshed: false };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let scheduled = 0;
|
|
44
|
+
|
|
45
|
+
for (const rule of rules) {
|
|
46
|
+
const result = await deps.recurrence.refreshSeries(rule);
|
|
47
|
+
scheduled += result.scheduled;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
operationCount: scheduled,
|
|
52
|
+
occurrencesScheduled: scheduled,
|
|
53
|
+
// A refresh that found nothing to add still ran; reporting it as
|
|
54
|
+
// refreshed distinguishes "nothing due" from "step never executed".
|
|
55
|
+
windowRefreshed: true,
|
|
56
|
+
// Refresh only ever adds occurrences within series the package owns.
|
|
57
|
+
cancelledIds: [],
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|