/**
 * FileEntryService — pure DB repository for the `file_entry` table.
 *
 * ## Scope
 *
 * - **Pure DB.** No FS IO, no path resolution, no canonicalization. Callers
 *   (e.g. `FileManager.ensureExternalEntry`) are responsible for passing a
 *   canonical `externalPath` on write and query.
 * - **Produces branded `FileEntry`.** Every returned row passes
 *   `FileEntrySchema.parse()` so the brand contract holds — downstream
 *   consumers cannot receive a raw object literal that satisfies the shape
 *   but bypasses validation.
 * - **No side effects beyond DB.** Dangling updates, watcher invalidation,
 *   and cache invalidation are orchestrated by FileManager — this service
 *   is strictly query/insert/update/delete.
 *
 * See [architecture.md](../../../docs/references/file/architecture.md) for the
 * module-level layering rules.
 */

import { application } from '@application'
import { fileEntryTable, fileRefTable } from '@data/db/schemas/file'
import type { DbOrTx } from '@data/db/types'
import { loggerService } from '@logger'
import { DataApiErrorFactory } from '@shared/data/api'
import type { FileEntryListResponse, FileEntryStats } from '@shared/data/api/schemas/files'
import type { CanonicalExternalPath, FileEntry, FileEntryId, FileEntryOrigin } from '@shared/data/types/file'
import {
  AbsolutePathSchema,
  ExternalEntrySchema,
  FileEntrySchema,
  InternalEntrySchema,
  SafeNameSchema
} from '@shared/data/types/file'
import { and, asc, count, eq, isNotNull, isNull, type SQL, sql, type SQLWrapper } from 'drizzle-orm'
import { v7 as uuidv7 } from 'uuid'
import * as z from 'zod'
import { ZodError } from 'zod'

import { asNumericKey, asStringKey, decodeListCursor, encodeCursor, keysetOrdering } from './utils/keysetCursor'

const logger = loggerService.withContext('FileEntryService')

const CreateFileEntryRowSchema = z.discriminatedUnion('origin', [
  z.strictObject({
    id: InternalEntrySchema.shape.id,
    origin: z.literal('internal'),
    name: InternalEntrySchema.shape.name,
    ext: InternalEntrySchema.shape.ext,
    size: InternalEntrySchema.shape.size
  }),
  z.strictObject({
    origin: z.literal('external'),
    name: ExternalEntrySchema.shape.name,
    ext: ExternalEntrySchema.shape.ext,
    externalPath: ExternalEntrySchema.shape.externalPath
  })
])

/**
 * BO-shaped columns a caller may provide on insert. Internal callers provide
 * the id because the physical blob is already written at `{id}.{ext}`;
 * external ids are generated by `create()`. DB-row-only null columns are
 * derived here too.
 */
export type CreateFileEntryRow = z.input<typeof CreateFileEntryRowSchema>

/**
 * Columns that may be mutated post-insert. Origin / id / externalPath are
 * immutable. Note `size` is included because internal-file writes update the
 * byte count atomically; on external rows it must remain `null`.
 */
export interface UpdateFileEntryRow {
  readonly name?: string
  readonly ext?: string | null
  readonly size?: number
  readonly deletedAt?: number | null
}

export interface FindEntriesQuery {
  readonly origin?: FileEntryOrigin
  readonly inTrash?: boolean
  readonly limit?: number
  readonly offset?: number
}

export type ListFilesSortBy = 'name' | 'createdAt' | 'updatedAt' | 'size' | 'ext'

export interface ListCursorQuery {
  readonly origin?: FileEntryOrigin
  readonly inTrash?: boolean
  readonly sortBy?: ListFilesSortBy
  readonly sortOrder?: 'asc' | 'desc'
  readonly cursor?: string
  readonly limit?: number
}

export interface FileEntryService {
  /** Run serialized DB writes through DbService.withWriteTx. */
  withWriteTx<T>(fn: (tx: DbOrTx) => Promise<T>): Promise<T>

  /** Return the entry, or `null` if not found. Trashed entries are included. */
  findById(id: FileEntryId): Promise<FileEntry | null>

  /** Tx-scoped variant of `findById` for composing write flows. */
  findByIdTx(tx: DbOrTx, id: FileEntryId): Promise<FileEntry | null>

  /** Return the entry, or throw. Trashed entries are included. */
  getById(id: FileEntryId): Promise<FileEntry>

  /** Tx-scoped variant of `getById` for composing write flows. */
  getByIdTx(tx: DbOrTx, id: FileEntryId): Promise<FileEntry>

  /**
   * Look up an external entry by canonical `externalPath`. Returns `null` when
   * no row matches. The `CanonicalExternalPath` brand forces callers through
   * `canonicalizeExternalPath()` at compile time — raw `string` values are
   * not assignable here, which prevents the "caller forgot to canonicalize"
   * class of bug that would silently miss all matches.
   */
  findByExternalPath(canonicalPath: CanonicalExternalPath): Promise<FileEntry | null>

  /**
   * Return external entries whose `externalPath` matches `canonicalPath`
   * case-insensitively (`lower(externalPath) = lower(canonicalPath)`).
   * Byte-exact matches are included; callers that only want "case-different
   * peers" should filter the byte-exact self-match from the result.
   *
   * Backed by the functional unique index `fe_external_path_lower_unique_idx`
   * (`lower(external_path)`) — SQLite expression-matches the WHERE clause
   * against the index, so this lookup is O(log N) and runs the same plan
   * regardless of `file_entry` table size. The size-threshold gate that the
   * earlier "best-effort warn-only" contract recommended is no longer needed
   * and has been removed from the architecture doc.
   *
   * Returns at most one row in practice: the same functional unique index
   * makes "two rows that case-collide" an unrepresentable DB state. The
   * array return shape is preserved for forward-compat with possible future
   * relaxations (e.g. a trashed-aware variant), and so call sites that
   * iterate stay stable.
   *
   * The application-layer reuse / reject decision for a case-collision peer
   * lives in `ensureExternalEntry` (`fs.realpath` resolves whether the two
   * case-different paths are the same FS entity); see
   * `file-manager-architecture.md §1.2 Duplicate-entry detection on insert`.
   *
   * Un-parseable rows are skipped with a warning (see `rowToFileEntrySafe`).
   */
  findCaseInsensitivePeers(canonicalPath: CanonicalExternalPath): Promise<FileEntry[]>

  /**
   * Flat listing. Trashed filter defaults to "active only" when `inTrash` is omitted.
   * Un-parseable rows are skipped with a warning (see `rowToFileEntrySafe`).
   */
  findMany(query?: FindEntriesQuery): Promise<FileEntry[]>

  /**
   * Cursor-and-count list backing `GET /files/entries`. Returns
   * `{ items, total, nextCursor }` matching the DataApi cursor response shape,
   * doing the select + count in a single round-trip via `Promise.all`.
   *
   * Defaults: `limit = 50`, `sortBy = 'createdAt'`, `sortOrder = 'asc'`.
   * Trashed filter defaults to "active only" when `inTrash` is omitted, matching
   * `findMany`.
   *
   * `sortBy: 'size'` is only meaningful within an `origin='internal'` filter
   * (external rows have `size IS NULL`); see the schema-level JSDoc for the
   * mixed-origin caveat.
   *
   * Un-parseable rows are skipped with a warning (see `rowToFileEntrySafe`).
   * `total` is a SQL count and may exceed `items.length` when corrupted
   * rows were skipped.
   */
  listCursor(query?: ListCursorQuery): Promise<FileEntryListResponse>

  /**
   * Pure-SQL aggregate counts backing the file sidebar. Counts active rows by
   * extension and active external rows by dirname, plus active/trash totals.
   */
  getStats(): Promise<FileEntryStats>

  /**
   * Active (non-trashed) entries with zero `file_ref` rows pointing at them.
   * Used by Phase 1b.4 OrphanRefScanner's report-only entry pass — see
   * file-manager-architecture §7.1 (default policy is "preserve").
   *
   * Un-parseable rows are skipped with a warning (see `rowToFileEntrySafe`).
   */
  findUnreferenced(query?: { origin?: FileEntryOrigin }): Promise<FileEntry[]>

  /**
   * All entry ids regardless of trashed state — backs the Phase 1b.4 startup
   * file sweep, which needs to know which on-disk UUID files have a DB row
   * (active or trashed; both are out of scope for unlink).
   */
  listAllIds(): Promise<Set<FileEntryId>>

  /** Insert a new row. Violates `fe_origin_consistency` / `fe_size_internal_only` → throws. */
  create(values: CreateFileEntryRow): Promise<FileEntry>

  /** Tx-scoped variant of `create` for composing write flows. */
  createTx(tx: DbOrTx, values: CreateFileEntryRow): Promise<FileEntry>

  /** Update mutable columns. Returns the refreshed row. Throws if not found. */
  update(id: FileEntryId, values: UpdateFileEntryRow): Promise<FileEntry>

  /** Tx-scoped variant of `update` for composing write flows. */
  updateTx(tx: DbOrTx, id: FileEntryId, values: UpdateFileEntryRow): Promise<FileEntry>

  /**
   * Atomically rewrite both `externalPath` and `name` for an external entry.
   * The rename flow is the **only** sanctioned mutation site for `externalPath`;
   * doing it as a single statement keeps the (path, name) pair consistent under
   * partial-failure scenarios (transient lock, schema constraint).
   */
  setExternalPathAndName(id: FileEntryId, externalPath: CanonicalExternalPath, name: string): Promise<FileEntry>

  /** Tx-scoped variant of `setExternalPathAndName` for composing write flows. */
  setExternalPathAndNameTx(
    tx: DbOrTx,
    id: FileEntryId,
    externalPath: CanonicalExternalPath,
    name: string
  ): Promise<FileEntry>

  /** Remove the row (CASCADE drops dependent `file_ref`s). No-op if already gone. */
  delete(id: FileEntryId): Promise<void>

  /** Tx-scoped variant of `delete` for composing write flows. */
  deleteTx(tx: DbOrTx, id: FileEntryId): Promise<void>
}

type FileEntryRow = typeof fileEntryTable.$inferSelect

/**
 * DB row → branded `FileEntry` BO.
 *
 * The DB row carries every column physically (with `null` for fields that
 * don't apply to the row's origin); the BO is a discriminated union where
 * each variant only declares the fields it actually owns. Dispatch on
 * `row.origin`, build a variant-specific plain object dropping the
 * irrelevant columns, then `FileEntrySchema.parse` rehydrates the brand
 * and validates the invariants. See the `fileEntry.ts` header docstring
 * for the "DB row vs Business Object" boundary.
 *
 * Why not align with the project's `nullsToUndefined + timestampToISO`
 * row→entity helper: those helpers were designed for entity types whose
 * schemas use `undefined` for absence and ISO strings for timestamps —
 * FileEntry uses field absence (`origin`-driven) for absence and `number`
 * ms-epoch for timestamps, so the helpers' translations would actively
 * misshape the row. The dispatch-then-parse pattern here is the
 * discriminated-union counterpart and is documented in
 * `docs/references/data/data-api-in-main.md` (§"When `nullsToUndefined +
 * spread` is NOT a fit") as an accepted alternative.
 */
function rowToFileEntry(row: FileEntryRow): FileEntry {
  if (row.origin === 'internal') {
    return FileEntrySchema.parse({
      id: row.id,
      origin: 'internal',
      name: row.name,
      ext: row.ext,
      size: row.size,
      // deletedAt is `optional` on the BO — present iff the DB column is
      // non-null. Bypass `nullsToUndefined` so we don't pull in a helper
      // whose project-wide meaning is "every null becomes undefined";
      // here only this specific column flips.
      ...(row.deletedAt !== null ? { deletedAt: row.deletedAt } : {}),
      createdAt: row.createdAt,
      updatedAt: row.updatedAt
    })
  }
  return FileEntrySchema.parse({
    id: row.id,
    origin: 'external',
    name: row.name,
    ext: row.ext,
    externalPath: row.externalPath,
    createdAt: row.createdAt,
    updatedAt: row.updatedAt
  })
}

/**
 * Fault-isolating variant of `rowToFileEntry` for BULK reads only.
 *
 * One legacy-corrupted row must not take down a whole collection query —
 * and with it `danglingCache.initFromDb` → `FileManager.onInit` → the
 * entire File IPC surface for the session (#15733). Point reads
 * (`findById` / `getById` / `findByExternalPath`) and write-read-backs
 * (`create` / `update` / `setExternalPathAndName`) deliberately keep
 * throwing: there the caller named a specific row or just validated the
 * payload pre-write, so a silent null would mask a real bug.
 *
 * Contract change for bulk reads: they return every PARSEABLE row, not
 * every physically existing row. Excluded rows are warned with their id.
 *
 * Only validation failures (`ZodError`) are isolated — anything else
 * rethrows, so a programming error inside `rowToFileEntry` cannot
 * masquerade as a corrupt row and vanish from bulk reads.
 */
function rowToFileEntrySafe(row: FileEntryRow): FileEntry | null {
  try {
    return rowToFileEntry(row)
  } catch (error) {
    if (!(error instanceof ZodError)) throw error
    logger.warn('Skipping un-parseable file_entry row in bulk read', {
      id: row.id,
      issues: error.issues
    })
    return null
  }
}

type ListSortBy = NonNullable<ListCursorQuery['sortBy']>
type ListSortOrder = NonNullable<ListCursorQuery['sortOrder']>
const DEFAULT_LIST_SORT_BY: ListSortBy = 'createdAt'
const DEFAULT_LIST_SORT_ORDER: ListSortOrder = 'asc'
const FILE_ENTRY_SIZE_NULL_SORT_VALUE = -1
// SafeExtSchema rejects whitespace, so this non-empty cursor key is reserved for NULL ext and sorts before real ext values.
const FILE_ENTRY_EXT_NULL_SORT_VALUE = ' '

function getListSortValue(row: FileEntryRow, sortBy: ListSortBy): string | number {
  switch (sortBy) {
    case 'name':
      return row.name
    case 'updatedAt':
      return row.updatedAt
    case 'size':
      return row.size ?? FILE_ENTRY_SIZE_NULL_SORT_VALUE
    case 'ext':
      return row.ext ?? FILE_ENTRY_EXT_NULL_SORT_VALUE
    case 'createdAt':
      return row.createdAt
  }
}

function getListSortExpression(sortBy: ListSortBy): SQLWrapper {
  switch (sortBy) {
    case 'name':
      return sql<string>`${fileEntryTable.name}`
    case 'updatedAt':
      return sql<number>`${fileEntryTable.updatedAt}`
    case 'size':
      return sql<number>`coalesce(${fileEntryTable.size}, ${FILE_ENTRY_SIZE_NULL_SORT_VALUE})`
    case 'ext':
      return sql<string>`coalesce(${fileEntryTable.ext}, ${FILE_ENTRY_EXT_NULL_SORT_VALUE})`
    case 'createdAt':
      return sql<number>`${fileEntryTable.createdAt}`
  }
}

function getListCursorParser(sortBy: ListSortBy): (raw: string) => string | number | null {
  return sortBy === 'name' || sortBy === 'ext' ? asStringKey : asNumericKey
}

class FileEntryServiceImpl implements FileEntryService {
  private getDbService() {
    return application.get('DbService')
  }

  private getDb() {
    return this.getDbService().getDb()
  }

  withWriteTx<T>(fn: (tx: DbOrTx) => Promise<T>): Promise<T> {
    return this.getDbService().withWriteTx(fn)
  }

  async findById(id: FileEntryId): Promise<FileEntry | null> {
    return this.findByIdTx(this.getDb(), id)
  }

  async findByIdTx(tx: DbOrTx, id: FileEntryId): Promise<FileEntry | null> {
    const rows = await tx.select().from(fileEntryTable).where(eq(fileEntryTable.id, id)).limit(1)
    return rows.length === 0 ? null : rowToFileEntry(rows[0])
  }

  async getById(id: FileEntryId): Promise<FileEntry> {
    return this.getByIdTx(this.getDb(), id)
  }

  async getByIdTx(tx: DbOrTx, id: FileEntryId): Promise<FileEntry> {
    const entry = await this.findByIdTx(tx, id)
    if (!entry) {
      throw DataApiErrorFactory.notFound('FileEntry', id)
    }
    return entry
  }

  async findByExternalPath(canonicalPath: CanonicalExternalPath): Promise<FileEntry | null> {
    const rows = await this.getDb()
      .select()
      .from(fileEntryTable)
      .where(eq(fileEntryTable.externalPath, canonicalPath))
      .limit(1)
    return rows.length === 0 ? null : rowToFileEntry(rows[0])
  }

  async findCaseInsensitivePeers(canonicalPath: CanonicalExternalPath): Promise<FileEntry[]> {
    const rows = await this.getDb()
      .select()
      .from(fileEntryTable)
      .where(
        and(
          isNotNull(fileEntryTable.externalPath),
          sql`lower(${fileEntryTable.externalPath}) = lower(${canonicalPath})`
        )
      )
      // Deterministic ordering for the duplicate-suspect warn log so the
      // first row in the list is stable across runs (helpful for snapshot
      // tests and for the surviving-row selection in any future FileMigrator
      // dedupe pass).
      .orderBy(asc(fileEntryTable.createdAt), asc(fileEntryTable.id))
    return rows.map(rowToFileEntrySafe).filter((e): e is FileEntry => e !== null)
  }

  async findMany(query: FindEntriesQuery = {}): Promise<FileEntry[]> {
    const conditions: SQL[] = []
    if (query.origin) {
      conditions.push(eq(fileEntryTable.origin, query.origin))
    }
    if (query.inTrash === true) {
      conditions.push(isNotNull(fileEntryTable.deletedAt))
    } else {
      conditions.push(isNull(fileEntryTable.deletedAt))
    }

    let queryBuilder = this.getDb()
      .select()
      .from(fileEntryTable)
      .where(and(...conditions))
      .orderBy(asc(fileEntryTable.createdAt))
      .$dynamic()

    if (query.limit !== undefined) {
      queryBuilder = queryBuilder.limit(query.limit)
    }
    if (query.offset !== undefined) {
      queryBuilder = queryBuilder.offset(query.offset)
    }

    const rows = await queryBuilder
    return rows.map(rowToFileEntrySafe).filter((e): e is FileEntry => e !== null)
  }

  async listCursor(query: ListCursorQuery = {}): Promise<FileEntryListResponse> {
    const filterConditions: SQL[] = []
    if (query.origin) {
      filterConditions.push(eq(fileEntryTable.origin, query.origin))
    }
    if (query.inTrash === true) {
      filterConditions.push(isNotNull(fileEntryTable.deletedAt))
    } else {
      filterConditions.push(isNull(fileEntryTable.deletedAt))
    }

    const sortBy = query.sortBy ?? DEFAULT_LIST_SORT_BY
    const sortOrder = query.sortOrder ?? DEFAULT_LIST_SORT_ORDER
    const sortExpression = getListSortExpression(sortBy)
    const ordering = keysetOrdering(sortExpression, fileEntryTable.id, { major: sortOrder, tie: sortOrder })
    const cursor = decodeListCursor(query.cursor, getListCursorParser(sortBy), 'file-entry')
    const conditions = [...filterConditions]
    if (cursor) {
      conditions.push(ordering.where(cursor))
    }

    const pageSize = query.limit ?? 50
    const where = and(...conditions)
    const filterWhere = and(...filterConditions)

    const [rows, totalRow] = await Promise.all([
      this.getDb()
        .select()
        .from(fileEntryTable)
        .where(where)
        .orderBy(...ordering.orderBy)
        .limit(pageSize + 1),
      this.getDb().select({ value: count() }).from(fileEntryTable).where(filterWhere)
    ])
    const pageRows = rows.slice(0, pageSize)

    return {
      items: pageRows.map(rowToFileEntrySafe).filter((e): e is FileEntry => e !== null),
      total: totalRow[0]?.value ?? 0,
      nextCursor:
        rows.length > pageSize && pageRows.length > 0
          ? encodeCursor(getListSortValue(pageRows[pageRows.length - 1], sortBy), pageRows[pageRows.length - 1].id)
          : undefined
    }
  }

  async getStats(): Promise<FileEntryStats> {
    const db = this.getDb()
    const [activeTotalRows, trashTotalRows, extRows] = await Promise.all([
      db.select({ count: count() }).from(fileEntryTable).where(isNull(fileEntryTable.deletedAt)),
      db.select({ count: count() }).from(fileEntryTable).where(isNotNull(fileEntryTable.deletedAt)),
      db
        .select({ ext: fileEntryTable.ext, count: count() })
        .from(fileEntryTable)
        .where(isNull(fileEntryTable.deletedAt))
        .groupBy(fileEntryTable.ext)
    ])

    return {
      activeTotal: activeTotalRows[0]?.count ?? 0,
      trashTotal: trashTotalRows[0]?.count ?? 0,
      extCounts: extRows.map((row) => ({ ext: row.ext, count: row.count }))
    }
  }

  async findUnreferenced(query: { origin?: FileEntryOrigin } = {}): Promise<FileEntry[]> {
    const conditions: SQL[] = [isNull(fileEntryTable.deletedAt), isNull(fileRefTable.id)]
    if (query.origin) conditions.push(eq(fileEntryTable.origin, query.origin))
    const rows = await this.getDb()
      .select({ entry: fileEntryTable })
      .from(fileEntryTable)
      .leftJoin(fileRefTable, eq(fileRefTable.fileEntryId, fileEntryTable.id))
      .where(and(...conditions))
      .orderBy(asc(fileEntryTable.createdAt))
    return rows.map((r) => rowToFileEntrySafe(r.entry)).filter((e): e is FileEntry => e !== null)
  }

  async listAllIds(): Promise<Set<FileEntryId>> {
    const rows = await this.getDb().select({ id: fileEntryTable.id }).from(fileEntryTable)
    return new Set(rows.map((r) => r.id))
  }

  async create(values: CreateFileEntryRow): Promise<FileEntry> {
    return this.withWriteTx((tx) => this.createTx(tx, values))
  }

  async createTx(tx: DbOrTx, values: CreateFileEntryRow): Promise<FileEntry> {
    const parsed = CreateFileEntryRowSchema.parse(values)
    const now = Date.now()
    const id = parsed.origin === 'internal' ? parsed.id : uuidv7()
    const rows = await tx
      .insert(fileEntryTable)
      .values({
        id,
        origin: parsed.origin,
        name: parsed.name,
        ext: parsed.ext,
        size: parsed.origin === 'internal' ? parsed.size : null,
        externalPath: parsed.origin === 'external' ? parsed.externalPath : null,
        deletedAt: null,
        createdAt: now,
        updatedAt: now
      })
      .returning()
    return rowToFileEntry(rows[0])
  }

  async update(id: FileEntryId, values: UpdateFileEntryRow): Promise<FileEntry> {
    return this.withWriteTx((tx) => this.updateTx(tx, id, values))
  }

  async updateTx(tx: DbOrTx, id: FileEntryId, values: UpdateFileEntryRow): Promise<FileEntry> {
    // Validate user-controlled string columns BEFORE the SQL UPDATE so a
    // rejected value never persists. Without this, a name / ext failing
    // the FileEntry field schemas commits to SQLite first and only fails at
    // `rowToFileEntry`'s schema parse — leaving the row permanently
    // un-parseable.
    if (values.name !== undefined) SafeNameSchema.parse(values.name)
    if (values.ext !== undefined) InternalEntrySchema.shape.ext.parse(values.ext)
    const updates: Partial<typeof fileEntryTable.$inferInsert> = {
      updatedAt: Date.now()
    }
    if (values.name !== undefined) updates.name = values.name
    if (values.ext !== undefined) updates.ext = values.ext
    if (values.size !== undefined) updates.size = values.size
    if (values.deletedAt !== undefined) updates.deletedAt = values.deletedAt
    const rows = await tx.update(fileEntryTable).set(updates).where(eq(fileEntryTable.id, id)).returning()
    if (rows.length === 0) {
      throw DataApiErrorFactory.notFound('FileEntry', id)
    }
    return rowToFileEntry(rows[0])
  }

  /**
   * Atomically rewrite both `externalPath` and `name` for an external entry —
   * the only sanctioned mutation site for `externalPath`. Used by the rename
   * flow so the (path, name) pair stays consistent under failure.
   */
  async setExternalPathAndName(id: FileEntryId, externalPath: CanonicalExternalPath, name: string): Promise<FileEntry> {
    return this.withWriteTx((tx) => this.setExternalPathAndNameTx(tx, id, externalPath, name))
  }

  async setExternalPathAndNameTx(
    tx: DbOrTx,
    id: FileEntryId,
    externalPath: CanonicalExternalPath,
    name: string
  ): Promise<FileEntry> {
    // Same pre-SQL validation rationale as `update` above; an unsafe value
    // for either column would corrupt the row past `rowToFileEntry` parse.
    // The `CanonicalExternalPath` brand is TS-only — defense-in-depth at the
    // runtime layer rejects path strings the brand failed to flag (e.g. a
    // caller that `as`-cast a raw user string instead of going through
    // `canonicalizeExternalPath`).
    SafeNameSchema.parse(name)
    AbsolutePathSchema.parse(externalPath)
    const rows = await tx
      .update(fileEntryTable)
      .set({ externalPath, name, updatedAt: Date.now() })
      .where(eq(fileEntryTable.id, id))
      .returning()
    if (rows.length === 0) {
      throw DataApiErrorFactory.notFound('FileEntry', id)
    }
    return rowToFileEntry(rows[0])
  }

  async delete(id: FileEntryId): Promise<void> {
    await this.withWriteTx((tx) => this.deleteTx(tx, id))
  }

  async deleteTx(tx: DbOrTx, id: FileEntryId): Promise<void> {
    await tx.delete(fileEntryTable).where(eq(fileEntryTable.id, id))
  }
}

export const fileEntryService: FileEntryService = new FileEntryServiceImpl()
