@nkzw/codiff-service 1.9.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/fate.ts ADDED
@@ -0,0 +1,1258 @@
1
+ import type { PullRequestGeneralCommentThread, SharedPlanSnapshot } from '@nkzw/codiff-core';
2
+ import { parsePlanShareManifest } from '@nkzw/codiff-core/share';
3
+ import {
4
+ createFateServer,
5
+ createResolver,
6
+ FateRequestError,
7
+ type LiveEventBus,
8
+ type SourceDefinition,
9
+ } from '@nkzw/fate/server';
10
+ import { createDrizzleSourceAdapter } from '@nkzw/fate/server/drizzle';
11
+ import { count } from 'drizzle-orm';
12
+ import { z } from 'zod';
13
+ import type { SharingBucket, SharingEnv } from './api.ts';
14
+ import {
15
+ and,
16
+ createDb,
17
+ eq,
18
+ gte,
19
+ runDatabaseBatches,
20
+ sql,
21
+ type Database,
22
+ type DatabaseBatchStatement,
23
+ } from './db.ts';
24
+ import schema, {
25
+ plan,
26
+ shareCommentMessage,
27
+ shareCommentThread,
28
+ uploadIntent,
29
+ user,
30
+ walkthrough,
31
+ walkthroughFile,
32
+ type ShareCommentThreadRow,
33
+ type UserRow,
34
+ } from './schema.ts';
35
+ import { hashUploadIntentSecret, verifyUploadIntentSecret } from './upload-intent.ts';
36
+ import {
37
+ SharingRoot,
38
+ getSessionUserId,
39
+ planDataView,
40
+ shareCommentMessageDataView,
41
+ shareCommentThreadDataView,
42
+ shareStatsDataView,
43
+ uploadIntentDataView,
44
+ uploadIntentSecretHeader,
45
+ walkthroughDataView,
46
+ type ShareStatsRecord,
47
+ } from './views.ts';
48
+
49
+ export type SharingAuth = {
50
+ api: {
51
+ getSession(options: {
52
+ asResponse?: false;
53
+ headers: Headers;
54
+ returnHeaders?: boolean;
55
+ }): Promise<unknown>;
56
+ };
57
+ };
58
+
59
+ export type SharingFateEnv = SharingEnv & Record<string, unknown>;
60
+
61
+ type SharingLive = LiveEventBus;
62
+
63
+ export type SharingFateContext = {
64
+ auth: SharingAuth | null;
65
+ db: Database;
66
+ env: SharingFateEnv;
67
+ request: Request;
68
+ };
69
+
70
+ type CreateSharingFateServerOptions = {
71
+ live: SharingLive;
72
+ providerLabel: string;
73
+ };
74
+
75
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
76
+ value !== null && typeof value === 'object';
77
+
78
+ const isBetterAuthApi = (value: unknown): value is SharingAuth =>
79
+ isRecord(value) && isRecord(value.api) && typeof value.api.getSession === 'function';
80
+
81
+ const isFateEnv = (value: unknown): value is SharingFateEnv =>
82
+ isRecord(value) && isRecord(value.DB) && typeof value.DB.prepare === 'function';
83
+
84
+ const getAuthenticatedUserId = async (context: SharingFateContext) => {
85
+ if (!context.auth) {
86
+ return null;
87
+ }
88
+ return getSessionUserId(
89
+ await context.auth.api.getSession({
90
+ asResponse: false,
91
+ headers: context.request.headers,
92
+ }),
93
+ );
94
+ };
95
+
96
+ const getShareCommentAuthorUsername = (
97
+ commenter: Pick<UserRow, 'displayUsername' | 'name' | 'username'>,
98
+ ) => commenter.displayUsername || commenter.username || commenter.name;
99
+
100
+ const validDate = (value: string | undefined, fallback: Date) => {
101
+ if (!value) {
102
+ return fallback;
103
+ }
104
+ const date = new Date(value);
105
+ return Number.isFinite(date.getTime()) ? date : fallback;
106
+ };
107
+
108
+ const importedThreadId = (parentId: string, sourceThreadId: string) =>
109
+ `imported:${parentId}:${sourceThreadId}`;
110
+
111
+ const importPlanComments = async (
112
+ database: Database,
113
+ planId: string,
114
+ snapshot: SharedPlanSnapshot,
115
+ ) => {
116
+ const statements: Array<DatabaseBatchStatement> = [];
117
+ for (const thread of snapshot.review.threads) {
118
+ const sourceThreadId = `plan:${thread.id}`;
119
+ const threadId = importedThreadId(planId, sourceThreadId);
120
+ statements.push(
121
+ database
122
+ .insert(shareCommentThread)
123
+ .values({
124
+ anchorJson: JSON.stringify(thread.anchor),
125
+ createdAt: validDate(thread.createdAt, new Date()),
126
+ id: threadId,
127
+ kind: 'plan',
128
+ planId,
129
+ resolvedAt:
130
+ thread.status === 'resolved'
131
+ ? validDate(thread.resolution?.resolvedAt, new Date())
132
+ : null,
133
+ sourceThreadId,
134
+ status: thread.status,
135
+ updatedAt: validDate(thread.updatedAt, new Date()),
136
+ })
137
+ .onConflictDoNothing(),
138
+ );
139
+ for (const message of thread.messages) {
140
+ statements.push(
141
+ database
142
+ .insert(shareCommentMessage)
143
+ .values({
144
+ authorImage: message.author.avatarUrl ?? null,
145
+ authorName: message.author.name,
146
+ authorUserId: null,
147
+ authorUsername: message.author.username ?? null,
148
+ body: message.body,
149
+ createdAt: validDate(message.createdAt, new Date()),
150
+ id: crypto.randomUUID(),
151
+ sourceMessageId: `plan:${message.id}`,
152
+ threadId,
153
+ updatedAt: validDate(message.updatedAt, new Date()),
154
+ })
155
+ .onConflictDoNothing(),
156
+ );
157
+ }
158
+ }
159
+ await runDatabaseBatches(database, statements);
160
+ await database.update(plan).set({ commentsImportedAt: new Date() }).where(eq(plan.id, planId));
161
+ };
162
+
163
+ type WalkthroughReviewComment = {
164
+ anchor?: 'file' | 'line';
165
+ author: { avatarUrl?: string; login: string; name?: string };
166
+ body: string;
167
+ filePath: string;
168
+ id: string;
169
+ isThreadResolved?: boolean;
170
+ lineNumber?: number;
171
+ sectionId?: string;
172
+ side?: 'additions' | 'deletions';
173
+ startLineNumber?: number;
174
+ startSide?: 'additions' | 'deletions';
175
+ submittedAt?: string;
176
+ threadId?: string;
177
+ };
178
+
179
+ type WalkthroughImportSnapshot = {
180
+ exportedAt?: string;
181
+ repository: { generalComments?: ReadonlyArray<PullRequestGeneralCommentThread> };
182
+ reviewComments?: ReadonlyArray<WalkthroughReviewComment>;
183
+ };
184
+
185
+ const parseWalkthroughImportSnapshot = (value: unknown): WalkthroughImportSnapshot => {
186
+ if (!isRecord(value) || !isRecord(value.repository)) {
187
+ throw new Error('Invalid walkthrough manifest.');
188
+ }
189
+ return value as WalkthroughImportSnapshot;
190
+ };
191
+
192
+ const importWalkthroughComments = async (
193
+ database: Database,
194
+ walkthroughId: string,
195
+ snapshot: WalkthroughImportSnapshot,
196
+ ) => {
197
+ const statements: Array<DatabaseBatchStatement> = [];
198
+ const groups = new Map<string, Array<WalkthroughReviewComment>>();
199
+ for (const comment of snapshot.reviewComments ?? []) {
200
+ const key = comment.threadId ? `thread:${comment.threadId}` : `comment:${comment.id}`;
201
+ const group = groups.get(key);
202
+ if (group) {
203
+ group.push(comment);
204
+ } else {
205
+ groups.set(key, [comment]);
206
+ }
207
+ }
208
+
209
+ for (const [sourceId, comments] of groups) {
210
+ const first = comments[0];
211
+ if (!first) {
212
+ continue;
213
+ }
214
+ const isFileComment = first.anchor === 'file';
215
+ if (!isFileComment && (first.lineNumber == null || first.side == null)) {
216
+ continue;
217
+ }
218
+ const createdAt = validDate(first.submittedAt, validDate(snapshot.exportedAt, new Date()));
219
+ const resolved = comments.some((comment) => comment.isThreadResolved === true);
220
+ const sourceThreadId = `walkthrough-diff:${sourceId}`;
221
+ const threadId = importedThreadId(walkthroughId, sourceThreadId);
222
+ statements.push(
223
+ database
224
+ .insert(shareCommentThread)
225
+ .values({
226
+ createdAt,
227
+ filePath: first.filePath,
228
+ id: threadId,
229
+ kind: 'walkthrough-diff',
230
+ lineNumber: isFileComment ? null : first.lineNumber,
231
+ resolvedAt: resolved ? createdAt : null,
232
+ sectionId: first.sectionId ?? null,
233
+ side: isFileComment ? null : first.side,
234
+ sourceThreadId,
235
+ startLineNumber: isFileComment ? null : (first.startLineNumber ?? null),
236
+ startSide: isFileComment ? null : (first.startSide ?? null),
237
+ status: resolved ? 'resolved' : 'open',
238
+ updatedAt: validDate(comments.at(-1)?.submittedAt, createdAt),
239
+ walkthroughId,
240
+ })
241
+ .onConflictDoNothing(),
242
+ );
243
+ for (const comment of comments) {
244
+ const submittedAt = validDate(comment.submittedAt, createdAt);
245
+ statements.push(
246
+ database
247
+ .insert(shareCommentMessage)
248
+ .values({
249
+ authorImage: comment.author.avatarUrl ?? null,
250
+ authorName: comment.author.name ?? comment.author.login,
251
+ authorUserId: null,
252
+ authorUsername: comment.author.login,
253
+ body: comment.body,
254
+ createdAt: submittedAt,
255
+ id: crypto.randomUUID(),
256
+ sourceMessageId: `walkthrough-review:${comment.id}`,
257
+ threadId,
258
+ updatedAt: submittedAt,
259
+ })
260
+ .onConflictDoNothing(),
261
+ );
262
+ }
263
+ }
264
+
265
+ for (const thread of snapshot.repository.generalComments ?? []) {
266
+ const first = thread.comments[0];
267
+ if (!first) {
268
+ continue;
269
+ }
270
+ const createdAt = validDate(first.submittedAt, validDate(snapshot.exportedAt, new Date()));
271
+ const sourceThreadId = `walkthrough-general:${thread.id}`;
272
+ const threadId = importedThreadId(walkthroughId, sourceThreadId);
273
+ statements.push(
274
+ database
275
+ .insert(shareCommentThread)
276
+ .values({
277
+ createdAt,
278
+ id: threadId,
279
+ kind: 'walkthrough-general',
280
+ resolvedAt: thread.isResolved ? createdAt : null,
281
+ sourceThreadId,
282
+ status: thread.isResolved ? 'resolved' : 'open',
283
+ updatedAt: validDate(thread.comments.at(-1)?.submittedAt, createdAt),
284
+ walkthroughId,
285
+ })
286
+ .onConflictDoNothing(),
287
+ );
288
+ for (const comment of thread.comments) {
289
+ const submittedAt = validDate(comment.submittedAt, createdAt);
290
+ statements.push(
291
+ database
292
+ .insert(shareCommentMessage)
293
+ .values({
294
+ authorImage: comment.author.avatarUrl ?? null,
295
+ authorName: comment.author.name ?? comment.author.login,
296
+ authorUserId: null,
297
+ authorUsername: comment.author.login,
298
+ body: comment.body,
299
+ createdAt: submittedAt,
300
+ id: crypto.randomUUID(),
301
+ sourceMessageId: `walkthrough-general:${comment.id}`,
302
+ threadId,
303
+ updatedAt: submittedAt,
304
+ })
305
+ .onConflictDoNothing(),
306
+ );
307
+ }
308
+ }
309
+
310
+ await runDatabaseBatches(database, statements);
311
+ await database
312
+ .update(walkthrough)
313
+ .set({ commentsImportedAt: new Date() })
314
+ .where(eq(walkthrough.id, walkthroughId));
315
+ };
316
+
317
+ const readManifest = async (bucket: SharingBucket, objectKey: string) => {
318
+ const object = await bucket.get(objectKey);
319
+ if (!object) {
320
+ throw new Error('Shared manifest not found.');
321
+ }
322
+ return new Response(object.body).json();
323
+ };
324
+
325
+ const ensurePlanCommentsImported = async (
326
+ database: Database,
327
+ bucket: SharingBucket,
328
+ record: { commentsImportedAt: Date | null; id: string; objectKey: string },
329
+ ) => {
330
+ if (!record.commentsImportedAt) {
331
+ await importPlanComments(
332
+ database,
333
+ record.id,
334
+ parsePlanShareManifest(await readManifest(bucket, record.objectKey)),
335
+ );
336
+ }
337
+ };
338
+
339
+ const ensureWalkthroughCommentsImported = async (
340
+ database: Database,
341
+ bucket: SharingBucket,
342
+ record: { commentsImportedAt: Date | null; id: string; objectKey: string },
343
+ ) => {
344
+ if (!record.commentsImportedAt) {
345
+ await importWalkthroughComments(
346
+ database,
347
+ record.id,
348
+ parseWalkthroughImportSnapshot(await readManifest(bucket, record.objectKey)),
349
+ );
350
+ }
351
+ };
352
+
353
+ const getWalkthroughFilePaths = (manifest: unknown) => {
354
+ if (!isRecord(manifest) || !Array.isArray(manifest.files)) {
355
+ return [];
356
+ }
357
+ return [
358
+ ...new Set(
359
+ manifest.files.flatMap((file) =>
360
+ isRecord(file) &&
361
+ typeof file.path === 'string' &&
362
+ file.path.length > 0 &&
363
+ file.path.length <= 4096
364
+ ? [file.path]
365
+ : [],
366
+ ),
367
+ ),
368
+ ];
369
+ };
370
+
371
+ const indexWalkthroughFiles = async (
372
+ database: Database,
373
+ walkthroughId: string,
374
+ filePaths: ReadonlyArray<string>,
375
+ ) => {
376
+ const statements: Array<DatabaseBatchStatement> = [];
377
+ for (let index = 0; index < filePaths.length; index += 40) {
378
+ statements.push(
379
+ database
380
+ .insert(walkthroughFile)
381
+ .values(
382
+ filePaths.slice(index, index + 40).map((path) => ({
383
+ path,
384
+ walkthroughId,
385
+ })),
386
+ )
387
+ .onConflictDoNothing(),
388
+ );
389
+ }
390
+ await runDatabaseBatches(database, statements);
391
+ await database
392
+ .update(walkthrough)
393
+ .set({ filesIndexedAt: new Date() })
394
+ .where(eq(walkthrough.id, walkthroughId));
395
+ };
396
+
397
+ const fetchShareStats = async (database: Database, now = new Date()): Promise<ShareStatsRecord> => {
398
+ const dayMilliseconds = 24 * 60 * 60 * 1000;
399
+ const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
400
+ const start = new Date(today.getTime() - 6 * dayMilliseconds);
401
+ const dayForPlan = sql<string>`date(${plan.createdAt}, 'unixepoch')`;
402
+ const dayForWalkthrough = sql<string>`date(${walkthrough.createdAt}, 'unixepoch')`;
403
+ const [planTotals, walkthroughTotals, planDays, walkthroughDays] = await database.batch([
404
+ database.select({ count: count() }).from(plan),
405
+ database.select({ count: count() }).from(walkthrough),
406
+ database
407
+ .select({ count: count(), date: dayForPlan })
408
+ .from(plan)
409
+ .where(gte(plan.createdAt, start))
410
+ .groupBy(dayForPlan),
411
+ database
412
+ .select({ count: count(), date: dayForWalkthrough })
413
+ .from(walkthrough)
414
+ .where(gte(walkthrough.createdAt, start))
415
+ .groupBy(dayForWalkthrough),
416
+ ]);
417
+ const plansByDate = new Map(planDays.map((entry) => [entry.date, entry.count]));
418
+ const walkthroughsByDate = new Map(walkthroughDays.map((entry) => [entry.date, entry.count]));
419
+ const days = Array.from({ length: 7 }, (_, index) => {
420
+ const date = new Date(start.getTime() + index * dayMilliseconds).toISOString().slice(0, 10);
421
+ return {
422
+ date,
423
+ id: `share-stats-day:${date}`,
424
+ mergeRequestWalkthroughs: 0,
425
+ plans: plansByDate.get(date) ?? 0,
426
+ walkthroughs: walkthroughsByDate.get(date) ?? 0,
427
+ };
428
+ });
429
+ return {
430
+ days,
431
+ id: 'share-stats',
432
+ maxDailyShares: Math.max(0, ...days.flatMap((day) => [day.plans, day.walkthroughs])),
433
+ totalMergeRequestWalkthroughs: 0,
434
+ totalPlans: planTotals[0]?.count ?? 0,
435
+ totalWalkthroughs: walkthroughTotals[0]?.count ?? 0,
436
+ };
437
+ };
438
+
439
+ const shareCommentBodySchema = z
440
+ .string()
441
+ .trim()
442
+ .min(1)
443
+ .max(128 * 1024);
444
+ const shareCommentAnchorBlockSchema = z.object({
445
+ fingerprint: z.string().min(1).max(256),
446
+ path: z.array(z.number().int().min(0).max(1_000_000)).max(64),
447
+ runtimeKey: z.string().max(256).optional(),
448
+ text: z.string().max(64 * 1024),
449
+ type: z.string().min(1).max(64),
450
+ });
451
+ export const shareCommentAnchorSchema = z
452
+ .object({
453
+ block: shareCommentAnchorBlockSchema,
454
+ kind: z.enum(['block', 'text']),
455
+ quote: z
456
+ .object({
457
+ end: z.number().int().min(0).max(1_000_000),
458
+ exact: z.string().max(64 * 1024),
459
+ prefix: z.string().max(1024),
460
+ start: z.number().int().min(0).max(1_000_000),
461
+ suffix: z.string().max(1024),
462
+ })
463
+ .refine(({ end, start }) => end >= start, 'Invalid quote range.')
464
+ .optional(),
465
+ version: z.literal(1),
466
+ })
467
+ .refine(({ kind, quote }) => kind !== 'text' || quote != null, {
468
+ message: 'Text anchors require a quote.',
469
+ path: ['quote'],
470
+ });
471
+ const walkthroughCommentTargetBaseSchema = z.object({
472
+ filePath: z.string().min(1).max(4096),
473
+ kind: z.literal('walkthrough-diff'),
474
+ });
475
+ export const shareCommentTargetSchema = z.union([
476
+ z.object({
477
+ anchor: shareCommentAnchorSchema,
478
+ kind: z.literal('plan'),
479
+ }),
480
+ walkthroughCommentTargetBaseSchema.extend({
481
+ anchor: z.literal('file'),
482
+ }),
483
+ walkthroughCommentTargetBaseSchema.extend({
484
+ anchor: z.literal('line').optional(),
485
+ lineNumber: z.number().int().positive(),
486
+ sectionId: z.string().min(1).max(4096).optional(),
487
+ side: z.enum(['additions', 'deletions']),
488
+ startLineNumber: z.number().int().positive().optional(),
489
+ startSide: z.enum(['additions', 'deletions']).optional(),
490
+ }),
491
+ z.object({
492
+ kind: z.literal('walkthrough-general'),
493
+ }),
494
+ ]);
495
+ const createShareCommentThreadInput = z.object({
496
+ body: shareCommentBodySchema,
497
+ shareId: z.string().min(1).max(256),
498
+ shareType: z.enum(['plan', 'walkthrough']),
499
+ target: shareCommentTargetSchema,
500
+ });
501
+ const replyShareCommentInput = z.object({
502
+ body: shareCommentBodySchema,
503
+ threadId: z.string().min(1).max(256),
504
+ });
505
+ const updateShareCommentInput = z.object({
506
+ body: shareCommentBodySchema,
507
+ messageId: z.string().min(1).max(256),
508
+ });
509
+ const deleteShareCommentInput = z.object({
510
+ id: z.string().min(1).max(256),
511
+ });
512
+ const deleteShareInput = z.object({
513
+ id: z.string().min(1).max(256),
514
+ });
515
+ const resolveShareCommentThreadInput = z.object({
516
+ resolved: z.boolean(),
517
+ threadId: z.string().min(1).max(256),
518
+ });
519
+
520
+ const databaseViews = {
521
+ planBySlug: planDataView,
522
+ shareCommentMessage: shareCommentMessageDataView,
523
+ shareCommentThread: shareCommentThreadDataView,
524
+ uploadIntentByCode: uploadIntentDataView,
525
+ walkthroughBySlug: walkthroughDataView,
526
+ };
527
+
528
+ const getCommentThread = async (context: SharingFateContext, threadId: string) => {
529
+ const [thread] = await context.db
530
+ .select()
531
+ .from(shareCommentThread)
532
+ .where(eq(shareCommentThread.id, threadId))
533
+ .limit(1);
534
+ if (!thread) {
535
+ throw new FateRequestError('NOT_FOUND', 'Comment thread not found.');
536
+ }
537
+ return thread;
538
+ };
539
+
540
+ const getThreadOwnerId = async (context: SharingFateContext, thread: ShareCommentThreadRow) => {
541
+ if (thread.planId) {
542
+ const [record] = await context.db
543
+ .select({ ownerId: plan.sharedByUserId })
544
+ .from(plan)
545
+ .where(eq(plan.id, thread.planId))
546
+ .limit(1);
547
+ return record?.ownerId ?? null;
548
+ }
549
+ if (thread.walkthroughId) {
550
+ const [record] = await context.db
551
+ .select({ ownerId: walkthrough.sharedByUserId })
552
+ .from(walkthrough)
553
+ .where(eq(walkthrough.id, thread.walkthroughId))
554
+ .limit(1);
555
+ return record?.ownerId ?? null;
556
+ }
557
+ return null;
558
+ };
559
+
560
+ export const createSharingFateServer = ({
561
+ live,
562
+ providerLabel,
563
+ }: CreateSharingFateServerOptions) => {
564
+ const sources = createDrizzleSourceAdapter<SharingFateContext>({
565
+ db: ({ db: database }) => database,
566
+ schema,
567
+ views: databaseViews,
568
+ });
569
+ const customSources = new WeakMap<object, SourceDefinition>();
570
+ const sourceRegistry = new Map(sources.registry);
571
+ const allSources = {
572
+ getSource: <Item extends Record<string, unknown>>(
573
+ target:
574
+ | SourceDefinition<Item, unknown>
575
+ | {
576
+ fields: Record<string, unknown>;
577
+ typeName: string;
578
+ },
579
+ ): SourceDefinition<Item, unknown> => {
580
+ if ('view' in target && 'id' in target) {
581
+ return target;
582
+ }
583
+ if (target.typeName !== 'ShareStats' && target.typeName !== 'ShareStatsDay') {
584
+ return sources.getSource(target as never);
585
+ }
586
+ let source = customSources.get(target) as SourceDefinition<Item, unknown> | undefined;
587
+ if (!source) {
588
+ source = { id: 'id', view: target as never };
589
+ customSources.set(target, source);
590
+ sourceRegistry.set(source, {});
591
+ }
592
+ return source;
593
+ },
594
+ registry: sourceRegistry,
595
+ };
596
+
597
+ const requireAuthenticatedUser = async (context: SharingFateContext): Promise<UserRow> => {
598
+ const userId = await getAuthenticatedUserId(context);
599
+ if (!userId) {
600
+ throw new FateRequestError('UNAUTHORIZED', `Sign in with ${providerLabel} to continue.`);
601
+ }
602
+ const [record] = await context.db.select().from(user).where(eq(user.id, userId)).limit(1);
603
+ if (!record || record.banned) {
604
+ throw new FateRequestError('UNAUTHORIZED', 'This account cannot comment.');
605
+ }
606
+ return record;
607
+ };
608
+
609
+ const publishThreadInsert = (thread: ShareCommentThreadRow) => {
610
+ if (thread.planId) {
611
+ live
612
+ .connection('Plan.commentThreads', { id: thread.planId })
613
+ .appendNode('ShareCommentThread', thread.id);
614
+ } else if (thread.walkthroughId) {
615
+ live
616
+ .connection('Walkthrough.commentThreads', { id: thread.walkthroughId })
617
+ .appendNode('ShareCommentThread', thread.id);
618
+ }
619
+ };
620
+
621
+ const publishThreadDelete = (thread: ShareCommentThreadRow) => {
622
+ if (thread.planId) {
623
+ live
624
+ .connection('Plan.commentThreads', { id: thread.planId })
625
+ .deleteEdge('ShareCommentThread', thread.id);
626
+ } else if (thread.walkthroughId) {
627
+ live
628
+ .connection('Walkthrough.commentThreads', { id: thread.walkthroughId })
629
+ .deleteEdge('ShareCommentThread', thread.id);
630
+ }
631
+ live.delete('ShareCommentThread', thread.id);
632
+ };
633
+
634
+ const validateWalkthroughCommentFile = async (
635
+ context: SharingFateContext,
636
+ record: { filesIndexedAt: Date | null; id: string; objectKey: string },
637
+ filePath: string,
638
+ ) => {
639
+ if (!record.filesIndexedAt) {
640
+ const manifest = await readManifest(context.env.WALKTHROUGH_BUCKET, record.objectKey);
641
+ const filePaths = getWalkthroughFilePaths(manifest);
642
+ await indexWalkthroughFiles(context.db, record.id, filePaths);
643
+ if (!filePaths.includes(filePath)) {
644
+ throw new FateRequestError('BAD_REQUEST', 'The comment target is not in this walkthrough.');
645
+ }
646
+ return;
647
+ }
648
+ const [file] = await context.db
649
+ .select({ path: walkthroughFile.path })
650
+ .from(walkthroughFile)
651
+ .where(and(eq(walkthroughFile.walkthroughId, record.id), eq(walkthroughFile.path, filePath)))
652
+ .limit(1);
653
+ if (!file) {
654
+ throw new FateRequestError('BAD_REQUEST', 'The comment target is not in this walkthrough.');
655
+ }
656
+ };
657
+
658
+ return createFateServer({
659
+ context: ({ adapterContext, request }) => {
660
+ const context = isRecord(adapterContext) ? adapterContext : {};
661
+ const env = context.env;
662
+ if (!isFateEnv(env)) {
663
+ throw new Error('Fate requires the D1 DB binding.');
664
+ }
665
+ return {
666
+ auth: isBetterAuthApi(context.auth) ? context.auth : null,
667
+ db: createDb(env.DB),
668
+ env,
669
+ request,
670
+ };
671
+ },
672
+ live,
673
+ mutations: {
674
+ 'plan.delete': {
675
+ input: deleteShareInput,
676
+ resolve: async ({
677
+ ctx,
678
+ input,
679
+ select,
680
+ }: {
681
+ ctx: SharingFateContext;
682
+ input: z.infer<typeof deleteShareInput>;
683
+ select: Array<string>;
684
+ }) => {
685
+ const owner = await requireAuthenticatedUser(ctx);
686
+ const [record] = await ctx.db.select().from(plan).where(eq(plan.id, input.id)).limit(1);
687
+ if (!record) {
688
+ throw new FateRequestError('NOT_FOUND', 'Shared plan not found.');
689
+ }
690
+ if (record.sharedByUserId !== owner.id) {
691
+ throw new FateRequestError('FORBIDDEN', 'Only the share owner can delete this plan.');
692
+ }
693
+ const deleted = await createResolver({
694
+ ctx,
695
+ select,
696
+ view: planDataView,
697
+ }).resolve(record);
698
+ await ctx.env.WALKTHROUGH_BUCKET.delete(record.objectKey);
699
+ const deletedRows = await ctx.db
700
+ .delete(plan)
701
+ .where(and(eq(plan.id, record.id), eq(plan.sharedByUserId, owner.id)))
702
+ .returning({ id: plan.id });
703
+ if (deletedRows.length === 0) {
704
+ throw new FateRequestError('NOT_FOUND', 'Shared plan not found.');
705
+ }
706
+ return deleted;
707
+ },
708
+ type: 'Plan',
709
+ },
710
+ 'shareComment.createThread': {
711
+ input: createShareCommentThreadInput,
712
+ resolve: async ({
713
+ ctx,
714
+ input,
715
+ select,
716
+ }: {
717
+ ctx: SharingFateContext;
718
+ input: z.infer<typeof createShareCommentThreadInput>;
719
+ select: Array<string>;
720
+ }) => {
721
+ const commenter = await requireAuthenticatedUser(ctx);
722
+ const now = new Date();
723
+ const threadId = crypto.randomUUID();
724
+ const messageId = crypto.randomUUID();
725
+ let thread: ShareCommentThreadRow;
726
+
727
+ if (input.shareType === 'plan') {
728
+ if (input.target.kind !== 'plan') {
729
+ throw new FateRequestError('BAD_REQUEST', 'A plan comment needs a plan target.');
730
+ }
731
+ const [record] = await ctx.db
732
+ .select({ id: plan.id })
733
+ .from(plan)
734
+ .where(eq(plan.id, input.shareId))
735
+ .limit(1);
736
+ if (!record) {
737
+ throw new FateRequestError('NOT_FOUND', 'Shared plan not found.');
738
+ }
739
+ thread = {
740
+ anchorJson: JSON.stringify(input.target.anchor),
741
+ createdAt: now,
742
+ filePath: null,
743
+ id: threadId,
744
+ kind: 'plan',
745
+ lineNumber: null,
746
+ planId: record.id,
747
+ resolvedAt: null,
748
+ sectionId: null,
749
+ side: null,
750
+ sourceThreadId: null,
751
+ startLineNumber: null,
752
+ startSide: null,
753
+ status: 'open',
754
+ updatedAt: now,
755
+ walkthroughId: null,
756
+ };
757
+ } else {
758
+ if (input.target.kind === 'plan') {
759
+ throw new FateRequestError(
760
+ 'BAD_REQUEST',
761
+ 'A walkthrough comment needs a walkthrough target.',
762
+ );
763
+ }
764
+ const [record] = await ctx.db
765
+ .select({
766
+ filesIndexedAt: walkthrough.filesIndexedAt,
767
+ id: walkthrough.id,
768
+ objectKey: walkthrough.objectKey,
769
+ })
770
+ .from(walkthrough)
771
+ .where(eq(walkthrough.id, input.shareId))
772
+ .limit(1);
773
+ if (!record) {
774
+ throw new FateRequestError('NOT_FOUND', 'Shared walkthrough not found.');
775
+ }
776
+ if (input.target.kind === 'walkthrough-diff') {
777
+ await validateWalkthroughCommentFile(ctx, record, input.target.filePath);
778
+ }
779
+ const lineTarget =
780
+ input.target.kind === 'walkthrough-diff' && input.target.anchor !== 'file'
781
+ ? input.target
782
+ : null;
783
+ thread = {
784
+ anchorJson: null,
785
+ createdAt: now,
786
+ filePath: input.target.kind === 'walkthrough-diff' ? input.target.filePath : null,
787
+ id: threadId,
788
+ kind: input.target.kind,
789
+ lineNumber: lineTarget?.lineNumber ?? null,
790
+ planId: null,
791
+ resolvedAt: null,
792
+ sectionId: lineTarget?.sectionId ?? null,
793
+ side: lineTarget?.side ?? null,
794
+ sourceThreadId: null,
795
+ startLineNumber: lineTarget?.startLineNumber ?? null,
796
+ startSide: lineTarget?.startSide ?? null,
797
+ status: 'open',
798
+ updatedAt: now,
799
+ walkthroughId: record.id,
800
+ };
801
+ }
802
+
803
+ await ctx.db.batch([
804
+ ctx.db.insert(shareCommentThread).values(thread),
805
+ ctx.db.insert(shareCommentMessage).values({
806
+ authorImage: commenter.image,
807
+ authorName: commenter.name,
808
+ authorUserId: commenter.id,
809
+ authorUsername: getShareCommentAuthorUsername(commenter),
810
+ body: input.body,
811
+ createdAt: now,
812
+ id: messageId,
813
+ threadId,
814
+ updatedAt: now,
815
+ }),
816
+ ]);
817
+ publishThreadInsert(thread);
818
+ return sources.resolveById({
819
+ ctx,
820
+ id: threadId,
821
+ input: { select },
822
+ view: shareCommentThreadDataView,
823
+ });
824
+ },
825
+ type: 'ShareCommentThread',
826
+ },
827
+ 'shareComment.deleteMessage': {
828
+ input: deleteShareCommentInput,
829
+ resolve: async ({
830
+ ctx,
831
+ input,
832
+ select,
833
+ }: {
834
+ ctx: SharingFateContext;
835
+ input: z.infer<typeof deleteShareCommentInput>;
836
+ select: Array<string>;
837
+ }) => {
838
+ const commenter = await requireAuthenticatedUser(ctx);
839
+ const [message] = await ctx.db
840
+ .select()
841
+ .from(shareCommentMessage)
842
+ .where(eq(shareCommentMessage.id, input.id))
843
+ .limit(1);
844
+ if (!message) {
845
+ throw new FateRequestError('NOT_FOUND', 'Comment not found.');
846
+ }
847
+ if (message.authorUserId !== commenter.id) {
848
+ throw new FateRequestError('FORBIDDEN', 'You can only delete your own comments.');
849
+ }
850
+ const thread = await getCommentThread(ctx, message.threadId);
851
+ const deleted = await createResolver({
852
+ ctx,
853
+ select,
854
+ view: shareCommentMessageDataView,
855
+ }).resolve(message);
856
+ const now = new Date();
857
+ const [, deletedThreads] = await ctx.db.batch([
858
+ ctx.db.delete(shareCommentMessage).where(eq(shareCommentMessage.id, message.id)),
859
+ ctx.db
860
+ .delete(shareCommentThread)
861
+ .where(
862
+ and(
863
+ eq(shareCommentThread.id, thread.id),
864
+ sql`not exists (
865
+ select 1 from ${shareCommentMessage}
866
+ where ${shareCommentMessage.threadId} = ${thread.id}
867
+ )`,
868
+ ),
869
+ )
870
+ .returning({ id: shareCommentThread.id }),
871
+ ctx.db
872
+ .update(shareCommentThread)
873
+ .set({ updatedAt: now })
874
+ .where(eq(shareCommentThread.id, thread.id)),
875
+ ]);
876
+ live.delete('ShareCommentMessage', message.id);
877
+ if (deletedThreads.length > 0) {
878
+ publishThreadDelete(thread);
879
+ } else {
880
+ live
881
+ .connection('ShareCommentThread.messages', { id: thread.id })
882
+ .deleteEdge('ShareCommentMessage', message.id);
883
+ live.update('ShareCommentThread', thread.id, { changed: ['updatedAt'] });
884
+ }
885
+ return deleted;
886
+ },
887
+ type: 'ShareCommentMessage',
888
+ },
889
+ 'shareComment.reply': {
890
+ input: replyShareCommentInput,
891
+ resolve: async ({
892
+ ctx,
893
+ input,
894
+ select,
895
+ }: {
896
+ ctx: SharingFateContext;
897
+ input: z.infer<typeof replyShareCommentInput>;
898
+ select: Array<string>;
899
+ }) => {
900
+ const commenter = await requireAuthenticatedUser(ctx);
901
+ const thread = await getCommentThread(ctx, input.threadId);
902
+ if (thread.status === 'resolved') {
903
+ throw new FateRequestError('BAD_REQUEST', 'Reopen this thread before replying.');
904
+ }
905
+ const now = new Date();
906
+ const messageId = crypto.randomUUID();
907
+ await ctx.db.batch([
908
+ ctx.db.insert(shareCommentMessage).values({
909
+ authorImage: commenter.image,
910
+ authorName: commenter.name,
911
+ authorUserId: commenter.id,
912
+ authorUsername: getShareCommentAuthorUsername(commenter),
913
+ body: input.body,
914
+ createdAt: now,
915
+ id: messageId,
916
+ threadId: thread.id,
917
+ updatedAt: now,
918
+ }),
919
+ ctx.db
920
+ .update(shareCommentThread)
921
+ .set({ updatedAt: now })
922
+ .where(eq(shareCommentThread.id, thread.id)),
923
+ ]);
924
+ live
925
+ .connection('ShareCommentThread.messages', { id: thread.id })
926
+ .appendNode('ShareCommentMessage', messageId);
927
+ live.update('ShareCommentThread', thread.id, { changed: ['updatedAt'] });
928
+ return sources.resolveById({
929
+ ctx,
930
+ id: messageId,
931
+ input: { select },
932
+ view: shareCommentMessageDataView,
933
+ });
934
+ },
935
+ type: 'ShareCommentMessage',
936
+ },
937
+ 'shareComment.resolveThread': {
938
+ input: resolveShareCommentThreadInput,
939
+ resolve: async ({
940
+ ctx,
941
+ input,
942
+ select,
943
+ }: {
944
+ ctx: SharingFateContext;
945
+ input: z.infer<typeof resolveShareCommentThreadInput>;
946
+ select: Array<string>;
947
+ }) => {
948
+ const commenter = await requireAuthenticatedUser(ctx);
949
+ const thread = await getCommentThread(ctx, input.threadId);
950
+ if ((await getThreadOwnerId(ctx, thread)) !== commenter.id) {
951
+ throw new FateRequestError(
952
+ 'FORBIDDEN',
953
+ 'Only the person who shared this can resolve comments.',
954
+ );
955
+ }
956
+ const now = new Date();
957
+ await ctx.db
958
+ .update(shareCommentThread)
959
+ .set({
960
+ resolvedAt: input.resolved ? now : null,
961
+ status: input.resolved ? 'resolved' : 'open',
962
+ updatedAt: now,
963
+ })
964
+ .where(eq(shareCommentThread.id, thread.id));
965
+ live.update('ShareCommentThread', thread.id, {
966
+ changed: ['resolvedAt', 'status', 'updatedAt'],
967
+ });
968
+ return sources.resolveById({
969
+ ctx,
970
+ id: thread.id,
971
+ input: { select },
972
+ view: shareCommentThreadDataView,
973
+ });
974
+ },
975
+ type: 'ShareCommentThread',
976
+ },
977
+ 'shareComment.updateMessage': {
978
+ input: updateShareCommentInput,
979
+ resolve: async ({
980
+ ctx,
981
+ input,
982
+ select,
983
+ }: {
984
+ ctx: SharingFateContext;
985
+ input: z.infer<typeof updateShareCommentInput>;
986
+ select: Array<string>;
987
+ }) => {
988
+ const commenter = await requireAuthenticatedUser(ctx);
989
+ const [message] = await ctx.db
990
+ .select()
991
+ .from(shareCommentMessage)
992
+ .where(eq(shareCommentMessage.id, input.messageId))
993
+ .limit(1);
994
+ if (!message) {
995
+ throw new FateRequestError('NOT_FOUND', 'Comment not found.');
996
+ }
997
+ if (message.authorUserId !== commenter.id) {
998
+ throw new FateRequestError('FORBIDDEN', 'You can only edit your own comments.');
999
+ }
1000
+ const now = new Date();
1001
+ await ctx.db.batch([
1002
+ ctx.db
1003
+ .update(shareCommentMessage)
1004
+ .set({ body: input.body, updatedAt: now })
1005
+ .where(eq(shareCommentMessage.id, message.id)),
1006
+ ctx.db
1007
+ .update(shareCommentThread)
1008
+ .set({ updatedAt: now })
1009
+ .where(eq(shareCommentThread.id, message.threadId)),
1010
+ ]);
1011
+ live.update('ShareCommentMessage', message.id, { changed: ['body', 'updatedAt'] });
1012
+ live.update('ShareCommentThread', message.threadId, { changed: ['updatedAt'] });
1013
+ return sources.resolveById({
1014
+ ctx,
1015
+ id: message.id,
1016
+ input: { select },
1017
+ view: shareCommentMessageDataView,
1018
+ });
1019
+ },
1020
+ type: 'ShareCommentMessage',
1021
+ },
1022
+ 'walkthrough.delete': {
1023
+ input: deleteShareInput,
1024
+ resolve: async ({
1025
+ ctx,
1026
+ input,
1027
+ select,
1028
+ }: {
1029
+ ctx: SharingFateContext;
1030
+ input: z.infer<typeof deleteShareInput>;
1031
+ select: Array<string>;
1032
+ }) => {
1033
+ const owner = await requireAuthenticatedUser(ctx);
1034
+ const [record] = await ctx.db
1035
+ .select()
1036
+ .from(walkthrough)
1037
+ .where(eq(walkthrough.id, input.id))
1038
+ .limit(1);
1039
+ if (!record) {
1040
+ throw new FateRequestError('NOT_FOUND', 'Shared walkthrough not found.');
1041
+ }
1042
+ if (record.sharedByUserId !== owner.id) {
1043
+ throw new FateRequestError(
1044
+ 'FORBIDDEN',
1045
+ 'Only the share owner can delete this walkthrough.',
1046
+ );
1047
+ }
1048
+ const deleted = await createResolver({
1049
+ ctx,
1050
+ select,
1051
+ view: walkthroughDataView,
1052
+ }).resolve(record);
1053
+ await ctx.env.WALKTHROUGH_BUCKET.delete(record.objectKey);
1054
+ const deletedRows = await ctx.db
1055
+ .delete(walkthrough)
1056
+ .where(and(eq(walkthrough.id, record.id), eq(walkthrough.sharedByUserId, owner.id)))
1057
+ .returning({ id: walkthrough.id });
1058
+ if (deletedRows.length === 0) {
1059
+ throw new FateRequestError('NOT_FOUND', 'Shared walkthrough not found.');
1060
+ }
1061
+ return deleted;
1062
+ },
1063
+ type: 'Walkthrough',
1064
+ },
1065
+ },
1066
+ queries: {
1067
+ planBySlug: {
1068
+ resolve: async ({
1069
+ ctx,
1070
+ input,
1071
+ select,
1072
+ }: {
1073
+ ctx: SharingFateContext;
1074
+ input: { args?: { slug: string } };
1075
+ select: Array<string>;
1076
+ }) => {
1077
+ const slug = input.args?.slug;
1078
+ if (!slug) {
1079
+ throw new FateRequestError('BAD_REQUEST', 'A plan slug is required.');
1080
+ }
1081
+ const [record] = await ctx.db
1082
+ .select({
1083
+ commentsImportedAt: plan.commentsImportedAt,
1084
+ id: plan.id,
1085
+ objectKey: plan.objectKey,
1086
+ })
1087
+ .from(plan)
1088
+ .where(eq(plan.slug, slug))
1089
+ .limit(1);
1090
+ if (!record) {
1091
+ return null;
1092
+ }
1093
+ if (
1094
+ select.some(
1095
+ (field) => field === 'commentThreads' || field.startsWith('commentThreads.'),
1096
+ )
1097
+ ) {
1098
+ await ensurePlanCommentsImported(ctx.db, ctx.env.WALKTHROUGH_BUCKET, record);
1099
+ }
1100
+ return sources.resolveById({
1101
+ ctx,
1102
+ id: record.id,
1103
+ input: { select },
1104
+ view: planDataView,
1105
+ });
1106
+ },
1107
+ type: 'Plan',
1108
+ },
1109
+ sharingStats: {
1110
+ resolve: async ({ ctx, select }: { ctx: SharingFateContext; select: Array<string> }) =>
1111
+ createResolver({
1112
+ ctx,
1113
+ select,
1114
+ view: shareStatsDataView,
1115
+ }).resolve(await fetchShareStats(ctx.db)),
1116
+ type: 'ShareStats',
1117
+ },
1118
+ uploadIntentByCode: {
1119
+ resolve: async ({
1120
+ ctx,
1121
+ input,
1122
+ select,
1123
+ }: {
1124
+ ctx: SharingFateContext;
1125
+ input: { args?: { code: string } };
1126
+ select: Array<string>;
1127
+ }) => {
1128
+ const code = input.args?.code;
1129
+ const secret = ctx.request.headers.get(uploadIntentSecretHeader);
1130
+ if (!code || !secret) {
1131
+ throw new FateRequestError('BAD_REQUEST', 'An upload code and secret are required.');
1132
+ }
1133
+ let [record] = await ctx.db
1134
+ .select()
1135
+ .from(uploadIntent)
1136
+ .where(eq(uploadIntent.code, code))
1137
+ .limit(1);
1138
+
1139
+ const secretHash = await hashUploadIntentSecret(secret);
1140
+ if (!record) {
1141
+ const pending = await verifyUploadIntentSecret(ctx.env, code, secret);
1142
+ if (!pending) {
1143
+ throw new FateRequestError('NOT_FOUND', 'Upload intent not found.');
1144
+ }
1145
+ if (pending.expiresAt.getTime() < Date.now()) {
1146
+ throw new FateRequestError('BAD_REQUEST', 'This upload intent has expired.');
1147
+ }
1148
+ const commenter = await requireAuthenticatedUser(ctx);
1149
+ const now = new Date();
1150
+ await ctx.db
1151
+ .insert(uploadIntent)
1152
+ .values({
1153
+ claimedAt: now,
1154
+ code,
1155
+ expiresAt: pending.expiresAt,
1156
+ secretHash,
1157
+ sharedByUserId: commenter.id,
1158
+ status: 'claimed',
1159
+ updatedAt: now,
1160
+ uploadTokenHash: secretHash,
1161
+ })
1162
+ .onConflictDoNothing();
1163
+ [record] = await ctx.db
1164
+ .select()
1165
+ .from(uploadIntent)
1166
+ .where(eq(uploadIntent.code, code))
1167
+ .limit(1);
1168
+ }
1169
+ if (!record || record.secretHash !== secretHash) {
1170
+ throw new FateRequestError('NOT_FOUND', 'Upload intent not found.');
1171
+ }
1172
+ if (record.expiresAt.getTime() < Date.now() && record.status !== 'uploaded') {
1173
+ throw new FateRequestError('BAD_REQUEST', 'This upload intent has expired.');
1174
+ }
1175
+ if (record.status !== 'uploaded') {
1176
+ const commenter = await requireAuthenticatedUser(ctx);
1177
+ if (record.sharedByUserId && record.sharedByUserId !== commenter.id) {
1178
+ throw new FateRequestError(
1179
+ 'FORBIDDEN',
1180
+ 'This upload was authorized by another account.',
1181
+ );
1182
+ }
1183
+ if (record.status === 'pending') {
1184
+ const now = new Date();
1185
+ await ctx.db
1186
+ .update(uploadIntent)
1187
+ .set({
1188
+ claimedAt: now,
1189
+ sharedByUserId: commenter.id,
1190
+ status: 'claimed',
1191
+ updatedAt: now,
1192
+ uploadTokenHash: record.secretHash,
1193
+ })
1194
+ .where(
1195
+ and(
1196
+ eq(uploadIntent.id, record.id),
1197
+ eq(uploadIntent.status, 'pending'),
1198
+ sql`${uploadIntent.sharedByUserId} is null`,
1199
+ ),
1200
+ );
1201
+ }
1202
+ }
1203
+ return sources.resolveById({
1204
+ ctx,
1205
+ id: record.id,
1206
+ input: { args: input.args, select },
1207
+ view: uploadIntentDataView,
1208
+ });
1209
+ },
1210
+ type: 'UploadIntent',
1211
+ },
1212
+ walkthroughBySlug: {
1213
+ resolve: async ({
1214
+ ctx,
1215
+ input,
1216
+ select,
1217
+ }: {
1218
+ ctx: SharingFateContext;
1219
+ input: { args?: { slug: string } };
1220
+ select: Array<string>;
1221
+ }) => {
1222
+ const slug = input.args?.slug;
1223
+ if (!slug) {
1224
+ throw new FateRequestError('BAD_REQUEST', 'A walkthrough slug is required.');
1225
+ }
1226
+ const [record] = await ctx.db
1227
+ .select({
1228
+ commentsImportedAt: walkthrough.commentsImportedAt,
1229
+ id: walkthrough.id,
1230
+ objectKey: walkthrough.objectKey,
1231
+ })
1232
+ .from(walkthrough)
1233
+ .where(eq(walkthrough.slug, slug))
1234
+ .limit(1);
1235
+ if (!record) {
1236
+ return null;
1237
+ }
1238
+ if (
1239
+ select.some(
1240
+ (field) => field === 'commentThreads' || field.startsWith('commentThreads.'),
1241
+ )
1242
+ ) {
1243
+ await ensureWalkthroughCommentsImported(ctx.db, ctx.env.WALKTHROUGH_BUCKET, record);
1244
+ }
1245
+ return sources.resolveById({
1246
+ ctx,
1247
+ id: record.id,
1248
+ input: { select },
1249
+ view: walkthroughDataView,
1250
+ });
1251
+ },
1252
+ type: 'Walkthrough',
1253
+ },
1254
+ },
1255
+ roots: SharingRoot,
1256
+ sources: allSources,
1257
+ });
1258
+ };