1import * as Context from "effect/Context";
2import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Schema from "effect/Schema";
6import * as SqlClient from "effect/sql/SqlClient";
7import * as SqlSchema from "effect/sql/SqlSchema";
8
9import { SourceControlProviderKind } from "@t3tools/contracts";
10
11import {
12 PersistenceDecodeError,
13 PersistenceSqlError,
14 type PullRequestFilesViewedRepositoryError,
15} from "./Errors.ts";
16
17export const PullRequestFilesViewedScope = Schema.Struct({
18 provider: SourceControlProviderKind,
19 host: Schema.String,
20 repository: Schema.String,
21 number: Schema.Int,
22 viewer: Schema.String,
23});
24export type PullRequestFilesViewedScope = typeof PullRequestFilesViewedScope.Type;
25
26export const PullRequestFileViewedMark = Schema.Struct({
27 path: Schema.String,
28 /** Empty means the host confirms deletion; null means no baseline, so the mark stays cleared until replaced. */
29 revision: Schema.NullOr(Schema.String),
30});
31export type PullRequestFileViewedMark = typeof PullRequestFileViewedMark.Type;
32
33export interface SetPullRequestFilesViewedInput extends PullRequestFilesViewedScope {
34 readonly files: ReadonlyArray<PullRequestFileViewedMark & { readonly viewed: boolean }>;
35 readonly viewedAt: string;
36}
37
38export const MAX_FILES_VIEWED_ROWS = 500;
39
40export interface PullRequestFilesViewedPage {
41 readonly files: ReadonlyArray<PullRequestFileViewedMark>;
42 readonly truncated: boolean;
43}
44
45export class PullRequestFilesViewedRepository extends Context.Service<
46 PullRequestFilesViewedRepository,
47 {
48 readonly list: (
49 input: PullRequestFilesViewedScope,
50 ) => Effect.Effect<PullRequestFilesViewedPage, PullRequestFilesViewedRepositoryError>;
51 readonly set: (
52 input: SetPullRequestFilesViewedInput,
53 ) => Effect.Effect<void, PullRequestFilesViewedRepositoryError>;
54 }
55>()("t3/persistence/PullRequestFilesViewed/PullRequestFilesViewedRepository") {}
56
57function toSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
58 return (cause: unknown): PullRequestFilesViewedRepositoryError =>
59 Schema.isSchemaError(cause)
60 ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause)
61 : new PersistenceSqlError({ operation: sqlOperation, cause });
62}
63
64const make = Effect.gen(function* () {
65 const sql = yield* SqlClient.SqlClient;
66
67 const listRows = SqlSchema.findAll({
68 Request: PullRequestFilesViewedScope,
69 Result: PullRequestFileViewedMark,
70 execute: ({ provider, host, repository, number, viewer }) =>
71 sql`
72 SELECT
73 path AS "path",
74 revision AS "revision"
75 FROM pull_request_files_viewed
76 WHERE provider = ${provider}
77 AND host = ${host}
78 AND repository = ${repository}
79 AND number = ${number}
80 AND viewer = ${viewer}
81 ORDER BY path
82 LIMIT ${MAX_FILES_VIEWED_ROWS + 1}
83 `,
84 });
85
86 return PullRequestFilesViewedRepository.of({
87 list: Effect.fn("PullRequestFilesViewedRepository.list")(function (input) {
88 return listRows(input).pipe(
89 NodeSqliteClient.readOnly,
90 Effect.map((rows) => ({
91 files: rows.slice(0, MAX_FILES_VIEWED_ROWS),
92 truncated: rows.length > MAX_FILES_VIEWED_ROWS,
93 })),
94 Effect.mapError(toSqlOrDecodeError("listPullRequestFilesViewed", "PullRequestFileViewed")),
95 );
96 }),
97
98 set: Effect.fn("PullRequestFilesViewedRepository.set")(function (input) {
99 return sql
100 .withTransaction(
101 Effect.forEach(
102 input.files,
103 (file) =>
104 file.viewed
105 ? sql`
106 INSERT INTO pull_request_files_viewed (
107 provider,
108 host,
109 repository,
110 number,
111 viewer,
112 path,
113 revision,
114 viewed_at
115 )
116 VALUES (
117 ${input.provider},
118 ${input.host},
119 ${input.repository},
120 ${input.number},
121 ${input.viewer},
122 ${file.path},
123 ${file.revision},
124 ${input.viewedAt}
125 )
126 ON CONFLICT (provider, host, repository, number, viewer, path)
127 DO UPDATE SET revision = excluded.revision, viewed_at = excluded.viewed_at
128 `
129 : sql`
130 DELETE FROM pull_request_files_viewed
131 WHERE provider = ${input.provider}
132 AND host = ${input.host}
133 AND repository = ${input.repository}
134 AND number = ${input.number}
135 AND viewer = ${input.viewer}
136 AND path = ${file.path}
137 `,
138 { discard: true },
139 ),
140 )
141 .pipe(
142 Effect.mapError(
143 (cause) => new PersistenceSqlError({ operation: "setPullRequestFilesViewed", cause }),
144 ),
145 );
146 }),
147 });
148});
149
150export const layer = Layer.effect(PullRequestFilesViewedRepository, make);