@chipmobilesdk/rn-notification 0.1.1 → 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 +35 -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/dedup-ledger.ts +35 -39
- 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/index.ts +2 -0
- package/src/testing/measure.ts +337 -0
package/src/push/gate.ts
CHANGED
|
@@ -1,190 +1,190 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The push gate.
|
|
3
|
-
*
|
|
4
|
-
* Everything a consumer sees about push comes through here, in one normalized
|
|
5
|
-
* shape with zero provider types. Substituting a provider is therefore an
|
|
6
|
-
* adapter swap rather than a consumer rewrite.
|
|
7
|
-
*
|
|
8
|
-
* Two behaviours are deliberate and easy to get wrong. A push arriving in the
|
|
9
|
-
* **foreground is never auto-displayed** — presentation is the app's decision,
|
|
10
|
-
* and a library that decided for it would fight every consumer's design. And a
|
|
11
|
-
* malformed payload is **dropped safely**, because a broken message from a
|
|
12
|
-
* server must not be able to crash an installed app.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import type { PushEnvelope, PushProviderAdapter, PushAvailability } from './provider-adapter';
|
|
16
|
-
import type { StateManager } from '../state/record';
|
|
17
|
-
import type { Unsubscribe } from '../contract/interaction';
|
|
18
|
-
import { DIAGNOSTIC_CODES, isNotificationError } from '../contract/errors';
|
|
19
|
-
import { normalizeEnvelope, IMPLEMENTED_PAYLOAD_VERSION } from './envelope';
|
|
20
|
-
import { recordSeen } from '../state/dedup-ledger';
|
|
21
|
-
|
|
22
|
-
export type PushHandler = (envelope: PushEnvelope) => void | Promise<void>;
|
|
23
|
-
|
|
24
|
-
export interface PushGateDependencies {
|
|
25
|
-
readonly provider: PushProviderAdapter;
|
|
26
|
-
readonly state: StateManager;
|
|
27
|
-
readonly now: () => number;
|
|
28
|
-
readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export type UndeliverableCause =
|
|
32
|
-
| 'missingPermission'
|
|
33
|
-
| 'expiredToken'
|
|
34
|
-
| 'platformRestriction'
|
|
35
|
-
| 'malformedPayload'
|
|
36
|
-
| 'suspectedRateLimit';
|
|
37
|
-
|
|
38
|
-
export function createPushGate(deps: PushGateDependencies) {
|
|
39
|
-
const emit = deps.emit ?? (() => {});
|
|
40
|
-
let handlers: PushHandler[] = [];
|
|
41
|
-
let subscription: Unsubscribe | undefined;
|
|
42
|
-
let tokenSubscription: Unsubscribe | undefined;
|
|
43
|
-
let deliveryChain: Promise<void> = Promise.resolve();
|
|
44
|
-
|
|
45
|
-
const dispatch = (envelope: PushEnvelope): void => {
|
|
46
|
-
deliveryChain = deliveryChain.then(async () => {
|
|
47
|
-
for (const handler of handlers) {
|
|
48
|
-
try {
|
|
49
|
-
await handler(envelope);
|
|
50
|
-
} catch {
|
|
51
|
-
// A consumer's handler failing must not stop the next payload.
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
});
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
const receive = (raw: unknown): PushEnvelope | undefined => {
|
|
58
|
-
let normalized;
|
|
59
|
-
try {
|
|
60
|
-
normalized = normalizeEnvelope(raw, deps.now());
|
|
61
|
-
} catch (error) {
|
|
62
|
-
// Dropped safely and recorded. A broken message must never crash the app
|
|
63
|
-
// or corrupt the local schedule.
|
|
64
|
-
emit(
|
|
65
|
-
DIAGNOSTIC_CODES.PAYLOAD_MALFORMED,
|
|
66
|
-
isNotificationError(error)
|
|
67
|
-
? error.message
|
|
68
|
-
: 'Push payload could not be interpreted and was discarded.',
|
|
69
|
-
isNotificationError(error) ? error.context : undefined,
|
|
70
|
-
);
|
|
71
|
-
return undefined;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const { envelope, versionSkew } = normalized;
|
|
75
|
-
|
|
76
|
-
if (versionSkew) {
|
|
77
|
-
emit(
|
|
78
|
-
DIAGNOSTIC_CODES.PAYLOAD_VERSION_SKEW,
|
|
79
|
-
`Push payload declares contract version ${envelope.version}; this build implements ${IMPLEMENTED_PAYLOAD_VERSION}. Processed best-effort — known fields honoured, unknown fields ignored.`,
|
|
80
|
-
{ received: envelope.version, implemented: IMPLEMENTED_PAYLOAD_VERSION },
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Deduplication survives process death, which is the case that matters:
|
|
85
|
-
// a redelivery usually arrives in a fresh process.
|
|
86
|
-
if (recordSeen(deps.state, envelope.dedupId, deps.now(), envelope.ownerTag)) {
|
|
87
|
-
emit(
|
|
88
|
-
DIAGNOSTIC_CODES.PAYLOAD_DUPLICATE,
|
|
89
|
-
'Push payload was already handled and was suppressed.',
|
|
90
|
-
{ dedupId: envelope.dedupId },
|
|
91
|
-
);
|
|
92
|
-
return undefined;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
dispatch(envelope);
|
|
96
|
-
return envelope;
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
return {
|
|
100
|
-
/** Reports whether platform push services exist, so degradation is controlled. */
|
|
101
|
-
async getAvailability(): Promise<PushAvailability> {
|
|
102
|
-
const availability = await deps.provider.getAvailability();
|
|
103
|
-
|
|
104
|
-
if (availability === 'unavailable') {
|
|
105
|
-
emit(
|
|
106
|
-
DIAGNOSTIC_CODES.PUSH_UNAVAILABLE,
|
|
107
|
-
'Platform push services are unavailable on this device. The push path is disabled; the entire local notification path is unaffected.',
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
return availability;
|
|
111
|
-
},
|
|
112
|
-
|
|
113
|
-
/** Waits for a genuine token. Never returns a placeholder presented as one. */
|
|
114
|
-
async getToken(): Promise<string> {
|
|
115
|
-
const token = await deps.provider.getToken();
|
|
116
|
-
|
|
117
|
-
if (typeof token !== 'string' || token.length === 0) {
|
|
118
|
-
emit(
|
|
119
|
-
DIAGNOSTIC_CODES.TOKEN_UNAVAILABLE,
|
|
120
|
-
'The provider has no registration token yet. An empty value is never returned as if it were one.',
|
|
121
|
-
);
|
|
122
|
-
throw new Error('Push token is not yet available.');
|
|
123
|
-
}
|
|
124
|
-
// The token value itself is never logged.
|
|
125
|
-
return token;
|
|
126
|
-
},
|
|
127
|
-
|
|
128
|
-
onTokenChange(handler: (token: string) => void): Unsubscribe {
|
|
129
|
-
tokenSubscription = deps.provider.onTokenChange(handler);
|
|
130
|
-
return tokenSubscription;
|
|
131
|
-
},
|
|
132
|
-
|
|
133
|
-
async invalidateToken(): Promise<boolean> {
|
|
134
|
-
const ok = await deps.provider.invalidateToken();
|
|
135
|
-
|
|
136
|
-
if (!ok) {
|
|
137
|
-
emit(
|
|
138
|
-
DIAGNOSTIC_CODES.TOKEN_INVALIDATION_FAILED,
|
|
139
|
-
'Token invalidation did not succeed. The device may keep receiving messages for the previous account.',
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
return ok;
|
|
143
|
-
},
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Subscribes to inbound payloads.
|
|
147
|
-
*
|
|
148
|
-
* Nothing is displayed automatically — the consumer decides whether and how
|
|
149
|
-
* to present a foreground push.
|
|
150
|
-
*/
|
|
151
|
-
onPushReceived(handler: PushHandler): Unsubscribe {
|
|
152
|
-
handlers.push(handler);
|
|
153
|
-
subscription ??= deps.provider.subscribeToMessages(receive);
|
|
154
|
-
|
|
155
|
-
return () => {
|
|
156
|
-
handlers = handlers.filter(h => h !== handler);
|
|
157
|
-
};
|
|
158
|
-
},
|
|
159
|
-
|
|
160
|
-
/** Processes the payload that launched the app, if any. */
|
|
161
|
-
async collectInitial(): Promise<PushEnvelope | undefined> {
|
|
162
|
-
const raw = await deps.provider.takeInitialMessage();
|
|
163
|
-
return raw === undefined ? undefined : receive(raw);
|
|
164
|
-
},
|
|
165
|
-
|
|
166
|
-
/** Reports a specific cause rather than leaving a non-delivery unexplained. */
|
|
167
|
-
reportUndeliverable(cause: UndeliverableCause, context?: Record<string, unknown>): void {
|
|
168
|
-
emit(
|
|
169
|
-
DIAGNOSTIC_CODES.PUSH_UNDELIVERABLE,
|
|
170
|
-
`A push could not be delivered or displayed: ${cause}.`,
|
|
171
|
-
{ cause, ...context },
|
|
172
|
-
);
|
|
173
|
-
},
|
|
174
|
-
|
|
175
|
-
/** Exposed for tests and orderly shutdown. */
|
|
176
|
-
async flush(): Promise<void> {
|
|
177
|
-
await deliveryChain;
|
|
178
|
-
},
|
|
179
|
-
|
|
180
|
-
dispose(): void {
|
|
181
|
-
subscription?.();
|
|
182
|
-
tokenSubscription?.();
|
|
183
|
-
subscription = undefined;
|
|
184
|
-
tokenSubscription = undefined;
|
|
185
|
-
handlers = [];
|
|
186
|
-
},
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
export type PushGate = ReturnType<typeof createPushGate>;
|
|
1
|
+
/**
|
|
2
|
+
* The push gate.
|
|
3
|
+
*
|
|
4
|
+
* Everything a consumer sees about push comes through here, in one normalized
|
|
5
|
+
* shape with zero provider types. Substituting a provider is therefore an
|
|
6
|
+
* adapter swap rather than a consumer rewrite.
|
|
7
|
+
*
|
|
8
|
+
* Two behaviours are deliberate and easy to get wrong. A push arriving in the
|
|
9
|
+
* **foreground is never auto-displayed** — presentation is the app's decision,
|
|
10
|
+
* and a library that decided for it would fight every consumer's design. And a
|
|
11
|
+
* malformed payload is **dropped safely**, because a broken message from a
|
|
12
|
+
* server must not be able to crash an installed app.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { PushEnvelope, PushProviderAdapter, PushAvailability } from './provider-adapter';
|
|
16
|
+
import type { StateManager } from '../state/record';
|
|
17
|
+
import type { Unsubscribe } from '../contract/interaction';
|
|
18
|
+
import { DIAGNOSTIC_CODES, isNotificationError } from '../contract/errors';
|
|
19
|
+
import { normalizeEnvelope, IMPLEMENTED_PAYLOAD_VERSION } from './envelope';
|
|
20
|
+
import { recordSeen } from '../state/dedup-ledger';
|
|
21
|
+
|
|
22
|
+
export type PushHandler = (envelope: PushEnvelope) => void | Promise<void>;
|
|
23
|
+
|
|
24
|
+
export interface PushGateDependencies {
|
|
25
|
+
readonly provider: PushProviderAdapter;
|
|
26
|
+
readonly state: StateManager;
|
|
27
|
+
readonly now: () => number;
|
|
28
|
+
readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type UndeliverableCause =
|
|
32
|
+
| 'missingPermission'
|
|
33
|
+
| 'expiredToken'
|
|
34
|
+
| 'platformRestriction'
|
|
35
|
+
| 'malformedPayload'
|
|
36
|
+
| 'suspectedRateLimit';
|
|
37
|
+
|
|
38
|
+
export function createPushGate(deps: PushGateDependencies) {
|
|
39
|
+
const emit = deps.emit ?? (() => {});
|
|
40
|
+
let handlers: PushHandler[] = [];
|
|
41
|
+
let subscription: Unsubscribe | undefined;
|
|
42
|
+
let tokenSubscription: Unsubscribe | undefined;
|
|
43
|
+
let deliveryChain: Promise<void> = Promise.resolve();
|
|
44
|
+
|
|
45
|
+
const dispatch = (envelope: PushEnvelope): void => {
|
|
46
|
+
deliveryChain = deliveryChain.then(async () => {
|
|
47
|
+
for (const handler of handlers) {
|
|
48
|
+
try {
|
|
49
|
+
await handler(envelope);
|
|
50
|
+
} catch {
|
|
51
|
+
// A consumer's handler failing must not stop the next payload.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const receive = (raw: unknown): PushEnvelope | undefined => {
|
|
58
|
+
let normalized;
|
|
59
|
+
try {
|
|
60
|
+
normalized = normalizeEnvelope(raw, deps.now());
|
|
61
|
+
} catch (error) {
|
|
62
|
+
// Dropped safely and recorded. A broken message must never crash the app
|
|
63
|
+
// or corrupt the local schedule.
|
|
64
|
+
emit(
|
|
65
|
+
DIAGNOSTIC_CODES.PAYLOAD_MALFORMED,
|
|
66
|
+
isNotificationError(error)
|
|
67
|
+
? error.message
|
|
68
|
+
: 'Push payload could not be interpreted and was discarded.',
|
|
69
|
+
isNotificationError(error) ? error.context : undefined,
|
|
70
|
+
);
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const { envelope, versionSkew } = normalized;
|
|
75
|
+
|
|
76
|
+
if (versionSkew) {
|
|
77
|
+
emit(
|
|
78
|
+
DIAGNOSTIC_CODES.PAYLOAD_VERSION_SKEW,
|
|
79
|
+
`Push payload declares contract version ${envelope.version}; this build implements ${IMPLEMENTED_PAYLOAD_VERSION}. Processed best-effort — known fields honoured, unknown fields ignored.`,
|
|
80
|
+
{ received: envelope.version, implemented: IMPLEMENTED_PAYLOAD_VERSION },
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Deduplication survives process death, which is the case that matters:
|
|
85
|
+
// a redelivery usually arrives in a fresh process.
|
|
86
|
+
if (recordSeen(deps.state, envelope.dedupId, deps.now(), envelope.ownerTag)) {
|
|
87
|
+
emit(
|
|
88
|
+
DIAGNOSTIC_CODES.PAYLOAD_DUPLICATE,
|
|
89
|
+
'Push payload was already handled and was suppressed.',
|
|
90
|
+
{ dedupId: envelope.dedupId },
|
|
91
|
+
);
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
dispatch(envelope);
|
|
96
|
+
return envelope;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
/** Reports whether platform push services exist, so degradation is controlled. */
|
|
101
|
+
async getAvailability(): Promise<PushAvailability> {
|
|
102
|
+
const availability = await deps.provider.getAvailability();
|
|
103
|
+
|
|
104
|
+
if (availability === 'unavailable') {
|
|
105
|
+
emit(
|
|
106
|
+
DIAGNOSTIC_CODES.PUSH_UNAVAILABLE,
|
|
107
|
+
'Platform push services are unavailable on this device. The push path is disabled; the entire local notification path is unaffected.',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return availability;
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
/** Waits for a genuine token. Never returns a placeholder presented as one. */
|
|
114
|
+
async getToken(): Promise<string> {
|
|
115
|
+
const token = await deps.provider.getToken();
|
|
116
|
+
|
|
117
|
+
if (typeof token !== 'string' || token.length === 0) {
|
|
118
|
+
emit(
|
|
119
|
+
DIAGNOSTIC_CODES.TOKEN_UNAVAILABLE,
|
|
120
|
+
'The provider has no registration token yet. An empty value is never returned as if it were one.',
|
|
121
|
+
);
|
|
122
|
+
throw new Error('Push token is not yet available.');
|
|
123
|
+
}
|
|
124
|
+
// The token value itself is never logged.
|
|
125
|
+
return token;
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
onTokenChange(handler: (token: string) => void): Unsubscribe {
|
|
129
|
+
tokenSubscription = deps.provider.onTokenChange(handler);
|
|
130
|
+
return tokenSubscription;
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
async invalidateToken(): Promise<boolean> {
|
|
134
|
+
const ok = await deps.provider.invalidateToken();
|
|
135
|
+
|
|
136
|
+
if (!ok) {
|
|
137
|
+
emit(
|
|
138
|
+
DIAGNOSTIC_CODES.TOKEN_INVALIDATION_FAILED,
|
|
139
|
+
'Token invalidation did not succeed. The device may keep receiving messages for the previous account.',
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return ok;
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Subscribes to inbound payloads.
|
|
147
|
+
*
|
|
148
|
+
* Nothing is displayed automatically — the consumer decides whether and how
|
|
149
|
+
* to present a foreground push.
|
|
150
|
+
*/
|
|
151
|
+
onPushReceived(handler: PushHandler): Unsubscribe {
|
|
152
|
+
handlers.push(handler);
|
|
153
|
+
subscription ??= deps.provider.subscribeToMessages(receive);
|
|
154
|
+
|
|
155
|
+
return () => {
|
|
156
|
+
handlers = handlers.filter(h => h !== handler);
|
|
157
|
+
};
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
/** Processes the payload that launched the app, if any. */
|
|
161
|
+
async collectInitial(): Promise<PushEnvelope | undefined> {
|
|
162
|
+
const raw = await deps.provider.takeInitialMessage();
|
|
163
|
+
return raw === undefined ? undefined : receive(raw);
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
/** Reports a specific cause rather than leaving a non-delivery unexplained. */
|
|
167
|
+
reportUndeliverable(cause: UndeliverableCause, context?: Record<string, unknown>): void {
|
|
168
|
+
emit(
|
|
169
|
+
DIAGNOSTIC_CODES.PUSH_UNDELIVERABLE,
|
|
170
|
+
`A push could not be delivered or displayed: ${cause}.`,
|
|
171
|
+
{ cause, ...context },
|
|
172
|
+
);
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
/** Exposed for tests and orderly shutdown. */
|
|
176
|
+
async flush(): Promise<void> {
|
|
177
|
+
await deliveryChain;
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
dispose(): void {
|
|
181
|
+
subscription?.();
|
|
182
|
+
tokenSubscription?.();
|
|
183
|
+
subscription = undefined;
|
|
184
|
+
tokenSubscription = undefined;
|
|
185
|
+
handlers = [];
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export type PushGate = ReturnType<typeof createPushGate>;
|
package/src/push/instructions.ts
CHANGED
|
@@ -1,109 +1,109 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Push-driven local scheduling.
|
|
3
|
-
*
|
|
4
|
-
* Where the two paths meet. A server can schedule or cancel a local
|
|
5
|
-
* notification, and it gets **no privileged path**: an instruction arriving by
|
|
6
|
-
* push passes exactly the same identifier validation, moment validation, tone
|
|
7
|
-
* resolution, and permission rules as a request from app code.
|
|
8
|
-
*
|
|
9
|
-
* That symmetry is the point. A server that could bypass validation would be
|
|
10
|
-
* able to put the device into a state the app itself could not produce, and no
|
|
11
|
-
* amount of local correctness would survive it.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import type { PushEnvelope } from './provider-adapter';
|
|
15
|
-
import type { Scheduler } from '../schedule';
|
|
16
|
-
import type { NotificationEngineAdapter } from '../engine/adapter';
|
|
17
|
-
import { DIAGNOSTIC_CODES, isNotificationError } from '../contract/errors';
|
|
18
|
-
|
|
19
|
-
export interface InstructionDependencies {
|
|
20
|
-
readonly scheduler: Scheduler;
|
|
21
|
-
readonly engine: NotificationEngineAdapter;
|
|
22
|
-
readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export type InstructionOutcome =
|
|
26
|
-
| { readonly kind: 'scheduled'; readonly id: string; readonly replaced: boolean }
|
|
27
|
-
| { readonly kind: 'cancelled'; readonly id: string }
|
|
28
|
-
| { readonly kind: 'suppressed'; readonly id: string; readonly reason: string }
|
|
29
|
-
| { readonly kind: 'rejected'; readonly id: string; readonly code: string; readonly reason: string }
|
|
30
|
-
| { readonly kind: 'ignored'; readonly id: string };
|
|
31
|
-
|
|
32
|
-
export function createInstructionHandler(deps: InstructionDependencies) {
|
|
33
|
-
const emit = deps.emit ?? (() => {});
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Whether a local schedule already covers this event.
|
|
37
|
-
*
|
|
38
|
-
* Local wins: it is the entry the device can present at the right moment with
|
|
39
|
-
* no network. A server that genuinely wants to override sends an explicit
|
|
40
|
-
* cancel followed by a schedule, which is unambiguous in a way "last write
|
|
41
|
-
* wins" is not.
|
|
42
|
-
*/
|
|
43
|
-
const localAlreadyCovers = async (id: string): Promise<boolean> => {
|
|
44
|
-
const pending = await deps.engine.getPending();
|
|
45
|
-
const existing = pending.find(entry => entry.id === id);
|
|
46
|
-
return existing !== undefined && !existing.pushCreated;
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
return {
|
|
50
|
-
async handle(envelope: PushEnvelope): Promise<InstructionOutcome> {
|
|
51
|
-
if (envelope.kind !== 'schedule' && envelope.kind !== 'cancel') {
|
|
52
|
-
return { kind: 'ignored', id: envelope.id };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
try {
|
|
56
|
-
if (envelope.kind === 'cancel') {
|
|
57
|
-
await deps.scheduler.cancel(envelope.id);
|
|
58
|
-
return { kind: 'cancelled', id: envelope.id };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (envelope.moment === undefined || envelope.content === undefined) {
|
|
62
|
-
throw Object.assign(
|
|
63
|
-
new Error('A schedule instruction must carry both a moment and content.'),
|
|
64
|
-
{ code: DIAGNOSTIC_CODES.PAYLOAD_MALFORMED },
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
if (await localAlreadyCovers(envelope.id)) {
|
|
69
|
-
emit(
|
|
70
|
-
DIAGNOSTIC_CODES.PAYLOAD_DUPLICATE,
|
|
71
|
-
'A local schedule already covers this event; the push instruction was suppressed so the user sees exactly one notification.',
|
|
72
|
-
{ id: envelope.id },
|
|
73
|
-
);
|
|
74
|
-
return {
|
|
75
|
-
kind: 'suppressed',
|
|
76
|
-
id: envelope.id,
|
|
77
|
-
reason: 'A local schedule already covers this event.',
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const result = await deps.scheduler.schedule(
|
|
82
|
-
{
|
|
83
|
-
id: envelope.id,
|
|
84
|
-
content: envelope.content,
|
|
85
|
-
tone: envelope.tone ?? '',
|
|
86
|
-
moment: envelope.moment,
|
|
87
|
-
routing: envelope.routing,
|
|
88
|
-
ownerTag: envelope.ownerTag,
|
|
89
|
-
},
|
|
90
|
-
{ pushCreated: true },
|
|
91
|
-
);
|
|
92
|
-
|
|
93
|
-
return { kind: 'scheduled', id: envelope.id, replaced: result.replaced };
|
|
94
|
-
} catch (error) {
|
|
95
|
-
// Rejected under exactly the rules an app-initiated request would face,
|
|
96
|
-
// and recorded rather than swallowed.
|
|
97
|
-
const code = isNotificationError(error)
|
|
98
|
-
? error.code
|
|
99
|
-
: ((error as { code?: string }).code ?? 'UNKNOWN');
|
|
100
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
101
|
-
|
|
102
|
-
emit(code, `Push instruction rejected: ${reason}`, { id: envelope.id });
|
|
103
|
-
return { kind: 'rejected', id: envelope.id, code, reason };
|
|
104
|
-
}
|
|
105
|
-
},
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export type InstructionHandler = ReturnType<typeof createInstructionHandler>;
|
|
1
|
+
/**
|
|
2
|
+
* Push-driven local scheduling.
|
|
3
|
+
*
|
|
4
|
+
* Where the two paths meet. A server can schedule or cancel a local
|
|
5
|
+
* notification, and it gets **no privileged path**: an instruction arriving by
|
|
6
|
+
* push passes exactly the same identifier validation, moment validation, tone
|
|
7
|
+
* resolution, and permission rules as a request from app code.
|
|
8
|
+
*
|
|
9
|
+
* That symmetry is the point. A server that could bypass validation would be
|
|
10
|
+
* able to put the device into a state the app itself could not produce, and no
|
|
11
|
+
* amount of local correctness would survive it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { PushEnvelope } from './provider-adapter';
|
|
15
|
+
import type { Scheduler } from '../schedule';
|
|
16
|
+
import type { NotificationEngineAdapter } from '../engine/adapter';
|
|
17
|
+
import { DIAGNOSTIC_CODES, isNotificationError } from '../contract/errors';
|
|
18
|
+
|
|
19
|
+
export interface InstructionDependencies {
|
|
20
|
+
readonly scheduler: Scheduler;
|
|
21
|
+
readonly engine: NotificationEngineAdapter;
|
|
22
|
+
readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type InstructionOutcome =
|
|
26
|
+
| { readonly kind: 'scheduled'; readonly id: string; readonly replaced: boolean }
|
|
27
|
+
| { readonly kind: 'cancelled'; readonly id: string }
|
|
28
|
+
| { readonly kind: 'suppressed'; readonly id: string; readonly reason: string }
|
|
29
|
+
| { readonly kind: 'rejected'; readonly id: string; readonly code: string; readonly reason: string }
|
|
30
|
+
| { readonly kind: 'ignored'; readonly id: string };
|
|
31
|
+
|
|
32
|
+
export function createInstructionHandler(deps: InstructionDependencies) {
|
|
33
|
+
const emit = deps.emit ?? (() => {});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether a local schedule already covers this event.
|
|
37
|
+
*
|
|
38
|
+
* Local wins: it is the entry the device can present at the right moment with
|
|
39
|
+
* no network. A server that genuinely wants to override sends an explicit
|
|
40
|
+
* cancel followed by a schedule, which is unambiguous in a way "last write
|
|
41
|
+
* wins" is not.
|
|
42
|
+
*/
|
|
43
|
+
const localAlreadyCovers = async (id: string): Promise<boolean> => {
|
|
44
|
+
const pending = await deps.engine.getPending();
|
|
45
|
+
const existing = pending.find(entry => entry.id === id);
|
|
46
|
+
return existing !== undefined && !existing.pushCreated;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
async handle(envelope: PushEnvelope): Promise<InstructionOutcome> {
|
|
51
|
+
if (envelope.kind !== 'schedule' && envelope.kind !== 'cancel') {
|
|
52
|
+
return { kind: 'ignored', id: envelope.id };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
if (envelope.kind === 'cancel') {
|
|
57
|
+
await deps.scheduler.cancel(envelope.id);
|
|
58
|
+
return { kind: 'cancelled', id: envelope.id };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (envelope.moment === undefined || envelope.content === undefined) {
|
|
62
|
+
throw Object.assign(
|
|
63
|
+
new Error('A schedule instruction must carry both a moment and content.'),
|
|
64
|
+
{ code: DIAGNOSTIC_CODES.PAYLOAD_MALFORMED },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (await localAlreadyCovers(envelope.id)) {
|
|
69
|
+
emit(
|
|
70
|
+
DIAGNOSTIC_CODES.PAYLOAD_DUPLICATE,
|
|
71
|
+
'A local schedule already covers this event; the push instruction was suppressed so the user sees exactly one notification.',
|
|
72
|
+
{ id: envelope.id },
|
|
73
|
+
);
|
|
74
|
+
return {
|
|
75
|
+
kind: 'suppressed',
|
|
76
|
+
id: envelope.id,
|
|
77
|
+
reason: 'A local schedule already covers this event.',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const result = await deps.scheduler.schedule(
|
|
82
|
+
{
|
|
83
|
+
id: envelope.id,
|
|
84
|
+
content: envelope.content,
|
|
85
|
+
tone: envelope.tone ?? '',
|
|
86
|
+
moment: envelope.moment,
|
|
87
|
+
routing: envelope.routing,
|
|
88
|
+
ownerTag: envelope.ownerTag,
|
|
89
|
+
},
|
|
90
|
+
{ pushCreated: true },
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return { kind: 'scheduled', id: envelope.id, replaced: result.replaced };
|
|
94
|
+
} catch (error) {
|
|
95
|
+
// Rejected under exactly the rules an app-initiated request would face,
|
|
96
|
+
// and recorded rather than swallowed.
|
|
97
|
+
const code = isNotificationError(error)
|
|
98
|
+
? error.code
|
|
99
|
+
: ((error as { code?: string }).code ?? 'UNKNOWN');
|
|
100
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
101
|
+
|
|
102
|
+
emit(code, `Push instruction rejected: ${reason}`, { id: envelope.id });
|
|
103
|
+
return { kind: 'rejected', id: envelope.id, code, reason };
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type InstructionHandler = ReturnType<typeof createInstructionHandler>;
|