import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import * as SqlClient from "effect/unstable/sql/SqlClient";
import * as SqlSchema from "effect/unstable/sql/SqlSchema";
import { SourceControlProviderKind } from "@t3tools/contracts";
import {
PersistenceDecodeError,
PersistenceSqlError,
type PullRequestFilesViewedRepositoryError,
} from "./Errors.ts";
/**
* Which change request, on which host, for which reader. The host is part of it because the same
* `group/project` exists on gitlab.com and on a self-managed instance, and the reader because
* signing in as somebody else must not inherit their ticks. A host that will not say who the
* reader is leaves it empty, which is one reader rather than none.
*/
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;
/** A file this reader cleared, and what it was when they cleared it. */
export const PullRequestFileViewedMark = Schema.Struct({
path: Schema.String,
/**
* The host's own name for that version of the file, opaque here. Empty where the host said it
* had none to give, which is an answer rather than a gap: a file with no version at the head is
* one the change request deletes. Null where the host could not say at all, which is no baseline
* rather than an empty one, and such a mark stays cleared until a press replaces it.
*
* This null is the only one this environment invents; the other two are in
* `docs/internals/pull-request-file-revisions.md`.
*/
revision: Schema.NullOr(Schema.String),
});
export type PullRequestFileViewedMark = typeof PullRequestFileViewedMark.Type;
export interface SetPullRequestFilesViewedInput extends PullRequestFilesViewedScope {
readonly files: ReadonlyArray<PullRequestFileViewedMark & { readonly viewed: boolean }>;
/** When the presses landed, as an ISO instant. */
readonly viewedAt: string;
}
/**
* How many marks one read of this store carries. Every one of them is a path held in a set and a
* map for as long as the caller holds the read, so an unbounded read of a change request with
* thousands of marks in it is paid for again per scope the caller is holding. Matched to what the
* GitHub reader walks in one go, past what anyone reviews in a sitting.
*/
export const MAX_FILES_VIEWED_ROWS = 500;
/** The marks for one scope, and whether the store had more of them than it carried. */
export interface PullRequestFilesViewedPage {
readonly files: ReadonlyArray<PullRequestFileViewedMark>;
readonly truncated: boolean;
}
/**
* The marks this environment keeps for hosts that keep none of their own.
*
* Only cleared files are rows. Unticking deletes rather than writing a "not viewed" row, so the
* table holds what a reader has done and not what they have merely scrolled past.
*/
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({
// Ordered by path and read one row past the ceiling, so the same marks come back on every
// read rather than a window that shuffles, and having more than were carried is known rather
// than guessed at from a full page.
list: (input) =>
listRows(input).pipe(
Effect.map((rows) => ({
files: rows.slice(0, MAX_FILES_VIEWED_ROWS),
truncated: rows.length > MAX_FILES_VIEWED_ROWS,
})),
Effect.mapError(toSqlOrDecodeError("listPullRequestFilesViewed", "PullRequestFileViewed")),
),
// One statement per file rather than one for the batch: the batch is what a reader ticked in
// the last few hundred milliseconds, so it is a handful of rows on a local database, and a
// mixed batch of clears and un-clears has no single statement anyway.
set: (input) =>
// One transaction for the batch. A press is a handful of files, and a failure part way
// through would otherwise leave some of them cleared and the rest not, which the reader
// sees on the next read as marks they never made.
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);