@indietabletop/appkit 4.0.0 → 4.1.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.
@@ -0,0 +1,227 @@
1
+ import { createActorContext } from "@xstate/react";
2
+ import { createContext, useContext, useMemo, type ReactNode } from "react";
3
+ import { fromCallback, fromPromise } from "xstate";
4
+ import { Failure, Success } from "../async-op.ts";
5
+ import type { GameCode, IndieTabletopClient, UserGameData } from "../client.ts";
6
+ import type { ModernIDB } from "../ModernIDB/ModernIDB.ts";
7
+ import type { ModernIDBIndexes, ModernIDBSchema } from "../ModernIDB/types.ts";
8
+ import type { CurrentUser } from "../types.ts";
9
+ import {
10
+ machine,
11
+ type PullChangesInput,
12
+ type PushChangesInput,
13
+ } from "./store.ts";
14
+ import type { MachineEvent, PullResult, PushResult } from "./types.ts";
15
+ import { toSyncedItems } from "./utils.ts";
16
+
17
+ export type AppActions = {
18
+ clientLogout: (params: { serverUser: CurrentUser }) => Promise<void>;
19
+ serverLogout: () => Promise<void>;
20
+ logout: () => Promise<void>;
21
+ push: () => void;
22
+ pull: () => void;
23
+ };
24
+
25
+ export const AppActionsContext = createContext<null | AppActions>(null);
26
+
27
+ const {
28
+ Provider: InternalMachineProvider,
29
+ useSelector,
30
+ useActorRef,
31
+ } = createActorContext(machine);
32
+
33
+ export { useActorRef, useSelector };
34
+
35
+ export function useCurrentUser() {
36
+ return useSelector((s) => s.context.currentUser);
37
+ }
38
+
39
+ export function useSessionInfo() {
40
+ return useSelector((s) => s.context.sessionInfo);
41
+ }
42
+
43
+ export function useAppActions() {
44
+ const actions = useContext(AppActionsContext);
45
+
46
+ if (!actions) {
47
+ throw new Error(`Missing context value for app actions.`);
48
+ }
49
+
50
+ return actions;
51
+ }
52
+
53
+ export type DatabaseAppMachineMethods = {
54
+ upsertGameData(data: UserGameData): Promise<Success<string[]>>;
55
+
56
+ getUpdatedGameDataSince(props: {
57
+ sinceTs: number | null;
58
+ exclude: Set<string>;
59
+ }): Promise<Success<UserGameData>>;
60
+
61
+ clearAll(): Promise<Success<string> | Failure<string>>;
62
+ };
63
+
64
+ export function createAppMachineProvider<
65
+ Schema extends ModernIDBSchema,
66
+ Indexes extends ModernIDBIndexes<Schema>,
67
+ >(options: {
68
+ database: ModernIDB<Schema, Indexes> & DatabaseAppMachineMethods;
69
+ client: IndieTabletopClient;
70
+ pullGameDataFor: GameCode | GameCode[];
71
+ }) {
72
+ const { client, database } = options;
73
+
74
+ // Concrete implementations of actors that our machine requires.
75
+
76
+ const auth = fromCallback<MachineEvent>(({ sendBack }) => {
77
+ const controller = new AbortController();
78
+
79
+ client.addEventListener(
80
+ "currentUser",
81
+ ({ detail }) => void sendBack({ type: "currentUser", ...detail }),
82
+ controller,
83
+ );
84
+
85
+ client.addEventListener(
86
+ "sessionInfo",
87
+ ({ detail }) => void sendBack({ type: "sessionInfo", ...detail }),
88
+ controller,
89
+ );
90
+
91
+ client.addEventListener(
92
+ "sessionExpired",
93
+ () => void sendBack({ type: "sessionExpired" }),
94
+ controller,
95
+ );
96
+
97
+ return () => {
98
+ controller.abort();
99
+ };
100
+ });
101
+
102
+ const sync = fromCallback<MachineEvent>(({ sendBack }) => {
103
+ const controller = new AbortController();
104
+
105
+ // As long as sync is active, make sure to `pull` after every window focus.
106
+ window.addEventListener(
107
+ "focus",
108
+ () => void sendBack({ type: "pull" }),
109
+ controller,
110
+ );
111
+
112
+ // If there is a `readwrite` transaction in IndexedDB trigger a `push`.
113
+ database.addEventListener(
114
+ "readwrite",
115
+ () => void sendBack({ type: "push" }),
116
+ controller,
117
+ );
118
+
119
+ return () => {
120
+ controller.abort();
121
+ };
122
+ });
123
+
124
+ const pullChanges = fromPromise<PullResult, PullChangesInput>(
125
+ async ({ input }) => {
126
+ const result = await client.pullUserData({
127
+ include: options.pullGameDataFor,
128
+ sinceTs: input.sinceTs,
129
+ expectCurrentUserId: input.currentUser.id,
130
+ });
131
+
132
+ const userGameData = result.valueOrThrow();
133
+ await database.upsertGameData(userGameData);
134
+
135
+ return new Success({ pulled: toSyncedItems(userGameData) });
136
+ },
137
+ );
138
+
139
+ const pushChanges = fromPromise<PushResult, PushChangesInput>(
140
+ async ({ input }) => {
141
+ const { value: changedData } = await database.getUpdatedGameDataSince({
142
+ sinceTs: input.sinceTs,
143
+ exclude: new Set(input.ignoredItems.map((i) => i.id)),
144
+ });
145
+
146
+ const changed = toSyncedItems(changedData);
147
+
148
+ if (changed.length === 0) {
149
+ return new Success({ pushed: [], pulled: [] });
150
+ }
151
+
152
+ const result = await client.pushUserData({
153
+ data: changedData,
154
+ pullSinceTs: input.currentSyncTs,
155
+ currentSyncTs: input.currentSyncTs,
156
+ expectCurrentUserId: input.currentUser.id,
157
+ });
158
+
159
+ const userGameData = result.valueOrThrow();
160
+ await database.upsertGameData(userGameData);
161
+
162
+ return new Success({
163
+ pushed: changed,
164
+ pulled: toSyncedItems(userGameData),
165
+ });
166
+ },
167
+ );
168
+
169
+ // Bound provider components
170
+
171
+ function AppActionsProvider(props: { children: ReactNode }) {
172
+ const { children } = props;
173
+ const app = useActorRef();
174
+
175
+ const actions: AppActions = useMemo(() => {
176
+ async function clientLogout(params: { serverUser: CurrentUser }) {
177
+ await database.clearAll();
178
+ app.send({ type: "serverUser", ...params });
179
+
180
+ // Get new session info
181
+ await client.refreshTokens();
182
+ }
183
+
184
+ async function serverLogout() {
185
+ await client.logout();
186
+ }
187
+
188
+ async function logout() {
189
+ await client.logout();
190
+ await database.clearAll();
191
+ app.send({ type: "reset" });
192
+ }
193
+
194
+ function push() {
195
+ app.send({ type: "push" });
196
+ }
197
+
198
+ function pull() {
199
+ app.send({ type: "pull" });
200
+ }
201
+
202
+ return {
203
+ pull,
204
+ push,
205
+ logout,
206
+ serverLogout,
207
+ clientLogout,
208
+ };
209
+ }, [app]);
210
+
211
+ return <AppActionsContext value={actions}>{children}</AppActionsContext>;
212
+ }
213
+
214
+ return function AppMachineProvider(props: { children: ReactNode }) {
215
+ const { children } = props;
216
+
217
+ return (
218
+ <InternalMachineProvider
219
+ logic={machine.provide({
220
+ actors: { auth, sync, pullChanges, pushChanges },
221
+ })}
222
+ >
223
+ <AppActionsProvider>{children}</AppActionsProvider>
224
+ </InternalMachineProvider>
225
+ );
226
+ };
227
+ }
@@ -0,0 +1,453 @@
1
+ import { number } from "superstruct";
2
+ import {
3
+ and,
4
+ assertEvent,
5
+ assign,
6
+ fromCallback,
7
+ fromPromise,
8
+ setup,
9
+ } from "xstate";
10
+ import {
11
+ createSafeStorage,
12
+ type SafeStorageKey,
13
+ } from "../createSafeStorage.ts";
14
+ import { currentUser, sessionInfo } from "../structs.ts";
15
+ import type { CurrentUser, SessionInfo } from "../types.ts";
16
+ import type {
17
+ MachineContext,
18
+ MachineEvent,
19
+ PullResult,
20
+ PushResult,
21
+ SyncedItem,
22
+ } from "./types.ts";
23
+ import { assertNonNullish, caughtToResult } from "./utils.ts";
24
+
25
+ const safeStorage = createSafeStorage({
26
+ currentUser: currentUser(),
27
+ sessionInfo: sessionInfo(),
28
+ lastSuccessfulSyncTs: number(),
29
+ });
30
+
31
+ function createInMemoryContext(): Omit<
32
+ MachineContext,
33
+ SafeStorageKey<typeof safeStorage>
34
+ > {
35
+ return {
36
+ currentSync: null,
37
+ pushOnceIdle: false,
38
+ syncLog: [],
39
+ };
40
+ }
41
+
42
+ function createInitialContext(): MachineContext {
43
+ return {
44
+ ...createInMemoryContext(),
45
+ currentUser: safeStorage.getItem("currentUser"),
46
+ sessionInfo: safeStorage.getItem("sessionInfo"),
47
+ lastSuccessfulSyncTs: safeStorage.getItem("lastSuccessfulSyncTs"),
48
+ };
49
+ }
50
+
51
+ const sync = fromCallback<MachineEvent>(() => {
52
+ throw new Error(`Sync actor not provided.`);
53
+ });
54
+
55
+ const auth = fromCallback<MachineEvent>(() => {
56
+ throw new Error(`Auth actor not provided.`);
57
+ });
58
+
59
+ export type PullChangesInput = {
60
+ sinceTs: number | null;
61
+ currentUser: CurrentUser;
62
+ };
63
+
64
+ const pullChanges = fromPromise<PullResult, PullChangesInput>(() => {
65
+ throw new Error(`Pull changes actor not provided.`);
66
+ });
67
+
68
+ export type PushChangesInput = {
69
+ sinceTs: number | null;
70
+ currentSyncTs: number;
71
+ ignoredItems: SyncedItem[];
72
+ currentUser: CurrentUser;
73
+ };
74
+
75
+ const pushChanges = fromPromise<PushResult, PushChangesInput>(async () => {
76
+ throw new Error(`Push changes actor not provided.`);
77
+ });
78
+
79
+ const config = setup({
80
+ types: {
81
+ context: {} as MachineContext,
82
+ events: {} as MachineEvent,
83
+ children: {} as {
84
+ sync: "sync";
85
+ auth: "auth";
86
+ },
87
+ },
88
+
89
+ actors: {
90
+ auth,
91
+ sync,
92
+ pullChanges,
93
+ pushChanges,
94
+ },
95
+
96
+ actions: {
97
+ setCurrentSync: assign({
98
+ currentSync: () => ({ startedTs: Date.now(), pull: null, push: null }),
99
+ }),
100
+
101
+ flushCurrentSync: assign(({ context }) => {
102
+ const { currentSync, syncLog } = context;
103
+
104
+ assertNonNullish(
105
+ currentSync,
106
+ "Flushing current sync but context.currentSync is nullish.",
107
+ );
108
+
109
+ return {
110
+ currentSync: null,
111
+ syncLog: [currentSync, ...syncLog],
112
+ };
113
+ }),
114
+
115
+ setLastSuccessfulSync: assign(({ context }) => {
116
+ assertNonNullish(
117
+ context.currentSync,
118
+ "Setting last successful sync but context.currentSync is nullish.",
119
+ );
120
+
121
+ const lastSuccessfulSyncTs = context.currentSync.startedTs;
122
+ safeStorage.setItem("lastSuccessfulSyncTs", lastSuccessfulSyncTs);
123
+ return { lastSuccessfulSyncTs };
124
+ }),
125
+
126
+ markSyncStepResult: assign({
127
+ currentSync: (
128
+ { context },
129
+ params: { pull?: PullResult; push?: PushResult },
130
+ ) => {
131
+ assertNonNullish(
132
+ context.currentSync,
133
+ "Marking sync step result but context.currentSync is nullish.",
134
+ );
135
+
136
+ return {
137
+ ...context.currentSync,
138
+ ...params,
139
+ };
140
+ },
141
+ }),
142
+
143
+ queuePush: assign({ pushOnceIdle: true }),
144
+
145
+ clearPushQueue: assign({ pushOnceIdle: false }),
146
+
147
+ setCurrentUser: assign((_, currentUser: CurrentUser) => {
148
+ safeStorage.setItem("currentUser", currentUser);
149
+ return { currentUser };
150
+ }),
151
+
152
+ patchSessionInfo: assign(({ context }, newSessionInfo: SessionInfo) => {
153
+ const sessionInfo = context.sessionInfo
154
+ ? { ...context.sessionInfo, expiresTs: newSessionInfo.expiresTs }
155
+ : newSessionInfo;
156
+
157
+ safeStorage.setItem("sessionInfo", sessionInfo);
158
+ return { sessionInfo };
159
+ }),
160
+
161
+ resetContext: assign((): MachineContext => {
162
+ safeStorage.clear();
163
+
164
+ return {
165
+ ...createInMemoryContext(),
166
+ currentUser: null,
167
+ sessionInfo: null,
168
+ lastSuccessfulSyncTs: null,
169
+ };
170
+ }),
171
+
172
+ resetWithServerUser: assign((_, serverUser: CurrentUser) => {
173
+ safeStorage.clear();
174
+ safeStorage.setItem("currentUser", serverUser);
175
+
176
+ return {
177
+ ...createInMemoryContext(),
178
+ currentUser: serverUser,
179
+ sessionInfo: null,
180
+ lastSuccessfulSyncTs: null,
181
+ };
182
+ }),
183
+ },
184
+
185
+ guards: {
186
+ shouldPushOnceIdle: ({ context }) => {
187
+ return context.pushOnceIdle;
188
+ },
189
+
190
+ isEligibleForSync: and(["isUserVerified", "isNotMismatched"]),
191
+
192
+ isUserVerified: ({ event }) => {
193
+ assertEvent(event, "currentUser");
194
+
195
+ return event.currentUser.isVerified;
196
+ },
197
+
198
+ isNotMismatched: ({ event, context }) => {
199
+ assertEvent(event, "currentUser");
200
+
201
+ // If there is no current user, there cannot be a mismatch
202
+ if (!context.currentUser) {
203
+ return true;
204
+ }
205
+
206
+ // Otherwise check that user IDs match.
207
+ return context.currentUser.id === event.currentUser.id;
208
+ },
209
+ },
210
+ });
211
+
212
+ export const machine = config.createMachine({
213
+ /** @xstate-layout N4IgpgJg5mDOIC5QEMAOqDEAnOYAuA2gAwC6ioqA9rAJZ42UB25IAHogLQBMRAbAHQBOQbwCMggOy8ALES7DRAGhABPTvIn8AzAA5B0rdN4BWCRKKDRvAL7XlaVPwCujZE7wALMI3oBjZHiQGL5OWDg+AKqwYFjEZEggVLT0TCzsCMai0vzSejrGRIayvLwSymoIHLmi-MZcvIa6+bk8xrb26M6u7l4+NP6BEMGh4XhRMQSi8RTUdAzMCem5RPw6WlziZryWEqJKqojCK1oSgvXrRMZXRu0gDl1unt5+AUEhYc-jsVzTibMpC1ASx05UOolu9xcj16L0GGGiWAAbjEvnEWEk5qlFoguDoiKDKntwXY7p1oc9+q8hu9RqjSOj-vM0ohpCIhKJClwuFIdnsCaIuEZ+LxcdzpLtpFxMhCyT0KQM3iNPgjJr8MQDmYSOfzBDLHOS+gqhtFYLQmABJRgAM0oaIS6qZ2IQOiF6y0gh0OismQken5goEIp0YolUuJHX1csNVPhcDNjAAoqxUDQcBA7TNko6gYhDDUtIUBUQDBIpTxeASOO6uPwNroJNIBcZPQ2bCT7gbYZB+LAVIxfD2+74aIwoBgIEwwD28K9+B2o12IIP+8vh6OM38s1icwguTJaqIG4IC3sG1oCdzjPxzJJjFpxBz78W9XOF5TBqvVyOx6gnLAPBuDrbmwiAckQNT6HogimPopiGAS0imPwoh6Fy5iSmsegvp277dr2K74WuUD8L+AA2pHfuOk78COiKUAA1lO85PNGH6EV+o4kU45HfggtGUAM8xxIBjLAekRbZPk+gGAWLolOeBwIFYqynMe+QSuB0nYW+RqfoR35cTxo4YDEWCUFgJGkQENpYAAtnOsosYuelDgZZEUaOfGMHRglMMJ9L2qJgIgUpWTZGI5imKWDY6BWilZFotasiYDS5MYurto5MK4Uu7H6Zxv7-pRE6MFO-GMQ5kZOTlLn9m5f4eLx-G+Yw-lqkFmpWL6qzgeYBZELs3L7BUBbIXwBSiLouJiMGXDadVul5a5BUNZRpnmZZ1nmfZzHZYtQ4ccRhWNZ5zUBEJpAiVuwXicYJSrDJvAuni+goRegq1IYZYeqeWRtJlVV7VSn40BApFgBgx1XZiN2gYIfAHkeJxELFLoElI2QodBJ6CnoDbzUDbEHaD4OQ9xpHQxqTobPUPUCp6hQlFKxgEg0gi1FyxjSIhWiyVKBPysD7EkxDlPZiFKFiNovMnOYoqIVwBJeolMtaHUkoNMG4akoDgtEyuADuyBzMZUMBZmMOdVINY+qyBY-WUilPTWWOtJK7oNBIAusXhB1GybY6sLAM6BHOVqBFgAAUogFEQACUGC7XrvuG8b9Drubm6W06WRcrW1vine0HSQSzaaCKyO8EQcgDdztgkowlAQHALAOAy12atwXNCCImwyHICiVmIAhyKlTRc8GL5QjpVLt9nO4cIe2TmIU1exWXa+VkYNQx8GDQyJN-X-RGr4LbPgUd06wbZFwuiXEjIjHlvUjCgYrLwwK3ImN7zkjmAFFQBoAAI3BnPKmO5PSJVOOBXQx4Ni+gJEcVYVxgymCsDoUsc0Aan0JinXwYDxbpAwTWW+eJTB20fgpCoVZuav0MEcT+Uhj46xwcnXKB18pQAIWJQ4PBax33IceShlZDw6Doe-cCaEZBthPjhfaBFlpHXJt+bhsMEAiDEaQ++FDthUNAuFca4FeqyA9BlWRM99YDk4VxIqo5VGdQwjkBoVgUYum9HopSkleZ8DVjwSaVhDw-xqktOqnFCKQHsTnSwNRIrNmELkSUjsKiTW0HdWOHozC3zqDoIJ8irGKP4FaY24MICRJ3H4zQaDGzpQ5DHBBilb7s0PDFQUgoRBbFyULYmYMwBlJChsFC2hJEYLWOYDB6MRSpIihIK4DYhqdMsfwf26cuEX3nhLRsYiTiHyyKWSUAoJk1jVtM2ZiStD12sEAA */
214
+ id: "app",
215
+ context: () => createInitialContext(),
216
+
217
+ invoke: {
218
+ id: "auth",
219
+ src: "auth",
220
+ },
221
+
222
+ initial: "unauthenticated",
223
+
224
+ on: {
225
+ reset: [
226
+ {
227
+ actions: "resetContext",
228
+ target: ".unauthenticated",
229
+ },
230
+ ],
231
+ },
232
+
233
+ states: {
234
+ unauthenticated: {
235
+ description:
236
+ "The user is either completely anonymous, authenticated via insecure means (localStorage), or in an otherwise no-correctly-authenticated state (e.g. user mismatch).",
237
+
238
+ on: {
239
+ currentUser: [
240
+ {
241
+ guard: "isEligibleForSync",
242
+ target: "authenticated",
243
+
244
+ actions: {
245
+ type: "setCurrentUser",
246
+ params: ({ event }) => event.currentUser,
247
+ },
248
+ },
249
+ {
250
+ guard: "isNotMismatched",
251
+ target: "authenticated.ineligible",
252
+
253
+ actions: {
254
+ type: "setCurrentUser",
255
+ params: ({ event }) => event.currentUser,
256
+ },
257
+ },
258
+ { target: "unauthenticated" },
259
+ ],
260
+ serverUser: {
261
+ target: "authenticated",
262
+ actions: {
263
+ type: "resetWithServerUser",
264
+ params: ({ event }) => event.serverUser,
265
+ },
266
+ },
267
+ },
268
+ },
269
+
270
+ authenticated: {
271
+ description: "The user has a session with ITC.",
272
+
273
+ initial: "sync",
274
+
275
+ on: {
276
+ currentUser: [
277
+ {
278
+ guard: "isNotMismatched",
279
+ actions: {
280
+ type: "setCurrentUser",
281
+ params: ({ event }) => event.currentUser,
282
+ },
283
+ },
284
+ {
285
+ target: "unauthenticated",
286
+ },
287
+ ],
288
+
289
+ sessionInfo: {
290
+ actions: {
291
+ type: "patchSessionInfo",
292
+ params: ({ event }) => event.sessionInfo,
293
+ },
294
+ },
295
+
296
+ sessionExpired: {
297
+ target: "unauthenticated",
298
+ },
299
+ },
300
+
301
+ states: {
302
+ ineligible: {
303
+ description: "The user is not eligible for sync (e.g. unverified)",
304
+ },
305
+
306
+ sync: {
307
+ description: "The user is eligible for the sync feature.",
308
+ invoke: {
309
+ id: "sync",
310
+ src: "sync",
311
+ },
312
+
313
+ initial: "syncing",
314
+
315
+ states: {
316
+ syncing: {
317
+ description: "The sync operation is in progress.",
318
+ initial: "pulling",
319
+ entry: "setCurrentSync",
320
+ exit: "flushCurrentSync",
321
+
322
+ on: {
323
+ push: {
324
+ actions: "queuePush",
325
+ },
326
+ },
327
+
328
+ states: {
329
+ pulling: {
330
+ invoke: {
331
+ src: "pullChanges",
332
+ input: ({ context }) => ({
333
+ sinceTs: context.lastSuccessfulSyncTs,
334
+ currentUser: context.currentUser!,
335
+ }),
336
+ onDone: {
337
+ target: "pushing",
338
+ actions: [
339
+ {
340
+ type: "markSyncStepResult",
341
+ params: ({ event }) => ({
342
+ pull: event.output,
343
+ }),
344
+ },
345
+ ],
346
+ },
347
+ onError: {
348
+ target: "failed",
349
+ actions: {
350
+ type: "markSyncStepResult",
351
+ params: ({ event }) => ({
352
+ pull: caughtToResult(event.error),
353
+ }),
354
+ },
355
+ },
356
+ },
357
+ },
358
+ pushing: {
359
+ entry: "clearPushQueue",
360
+
361
+ invoke: {
362
+ src: "pushChanges",
363
+ input: ({ context }) => {
364
+ assertNonNullish(
365
+ context.currentSync,
366
+ "Setting pushChanges input but context.currentSync is unset.",
367
+ );
368
+
369
+ assertNonNullish(
370
+ context.currentUser,
371
+ "Setting pushChanges input but context.currentUser is unset.",
372
+ );
373
+
374
+ // This value might be unset if no push was made during
375
+ // this sync attempt.
376
+ const pulledItems =
377
+ context.currentSync.pull?.valueOrNull()?.pulled ?? [];
378
+
379
+ return {
380
+ ignoredItems: pulledItems,
381
+ currentSyncTs: context.currentSync.startedTs,
382
+ sinceTs: context.lastSuccessfulSyncTs,
383
+ currentUser: context.currentUser,
384
+ };
385
+ },
386
+ onDone: {
387
+ target: "synced",
388
+ actions: {
389
+ type: "markSyncStepResult",
390
+ params: ({ event }) => ({
391
+ push: event.output,
392
+ }),
393
+ },
394
+ },
395
+ onError: {
396
+ target: "failed",
397
+ actions: {
398
+ type: "markSyncStepResult",
399
+ params: ({ event }) => ({
400
+ push: caughtToResult(event.error),
401
+ }),
402
+ },
403
+ },
404
+ },
405
+ },
406
+ synced: {
407
+ type: "final",
408
+ entry: "setLastSuccessfulSync",
409
+ },
410
+
411
+ failed: {
412
+ type: "final",
413
+ },
414
+ },
415
+
416
+ onDone: "idle",
417
+ },
418
+
419
+ idle: {
420
+ description: "The app is listening for sync events.",
421
+ always: {
422
+ guard: "shouldPushOnceIdle",
423
+ target: "waiting",
424
+ },
425
+ on: {
426
+ push: "waiting",
427
+ pull: "syncing",
428
+ },
429
+ },
430
+
431
+ waiting: {
432
+ description:
433
+ "We are waiting for further push events to batch them.",
434
+
435
+ after: {
436
+ 1500: {
437
+ target: "syncing.pushing",
438
+ },
439
+ },
440
+
441
+ on: {
442
+ push: {
443
+ target: "waiting",
444
+ reenter: true,
445
+ },
446
+ },
447
+ },
448
+ },
449
+ },
450
+ },
451
+ },
452
+ },
453
+ });