Files
transcriber/internal/controller/http/app.go
T
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00

682 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package http
import (
"context"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
"git.vakhrushev.me/av/transcriber/internal/metrics"
"git.vakhrushev.me/av/transcriber/internal/service"
)
// AppRoot — корень адресов приложения.
//
// Корень остался **один**: пространства `/api/`, принадлежавшего встроенному
// хранилищу, и адреса панели `/_/` больше не существует — сервис их не занимает.
// Соседство, ради которого корень был выбран, кончилось вместе с соседом.
const AppRoot = "/app"
// Пределы страницы. Умолчание — столько, сколько помещается на экран телефона
// без прокрутки в два экрана; потолок — против того, чтобы попросить весь архив
// одним запросом и тем обойти постраничность её же параметром.
const (
DefaultPageLimit = 30
MaxPageLimit = 100
)
// pollBudgetShare — какую долю бюджета ограничителя занимает опрос карточки.
//
// Доля, а не весь бюджет: опрос идёт не один. В ту же секунду человек листает
// список, открывает карточку соседней записи и грузит новую, а бюджет
// ограничителя один на все адреса приложения и считается по адресу
// спрашивающего, а не по учётной записи — двое за одним домашним адресом делят
// его пополам.
const pollBudgetShare = 8
// PollIntervalMs — частота, с которой приложению разрешено опрашивать карточку.
//
// Выводится из настройки ограничителя частоты под корнем приложения, а не
// задаётся своей константой: иначе приложение, честно опрашивающее карточку с
// объявленной частотой, упирается в ограничитель сервиса — и получает отказ,
// которого сервис сам же ему обещал избежать.
const PollIntervalMs = int64(appRateWindowSec * 1000 * pollBudgetShare / appRateMaxRequests)
// Значения параметра `copy` у адреса файла записи. Перечень закрыт, и каждое
// значение называет ровно одну хранимую вещь: имя параметра нормативно наравне
// со значениями — разбирает его каждый экран, и выбранное кодом оно стало бы
// публичным контрактом молча.
const (
CopyParam = "copy"
CopyOriginal = "original"
CopyNormalized = "normalized"
)
type AppHandler struct {
recordRepo contract.AudioRecordRepository
textRepo contract.TextRepository
structureRepo contract.StructureRepository
fileRepo contract.FileRepository
trsService *service.TranscribeService
logger *slog.Logger
}
func NewAppHandler(
recordRepo contract.AudioRecordRepository,
textRepo contract.TextRepository,
structureRepo contract.StructureRepository,
fileRepo contract.FileRepository,
trsService *service.TranscribeService,
logger *slog.Logger,
) *AppHandler {
if logger == nil {
logger = slog.Default()
}
return &AppHandler{
recordRepo: recordRepo,
textRepo: textRepo,
structureRepo: structureRepo,
fileRepo: fileRepo,
trsService: trsService,
logger: logger,
}
}
// RecordView — карточка записи и элемент страницы: **одна форма**. Две формы
// одной вещи разошлись бы молча, и экран, написанный по одной, ломался бы о
// другую.
//
// Машинного текста отказа здесь нет: он принадлежит журналу владельца сервиса.
// Причина остановки — значение из закрытого перечня, и она не он: без причины
// признак остановки не говорит человеку, чего ждать.
//
// Перечня доступных копий файла здесь нет намеренно: копий две, и каждая
// выводится из рубежа записи, который карточка несёт и так. Второе поле
// повторяло бы рубеж и разошлось бы с ним молча.
type RecordView struct {
ID string `json:"id"`
Title *string `json:"title"`
OriginalFilename *string `json:"original_filename"`
Brief *string `json:"brief"`
Topics []string `json:"topics"`
State string `json:"state"`
Halted bool `json:"halted"`
HaltReason *string `json:"halt_reason"`
DurationMs *int64 `json:"duration_ms"`
SizeBytes *int64 `json:"size_bytes"`
CreatedAt string `json:"created_at"`
// AvailableViews — перечень доступных видов текста, а не признак «текст
// есть». Видов больше одного, и шаг завершения пишет их несколькими
// операциями: состояние «сплошной текст есть, реплик ещё нет» достижимо. Один
// признак отправил бы приложение за репликами, которых нет, и исход стал бы
// функцией того, где прервался шаг. Пустой перечень значит «текста ещё нет».
//
// У элемента страницы поле опущено: страница видов не читает.
AvailableViews *[]string `json:"available_views,omitempty"`
}
// IntakeItem — элемент ответа приёма: карточка плюс признак повторного файла.
type IntakeItem struct {
RecordView
Duplicate bool `json:"duplicate"`
}
type PageView struct {
Items []RecordView `json:"items"`
NextCursor *string `json:"next_cursor"`
TotalItems int `json:"total_items"`
}
type MeView struct {
ID string `json:"id"`
Name string `json:"name"`
}
type ConfigView struct {
MaxRecordSizeBytes int64 `json:"max_record_size_bytes"`
MaxPageSize int `json:"max_page_size"`
PollIntervalMs int64 `json:"poll_interval_ms"`
KnownExtensions []string `json:"known_extensions"`
MaxTopicsPerRecord int `json:"max_topics_per_record"`
}
type TextView struct {
View string `json:"view"`
Contents string `json:"contents,omitempty"`
Replicas []ReplicaView `json:"replicas,omitempty"`
}
type ReplicaView struct {
StartMs int64 `json:"start_ms"`
EndMs int64 `json:"end_ms"`
Text string `json:"text"`
}
// Routes — адреса приложения одним обработчиком.
//
// Слоёв здесь нет: ограничитель частоты, узнавание и требование учётной записи
// вешаются на **всю** цепочку корня приложения, а корень берётся из перечня
// адресного пространства. Так область их действия выводится из объявленного
// пространства, а не перечисляется вторым списком.
//
// Метод разбирается обработчиком, а не образцом маршрута: отказ маршрутизатора
// на неверный метод ушёл бы его формой тела, а форма отказа под корнем
// приложения одна.
func (h *AppHandler) Routes() http.Handler {
mux := http.NewServeMux()
for _, pattern := range AppRoutePatterns {
mux.HandleFunc(pattern, h.handlerOf(pattern))
}
// Перехват «под нашим корнем такого адреса нет». Голый корень попадает сюда
// же: он принадлежит корню приложения, адресом приложения не является и
// потому отвечает как неизвестный путь под ним.
//
// Оба образца обязательны: без точного `/app` маршрутизатор увёл бы его
// перенаправлением на `/app/`, а перенаправления норма не заказывала. В
// закрытый перечень образцов они не входят: под них подходит **всё**, что
// накрыто корнем, а значит путь под ними выбирает спрашивающий.
mux.HandleFunc(AppRoot+"/", h.notFound)
mux.HandleFunc(AppRoot, h.notFound)
return mux
}
// Образцы адресов приложения. Перечень закрытый и **единственный**: из него
// вешаются обработчики, и из него же берётся значение `http.route` для журнала.
// Второй список образцов разошёлся бы с первым молча, и разошёлся бы в сторону
// журнала — путь, не попавший в перечень, уехал бы в строку дословно.
const (
AppRouteMe = AppRoot + "/me"
AppRouteConfig = AppRoot + "/config"
AppRouteRecords = AppRoot + "/audiorecords"
AppRouteRecord = AppRoot + "/audiorecords/{id}"
AppRouteRecordText = AppRoot + "/audiorecords/{id}/text"
AppRouteRecordFile = AppRoot + "/audiorecords/{id}/file"
)
// AppRoutePatterns — тот самый перечень. Порядок значения не имеет:
// маршрутизатор выбирает образец по точности, а не по месту в списке.
var AppRoutePatterns = []string{
AppRouteMe,
AppRouteConfig,
AppRouteRecords,
AppRouteRecord,
AppRouteRecordText,
AppRouteRecordFile,
}
// handlerOf выдаёт обработчик образца.
//
// Ветка на каждый образец, а не карта рядом с перечнем: недостающий образец
// здесь — отказ на подъёме, а не тихо не заведённый адрес.
func (h *AppHandler) handlerOf(pattern string) http.HandlerFunc {
switch pattern {
case AppRouteMe:
return only(h.Me, http.MethodGet)
case AppRouteConfig:
return only(h.Config, http.MethodGet)
// Приём стоит тем же адресом, что и список, и отличается только методом: он
// заводит аудиозапись, а не кладёт файл.
case AppRouteRecords:
return h.records
case AppRouteRecord:
return only(h.GetRecord, http.MethodGet)
case AppRouteRecordText:
return only(h.GetRecordText, http.MethodGet)
case AppRouteRecordFile:
return only(h.GetRecordFile, http.MethodGet, http.MethodHead)
}
panic("адрес приложения " + pattern + " объявлен перечнем, но обработчика у него нет")
}
// only ограничивает адрес перечнем методов.
//
// Неверный метод отвечает «адреса нет»: код отказа принадлежит закрытому
// перечню, и своего значения у «метод не тот» в нём не заведено — адрес,
// которого нет для этого метода, и есть ненайденный адрес.
func only(handler http.HandlerFunc, methods ...string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
for _, method := range methods {
if r.Method == method {
handler(w, r)
return
}
}
fail(w, errWithMessage(contract.ErrNotFound, "Адрес не найден"))
}
}
func (h *AppHandler) notFound(w http.ResponseWriter, _ *http.Request) {
fail(w, errWithMessage(contract.ErrNotFound, "Адрес не найден"))
}
// records — приём и список одним адресом: разница только в методе.
func (h *AppHandler) records(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.ListRecords(w, r)
case http.MethodPost:
h.CreateRecord(w, r)
default:
fail(w, errWithMessage(contract.ErrNotFound, "Адрес не найден"))
}
}
func (h *AppHandler) Me(w http.ResponseWriter, r *http.Request) {
account, _ := AccountOf(r)
// Адрес почты в ответ не идёт: он приходит от провайдера и принадлежит
// человеку, а не сервису. Логин у провайдера — тоже: это его имя у
// провайдера, и правило о непечатаемых значениях запрещает ему выходить
// наружу наравне с журналом.
writeJSON(w, http.StatusOK, MeView{ID: account.ID, Name: account.Name})
}
func (h *AppHandler) Config(w http.ResponseWriter, _ *http.Request) {
// Каждый предел — то же значение, которое сервис применяет, а не его копия.
// Приложение, знающее предел своей константой, расходится с сервером молча —
// до первого отказа на записи, которую человек уже успел отправить.
writeJSON(w, http.StatusOK, ConfigView{
MaxRecordSizeBytes: entity.MaxRecordSize,
MaxPageSize: MaxPageLimit,
PollIntervalMs: PollIntervalMs,
KnownExtensions: metrics.PublicFormats(),
MaxTopicsPerRecord: entity.MaxTopicsPerRecord,
})
}
func (h *AppHandler) CreateRecord(w http.ResponseWriter, r *http.Request) {
account, _ := AccountOf(r)
// Предел тела назван числом: умолчания здесь не «без предела», а величины на
// два-три порядка меньше нужного, и оставленные как есть они отвергли бы
// штатную запись сервиса. Отказ по нему уходит нашей формой тела.
//
// Ловится он **дважды**, и это не избыточность. Объявленная длина судится
// заранее: запись, за которую сервис платить не станет, не должна попасть
// даже в память. Необъявленная и солгавшая ловятся на чтении — объявленной
// длины у запроса с кусочной передачей нет вовсе.
if r.ContentLength > entity.MaxRecordSize {
fail(w, contract.ErrRecordTooLarge)
return
}
r.Body = http.MaxBytesReader(w, r.Body, entity.MaxRecordSize)
file, header, err := r.FormFile("audio")
if err != nil {
// Предел тела ловит запись на чтении. Не различив этот отказ и
// отсутствующее поле, приём сказал бы человеку «вы не приложили файл» о
// записи, которую он приложил и которая просто больше потолка.
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
fail(w, contract.ErrRecordTooLarge)
return
}
fail(w, errWithMessage(contract.ErrBadRequest, "Запись не приложена к запросу"))
return
}
defer func() {
if err := file.Close(); err != nil {
h.logger.Error("Failed to close uploaded file", "error", err)
}
}()
// Запись доехала целиком, поэтому она заводится независимо от того, дождётся
// ли отправитель ответа: на контексте запроса приём терял бы полностью
// загруженную запись от одного обрыва соединения, а забрать результат он
// может и позже — карточкой записи.
ctx := context.WithoutCancel(r.Context())
// Владелец берётся из узнанного предъявителя и ниоткуда больше: владелец,
// пришедший полем запроса, дал бы всякому узнанному право завести запись на
// чужое имя.
record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, account.ID)
if err != nil {
// Второй раз отказ не логируем: приём назван конвенцией логирующей
// границей и уже написал о нём. Транспорт переводит ошибку в ответ, и
// делает это одним местом — по причине отказа, а не по месту.
fail(w, err)
return
}
// Ответ списком, даже когда файл в запросе один: форма согласована вперёд,
// чтобы приём нескольких файлов и распознавание повтора её не переписывали.
writeJSON(w, http.StatusCreated, []IntakeItem{{
// Свежая запись текстов не имеет, но поле обязано быть на проводе:
// отсутствие поля и пустой перечень приложение не различит.
RecordView: h.viewOf(record, nil, &[]string{}),
}})
}
func (h *AppHandler) ListRecords(w http.ResponseWriter, r *http.Request) {
account, _ := AccountOf(r)
q := contract.RecordQuery{OwnerID: account.ID, Limit: DefaultPageLimit}
if raw := r.URL.Query().Get("limit"); raw != "" {
limit, err := strconv.Atoi(raw)
if err != nil || limit <= 0 {
fail(w, errWithMessage(contract.ErrBadRequest, "Размер страницы должен быть положительным числом"))
return
}
// Сверх потолка — усечение, а не отказ: человек попросил больше, чем
// сервис отдаёт, но просьба сама по себе не негодна.
q.Limit = min(limit, MaxPageLimit)
}
if raw := r.URL.Query().Get("filter"); raw != "" {
filter, ok := entity.ParseListFilter(raw)
if !ok {
fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестное состояние отбора"))
return
}
q.Filter = &filter
}
if raw := r.URL.Query().Get("cursor"); raw != "" {
cursor, err := decodeCursor(raw)
if err != nil {
// Молчаливая отдача первой страницы вместо отказа дала бы человеку
// архив, листающийся по кругу, и ни строки в журнале.
fail(w, errWithMessage(contract.ErrBadRequest, "Ключ страницы не читается"))
return
}
q.Cursor = cursor
}
page, err := h.recordRepo.List(q)
if err != nil {
h.logger.Error("Failed to list audio records", "error", err, "owner_id", account.ID)
fail(w, err)
return
}
names, err := h.topicNames(account.ID, page.Items)
if err != nil {
h.logger.Error("Failed to resolve topics", "error", err, "owner_id", account.ID)
fail(w, err)
return
}
view := PageView{Items: make([]RecordView, 0, len(page.Items)), TotalItems: page.TotalItems}
for _, record := range page.Items {
// Страница видов текста не читает: перечень доступных видов есть только у
// карточки, и опущенное поле честнее пустого — пустое читалось бы как
// «текста нет».
view.Items = append(view.Items, h.viewOf(record, names, nil))
}
if page.NextCursor != nil {
encoded := encodeCursor(page.NextCursor)
view.NextCursor = &encoded
}
writeJSON(w, http.StatusOK, view)
}
func (h *AppHandler) GetRecord(w http.ResponseWriter, r *http.Request) {
account, _ := AccountOf(r)
record, err := h.readOwn(r, account.ID)
if err != nil {
fail(w, err)
return
}
names, err := h.topicNames(account.ID, []*entity.AudioRecord{record})
if err != nil {
h.logger.Error("Failed to resolve topics", "error", err, "record_id", record.Id)
fail(w, err)
return
}
views := h.availableViews(record)
writeJSON(w, http.StatusOK, h.viewOf(record, names, &views))
}
func (h *AppHandler) GetRecordText(w http.ResponseWriter, r *http.Request) {
account, _ := AccountOf(r)
view := r.URL.Query().Get("view")
if !entity.IsKnownTextView(view) {
fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестный вид текста"))
return
}
record, err := h.readOwn(r, account.ID)
if err != nil {
fail(w, err)
return
}
if view == entity.TextViewReplicas {
h.replicasOf(w, record)
return
}
h.plainTextOf(w, record, view)
}
// readOwn читает запись спрашивающего. Чужая, ничья, несуществующая и
// нечитаемая по виду идентификатора отвечают одним и тем же: по разнице ответов
// иначе перебирается список заведённых записей.
func (h *AppHandler) readOwn(r *http.Request, ownerID string) (*entity.AudioRecord, error) {
// Идентификатор разбирается на границе: он приходит от спрашивающего, а
// сравнение в базе побайтово — запись в верхнем регистре не совпала бы ни с
// одной строкой. Негодный по виду считается несуществующим и до базы не
// доходит вовсе.
recordID, ok := ident.Parse(r.PathValue("id"))
if !ok {
return nil, &contract.JobNotFoundError{Message: "record not found"}
}
record, err := h.recordRepo.GetByID(recordID, ownerID)
if err != nil {
// Наружу ответ один на все исходы, а в журнал они идут по-разному.
// «Записи нет» и «запись чужая» — штатная работа разграничения, о ней
// писать нечего; всё прочее — отказ базы, и без этой строки он приходит
// отправителю как «вашей записи нет», а владелец сервиса об аварии не
// узнаёт ниоткуда.
var notFound *contract.JobNotFoundError
if !errors.As(err, &notFound) {
h.logger.Error("Failed to read audio record", "error", err, "record_id", recordID)
}
return nil, err
}
return record, nil
}
func (h *AppHandler) plainTextOf(w http.ResponseWriter, record *entity.AudioRecord, view string) {
textID := record.TranscriptTextID
if view == entity.TextViewLiterary {
textID = record.LiteraryTextID
}
if textID == nil {
fail(w, contract.ErrTextNotReady)
return
}
text, err := h.textRepo.GetByID(*textID)
if err != nil {
h.logger.Error("Failed to read text", "error", err, "record_id", record.Id)
fail(w, err)
return
}
if text.Contents == "" {
fail(w, contract.ErrTextNotReady)
return
}
writeJSON(w, http.StatusOK, TextView{View: view, Contents: text.Contents})
}
func (h *AppHandler) replicasOf(w http.ResponseWriter, record *entity.AudioRecord) {
if record.StructureID == nil {
fail(w, contract.ErrTextNotReady)
return
}
structure, err := h.structureRepo.GetByID(*record.StructureID)
if err != nil {
h.logger.Error("Failed to read structure", "error", err, "record_id", record.Id)
fail(w, err)
return
}
if len(structure.Replicas) == 0 {
fail(w, contract.ErrTextNotReady)
return
}
replicas := make([]ReplicaView, 0, len(structure.Replicas))
for _, replica := range structure.Replicas {
replicas = append(replicas, ReplicaView{
StartMs: replica.StartMs,
EndMs: replica.EndMs,
Text: replica.Text,
})
}
writeJSON(w, http.StatusOK, TextView{View: entity.TextViewReplicas, Replicas: replicas})
}
// topicNames разрешает темы всех записей страницы **одним** запросом: страница в
// сотню записей иначе стоила бы сотни обращений к базе.
func (h *AppHandler) topicNames(ownerID string, records []*entity.AudioRecord) (map[string]string, error) {
seen := map[string]bool{}
ids := []string{}
for _, record := range records {
for _, id := range record.TopicIDs {
if !seen[id] {
seen[id] = true
ids = append(ids, id)
}
}
}
return h.recordRepo.ResolveTopicNames(ownerID, ids)
}
func (h *AppHandler) viewOf(record *entity.AudioRecord, names map[string]string, views *[]string) RecordView {
topics := make([]string, 0, len(record.TopicIDs))
for _, id := range record.TopicIDs {
if name, ok := names[id]; ok {
topics = append(topics, name)
}
}
return RecordView{
ID: record.Id,
Title: record.Title,
OriginalFilename: record.OriginalFilename,
Brief: record.Brief,
Topics: topics,
State: record.State,
Halted: record.IsHalted(),
HaltReason: record.HaltReason,
DurationMs: record.DurationMs,
SizeBytes: record.SizeBytes,
CreatedAt: record.CreatedAt.Format(time.RFC3339),
AvailableViews: views,
}
}
// availableViews — какие виды текста у записи есть **сейчас**.
//
// Перечень, а не признак: состояние «сплошной текст есть, реплик ещё нет»
// достижимо, потому что шаг завершения пишет их несколькими операциями.
//
// Вид считается доступным по **содержимому**, а не по наличию ссылки. Ссылка
// без содержимого — состояние штатное: пустой ответ распознавания проект признаёт
// нормой и записывает его в журнал. Строй мы перечень по ссылкам, карточка
// объявляла бы вид доступным, а адрес текста отвечал бы «ещё не готов» вечно.
func (h *AppHandler) availableViews(record *entity.AudioRecord) []string {
views := []string{}
if h.hasText(record.TranscriptTextID) {
views = append(views, entity.TextViewTranscript)
}
if h.hasText(record.LiteraryTextID) {
views = append(views, entity.TextViewLiterary)
}
if h.hasReplicas(record.StructureID) {
views = append(views, entity.TextViewReplicas)
}
return views
}
// hasText — есть ли у записи непустой текст этого вида. Отказ чтения читается
// как «вида нет»: перечень доступных видов — подсказка приложению, и уронить
// из-за неё карточку хуже, чем недосказать.
func (h *AppHandler) hasText(textID *string) bool {
if textID == nil {
return false
}
text, err := h.textRepo.GetByID(*textID)
if err != nil {
h.logger.Error("Failed to read text while listing views", "error", err)
return false
}
return text.Contents != ""
}
func (h *AppHandler) hasReplicas(structureID *string) bool {
if structureID == nil {
return false
}
structure, err := h.structureRepo.GetByID(*structureID)
if err != nil {
h.logger.Error("Failed to read structure while listing views", "error", err)
return false
}
return len(structure.Replicas) > 0
}
// encodeCursor и decodeCursor прячут пару «время заведения и идентификатор» за
// непрозрачной строкой: спрашивающему её содержимое не принадлежит, а
// составлять ключ руками значило бы завязаться на порядок сортировки.
//
// Кодирование без набивки и в адресном алфавите — ключ уезжает параметром, а не
// телом.
func encodeCursor(c *contract.RecordCursor) string {
return base64.RawURLEncoding.EncodeToString(
[]byte(c.CreatedAt.UTC().Format(time.RFC3339) + "|" + c.ID),
)
}
func decodeCursor(raw string) (*contract.RecordCursor, error) {
decoded, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return nil, fmt.Errorf("cursor is not decodable: %w", err)
}
createdAt, id, ok := strings.Cut(string(decoded), "|")
if !ok {
return nil, errors.New("malformed cursor")
}
// Обе половины ключа разбираются, а не берутся строкой: время уходит в
// запрос сравнением, а идентификатор — точным совпадением, и негодная
// половина дала бы человеку либо пустой архив при непустом счётчике, либо
// ленту с начала.
parsed, err := time.Parse(time.RFC3339, createdAt)
if err != nil {
return nil, errors.New("cursor carries no readable time")
}
recordID, valid := ident.Parse(id)
if !valid {
return nil, errors.New("cursor carries no readable record key")
}
return &contract.RecordCursor{CreatedAt: parsed.UTC(), ID: recordID}, nil
}