@chipmobilesdk/rn-notification 0.1.2 → 0.2.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +96 -42
  2. package/README.md +399 -306
  3. package/package.json +2 -1
  4. package/skills/sdk-rn-notification/SKILL.md +68 -3
  5. package/src/channel/migrate.ts +86 -12
  6. package/src/channel/tone-map.ts +130 -90
  7. package/src/channel/validate-tone.ts +61 -0
  8. package/src/contract/channel.ts +188 -89
  9. package/src/contract/diagnostics.ts +102 -102
  10. package/src/contract/errors.ts +130 -123
  11. package/src/contract/index.ts +10 -10
  12. package/src/contract/interaction.ts +62 -62
  13. package/src/contract/notification.ts +118 -118
  14. package/src/contract/permission.ts +60 -60
  15. package/src/contract/reconcile.ts +88 -88
  16. package/src/contract/recurrence.ts +123 -123
  17. package/src/diagnostics/burst.ts +101 -101
  18. package/src/diagnostics/events.ts +72 -72
  19. package/src/diagnostics/redact.ts +72 -72
  20. package/src/diagnostics/snapshot.ts +64 -64
  21. package/src/engine/adapter.ts +172 -79
  22. package/src/engine/notify-kit-engine.ts +761 -535
  23. package/src/index.ts +24 -0
  24. package/src/initialize.ts +174 -174
  25. package/src/interaction/index.ts +146 -146
  26. package/src/maintenance/cycle.ts +147 -147
  27. package/src/maintenance/permission-refresh.ts +41 -41
  28. package/src/maintenance/reanchor.ts +95 -95
  29. package/src/maintenance/toggle.ts +60 -60
  30. package/src/maintenance/verify.ts +83 -83
  31. package/src/maintenance/window-refresh.ts +61 -61
  32. package/src/permission/index.ts +183 -183
  33. package/src/permission/state.ts +77 -77
  34. package/src/push/envelope.ts +165 -165
  35. package/src/push/fcm-provider.ts +146 -146
  36. package/src/push/gate.ts +190 -190
  37. package/src/push/instructions.ts +109 -109
  38. package/src/push/provider-adapter.ts +54 -54
  39. package/src/push/signout.ts +65 -65
  40. package/src/reconcile/chunk.ts +53 -53
  41. package/src/reconcile/diff.ts +145 -145
  42. package/src/reconcile/duplicates.ts +73 -73
  43. package/src/reconcile/index.ts +14 -1
  44. package/src/reconcile/truncate.ts +56 -56
  45. package/src/recurrence/clock-guard.ts +71 -71
  46. package/src/recurrence/expand.ts +292 -292
  47. package/src/repeat/expiry.ts +55 -0
  48. package/src/repeat/index.ts +54 -0
  49. package/src/repeat/marker.ts +72 -0
  50. package/src/repeat/place.ts +73 -0
  51. package/src/schedule/anchor.ts +267 -267
  52. package/src/schedule/index.ts +184 -173
  53. package/src/schedule/partition.ts +85 -85
  54. package/src/schedule/resolve-moment.ts +80 -80
  55. package/src/schedule/validate-content.ts +111 -111
  56. package/src/schedule/validate-identifier.ts +126 -114
  57. package/src/state/caps.ts +71 -71
  58. package/src/state/interaction-buffer.ts +73 -73
  59. package/src/state/mmkv-store.ts +75 -71
  60. package/src/state/permission-history.ts +41 -41
  61. package/src/state/record.ts +258 -246
  62. package/src/state/reset.ts +41 -41
  63. package/src/state/series-ledger.ts +84 -84
  64. package/src/state/store.ts +40 -40
  65. package/src/testing/conformance/index.ts +248 -194
  66. package/src/testing/fake-engine.ts +469 -284
  67. package/src/testing/fake-provider.ts +129 -129
  68. package/src/testing/faults.ts +72 -72
  69. package/src/testing/measure.ts +337 -334
@@ -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
+ }