apps/server/src/persistence/PullRequestFilesViewed.ts
import * as Context from "effect/Context";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import * as SqlClient from "effect/sql/SqlClient";
import * as SqlSchema from "effect/sql/SqlSchema";
import { SourceControlProviderKind } from "@t3tools/contracts";
import {
PersistenceDecodeError,
PersistenceSqlError,
type PullRequestFilesViewedRepositoryError,
} from "./Errors.ts";
export const PullRequestFilesViewedScope = Schema.Struct({
provider: SourceControlProviderKind,
host: Schema.String,
repository: Schema.String,
number: Schema.Int,
viewer: Schema.String,
});
export type PullRequestFilesViewedScope = typeof PullRequestFilesViewedScope.Type;
export const PullRequestFileViewedMark = Schema.Struct({
path: Schema.String,
/** Empty means the host confirms deletion; null means no baseline, so the mark stays cleared until replaced. */
revision: Schema.NullOr(Schema.String),
});
export type PullRequestFileViewedMark = typeof PullRequestFileViewedMark.Type;
export interface SetPullRequestFilesViewedInput extends PullRequestFilesViewedScope {
readonly files: ReadonlyArray<PullRequestFileViewedMark & { readonly viewed: boolean }>;
readonly viewedAt: string;
}
export const MAX_FILES_VIEWED_ROWS = 500;
export interface PullRequestFilesViewedPage {
readonly files: ReadonlyArray<PullRequestFileViewedMark>;
readonly truncated: boolean;
}
export class PullRequestFilesViewedRepository extends Context.Service<
PullRequestFilesViewedRepository,
{
readonly list: (
input: PullRequestFilesViewedScope,
) => Effect.Effect<PullRequestFilesViewedPage, PullRequestFilesViewedRepositoryError>;
readonly set: (
input: SetPullRequestFilesViewedInput,
) => Effect.Effect<void, PullRequestFilesViewedRepositoryError>;
}
>()("t3/persistence/PullRequestFilesViewed/PullRequestFilesViewedRepository") {}
function toSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
return (cause: unknown): PullRequestFilesViewedRepositoryError =>
Schema.isSchemaError(cause)
? PersistenceDecodeError.fromSchemaError(decodeOperation, cause)
: new PersistenceSqlError({ operation: sqlOperation, cause });
}
const make = Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
const listRows = SqlSchema.findAll({
Request: PullRequestFilesViewedScope,
Result: PullRequestFileViewedMark,
execute: ({ provider, host, repository, number, viewer }) =>
sql`
SELECT
path AS "path",
revision AS "revision"
FROM pull_request_files_viewed
WHERE provider = ${provider}
AND host = ${host}
AND repository = ${repository}
AND number = ${number}
AND viewer = ${viewer}
ORDER BY path
LIMIT ${MAX_FILES_VIEWED_ROWS + 1}
`,
});
return PullRequestFilesViewedRepository.of({
list: Effect.fn("PullRequestFilesViewedRepository.list")(function (input) {
return listRows(input).pipe(
NodeSqliteClient.readOnly,
Effect.map((rows) => ({
files: rows.slice(0, MAX_FILES_VIEWED_ROWS),
truncated: rows.length > MAX_FILES_VIEWED_ROWS,
})),
Effect.mapError(toSqlOrDecodeError("listPullRequestFilesViewed", "PullRequestFileViewed")),
);
}),
set: Effect.fn("PullRequestFilesViewedRepository.set")(function (input) {
return sql
.withTransaction(
Effect.forEach(
input.files,
(file) =>
file.viewed
? sql`
INSERT INTO pull_request_files_viewed (
provider,
host,
repository,
number,
viewer,
path,
revision,
viewed_at
)
VALUES (
${input.provider},
${input.host},
${input.repository},
${input.number},
${input.viewer},
${file.path},
${file.revision},
${input.viewedAt}
)
ON CONFLICT (provider, host, repository, number, viewer, path)
DO UPDATE SET revision = excluded.revision, viewed_at = excluded.viewed_at
`
: sql`
DELETE FROM pull_request_files_viewed
WHERE provider = ${input.provider}
AND host = ${input.host}
AND repository = ${input.repository}
AND number = ${input.number}
AND viewer = ${input.viewer}
AND path = ${file.path}
`,
{ discard: true },
),
)
.pipe(
Effect.mapError(
(cause) => new PersistenceSqlError({ operation: "setPullRequestFilesViewed", cause }),
),
);
}),
});
});
export const layer = Layer.effect(PullRequestFilesViewedRepository, make);