@epic-web/workshop-utils 6.85.5 → 6.86.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.
@@ -20,6 +20,20 @@ export declare const PlayerPreferencesSchema: z.ZodDefault<z.ZodOptional<z.ZodOb
20
20
  defaultView: z.ZodOptional<z.ZodString>;
21
21
  activeSidebarTab: z.ZodOptional<z.ZodNumber>;
22
22
  }, z.core.$strip>>>;
23
+ declare const PendingProgressMutationSchema: z.ZodObject<{
24
+ lessonSlug: z.ZodString;
25
+ complete: z.ZodBoolean;
26
+ queuedAt: z.ZodString;
27
+ host: z.ZodOptional<z.ZodString>;
28
+ workshopSlug: z.ZodOptional<z.ZodString>;
29
+ userId: z.ZodOptional<z.ZodString>;
30
+ }, z.core.$strip>;
31
+ export type PendingProgressMutation = z.infer<typeof PendingProgressMutationSchema>;
32
+ export type PendingProgressMutationScope = {
33
+ host: string;
34
+ workshopSlug: string;
35
+ userId: string;
36
+ };
23
37
  declare const DataSchema: z.ZodObject<{
24
38
  preferences: z.ZodDefault<z.ZodOptional<z.ZodObject<{
25
39
  player: z.ZodDefault<z.ZodOptional<z.ZodObject<{
@@ -79,6 +93,14 @@ declare const DataSchema: z.ZodObject<{
79
93
  }, z.core.$strip>>>;
80
94
  clientId: z.ZodOptional<z.ZodString>;
81
95
  mutedNotifications: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
96
+ pendingProgressMutations: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodObject<{
97
+ lessonSlug: z.ZodString;
98
+ complete: z.ZodBoolean;
99
+ queuedAt: z.ZodString;
100
+ host: z.ZodOptional<z.ZodString>;
101
+ workshopSlug: z.ZodOptional<z.ZodString>;
102
+ userId: z.ZodOptional<z.ZodString>;
103
+ }, z.core.$strip>>>>;
82
104
  }, z.core.$strip>;
83
105
  export declare function getClientId(): Promise<string>;
84
106
  export declare function logout({ productHost }?: {
@@ -117,6 +139,14 @@ export declare function readDb(): Promise<{
117
139
  } | undefined;
118
140
  fontSize?: number | undefined;
119
141
  };
142
+ pendingProgressMutations: {
143
+ lessonSlug: string;
144
+ complete: boolean;
145
+ queuedAt: string;
146
+ host?: string | undefined;
147
+ workshopSlug?: string | undefined;
148
+ userId?: string | undefined;
149
+ }[];
120
150
  authInfo?: {
121
151
  id: string;
122
152
  tokenSet: {
@@ -248,6 +278,65 @@ export declare function setPreferences(preferences: z.input<typeof DataSchema>['
248
278
  fontSize?: number | undefined;
249
279
  onboardingComplete?: string[] | undefined;
250
280
  }>;
281
+ export declare function hasPendingProgressMutationScope(mutation: PendingProgressMutation): mutation is PendingProgressMutation & PendingProgressMutationScope;
282
+ export declare function isPendingProgressMutationInScope(mutation: PendingProgressMutation, scope: PendingProgressMutationScope): boolean;
283
+ export declare function mutatePendingProgressMutations(updater: (pendingProgressMutations: Array<PendingProgressMutation>) => Array<PendingProgressMutation>): Promise<{
284
+ lessonSlug: string;
285
+ complete: boolean;
286
+ queuedAt: string;
287
+ host?: string | undefined;
288
+ workshopSlug?: string | undefined;
289
+ userId?: string | undefined;
290
+ }[]>;
291
+ export declare function getPendingProgressMutations({ scope, }?: {
292
+ scope?: PendingProgressMutationScope;
293
+ }): Promise<{
294
+ lessonSlug: string;
295
+ complete: boolean;
296
+ queuedAt: string;
297
+ host?: string | undefined;
298
+ workshopSlug?: string | undefined;
299
+ userId?: string | undefined;
300
+ }[]>;
301
+ export declare function queuePendingProgressMutation({ scope, lessonSlug, complete, queuedAt, }: {
302
+ scope: PendingProgressMutationScope;
303
+ lessonSlug: string;
304
+ complete: boolean;
305
+ queuedAt?: string;
306
+ }): Promise<{
307
+ lessonSlug: string;
308
+ complete: boolean;
309
+ queuedAt: string;
310
+ host?: string | undefined;
311
+ workshopSlug?: string | undefined;
312
+ userId?: string | undefined;
313
+ }[]>;
314
+ export declare function reconcileQueuedProgressMutation({ pendingProgressMutations, scope, lessonSlug, complete, queuedAt, }: {
315
+ pendingProgressMutations: Array<PendingProgressMutation>;
316
+ scope: PendingProgressMutationScope;
317
+ lessonSlug: string;
318
+ complete: boolean;
319
+ queuedAt?: string;
320
+ }): {
321
+ lessonSlug: string;
322
+ complete: boolean;
323
+ queuedAt: string;
324
+ host?: string | undefined;
325
+ workshopSlug?: string | undefined;
326
+ userId?: string | undefined;
327
+ }[];
328
+ export declare function replacePendingProgressMutationsForScope({ scope, basePendingProgressMutations, nextPendingProgressMutations, }: {
329
+ scope: PendingProgressMutationScope;
330
+ basePendingProgressMutations: Array<PendingProgressMutation>;
331
+ nextPendingProgressMutations: Array<PendingProgressMutation>;
332
+ }): Promise<{
333
+ lessonSlug: string;
334
+ complete: boolean;
335
+ queuedAt: string;
336
+ host?: string | undefined;
337
+ workshopSlug?: string | undefined;
338
+ userId?: string | undefined;
339
+ }[]>;
251
340
  /**
252
341
  * Mark an onboarding feature as complete.
253
342
  * This is used to track which tips/indicators have been dismissed.
package/dist/db.server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import "./init-env.js";
2
2
  import { randomUUID as cuid } from 'crypto';
3
3
  import fsExtra from 'fs-extra';
4
+ import PQueue from 'p-queue';
4
5
  import { redirect } from 'react-router';
5
6
  import { z } from 'zod';
6
7
  import { getWorkshopConfig } from "./config.server.js";
@@ -78,6 +79,14 @@ const AuthInfoSchema = z.object({
78
79
  name: z.string().nullable().optional(),
79
80
  });
80
81
  const MutedNotificationSchema = z.array(z.string()).default([]);
82
+ const PendingProgressMutationSchema = z.object({
83
+ lessonSlug: z.string(),
84
+ complete: z.boolean(),
85
+ queuedAt: z.string(),
86
+ host: z.string().optional(),
87
+ workshopSlug: z.string().optional(),
88
+ userId: z.string().optional(),
89
+ });
81
90
  const DataSchema = z.object({
82
91
  preferences: z
83
92
  .object({
@@ -107,6 +116,10 @@ const DataSchema = z.object({
107
116
  authInfos: z.record(z.string(), AuthInfoSchema).optional(),
108
117
  clientId: z.string().optional(),
109
118
  mutedNotifications: MutedNotificationSchema.optional(),
119
+ pendingProgressMutations: z
120
+ .array(PendingProgressMutationSchema)
121
+ .optional()
122
+ .default([]),
110
123
  });
111
124
  export async function getClientId() {
112
125
  const data = await readDb();
@@ -336,6 +349,109 @@ export async function setPreferences(preferences) {
336
349
  await saveJSON(updatedData);
337
350
  return updatedData.preferences;
338
351
  }
352
+ const pendingProgressMutationWriteQueue = new PQueue({ concurrency: 1 });
353
+ export function hasPendingProgressMutationScope(mutation) {
354
+ return Boolean(mutation.host && mutation.workshopSlug && mutation.userId);
355
+ }
356
+ export function isPendingProgressMutationInScope(mutation, scope) {
357
+ return (hasPendingProgressMutationScope(mutation) &&
358
+ mutation.host === scope.host &&
359
+ mutation.workshopSlug === scope.workshopSlug &&
360
+ mutation.userId === scope.userId);
361
+ }
362
+ function getPendingProgressMutationKey(mutation) {
363
+ return [
364
+ mutation.host ?? '',
365
+ mutation.workshopSlug ?? '',
366
+ mutation.userId ?? '',
367
+ mutation.lessonSlug,
368
+ mutation.complete ? '1' : '0',
369
+ mutation.queuedAt,
370
+ ].join('|');
371
+ }
372
+ function mergePendingProgressMutationsByLesson(mutations) {
373
+ const mergedByLessonSlug = new Map();
374
+ for (const mutation of mutations) {
375
+ const existingMutation = mergedByLessonSlug.get(mutation.lessonSlug);
376
+ if (!existingMutation) {
377
+ mergedByLessonSlug.set(mutation.lessonSlug, mutation);
378
+ continue;
379
+ }
380
+ const existingQueuedAt = Date.parse(existingMutation.queuedAt);
381
+ const nextQueuedAt = Date.parse(mutation.queuedAt);
382
+ if (nextQueuedAt >= existingQueuedAt) {
383
+ mergedByLessonSlug.set(mutation.lessonSlug, mutation);
384
+ }
385
+ }
386
+ return Array.from(mergedByLessonSlug.values());
387
+ }
388
+ export async function mutatePendingProgressMutations(updater) {
389
+ return pendingProgressMutationWriteQueue.add(async () => {
390
+ const data = await readDb();
391
+ const pendingProgressMutations = data?.pendingProgressMutations ?? [];
392
+ const nextPendingProgressMutations = updater(pendingProgressMutations);
393
+ const updatedData = {
394
+ ...data,
395
+ pendingProgressMutations: nextPendingProgressMutations,
396
+ };
397
+ await saveJSON(updatedData);
398
+ return updatedData.pendingProgressMutations;
399
+ });
400
+ }
401
+ export async function getPendingProgressMutations({ scope, } = {}) {
402
+ const data = await readDb();
403
+ const pendingProgressMutations = data?.pendingProgressMutations ?? [];
404
+ if (!scope)
405
+ return pendingProgressMutations;
406
+ return pendingProgressMutations.filter((mutation) => isPendingProgressMutationInScope(mutation, scope));
407
+ }
408
+ export async function queuePendingProgressMutation({ scope, lessonSlug, complete, queuedAt = new Date().toISOString(), }) {
409
+ return mutatePendingProgressMutations((pendingProgressMutations) => reconcileQueuedProgressMutation({
410
+ pendingProgressMutations,
411
+ scope,
412
+ lessonSlug,
413
+ complete,
414
+ queuedAt,
415
+ }));
416
+ }
417
+ export function reconcileQueuedProgressMutation({ pendingProgressMutations, scope, lessonSlug, complete, queuedAt = new Date().toISOString(), }) {
418
+ const existingMutation = pendingProgressMutations.find((mutation) => isPendingProgressMutationInScope(mutation, scope) &&
419
+ mutation.lessonSlug === lessonSlug);
420
+ const pendingWithoutLessonMutation = pendingProgressMutations.filter((mutation) => !(isPendingProgressMutationInScope(mutation, scope) &&
421
+ mutation.lessonSlug === lessonSlug));
422
+ // If the new mutation undoes the queued one for this lesson, drop both so
423
+ // the queue reflects the net effect.
424
+ if (existingMutation && existingMutation.complete !== complete) {
425
+ return pendingWithoutLessonMutation;
426
+ }
427
+ return [
428
+ ...pendingWithoutLessonMutation,
429
+ {
430
+ host: scope.host,
431
+ workshopSlug: scope.workshopSlug,
432
+ userId: scope.userId,
433
+ lessonSlug,
434
+ complete,
435
+ queuedAt,
436
+ },
437
+ ];
438
+ }
439
+ export async function replacePendingProgressMutationsForScope({ scope, basePendingProgressMutations, nextPendingProgressMutations, }) {
440
+ return mutatePendingProgressMutations((pendingProgressMutations) => {
441
+ const outOfScopePendingProgressMutations = pendingProgressMutations.filter((mutation) => !isPendingProgressMutationInScope(mutation, scope));
442
+ const currentScopedPendingProgressMutations = pendingProgressMutations.filter((mutation) => isPendingProgressMutationInScope(mutation, scope));
443
+ const basePendingProgressMutationKeys = new Set(basePendingProgressMutations.map(getPendingProgressMutationKey));
444
+ const newScopedPendingProgressMutations = currentScopedPendingProgressMutations.filter((mutation) => !basePendingProgressMutationKeys.has(getPendingProgressMutationKey(mutation)));
445
+ const mergedScopedPendingProgressMutations = mergePendingProgressMutationsByLesson([
446
+ ...nextPendingProgressMutations,
447
+ ...newScopedPendingProgressMutations,
448
+ ]);
449
+ return [
450
+ ...outOfScopePendingProgressMutations,
451
+ ...mergedScopedPendingProgressMutations,
452
+ ];
453
+ });
454
+ }
339
455
  /**
340
456
  * Mark an onboarding feature as complete.
341
457
  * This is used to track which tips/indicators have been dismissed.
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { getExercises, getWorkshopFinished, getWorkshopInstructions } from "./apps.server.js";
3
+ import { type PendingProgressMutation } from "./db.server.js";
3
4
  import { type Timings } from "./timing.server.js";
4
5
  declare const EpicVideoMetadataSchema: z.ZodObject<{
5
6
  playbackId: z.ZodString;
@@ -74,6 +75,41 @@ declare function getEpicVideoInfo({ epicVideoEmbed, accessToken, request, timing
74
75
  requestCountry: string;
75
76
  restrictedCountry: string;
76
77
  } | null>;
78
+ export declare function shouldQueueProgressMutationForStatus(status: number): boolean;
79
+ export declare function resolveProgressSyncState({ epicCompletedAt, pendingProgressMutation, }: {
80
+ epicCompletedAt: string | null;
81
+ pendingProgressMutation?: PendingProgressMutation;
82
+ }): {
83
+ epicCompletedAt: string | null;
84
+ syncStatus: "synced";
85
+ } | {
86
+ epicCompletedAt: string | null;
87
+ syncStatus: "pending";
88
+ };
89
+ type DroppedProgressMutation = {
90
+ lessonSlug: string;
91
+ complete: boolean;
92
+ reason: string;
93
+ };
94
+ export declare function resolveProgressMutationOutcome({ lessonSlug, syncResult, }: {
95
+ lessonSlug: string;
96
+ syncResult: {
97
+ pendingProgressMutations: Array<PendingProgressMutation>;
98
+ droppedProgressMutations: Array<DroppedProgressMutation>;
99
+ };
100
+ }): {
101
+ readonly status: "error";
102
+ readonly error: string;
103
+ readonly queuedCount?: undefined;
104
+ } | {
105
+ readonly status: "queued";
106
+ readonly queuedCount: number;
107
+ readonly error?: undefined;
108
+ } | {
109
+ readonly status: "success";
110
+ readonly error?: undefined;
111
+ readonly queuedCount?: undefined;
112
+ };
77
113
  export type Progress = Awaited<ReturnType<typeof getProgress>>[number];
78
114
  export declare function getProgress({ timings, request, }?: {
79
115
  timings?: Timings;
@@ -82,6 +118,7 @@ export declare function getProgress({ timings, request, }?: {
82
118
  epicLessonUrl: string;
83
119
  epicLessonSlug: string;
84
120
  epicCompletedAt: string | null;
121
+ syncStatus: "synced" | "pending";
85
122
  } & (LocalProgressForEpicLesson | {
86
123
  type: "unknown";
87
124
  }))[]>;
@@ -144,16 +181,16 @@ export declare function updateProgress({ lessonSlug, complete }: {
144
181
  request?: Request;
145
182
  }): Promise<{
146
183
  readonly status: "error";
147
- readonly error: "cannot update progress when deployed";
148
- } | {
149
- readonly status: "error";
150
- readonly error: "not authenticated";
184
+ readonly error: string;
185
+ readonly queuedCount?: undefined;
151
186
  } | {
152
- readonly status: "error";
153
- readonly error: `${number} ${string}`;
187
+ readonly status: "queued";
188
+ readonly queuedCount: number;
189
+ readonly error?: undefined;
154
190
  } | {
155
191
  readonly status: "success";
156
192
  readonly error?: undefined;
193
+ readonly queuedCount?: undefined;
157
194
  }>;
158
195
  export declare function getWorkshopData(slug: string, { timings, request, forceFresh, }?: {
159
196
  timings?: Timings;
@@ -6,10 +6,11 @@ import { z } from 'zod';
6
6
  import { getExerciseApp, getExercises, getWorkshopFinished, getWorkshopInstructions, } from "./apps.server.js";
7
7
  import { cachified, epicApiCache } from "./cache.server.js";
8
8
  import { getWorkshopConfig } from "./config.server.js";
9
- import { getAuthInfo, setAuthInfo } from "./db.server.js";
9
+ import { getAuthInfo, getPendingProgressMutations, isPendingProgressMutationInScope, queuePendingProgressMutation, replacePendingProgressMutationsForScope, setAuthInfo, } from "./db.server.js";
10
10
  import { getEnv } from "./init-env.js";
11
11
  import { logger } from "./logger.js";
12
12
  import { getErrorMessage } from "./utils.js";
13
+ import { checkConnection } from "./utils.server.js";
13
14
  // Module-level logger for epic-api operations
14
15
  const log = logger('epic:api');
15
16
  const Transcript = z
@@ -131,7 +132,7 @@ export async function getEpicVideoMetadata({ playbackId, host, accessToken, requ
131
132
  swr: 1000 * 60 * 60 * 24 * 365 * 10,
132
133
  offlineFallbackValue: null,
133
134
  checkValue: EpicVideoMetadataSchema.nullable(),
134
- async getFreshValue(context) {
135
+ async getFreshValue() {
135
136
  const apiUrl = `https://${normalizedHost}/api/video/${encodeURIComponent(playbackId)}`;
136
137
  videoMetadataLog(`making video metadata request to: ${apiUrl}`);
137
138
  const response = await fetch(apiUrl, accessToken
@@ -139,23 +140,19 @@ export async function getEpicVideoMetadata({ playbackId, host, accessToken, requ
139
140
  : undefined).catch((e) => new Response(getErrorMessage(e), { status: 500 }));
140
141
  videoMetadataLog(`video metadata response: ${response.status} ${response.statusText}`);
141
142
  if (!response.ok) {
142
- context.metadata.ttl = 1000 * 2;
143
- context.metadata.swr = 0;
144
- return null;
143
+ throw new Error(`Failed to fetch video metadata: ${response.status} ${response.statusText}`);
145
144
  }
146
145
  const rawInfo = await response.json();
147
146
  const parsedInfo = EpicVideoMetadataSchema.safeParse(rawInfo);
148
147
  if (parsedInfo.success) {
149
148
  return parsedInfo.data;
150
149
  }
151
- context.metadata.ttl = 1000 * 2;
152
- context.metadata.swr = 0;
153
150
  videoMetadataLog.error(`video metadata parsing failed for ${playbackId}`, {
154
151
  host: normalizedHost,
155
152
  rawInfo,
156
153
  parseError: parsedInfo.error,
157
154
  });
158
- return null;
155
+ throw new Error(`Failed to parse video metadata for ${playbackId}`);
159
156
  },
160
157
  }).catch((e) => {
161
158
  videoMetadataLog.error(`failed to fetch video metadata for ${playbackId}:`, e);
@@ -384,7 +381,7 @@ async function getEpicProgress({ timings, request, forceFresh, } = {}) {
384
381
  swr: 1000 * 60 * 60 * 24 * 365 * 10,
385
382
  offlineFallbackValue: [],
386
383
  checkValue: EpicProgressSchema,
387
- async getFreshValue(context) {
384
+ async getFreshValue() {
388
385
  const progressUrl = `https://${host}/api/progress`;
389
386
  log(`making progress API request to: ${progressUrl}`);
390
387
  const response = await fetch(progressUrl, {
@@ -396,17 +393,172 @@ async function getEpicProgress({ timings, request, forceFresh, } = {}) {
396
393
  if (response.status < 200 || response.status >= 300) {
397
394
  log.error(`failed to fetch progress from EpicWeb: ${response.status} ${response.statusText}`);
398
395
  console.error(`Failed to fetch progress from EpicWeb: ${response.status} ${response.statusText}`);
399
- // don't cache errors for long...
400
- context.metadata.ttl = 1000 * 2;
401
- context.metadata.swr = 0;
402
- return [];
396
+ throw new Error(`Failed to fetch progress from EpicWeb: ${response.status} ${response.statusText}`);
403
397
  }
404
398
  const progressData = await response.json();
405
399
  const parsedProgress = EpicProgressSchema.parse(progressData);
406
400
  log(`successfully fetched ${parsedProgress.length} progress entries`);
407
401
  return parsedProgress;
408
402
  },
403
+ }).catch((error) => {
404
+ log.error('failed to get progress via cache/api and no cache fallback is available', error);
405
+ return [];
406
+ });
407
+ }
408
+ export function shouldQueueProgressMutationForStatus(status) {
409
+ // Retry-worthy statuses and auth failures should remain queued so they can
410
+ // be replayed later once connectivity/auth recovers.
411
+ return (status === 401 ||
412
+ status === 403 ||
413
+ status === 408 ||
414
+ status === 425 ||
415
+ status === 429 ||
416
+ status >= 500);
417
+ }
418
+ export function resolveProgressSyncState({ epicCompletedAt, pendingProgressMutation, }) {
419
+ if (!pendingProgressMutation) {
420
+ return { epicCompletedAt, syncStatus: 'synced' };
421
+ }
422
+ return {
423
+ epicCompletedAt: pendingProgressMutation.complete
424
+ ? pendingProgressMutation.queuedAt
425
+ : null,
426
+ syncStatus: 'pending',
427
+ };
428
+ }
429
+ function createPendingProgressMutationScope({ host, workshopSlug, userId, }) {
430
+ return {
431
+ host,
432
+ workshopSlug: workshopSlug ?? '__unknown-workshop__',
433
+ userId,
434
+ };
435
+ }
436
+ function pendingProgressQueuesEqual(a, b) {
437
+ if (a.length !== b.length)
438
+ return false;
439
+ return a.every((entry, index) => {
440
+ const other = b[index];
441
+ if (!other)
442
+ return false;
443
+ return (entry.lessonSlug === other.lessonSlug &&
444
+ entry.complete === other.complete &&
445
+ entry.queuedAt === other.queuedAt);
446
+ });
447
+ }
448
+ async function postProgressMutation({ host, accessToken, lessonSlug, complete, }) {
449
+ const progressUrl = `https://${host}/api/progress`;
450
+ const payload = complete
451
+ ? { lessonSlug }
452
+ : { lessonSlug, remove: true };
453
+ log(`making POST request to: ${progressUrl} with payload: ${JSON.stringify(payload)}`);
454
+ try {
455
+ const response = await fetch(progressUrl, {
456
+ method: 'POST',
457
+ headers: {
458
+ authorization: `Bearer ${accessToken}`,
459
+ 'content-type': 'application/json',
460
+ },
461
+ body: JSON.stringify(payload),
462
+ });
463
+ return { status: 'success', response, payload };
464
+ }
465
+ catch (error) {
466
+ return { status: 'error', error: getErrorMessage(error), payload };
467
+ }
468
+ }
469
+ async function syncPendingProgressMutations({ scope, host, accessToken, request, timings, }) {
470
+ const pendingProgressMutations = await getPendingProgressMutations({ scope });
471
+ if (!pendingProgressMutations.length) {
472
+ return {
473
+ pendingProgressMutations,
474
+ syncedCount: 0,
475
+ droppedProgressMutations: [],
476
+ };
477
+ }
478
+ const isOnline = await checkConnection({ request, timings }).catch(() => false);
479
+ if (!isOnline) {
480
+ log(`connection offline, skipping replay of ${pendingProgressMutations.length} queued progress mutation${pendingProgressMutations.length === 1 ? '' : 's'}`);
481
+ return {
482
+ pendingProgressMutations,
483
+ syncedCount: 0,
484
+ droppedProgressMutations: [],
485
+ };
486
+ }
487
+ let syncedCount = 0;
488
+ let queueChanged = false;
489
+ const droppedProgressMutations = [];
490
+ let remainingProgressMutations = [];
491
+ for (let index = 0; index < pendingProgressMutations.length; index++) {
492
+ const pendingMutation = pendingProgressMutations[index];
493
+ if (!pendingMutation)
494
+ continue;
495
+ log(`replaying queued progress mutation (${index + 1}/${pendingProgressMutations.length}) for lesson: ${pendingMutation.lessonSlug} (complete: ${pendingMutation.complete})`);
496
+ const response = await postProgressMutation({
497
+ host,
498
+ accessToken,
499
+ lessonSlug: pendingMutation.lessonSlug,
500
+ complete: pendingMutation.complete,
501
+ });
502
+ if (response.status === 'error') {
503
+ log.error(`failed replaying queued progress mutation for ${pendingMutation.lessonSlug}: ${response.error}`);
504
+ remainingProgressMutations = pendingProgressMutations.slice(index);
505
+ break;
506
+ }
507
+ log(`queued progress replay response: ${response.response.status} ${response.response.statusText}`);
508
+ if (response.response.ok) {
509
+ syncedCount++;
510
+ queueChanged = true;
511
+ continue;
512
+ }
513
+ if (shouldQueueProgressMutationForStatus(response.response.status)) {
514
+ log(`queued progress mutation for ${pendingMutation.lessonSlug} still not syncable (${response.response.status}), keeping in queue`);
515
+ remainingProgressMutations = pendingProgressMutations.slice(index);
516
+ break;
517
+ }
518
+ log.error(`dropping queued progress mutation for ${pendingMutation.lessonSlug}: ${response.response.status} ${response.response.statusText}`);
519
+ droppedProgressMutations.push({
520
+ lessonSlug: pendingMutation.lessonSlug,
521
+ complete: pendingMutation.complete,
522
+ reason: `${response.response.status} ${response.response.statusText}`,
523
+ });
524
+ queueChanged = true;
525
+ }
526
+ if (!queueChanged &&
527
+ pendingProgressQueuesEqual(remainingProgressMutations, pendingProgressMutations)) {
528
+ return {
529
+ pendingProgressMutations,
530
+ syncedCount,
531
+ droppedProgressMutations,
532
+ };
533
+ }
534
+ await replacePendingProgressMutationsForScope({
535
+ scope,
536
+ basePendingProgressMutations: pendingProgressMutations,
537
+ nextPendingProgressMutations: remainingProgressMutations,
409
538
  });
539
+ log(`queued progress replay complete. synced: ${syncedCount}, remaining: ${remainingProgressMutations.length}`);
540
+ return {
541
+ pendingProgressMutations: remainingProgressMutations,
542
+ syncedCount,
543
+ droppedProgressMutations,
544
+ };
545
+ }
546
+ export function resolveProgressMutationOutcome({ lessonSlug, syncResult, }) {
547
+ const droppedProgressMutation = syncResult.droppedProgressMutations.find((mutation) => mutation.lessonSlug === lessonSlug);
548
+ if (droppedProgressMutation) {
549
+ return {
550
+ status: 'error',
551
+ error: droppedProgressMutation.reason,
552
+ };
553
+ }
554
+ const isStillPending = syncResult.pendingProgressMutations.some((mutation) => mutation.lessonSlug === lessonSlug);
555
+ if (isStillPending) {
556
+ return {
557
+ status: 'queued',
558
+ queuedCount: syncResult.pendingProgressMutations.length,
559
+ };
560
+ }
561
+ return { status: 'success' };
410
562
  }
411
563
  export async function getProgress({ timings, request, } = {}) {
412
564
  if (getEnv().EPICSHOP_DEPLOYED)
@@ -417,10 +569,30 @@ export async function getProgress({ timings, request, } = {}) {
417
569
  const { product: { slug, host }, } = getWorkshopConfig();
418
570
  if (!slug)
419
571
  return [];
572
+ const progressMutationScope = createPendingProgressMutationScope({
573
+ host,
574
+ workshopSlug: slug,
575
+ userId: authInfo.id,
576
+ });
577
+ const syncResult = await syncPendingProgressMutations({
578
+ scope: progressMutationScope,
579
+ host,
580
+ accessToken: authInfo.tokenSet.access_token,
581
+ request,
582
+ timings,
583
+ });
584
+ const pendingProgressByLessonSlug = new Map(syncResult.pendingProgressMutations.map((mutation) => [
585
+ mutation.lessonSlug,
586
+ mutation,
587
+ ]));
420
588
  log(`aggregating progress data for workshop: ${slug}`);
421
589
  const [workshopData, epicProgress, workshopInstructions, workshopFinished, exercises,] = await Promise.all([
422
590
  getWorkshopData(slug, { request, timings }),
423
- getEpicProgress({ request, timings }),
591
+ getEpicProgress({
592
+ request,
593
+ timings,
594
+ forceFresh: syncResult.syncedCount > 0,
595
+ }),
424
596
  getWorkshopInstructions({ request }),
425
597
  getWorkshopFinished({ request }),
426
598
  getExercises({ request, timings }),
@@ -431,7 +603,11 @@ export async function getProgress({ timings, request, } = {}) {
431
603
  for (const lesson of lessons) {
432
604
  const epicLessonSlug = lesson.slug;
433
605
  const lessonProgress = epicProgress.find(({ lessonId }) => lessonId === lesson._id);
434
- const epicCompletedAt = lessonProgress ? lessonProgress.completedAt : null;
606
+ const pendingProgressMutation = pendingProgressByLessonSlug.get(epicLessonSlug);
607
+ const progressSyncState = resolveProgressSyncState({
608
+ epicCompletedAt: lessonProgress ? lessonProgress.completedAt : null,
609
+ pendingProgressMutation,
610
+ });
435
611
  const progressForLesson = resolveLocalProgressForEpicLesson(epicLessonSlug, {
436
612
  workshopInstructions,
437
613
  workshopFinished,
@@ -443,7 +619,8 @@ export async function getProgress({ timings, request, } = {}) {
443
619
  ...progressForLesson,
444
620
  epicLessonUrl,
445
621
  epicLessonSlug,
446
- epicCompletedAt,
622
+ epicCompletedAt: progressSyncState.epicCompletedAt,
623
+ syncStatus: progressSyncState.syncStatus,
447
624
  });
448
625
  }
449
626
  else {
@@ -451,7 +628,8 @@ export async function getProgress({ timings, request, } = {}) {
451
628
  type: 'unknown',
452
629
  epicLessonUrl,
453
630
  epicLessonSlug,
454
- epicCompletedAt,
631
+ epicCompletedAt: progressSyncState.epicCompletedAt,
632
+ syncStatus: progressSyncState.syncStatus,
455
633
  });
456
634
  }
457
635
  }
@@ -565,31 +743,43 @@ export async function updateProgress({ lessonSlug, complete }, { timings, reques
565
743
  if (!authInfo) {
566
744
  return { status: 'error', error: 'not authenticated' };
567
745
  }
568
- const { product: { host }, } = getWorkshopConfig();
569
- const progressUrl = `https://${host}/api/progress`;
570
- const payload = complete ? { lessonSlug } : { lessonSlug, remove: true };
571
- log(`updating progress for lesson: ${lessonSlug} (complete: ${complete})`);
572
- log(`making POST request to: ${progressUrl} with payload: ${JSON.stringify(payload)}`);
573
- const response = await fetch(progressUrl, {
574
- method: 'POST',
575
- headers: {
576
- authorization: `Bearer ${authInfo.tokenSet.access_token}`,
577
- 'content-type': 'application/json',
578
- },
579
- body: JSON.stringify(payload),
580
- }).catch((e) => new Response(getErrorMessage(e), { status: 500 }));
581
- log(`progress update response: ${response.status} ${response.statusText}`);
582
- // force the progress to be fresh whether or not we're successful
583
- await getEpicProgress({ forceFresh: true, request, timings });
584
- if (response.status < 200 || response.status >= 300) {
585
- log(`progress update failed: ${response.status} ${response.statusText}`);
586
- return {
587
- status: 'error',
588
- error: `${response.status} ${response.statusText}`,
589
- };
746
+ const { product: { host, slug }, } = getWorkshopConfig();
747
+ const progressMutationScope = createPendingProgressMutationScope({
748
+ host,
749
+ workshopSlug: slug,
750
+ userId: authInfo.id,
751
+ });
752
+ const normalizedComplete = Boolean(complete);
753
+ log(`updating progress for lesson: ${lessonSlug} (complete: ${normalizedComplete})`);
754
+ const pendingProgressMutations = await queuePendingProgressMutation({
755
+ scope: progressMutationScope,
756
+ lessonSlug,
757
+ complete: normalizedComplete,
758
+ });
759
+ const scopedPendingProgressMutations = pendingProgressMutations.filter((mutation) => isPendingProgressMutationInScope(mutation, progressMutationScope));
760
+ log(`queued progress mutation for lesson: ${lessonSlug}. pending count: ${scopedPendingProgressMutations.length}`);
761
+ const syncResult = await syncPendingProgressMutations({
762
+ scope: progressMutationScope,
763
+ host,
764
+ accessToken: authInfo.tokenSet.access_token,
765
+ request,
766
+ timings,
767
+ });
768
+ if (syncResult.syncedCount > 0) {
769
+ // Force a fresh pull so loaders and CLIs see synchronized progress.
770
+ await getEpicProgress({ forceFresh: true, request, timings });
590
771
  }
591
- log(`progress update successful for lesson: ${lessonSlug}`);
592
- return { status: 'success' };
772
+ const outcome = resolveProgressMutationOutcome({ lessonSlug, syncResult });
773
+ if (outcome.status === 'success') {
774
+ log(`progress update successful for lesson: ${lessonSlug}`);
775
+ }
776
+ else if (outcome.status === 'queued') {
777
+ log(`progress update queued for lesson: ${lessonSlug}. pending count: ${outcome.queuedCount}`);
778
+ }
779
+ else {
780
+ log(`progress update failed for lesson: ${lessonSlug}: ${outcome.error}`);
781
+ }
782
+ return outcome;
593
783
  }
594
784
  const ModuleSchema = z.object({
595
785
  resources: z
@@ -632,15 +822,20 @@ export async function getWorkshopData(slug, { timings, request, forceFresh, } =
632
822
  const response = await fetch(workshopUrl).catch((e) => new Response(getErrorMessage(e), { status: 500 }));
633
823
  log(`workshop data response: ${response.status} ${response.statusText}`);
634
824
  if (response.status < 200 || response.status >= 300) {
635
- log.error(`failed to fetch workshop data from EpicWeb for ${slug}: ${response.status} ${response.statusText}`);
636
- console.error(`Failed to fetch workshop data from EpicWeb for ${slug}: ${response.status} ${response.statusText}`);
637
- return { resources: [] };
825
+ const errorMessage = `Failed to fetch workshop data from EpicWeb for ${slug}: ${response.status} ${response.statusText}`;
826
+ log.error(errorMessage);
827
+ if (!log.enabled)
828
+ console.error(errorMessage);
829
+ throw new Error(errorMessage);
638
830
  }
639
831
  const jsonResponse = await response.json();
640
832
  const parsedData = ModuleSchema.parse(jsonResponse);
641
833
  log(`successfully fetched workshop data for ${slug} with ${parsedData.resources?.length ?? 0} resources`);
642
834
  return parsedData;
643
835
  },
836
+ }).catch((error) => {
837
+ log.error(`failed to get workshop data for ${slug} via cache/api`, error);
838
+ return { resources: [] };
644
839
  });
645
840
  }
646
841
  export async function userHasAccessToExerciseStep({ exerciseNumber, stepNumber, timings, request, forceFresh, }) {
@@ -1,7 +1,8 @@
1
1
  import "./init-env.js";
2
2
  import { type Timings } from "./timing.server.js";
3
3
  export { dayjs } from "./utils.js";
4
- export declare function checkConnection({ request, timings, }?: {
4
+ export declare function checkConnection({ request, timings, forceFresh, }?: {
5
5
  request?: Request;
6
6
  timings?: Timings;
7
+ forceFresh?: boolean;
7
8
  }): Promise<boolean>;
@@ -35,7 +35,7 @@ async function raceConnectivity() {
35
35
  return false;
36
36
  }
37
37
  }
38
- export async function checkConnection({ request, timings, } = {}) {
38
+ export async function checkConnection({ request, timings, forceFresh, } = {}) {
39
39
  connectionLog('calling cachified to check connection');
40
40
  const isOnline = await cachified({
41
41
  cache: connectionCache,
@@ -43,6 +43,7 @@ export async function checkConnection({ request, timings, } = {}) {
43
43
  timings,
44
44
  key: 'connected',
45
45
  ttl: 1000 * 10,
46
+ forceFresh,
46
47
  checkValue: z.boolean(),
47
48
  async getFreshValue(context) {
48
49
  connectionLog('getting fresh connection value');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@epic-web/workshop-utils",
3
- "version": "6.85.5",
3
+ "version": "6.86.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },