1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Schema from "effect/Schema";
5import * as SqlClient from "effect/sql/SqlClient";
6import * as SqlSchema from "effect/sql/SqlSchema";
7
8import { SourceControlProviderKind } from "@t3tools/contracts";
9
10import {
11 PersistenceDecodeError,
12 PersistenceSqlError,
13 type PullRequestFilesViewedRepositoryError,
14} from "./Errors.ts";
15
16/**
17 * Which change request, on which host, for which reader. The host is part of it because the same
18 * `group/project` exists on gitlab.com and on a self-managed instance, and the reader because
19 * signing in as somebody else must not inherit their ticks. A host that will not say who the
20 * reader is leaves it empty, which is one reader rather than none.
21 */
22export const PullRequestFilesViewedScope = Schema.Struct({
23 provider: SourceControlProviderKind,
24 host: Schema.String,
25 repository: Schema.String,
26 number: Schema.Int,
27 viewer: Schema.String,
28});
29export type PullRequestFilesViewedScope = typeof PullRequestFilesViewedScope.Type;
30
31/** A file this reader cleared, and what it was when they cleared it. */
32export const PullRequestFileViewedMark = Schema.Struct({
33 path: Schema.String,
34 /**
35 * The host's own name for that version of the file, opaque here. Empty where the host said it
36 * had none to give, which is an answer rather than a gap: a file with no version at the head is
37 * one the change request deletes. Null where the host could not say at all, which is no baseline
38 * rather than an empty one, and such a mark stays cleared until a press replaces it.
39 *
40 * This null is the only one this environment invents; the other two are in
41 * `docs/internals/pull-request-file-revisions.md`.
42 */
43 revision: Schema.NullOr(Schema.String),
44});
45export type PullRequestFileViewedMark = typeof PullRequestFileViewedMark.Type;
46
47export interface SetPullRequestFilesViewedInput extends PullRequestFilesViewedScope {
48 readonly files: ReadonlyArray<PullRequestFileViewedMark & { readonly viewed: boolean }>;
49 /** When the presses landed, as an ISO instant. */
50 readonly viewedAt: string;
51}
52
53/**
54 * How many marks one read of this store carries. Every one of them is a path held in a set and a
55 * map for as long as the caller holds the read, so an unbounded read of a change request with
56 * thousands of marks in it is paid for again per scope the caller is holding. Matched to what the
57 * GitHub reader walks in one go, past what anyone reviews in a sitting.
58 */
59export const MAX_FILES_VIEWED_ROWS = 500;
60
61/** The marks for one scope, and whether the store had more of them than it carried. */
62export interface PullRequestFilesViewedPage {
63 readonly files: ReadonlyArray<PullRequestFileViewedMark>;
64 readonly truncated: boolean;
65}
66
67/**
68 * The marks this environment keeps for hosts that keep none of their own.
69 *
70 * Only cleared files are rows. Unticking deletes rather than writing a "not viewed" row, so the
71 * table holds what a reader has done and not what they have merely scrolled past.
72 */
73export class PullRequestFilesViewedRepository extends Context.Service<
74 PullRequestFilesViewedRepository,
75 {
76 readonly list: (
77 input: PullRequestFilesViewedScope,
78 ) => Effect.Effect<PullRequestFilesViewedPage, PullRequestFilesViewedRepositoryError>;
79 readonly set: (
80 input: SetPullRequestFilesViewedInput,
81 ) => Effect.Effect<void, PullRequestFilesViewedRepositoryError>;
82 }
83>()("t3/persistence/PullRequestFilesViewed/PullRequestFilesViewedRepository") {}
84
85function toSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
86 return (cause: unknown): PullRequestFilesViewedRepositoryError =>
87 Schema.isSchemaError(cause)
88 ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause)
89 : new PersistenceSqlError({ operation: sqlOperation, cause });
90}
91
92const make = Effect.gen(function* () {
93 const sql = yield* SqlClient.SqlClient;
94
95 const listRows = SqlSchema.findAll({
96 Request: PullRequestFilesViewedScope,
97 Result: PullRequestFileViewedMark,
98 execute: ({ provider, host, repository, number, viewer }) =>
99 sql`
100 SELECT
101 path AS "path",
102 revision AS "revision"
103 FROM pull_request_files_viewed
104 WHERE provider = ${provider}
105 AND host = ${host}
106 AND repository = ${repository}
107 AND number = ${number}
108 AND viewer = ${viewer}
109 ORDER BY path
110 LIMIT ${MAX_FILES_VIEWED_ROWS + 1}
111 `,
112 });
113
114 return PullRequestFilesViewedRepository.of({
115 // Ordered by path and read one row past the ceiling, so the same marks come back on every
116 // read rather than a window that shuffles, and having more than were carried is known rather
117 // than guessed at from a full page.
118 list: (input) =>
119 listRows(input).pipe(
120 Effect.map((rows) => ({
121 files: rows.slice(0, MAX_FILES_VIEWED_ROWS),
122 truncated: rows.length > MAX_FILES_VIEWED_ROWS,
123 })),
124 Effect.mapError(toSqlOrDecodeError("listPullRequestFilesViewed", "PullRequestFileViewed")),
125 ),
126
127 // One statement per file rather than one for the batch: the batch is what a reader ticked in
128 // the last few hundred milliseconds, so it is a handful of rows on a local database, and a
129 // mixed batch of clears and un-clears has no single statement anyway.
130 set: (input) =>
131 // One transaction for the batch. A press is a handful of files, and a failure part way
132 // through would otherwise leave some of them cleared and the rest not, which the reader
133 // sees on the next read as marks they never made.
134 sql
135 .withTransaction(
136 Effect.forEach(
137 input.files,
138 (file) =>
139 file.viewed
140 ? sql`
141 INSERT INTO pull_request_files_viewed (
142 provider,
143 host,
144 repository,
145 number,
146 viewer,
147 path,
148 revision,
149 viewed_at
150 )
151 VALUES (
152 ${input.provider},
153 ${input.host},
154 ${input.repository},
155 ${input.number},
156 ${input.viewer},
157 ${file.path},
158 ${file.revision},
159 ${input.viewedAt}
160 )
161 ON CONFLICT (provider, host, repository, number, viewer, path)
162 DO UPDATE SET revision = excluded.revision, viewed_at = excluded.viewed_at
163 `
164 : sql`
165 DELETE FROM pull_request_files_viewed
166 WHERE provider = ${input.provider}
167 AND host = ${input.host}
168 AND repository = ${input.repository}
169 AND number = ${input.number}
170 AND viewer = ${input.viewer}
171 AND path = ${file.path}
172 `,
173 { discard: true },
174 ),
175 )
176 .pipe(
177 Effect.mapError(
178 (cause) => new PersistenceSqlError({ operation: "setPullRequestFilesViewed", cause }),
179 ),
180 ),
181 });
182});
183
184export const layer = Layer.effect(PullRequestFilesViewedRepository, make);