@epic-web/workshop-utils 6.85.4 → 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.
- package/dist/cache.server.d.ts +1 -1
- package/dist/cache.server.js +1 -1
- package/dist/db.server.d.ts +89 -0
- package/dist/db.server.js +116 -0
- package/dist/diff.server.d.ts +1 -1
- package/dist/diff.server.js +70 -205
- package/dist/epic-api.server.d.ts +43 -6
- package/dist/epic-api.server.js +239 -44
- package/dist/utils.server.d.ts +2 -1
- package/dist/utils.server.js +2 -1
- package/package.json +1 -2
package/dist/cache.server.d.ts
CHANGED
|
@@ -167,7 +167,7 @@ export declare const playgroundAppCache: C.Cache<{
|
|
|
167
167
|
instructionsCode?: string | undefined;
|
|
168
168
|
epicVideoEmbeds?: string[] | undefined;
|
|
169
169
|
}>;
|
|
170
|
-
export declare const
|
|
170
|
+
export declare const diffPatchCache: C.Cache<string>;
|
|
171
171
|
export declare const diffFilesCache: C.Cache<DiffFile[]>;
|
|
172
172
|
export declare const copyUnignoredFilesCache: {
|
|
173
173
|
name: string;
|
package/dist/cache.server.js
CHANGED
|
@@ -168,7 +168,7 @@ export const solutionAppCache = makeSingletonFsCache('SolutionAppCache');
|
|
|
168
168
|
export const problemAppCache = makeSingletonFsCache('ProblemAppCache');
|
|
169
169
|
export const extraAppCache = makeSingletonFsCache('ExtraAppCache');
|
|
170
170
|
export const playgroundAppCache = makeSingletonFsCache('PlaygroundAppCache');
|
|
171
|
-
export const
|
|
171
|
+
export const diffPatchCache = makeSingletonFsCache('DiffPatchCache');
|
|
172
172
|
export const diffFilesCache = makeSingletonFsCache('DiffFilesCache');
|
|
173
173
|
export const copyUnignoredFilesCache = makeSingletonCache('CopyUnignoredFilesCache');
|
|
174
174
|
export const compiledMarkdownCache = makeSingletonFsCache('CompiledMarkdownCache');
|
package/dist/db.server.d.ts
CHANGED
|
@@ -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.
|
package/dist/diff.server.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export declare function getDiffFiles(app1: App, app2: App, { forceFresh, timings
|
|
|
9
9
|
path: string;
|
|
10
10
|
line: number;
|
|
11
11
|
}[]>;
|
|
12
|
-
export declare function
|
|
12
|
+
export declare function getDiffPatch(app1: App, app2: App, { forceFresh, timings, request, }?: {
|
|
13
13
|
forceFresh?: boolean;
|
|
14
14
|
timings?: Timings;
|
|
15
15
|
request?: Request;
|
package/dist/diff.server.js
CHANGED
|
@@ -1,19 +1,14 @@
|
|
|
1
|
-
// oxlint-disable-next-line import/order -- this must be first
|
|
2
|
-
import { getEnv } from "./init-env.js";
|
|
3
1
|
import os from 'os';
|
|
4
2
|
import path from 'path';
|
|
5
3
|
import { execa } from 'execa';
|
|
6
4
|
import fsExtra from 'fs-extra';
|
|
7
5
|
import ignore from 'ignore';
|
|
8
6
|
import parseGitDiff from 'parse-git-diff';
|
|
9
|
-
import { bundledLanguagesInfo } from 'shiki/langs';
|
|
10
7
|
import { z } from 'zod';
|
|
11
|
-
import { getForceFreshForDir,
|
|
12
|
-
import { cachified, copyUnignoredFilesCache,
|
|
13
|
-
import { compileMarkdownString } from "./compile-mdx.server.js";
|
|
8
|
+
import { getForceFreshForDir, getWorkshopRoot, modifiedTimes, } from "./apps.server.js";
|
|
9
|
+
import { cachified, copyUnignoredFilesCache, diffFilesCache, diffPatchCache, } from "./cache.server.js";
|
|
14
10
|
import { modifiedMoreRecentlyThan } from "./modified-time.server.js";
|
|
15
11
|
const epicshopTempDir = path.join(os.tmpdir(), 'epicshop');
|
|
16
|
-
const isDeployed = getEnv().EPICSHOP_DEPLOYED;
|
|
17
12
|
const diffTmpDir = path.join(epicshopTempDir, 'diff');
|
|
18
13
|
const DiffStatusSchema = z.enum([
|
|
19
14
|
'renamed',
|
|
@@ -65,123 +60,6 @@ function diffPathToRelative(filePath) {
|
|
|
65
60
|
.slice(3);
|
|
66
61
|
return relativePath.join(path.sep);
|
|
67
62
|
}
|
|
68
|
-
function getLanguage(ext) {
|
|
69
|
-
return (bundledLanguagesInfo.find((l) => l.id === ext || l.aliases?.includes(ext))
|
|
70
|
-
?.id ?? 'text');
|
|
71
|
-
}
|
|
72
|
-
function getFileCodeblocks(file, filePathApp1, filePathApp2, type) {
|
|
73
|
-
if (!file.chunks.length) {
|
|
74
|
-
return [
|
|
75
|
-
`<p className="m-0 p-4 border-b text-muted-foreground">No changes</p>`,
|
|
76
|
-
];
|
|
77
|
-
}
|
|
78
|
-
const filepath = diffPathToRelative(file.type === 'RenamedFile' ? file.pathAfter : file.path);
|
|
79
|
-
const extension = path.extname(filepath).slice(1);
|
|
80
|
-
const lang = getLanguage(extension);
|
|
81
|
-
const pathToCopy = file.type === 'RenamedFile' ? file.pathBefore : file.path;
|
|
82
|
-
const relativePath = diffPathToRelative(pathToCopy);
|
|
83
|
-
const markdownLines = [];
|
|
84
|
-
for (const chunk of file.chunks) {
|
|
85
|
-
const removedLineNumbers = [];
|
|
86
|
-
const addedLineNumbers = [];
|
|
87
|
-
const lines = [];
|
|
88
|
-
let toStartLine = 0;
|
|
89
|
-
let startLine = 1;
|
|
90
|
-
if (chunk.type === 'BinaryFilesChunk') {
|
|
91
|
-
lines.push(type === 'AddedFile'
|
|
92
|
-
? `Binary file added`
|
|
93
|
-
: type === 'DeletedFile'
|
|
94
|
-
? 'Binary file deleted'
|
|
95
|
-
: 'Binary file changed');
|
|
96
|
-
}
|
|
97
|
-
else {
|
|
98
|
-
startLine =
|
|
99
|
-
chunk.type === 'Chunk'
|
|
100
|
-
? chunk.fromFileRange.start
|
|
101
|
-
: chunk.type === 'CombinedChunk'
|
|
102
|
-
? chunk.fromFileRangeA.start
|
|
103
|
-
: 1;
|
|
104
|
-
toStartLine = chunk.toFileRange.start;
|
|
105
|
-
for (let lineNumber = 0; lineNumber < chunk.changes.length; lineNumber++) {
|
|
106
|
-
const change = chunk.changes[lineNumber];
|
|
107
|
-
if (!change)
|
|
108
|
-
continue;
|
|
109
|
-
lines.push(change.content);
|
|
110
|
-
switch (change.type) {
|
|
111
|
-
case 'AddedLine': {
|
|
112
|
-
addedLineNumbers.push(startLine + lineNumber);
|
|
113
|
-
break;
|
|
114
|
-
}
|
|
115
|
-
case 'DeletedLine': {
|
|
116
|
-
removedLineNumbers.push(startLine + lineNumber);
|
|
117
|
-
break;
|
|
118
|
-
}
|
|
119
|
-
default: {
|
|
120
|
-
break;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
const params = [
|
|
126
|
-
['filename', relativePath.replace(/\\/g, '\\\\')],
|
|
127
|
-
['start', startLine.toString()],
|
|
128
|
-
removedLineNumbers.length
|
|
129
|
-
? ['remove', removedLineNumbers.join(',')]
|
|
130
|
-
: null,
|
|
131
|
-
addedLineNumbers.length ? ['add', addedLineNumbers.join(',')] : null,
|
|
132
|
-
]
|
|
133
|
-
.filter(Boolean)
|
|
134
|
-
.map(([key, value]) => `${key}=${value}`)
|
|
135
|
-
.join(' ');
|
|
136
|
-
const launchEditorClassName = 'border hover:bg-foreground/20 rounded px-2 py-0.5 font-mono text-xs font-semibold';
|
|
137
|
-
function launchEditor(appNum, line) {
|
|
138
|
-
line ||= 1; // handle 0
|
|
139
|
-
if (isDeployed) {
|
|
140
|
-
if (type === 'DeletedFile' && appNum === 2)
|
|
141
|
-
return '';
|
|
142
|
-
if (type === 'AddedFile' && appNum === 1)
|
|
143
|
-
return '';
|
|
144
|
-
}
|
|
145
|
-
const label = (type === 'AddedFile' && appNum === 1) ||
|
|
146
|
-
(type === 'DeletedFile' && appNum === 2)
|
|
147
|
-
? `CREATE in APP ${appNum}`
|
|
148
|
-
: `OPEN in APP ${appNum}`;
|
|
149
|
-
const file = JSON.stringify(appNum === 1 ? filePathApp1 : filePathApp2);
|
|
150
|
-
const fixedTitle = getRelativePath(file);
|
|
151
|
-
return `
|
|
152
|
-
<LaunchEditor file=${file} line={${line}}>
|
|
153
|
-
<span title="${fixedTitle}" className="${launchEditorClassName}">${label}</span>
|
|
154
|
-
</LaunchEditor>`;
|
|
155
|
-
}
|
|
156
|
-
markdownLines.push(`
|
|
157
|
-
<div className="relative">
|
|
158
|
-
|
|
159
|
-
\`\`\`${lang} ${params}
|
|
160
|
-
${lines.join('\n')}
|
|
161
|
-
\`\`\`
|
|
162
|
-
|
|
163
|
-
<div className="flex gap-4 absolute top-1 right-3 items-center">
|
|
164
|
-
${launchEditor(1, startLine)}
|
|
165
|
-
<div className="display-alt-down flex gap-2">
|
|
166
|
-
<LaunchEditor file=${JSON.stringify(filePathApp1)} syncTo={{file: ${JSON.stringify(filePathApp2)}}}>
|
|
167
|
-
<span className="block ${launchEditorClassName}">
|
|
168
|
-
<Icon name="ArrowLeft" title="Copy app 2 file to app 1" />
|
|
169
|
-
</span>
|
|
170
|
-
</LaunchEditor>
|
|
171
|
-
<LaunchEditor file=${JSON.stringify(filePathApp2)} syncTo={{file: ${JSON.stringify(filePathApp1)}}}>
|
|
172
|
-
<span className="block ${launchEditorClassName}">
|
|
173
|
-
<Icon name="ArrowRight" title="Copy app 1 file to app 2" />
|
|
174
|
-
</span>
|
|
175
|
-
</LaunchEditor>
|
|
176
|
-
</div>
|
|
177
|
-
${launchEditor(2, toStartLine)}
|
|
178
|
-
</div>
|
|
179
|
-
|
|
180
|
-
</div>
|
|
181
|
-
`);
|
|
182
|
-
}
|
|
183
|
-
return markdownLines;
|
|
184
|
-
}
|
|
185
63
|
const DEFAULT_IGNORE_PATTERNS = [
|
|
186
64
|
'**/README.*',
|
|
187
65
|
'**/package-lock.json',
|
|
@@ -308,6 +186,51 @@ export async function getDiffFiles(app1, app2, { forceFresh, timings, request, }
|
|
|
308
186
|
function getAppTestFiles(app) {
|
|
309
187
|
return app.test.type === 'browser' ? app.test.testFiles : [];
|
|
310
188
|
}
|
|
189
|
+
function filterTestFilesFromPatch(patch, testFiles) {
|
|
190
|
+
if (!patch || testFiles.size === 0) {
|
|
191
|
+
return patch;
|
|
192
|
+
}
|
|
193
|
+
const normalizePath = (value) => value.replace(/^\.\/+/, '');
|
|
194
|
+
const parseDiffPaths = (line) => {
|
|
195
|
+
if (!line.startsWith('diff --git '))
|
|
196
|
+
return null;
|
|
197
|
+
const rest = line.slice('diff --git '.length);
|
|
198
|
+
const quotedMatch = rest.match(/^"a\/(.+)" "b\/(.+)"$/);
|
|
199
|
+
if (quotedMatch?.[1] && quotedMatch?.[2]) {
|
|
200
|
+
return {
|
|
201
|
+
a: normalizePath(quotedMatch[1]),
|
|
202
|
+
b: normalizePath(quotedMatch[2]),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const match = rest.match(/^a\/(.+) b\/(.+)$/);
|
|
206
|
+
if (match?.[1] && match?.[2]) {
|
|
207
|
+
return { a: normalizePath(match[1]), b: normalizePath(match[2]) };
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
};
|
|
211
|
+
const lines = patch.split('\n');
|
|
212
|
+
const filtered = [];
|
|
213
|
+
let currentBlock = [];
|
|
214
|
+
let includeBlock = true;
|
|
215
|
+
const flushBlock = () => {
|
|
216
|
+
if (currentBlock.length > 0 && includeBlock) {
|
|
217
|
+
filtered.push(...currentBlock);
|
|
218
|
+
}
|
|
219
|
+
currentBlock = [];
|
|
220
|
+
};
|
|
221
|
+
for (const line of lines) {
|
|
222
|
+
if (line.startsWith('diff --git ')) {
|
|
223
|
+
flushBlock();
|
|
224
|
+
const paths = parseDiffPaths(line);
|
|
225
|
+
includeBlock = paths
|
|
226
|
+
? !testFiles.has(paths.a) && !testFiles.has(paths.b)
|
|
227
|
+
: true;
|
|
228
|
+
}
|
|
229
|
+
currentBlock.push(line);
|
|
230
|
+
}
|
|
231
|
+
flushBlock();
|
|
232
|
+
return filtered.join('\n');
|
|
233
|
+
}
|
|
311
234
|
async function getDiffFilesImpl(app1, app2) {
|
|
312
235
|
if (app1.name === app2.name) {
|
|
313
236
|
return [];
|
|
@@ -344,26 +267,23 @@ async function getDiffFilesImpl(app1, app2) {
|
|
|
344
267
|
}))
|
|
345
268
|
.filter((file) => !testFiles.includes(file.path));
|
|
346
269
|
}
|
|
347
|
-
export async function
|
|
270
|
+
export async function getDiffPatch(app1, app2, { forceFresh, timings, request, } = {}) {
|
|
348
271
|
const key = `${app1.relativePath}__vs__${app2.relativePath}`;
|
|
349
|
-
const cacheEntry = await
|
|
272
|
+
const cacheEntry = await diffPatchCache.get(key);
|
|
350
273
|
const result = await cachified({
|
|
351
274
|
key,
|
|
352
|
-
cache:
|
|
275
|
+
cache: diffPatchCache,
|
|
353
276
|
forceFresh: forceFresh || (await getForceFreshForDiff(app1, app2, cacheEntry)),
|
|
354
277
|
timings,
|
|
355
278
|
request,
|
|
356
279
|
checkValue: z.string(),
|
|
357
|
-
getFreshValue: () =>
|
|
280
|
+
getFreshValue: () => getDiffPatchImpl(app1, app2),
|
|
358
281
|
});
|
|
359
282
|
return result;
|
|
360
283
|
}
|
|
361
|
-
async function
|
|
362
|
-
const markdownLines = [''];
|
|
284
|
+
async function getDiffPatchImpl(app1, app2) {
|
|
363
285
|
if (app1.name === app2.name) {
|
|
364
|
-
|
|
365
|
-
const code = await compileMarkdownString(markdownLines.join('\n'));
|
|
366
|
-
return code;
|
|
286
|
+
return '';
|
|
367
287
|
}
|
|
368
288
|
const { app1CopyPath, app2CopyPath } = await prepareForDiff(app1, app2);
|
|
369
289
|
const { stdout: diffOutput } = await execa('git', [
|
|
@@ -373,83 +293,28 @@ async function getDiffCodeImpl(app1, app2) {
|
|
|
373
293
|
app2CopyPath,
|
|
374
294
|
'--color=never',
|
|
375
295
|
'--color-moved-ws=allow-indentation-change',
|
|
376
|
-
'--no-prefix',
|
|
377
296
|
'--ignore-blank-lines',
|
|
378
297
|
'--ignore-space-change',
|
|
379
298
|
], { cwd: diffTmpDir }).catch((e) => e);
|
|
380
299
|
void fsExtra.remove(app1CopyPath).catch(() => { });
|
|
381
300
|
void fsExtra.remove(app2CopyPath).catch(() => { });
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
switch (file.type) {
|
|
400
|
-
case 'ChangedFile': {
|
|
401
|
-
markdownLines.push(`
|
|
402
|
-
|
|
403
|
-
<Accordion title=${JSON.stringify(relativePath)} variant="changed">
|
|
404
|
-
|
|
405
|
-
${getFileCodeblocks(file, filePathApp1, filePathApp2, file.type).join('\n')}
|
|
406
|
-
|
|
407
|
-
</Accordion>
|
|
408
|
-
|
|
409
|
-
`);
|
|
410
|
-
break;
|
|
411
|
-
}
|
|
412
|
-
case 'DeletedFile': {
|
|
413
|
-
markdownLines.push(`
|
|
414
|
-
<Accordion title=${JSON.stringify(relativePath)} variant="deleted">
|
|
415
|
-
|
|
416
|
-
${getFileCodeblocks(file, filePathApp1, filePathApp2, file.type).join('\n')}
|
|
417
|
-
|
|
418
|
-
</Accordion>
|
|
419
|
-
`);
|
|
420
|
-
break;
|
|
421
|
-
}
|
|
422
|
-
case 'RenamedFile': {
|
|
423
|
-
const relativeBefore = diffPathToRelative(file.pathBefore);
|
|
424
|
-
const relativeAfter = diffPathToRelative(file.pathAfter);
|
|
425
|
-
const title = JSON.stringify(`${relativeBefore} ▶️ ${relativeAfter}`);
|
|
426
|
-
markdownLines.push(`
|
|
427
|
-
<Accordion title=${title} variant="renamed">
|
|
428
|
-
|
|
429
|
-
${getFileCodeblocks(file, filePathApp1, filePathApp2, file.type).join('\n')}
|
|
430
|
-
|
|
431
|
-
</Accordion>
|
|
432
|
-
`);
|
|
433
|
-
break;
|
|
434
|
-
}
|
|
435
|
-
case 'AddedFile': {
|
|
436
|
-
markdownLines.push(`
|
|
437
|
-
<Accordion title=${JSON.stringify(relativePath)} variant="added">
|
|
438
|
-
|
|
439
|
-
${getFileCodeblocks(file, filePathApp1, filePathApp2, file.type).join('\n')}
|
|
440
|
-
|
|
441
|
-
</Accordion>
|
|
442
|
-
`);
|
|
443
|
-
break;
|
|
444
|
-
}
|
|
445
|
-
default: {
|
|
446
|
-
console.error(file);
|
|
447
|
-
throw new Error(`Unknown file type: ${file}`);
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
const code = await compileMarkdownString(markdownLines.join('\n'));
|
|
452
|
-
return code;
|
|
301
|
+
const normalizedOutput = String(diffOutput ?? '');
|
|
302
|
+
const app1Relative = app1CopyPath.slice(1);
|
|
303
|
+
const app2Relative = app2CopyPath.slice(1);
|
|
304
|
+
const testFiles = new Set([
|
|
305
|
+
...getAppTestFiles(app1),
|
|
306
|
+
...getAppTestFiles(app2),
|
|
307
|
+
]);
|
|
308
|
+
const filteredOutput = filterTestFilesFromPatch(normalizedOutput
|
|
309
|
+
.replaceAll(`a${app1CopyPath}`, 'a')
|
|
310
|
+
.replaceAll(`b${app1CopyPath}`, 'b')
|
|
311
|
+
.replaceAll(`a${app2CopyPath}`, 'a')
|
|
312
|
+
.replaceAll(`b${app2CopyPath}`, 'b')
|
|
313
|
+
.replaceAll(`${app1CopyPath}/`, '')
|
|
314
|
+
.replaceAll(`${app2CopyPath}/`, '')
|
|
315
|
+
.replaceAll(`${app1Relative}/`, '')
|
|
316
|
+
.replaceAll(`${app2Relative}/`, ''), testFiles);
|
|
317
|
+
return filteredOutput;
|
|
453
318
|
}
|
|
454
319
|
export async function getDiffOutputWithRelativePaths(app1, app2) {
|
|
455
320
|
const { app1CopyPath, app2CopyPath } = await prepareForDiff(app1, app2);
|
|
@@ -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:
|
|
148
|
-
|
|
149
|
-
readonly status: "error";
|
|
150
|
-
readonly error: "not authenticated";
|
|
184
|
+
readonly error: string;
|
|
185
|
+
readonly queuedCount?: undefined;
|
|
151
186
|
} | {
|
|
152
|
-
readonly status: "
|
|
153
|
-
readonly
|
|
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;
|
package/dist/epic-api.server.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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({
|
|
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
|
|
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
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
})
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
await
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
592
|
-
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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, }) {
|
package/dist/utils.server.d.ts
CHANGED
|
@@ -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>;
|
package/dist/utils.server.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "6.86.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -237,7 +237,6 @@
|
|
|
237
237
|
"remark-emoji": "^5.0.2",
|
|
238
238
|
"remark-gfm": "^4.0.1",
|
|
239
239
|
"shell-quote": "^1.8.3",
|
|
240
|
-
"shiki": "^3.22.0",
|
|
241
240
|
"unified": "^11.0.5",
|
|
242
241
|
"unist-util-remove-position": "^5.0.0",
|
|
243
242
|
"unist-util-visit": "^5.1.0",
|