@chipmobilesdk/rn-notification 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +174 -109
- package/README.md +414 -399
- package/package.json +4 -21
- package/skills/sdk-rn-notification/SKILL.md +5 -4
- package/src/channel/migrate.ts +285 -285
- package/src/channel/tone-map.ts +130 -130
- package/src/channel/validate-tone.ts +61 -61
- package/src/contract/channel.ts +192 -188
- package/src/contract/diagnostics.ts +102 -102
- package/src/contract/errors.ts +134 -130
- 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 -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 +67 -64
- package/src/engine/adapter.ts +172 -172
- package/src/engine/codec.ts +89 -0
- package/src/engine/notify-kit-engine.ts +706 -761
- package/src/index.ts +208 -208
- package/src/initialize.ts +240 -174
- package/src/interaction/index.ts +146 -146
- package/src/maintenance/cycle.ts +150 -147
- package/src/maintenance/permission-refresh.ts +39 -41
- package/src/maintenance/reanchor.ts +101 -95
- package/src/maintenance/toggle.ts +66 -60
- package/src/maintenance/verify.ts +83 -83
- package/src/maintenance/window-refresh.ts +64 -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 +143 -146
- package/src/push/gate.ts +190 -190
- package/src/push/index.ts +33 -29
- package/src/push/instructions.ts +114 -109
- package/src/push/provider-adapter.ts +54 -54
- package/src/push/signout.ts +65 -65
- package/src/reconcile/chunk.ts +53 -53
- package/src/reconcile/diff.ts +142 -145
- package/src/reconcile/duplicates.ts +73 -73
- package/src/reconcile/index.ts +180 -188
- package/src/reconcile/truncate.ts +56 -56
- package/src/recurrence/clock-guard.ts +73 -71
- package/src/recurrence/expand.ts +326 -292
- package/src/recurrence/index.ts +270 -177
- package/src/repeat/expiry.ts +53 -55
- package/src/repeat/index.ts +54 -54
- package/src/repeat/marker.ts +72 -72
- package/src/repeat/place.ts +73 -73
- package/src/schedule/anchor.ts +278 -267
- package/src/schedule/index.ts +174 -184
- 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 -0
- package/src/state/caps.ts +71 -71
- package/src/state/dedup-ledger.ts +35 -35
- package/src/state/interaction-buffer.ts +73 -73
- package/src/state/mmkv-store.ts +75 -75
- package/src/state/permission-history.ts +41 -41
- package/src/state/record.ts +266 -258
- package/src/state/reset.ts +41 -41
- package/src/state/series-ledger.ts +91 -84
- package/src/state/store.ts +40 -40
- package/src/testing/conformance/index.ts +258 -248
- package/src/testing/fake-engine.ts +462 -469
- package/src/testing/fake-provider.ts +126 -129
- package/src/testing/faults.ts +72 -72
- package/src/testing/index.ts +23 -28
- package/src/testing/measure.ts +337 -337
package/src/push/instructions.ts
CHANGED
|
@@ -1,109 +1,114 @@
|
|
|
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
|
-
| {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
*
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (envelope.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
|
|
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
|
+
| {
|
|
30
|
+
readonly kind: 'rejected';
|
|
31
|
+
readonly id: string;
|
|
32
|
+
readonly code: string;
|
|
33
|
+
readonly reason: string;
|
|
34
|
+
}
|
|
35
|
+
| { readonly kind: 'ignored'; readonly id: string };
|
|
36
|
+
|
|
37
|
+
export function createInstructionHandler(deps: InstructionDependencies) {
|
|
38
|
+
const emit = deps.emit ?? (() => {});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether a local schedule already covers this event.
|
|
42
|
+
*
|
|
43
|
+
* Local wins: it is the entry the device can present at the right moment with
|
|
44
|
+
* no network. A server that genuinely wants to override sends an explicit
|
|
45
|
+
* cancel followed by a schedule, which is unambiguous in a way "last write
|
|
46
|
+
* wins" is not.
|
|
47
|
+
*/
|
|
48
|
+
const localAlreadyCovers = async (id: string): Promise<boolean> => {
|
|
49
|
+
const pending = await deps.engine.getPending();
|
|
50
|
+
const existing = pending.find(entry => entry.id === id);
|
|
51
|
+
return existing !== undefined && !existing.pushCreated;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
async handle(envelope: PushEnvelope): Promise<InstructionOutcome> {
|
|
56
|
+
if (envelope.kind !== 'schedule' && envelope.kind !== 'cancel') {
|
|
57
|
+
return { kind: 'ignored', id: envelope.id };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
if (envelope.kind === 'cancel') {
|
|
62
|
+
await deps.scheduler.cancel(envelope.id);
|
|
63
|
+
return { kind: 'cancelled', id: envelope.id };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (envelope.moment === undefined || envelope.content === undefined) {
|
|
67
|
+
throw Object.assign(
|
|
68
|
+
new Error('A schedule instruction must carry both a moment and content.'),
|
|
69
|
+
{ code: DIAGNOSTIC_CODES.PAYLOAD_MALFORMED },
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (await localAlreadyCovers(envelope.id)) {
|
|
74
|
+
emit(
|
|
75
|
+
DIAGNOSTIC_CODES.PAYLOAD_DUPLICATE,
|
|
76
|
+
'A local schedule already covers this event; the push instruction was suppressed so the user sees exactly one notification.',
|
|
77
|
+
{ id: envelope.id },
|
|
78
|
+
);
|
|
79
|
+
return {
|
|
80
|
+
kind: 'suppressed',
|
|
81
|
+
id: envelope.id,
|
|
82
|
+
reason: 'A local schedule already covers this event.',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const result = await deps.scheduler.schedule(
|
|
87
|
+
{
|
|
88
|
+
id: envelope.id,
|
|
89
|
+
content: envelope.content,
|
|
90
|
+
tone: envelope.tone ?? '',
|
|
91
|
+
moment: envelope.moment,
|
|
92
|
+
routing: envelope.routing,
|
|
93
|
+
ownerTag: envelope.ownerTag,
|
|
94
|
+
},
|
|
95
|
+
{ pushCreated: true },
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
return { kind: 'scheduled', id: envelope.id, replaced: result.replaced };
|
|
99
|
+
} catch (error) {
|
|
100
|
+
// Rejected under exactly the rules an app-initiated request would face,
|
|
101
|
+
// and recorded rather than swallowed.
|
|
102
|
+
const code = isNotificationError(error)
|
|
103
|
+
? error.code
|
|
104
|
+
: ((error as { code?: string }).code ?? 'UNKNOWN');
|
|
105
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
106
|
+
|
|
107
|
+
emit(code, `Push instruction rejected: ${reason}`, { id: envelope.id });
|
|
108
|
+
return { kind: 'rejected', id: envelope.id, code, reason };
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type InstructionHandler = ReturnType<typeof createInstructionHandler>;
|
|
@@ -1,54 +1,54 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The port every push provider satisfies.
|
|
3
|
-
*
|
|
4
|
-
* No provider type appears in any signature, which is what makes swapping the
|
|
5
|
-
* provider cost the consumer nothing.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { RoutingPayload, NotificationContent } from '../contract/notification';
|
|
9
|
-
import type { Unsubscribe } from '../contract/interaction';
|
|
10
|
-
|
|
11
|
-
/** The single normalized shape every provider's payload is reduced to. */
|
|
12
|
-
export interface PushEnvelope {
|
|
13
|
-
/** Contract version carried by the payload. See contracts/push-payload.md. */
|
|
14
|
-
readonly version: number;
|
|
15
|
-
readonly kind: 'display' | 'data' | 'schedule' | 'cancel';
|
|
16
|
-
readonly id: string;
|
|
17
|
-
readonly ownerTag?: string;
|
|
18
|
-
readonly content?: NotificationContent;
|
|
19
|
-
readonly tone?: string;
|
|
20
|
-
readonly moment?: { readonly at: number; readonly anchoring?: 'wallClock' | 'absolute' };
|
|
21
|
-
readonly routing?: RoutingPayload;
|
|
22
|
-
/** De-duplication key, falling back to `id` when absent. */
|
|
23
|
-
readonly dedupId: string;
|
|
24
|
-
readonly receivedAt: number;
|
|
25
|
-
/**
|
|
26
|
-
* True when the payload declared a version this build does not implement and
|
|
27
|
-
* was processed best-effort, honouring known fields and ignoring the rest.
|
|
28
|
-
*/
|
|
29
|
-
readonly versionSkew: boolean;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface PushConfig {
|
|
33
|
-
/** Called when the provider reports a new or refreshed registration token. */
|
|
34
|
-
readonly onTokenChange?: (token: string) => void;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export type PushAvailability = 'available' | 'unavailable' | 'unknown';
|
|
38
|
-
|
|
39
|
-
export interface PushProviderAdapter {
|
|
40
|
-
readonly name: string;
|
|
41
|
-
|
|
42
|
-
/** Reports whether platform push services exist, so degradation is controlled rather than opaque. */
|
|
43
|
-
getAvailability(): Promise<PushAvailability>;
|
|
44
|
-
|
|
45
|
-
/** Waits for a genuine token. Never returns a placeholder presented as a token. */
|
|
46
|
-
getToken(): Promise<string>;
|
|
47
|
-
onTokenChange(handler: (token: string) => void): Unsubscribe;
|
|
48
|
-
invalidateToken(): Promise<boolean>;
|
|
49
|
-
|
|
50
|
-
/** Raw provider payloads, normalized by the gate rather than by the adapter. */
|
|
51
|
-
subscribeToMessages(handler: (raw: unknown) => void): Unsubscribe;
|
|
52
|
-
/** A payload that launched the app, consumed exactly once. */
|
|
53
|
-
takeInitialMessage(): Promise<unknown | undefined>;
|
|
54
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The port every push provider satisfies.
|
|
3
|
+
*
|
|
4
|
+
* No provider type appears in any signature, which is what makes swapping the
|
|
5
|
+
* provider cost the consumer nothing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { RoutingPayload, NotificationContent } from '../contract/notification';
|
|
9
|
+
import type { Unsubscribe } from '../contract/interaction';
|
|
10
|
+
|
|
11
|
+
/** The single normalized shape every provider's payload is reduced to. */
|
|
12
|
+
export interface PushEnvelope {
|
|
13
|
+
/** Contract version carried by the payload. See contracts/push-payload.md. */
|
|
14
|
+
readonly version: number;
|
|
15
|
+
readonly kind: 'display' | 'data' | 'schedule' | 'cancel';
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly ownerTag?: string;
|
|
18
|
+
readonly content?: NotificationContent;
|
|
19
|
+
readonly tone?: string;
|
|
20
|
+
readonly moment?: { readonly at: number; readonly anchoring?: 'wallClock' | 'absolute' };
|
|
21
|
+
readonly routing?: RoutingPayload;
|
|
22
|
+
/** De-duplication key, falling back to `id` when absent. */
|
|
23
|
+
readonly dedupId: string;
|
|
24
|
+
readonly receivedAt: number;
|
|
25
|
+
/**
|
|
26
|
+
* True when the payload declared a version this build does not implement and
|
|
27
|
+
* was processed best-effort, honouring known fields and ignoring the rest.
|
|
28
|
+
*/
|
|
29
|
+
readonly versionSkew: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PushConfig {
|
|
33
|
+
/** Called when the provider reports a new or refreshed registration token. */
|
|
34
|
+
readonly onTokenChange?: (token: string) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type PushAvailability = 'available' | 'unavailable' | 'unknown';
|
|
38
|
+
|
|
39
|
+
export interface PushProviderAdapter {
|
|
40
|
+
readonly name: string;
|
|
41
|
+
|
|
42
|
+
/** Reports whether platform push services exist, so degradation is controlled rather than opaque. */
|
|
43
|
+
getAvailability(): Promise<PushAvailability>;
|
|
44
|
+
|
|
45
|
+
/** Waits for a genuine token. Never returns a placeholder presented as a token. */
|
|
46
|
+
getToken(): Promise<string>;
|
|
47
|
+
onTokenChange(handler: (token: string) => void): Unsubscribe;
|
|
48
|
+
invalidateToken(): Promise<boolean>;
|
|
49
|
+
|
|
50
|
+
/** Raw provider payloads, normalized by the gate rather than by the adapter. */
|
|
51
|
+
subscribeToMessages(handler: (raw: unknown) => void): Unsubscribe;
|
|
52
|
+
/** A payload that launched the app, consumed exactly once. */
|
|
53
|
+
takeInitialMessage(): Promise<unknown | undefined>;
|
|
54
|
+
}
|
package/src/push/signout.ts
CHANGED
|
@@ -1,65 +1,65 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sign-out cleanup.
|
|
3
|
-
*
|
|
4
|
-
* Three things must happen together, and the order matters: invalidate the
|
|
5
|
-
* token first so no further messages arrive for the account being left, then
|
|
6
|
-
* cancel its notifications, then clear its persisted state.
|
|
7
|
-
*
|
|
8
|
-
* Deliberately **scoped to one owner tag**. On a shared device, signing one
|
|
9
|
-
* account out must not clear another's deduplication history or cancel their
|
|
10
|
-
* reminders — and must not discard the channel migration ledger, which
|
|
11
|
-
* describes the device rather than any account.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import type { PushGate } from './gate';
|
|
15
|
-
import type { Scheduler } from '../schedule';
|
|
16
|
-
import type { StateManager } from '../state/record';
|
|
17
|
-
import { clearOwner } from '../state/reset';
|
|
18
|
-
|
|
19
|
-
export interface SignOutDependencies {
|
|
20
|
-
readonly scheduler: Scheduler;
|
|
21
|
-
readonly state: StateManager;
|
|
22
|
-
/** Absent for a local-only app, which still needs the rest of the cleanup. */
|
|
23
|
-
readonly push?: PushGate;
|
|
24
|
-
readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface SignOutResult {
|
|
28
|
-
readonly ownerTag: string;
|
|
29
|
-
readonly tokenInvalidated: boolean;
|
|
30
|
-
readonly cancelledIds: readonly string[];
|
|
31
|
-
readonly clearedCollections: readonly string[];
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function createSignOut(deps: SignOutDependencies) {
|
|
35
|
-
return {
|
|
36
|
-
async signOut(ownerTag: string): Promise<SignOutResult> {
|
|
37
|
-
// Token first: stop the inbound flow before removing what would have
|
|
38
|
-
// handled it, so nothing arrives for an account that is already gone.
|
|
39
|
-
let tokenInvalidated = false;
|
|
40
|
-
if (deps.push !== undefined) {
|
|
41
|
-
try {
|
|
42
|
-
tokenInvalidated = await deps.push.invalidateToken();
|
|
43
|
-
} catch {
|
|
44
|
-
// A failed invalidation must not prevent local cleanup — leaving a
|
|
45
|
-
// signed-out account's notifications on the device is the worse of
|
|
46
|
-
// the two outcomes.
|
|
47
|
-
tokenInvalidated = false;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const cancelledIds = await deps.scheduler.cancelByOwner(ownerTag);
|
|
52
|
-
const { clearedCollections } = clearOwner(deps.state, ownerTag);
|
|
53
|
-
|
|
54
|
-
deps.emit?.(
|
|
55
|
-
'SIGN_OUT',
|
|
56
|
-
`Signed out ${ownerTag}: token ${tokenInvalidated ? 'invalidated' : 'not invalidated'}, ${cancelledIds.length} notification(s) cancelled, per-owner state cleared.`,
|
|
57
|
-
{ ownerTag, tokenInvalidated, cancelled: cancelledIds.length },
|
|
58
|
-
);
|
|
59
|
-
|
|
60
|
-
return { ownerTag, tokenInvalidated, cancelledIds, clearedCollections };
|
|
61
|
-
},
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export type SignOut = ReturnType<typeof createSignOut>;
|
|
1
|
+
/**
|
|
2
|
+
* Sign-out cleanup.
|
|
3
|
+
*
|
|
4
|
+
* Three things must happen together, and the order matters: invalidate the
|
|
5
|
+
* token first so no further messages arrive for the account being left, then
|
|
6
|
+
* cancel its notifications, then clear its persisted state.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately **scoped to one owner tag**. On a shared device, signing one
|
|
9
|
+
* account out must not clear another's deduplication history or cancel their
|
|
10
|
+
* reminders — and must not discard the channel migration ledger, which
|
|
11
|
+
* describes the device rather than any account.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { PushGate } from './gate';
|
|
15
|
+
import type { Scheduler } from '../schedule';
|
|
16
|
+
import type { StateManager } from '../state/record';
|
|
17
|
+
import { clearOwner } from '../state/reset';
|
|
18
|
+
|
|
19
|
+
export interface SignOutDependencies {
|
|
20
|
+
readonly scheduler: Scheduler;
|
|
21
|
+
readonly state: StateManager;
|
|
22
|
+
/** Absent for a local-only app, which still needs the rest of the cleanup. */
|
|
23
|
+
readonly push?: PushGate;
|
|
24
|
+
readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SignOutResult {
|
|
28
|
+
readonly ownerTag: string;
|
|
29
|
+
readonly tokenInvalidated: boolean;
|
|
30
|
+
readonly cancelledIds: readonly string[];
|
|
31
|
+
readonly clearedCollections: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createSignOut(deps: SignOutDependencies) {
|
|
35
|
+
return {
|
|
36
|
+
async signOut(ownerTag: string): Promise<SignOutResult> {
|
|
37
|
+
// Token first: stop the inbound flow before removing what would have
|
|
38
|
+
// handled it, so nothing arrives for an account that is already gone.
|
|
39
|
+
let tokenInvalidated = false;
|
|
40
|
+
if (deps.push !== undefined) {
|
|
41
|
+
try {
|
|
42
|
+
tokenInvalidated = await deps.push.invalidateToken();
|
|
43
|
+
} catch {
|
|
44
|
+
// A failed invalidation must not prevent local cleanup — leaving a
|
|
45
|
+
// signed-out account's notifications on the device is the worse of
|
|
46
|
+
// the two outcomes.
|
|
47
|
+
tokenInvalidated = false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const cancelledIds = await deps.scheduler.cancelByOwner(ownerTag);
|
|
52
|
+
const { clearedCollections } = clearOwner(deps.state, ownerTag);
|
|
53
|
+
|
|
54
|
+
deps.emit?.(
|
|
55
|
+
'SIGN_OUT',
|
|
56
|
+
`Signed out ${ownerTag}: token ${tokenInvalidated ? 'invalidated' : 'not invalidated'}, ${cancelledIds.length} notification(s) cancelled, per-owner state cleared.`,
|
|
57
|
+
{ ownerTag, tokenInvalidated, cancelled: cancelledIds.length },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
return { ownerTag, tokenInvalidated, cancelledIds, clearedCollections };
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type SignOut = ReturnType<typeof createSignOut>;
|
package/src/reconcile/chunk.ts
CHANGED
|
@@ -1,53 +1,53 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Chunked application.
|
|
3
|
-
*
|
|
4
|
-
* A 500-entry reconciliation must not drop frames on a list screen, so work is
|
|
5
|
-
* applied in slices with a yield between them. No single slice may occupy the
|
|
6
|
-
* interface thread for more than 16 ms, which is what holds 60 fps while
|
|
7
|
-
* reconciliation is in flight.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export const DEFAULT_CHUNK_SIZE = 25;
|
|
11
|
-
|
|
12
|
-
/** Yields to the event loop so the interface thread can render between slices. */
|
|
13
|
-
export function yieldToEventLoop(): Promise<void> {
|
|
14
|
-
return new Promise(resolve => setTimeout(resolve, 0));
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface ChunkOptions {
|
|
18
|
-
readonly size?: number;
|
|
19
|
-
readonly onYield?: () => Promise<void>;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Applies `work` to every item, yielding between chunks.
|
|
24
|
-
*
|
|
25
|
-
* Sequential within a chunk rather than parallel: these calls bridge to native,
|
|
26
|
-
* and a burst of concurrent scheduling is where duplicate-delivery races come
|
|
27
|
-
* from.
|
|
28
|
-
*/
|
|
29
|
-
export async function applyInChunks<T>(
|
|
30
|
-
items: readonly T[],
|
|
31
|
-
work: (item: T) => Promise<void>,
|
|
32
|
-
options: ChunkOptions = {},
|
|
33
|
-
): Promise<number> {
|
|
34
|
-
const size = Math.max(1, options.size ?? DEFAULT_CHUNK_SIZE);
|
|
35
|
-
const yieldFn = options.onYield ?? yieldToEventLoop;
|
|
36
|
-
let applied = 0;
|
|
37
|
-
|
|
38
|
-
for (let start = 0; start < items.length; start += size) {
|
|
39
|
-
const chunk = items.slice(start, start + size);
|
|
40
|
-
|
|
41
|
-
for (const item of chunk) {
|
|
42
|
-
await work(item);
|
|
43
|
-
applied += 1;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// No yield after the final chunk — the caller is about to resume anyway.
|
|
47
|
-
if (start + size < items.length) {
|
|
48
|
-
await yieldFn();
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return applied;
|
|
53
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Chunked application.
|
|
3
|
+
*
|
|
4
|
+
* A 500-entry reconciliation must not drop frames on a list screen, so work is
|
|
5
|
+
* applied in slices with a yield between them. No single slice may occupy the
|
|
6
|
+
* interface thread for more than 16 ms, which is what holds 60 fps while
|
|
7
|
+
* reconciliation is in flight.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_CHUNK_SIZE = 25;
|
|
11
|
+
|
|
12
|
+
/** Yields to the event loop so the interface thread can render between slices. */
|
|
13
|
+
export function yieldToEventLoop(): Promise<void> {
|
|
14
|
+
return new Promise(resolve => setTimeout(resolve, 0));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ChunkOptions {
|
|
18
|
+
readonly size?: number;
|
|
19
|
+
readonly onYield?: () => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Applies `work` to every item, yielding between chunks.
|
|
24
|
+
*
|
|
25
|
+
* Sequential within a chunk rather than parallel: these calls bridge to native,
|
|
26
|
+
* and a burst of concurrent scheduling is where duplicate-delivery races come
|
|
27
|
+
* from.
|
|
28
|
+
*/
|
|
29
|
+
export async function applyInChunks<T>(
|
|
30
|
+
items: readonly T[],
|
|
31
|
+
work: (item: T) => Promise<void>,
|
|
32
|
+
options: ChunkOptions = {},
|
|
33
|
+
): Promise<number> {
|
|
34
|
+
const size = Math.max(1, options.size ?? DEFAULT_CHUNK_SIZE);
|
|
35
|
+
const yieldFn = options.onYield ?? yieldToEventLoop;
|
|
36
|
+
let applied = 0;
|
|
37
|
+
|
|
38
|
+
for (let start = 0; start < items.length; start += size) {
|
|
39
|
+
const chunk = items.slice(start, start + size);
|
|
40
|
+
|
|
41
|
+
for (const item of chunk) {
|
|
42
|
+
await work(item);
|
|
43
|
+
applied += 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// No yield after the final chunk — the caller is about to resume anyway.
|
|
47
|
+
if (start + size < items.length) {
|
|
48
|
+
await yieldFn();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return applied;
|
|
53
|
+
}
|