feat: add cloudsave configuration w/ ludusavi search

This commit is contained in:
DecDuck
2025-06-11 22:14:21 +10:00
parent 5b27430ece
commit 95d6089453
20 changed files with 482 additions and 146 deletions

View File

@ -1,88 +1,149 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div v-if="game" class="h-full grow flex flex-row gap-y-8">
<div class="grow w-full h-full px-6 py-4 flex flex-col">
<div class="grow w-full h-full px-6 py-4 flex flex-col gap-8">
<!-- version manager -->
<div>
<!-- version priority -->
<div>
<div class="border-b border-zinc-800 pb-3">
<div
class="flex flex-wrap items-center justify-between sm:flex-nowrap"
<div class="rounded-lg bg-zinc-950 shadow-sm divide-y divide-zinc-800">
<div class="px-4 py-3 sm:px-6">
<h1
class="w-full inline-flex items-center justify gap-x-2 text-lg text-zinc-200 font-semibold"
>
<ServerStackIcon class="size-5" />
{{ $t("library.admin.versionPriority.title") }}
<NuxtLink
v-if="unimportedVersions !== undefined"
:href="
unimportedVersions.length > 0
? `/admin/library/${game.id}/import`
: ''
"
type="button"
:class="[
unimportedVersions.length > 0
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-blue-800/50',
'ml-auto inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1.5 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
]"
>
<h3
class="text-base font-semibold font-display leading-6 text-zinc-100"
>
{{ $t("library.admin.versionPriority") }}
<!-- import games button -->
<NuxtLink
v-if="unimportedVersions !== undefined"
:href="
unimportedVersions.length > 0
? `/admin/library/${game.id}/import`
: ''
"
type="button"
:class="[
unimportedVersions.length > 0
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-blue-800/50',
'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
]"
{{
unimportedVersions.length > 0
? $t("library.admin.import.version.import")
: $t("library.admin.import.version.noVersions")
}}
</NuxtLink>
</h1>
<p class="text-sm text-zinc-400">
{{ $t("library.admin.versionPriority.description") }}
</p>
</div>
<div class="bg-zinc-950/10 px-4 py-5 sm:p-6">
<!-- version priority -->
<div>
<div class="text-center w-full text-sm text-zinc-600">
{{ $t("lowest") }}
</div>
<draggable
:list="game.versions"
handle=".handle"
class="mt-2 space-y-4"
@update="() => updateVersionOrder()"
>
<template #item="{ element: item }: { element: GameVersion }">
<div
class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between"
>
{{
unimportedVersions.length > 0
? $t("library.admin.import.version.import")
: $t("library.admin.import.version.noVersions")
}}
</NuxtLink>
</h3>
<div class="text-zinc-100 font-semibold">
{{ item.versionName }}
</div>
<div class="text-zinc-400">
{{ item.delta ? $t("library.admin.version.delta") : "" }}
</div>
<div class="inline-flex items-center gap-x-2">
<component
:is="PLATFORM_ICONS[item.platform]"
class="size-6 text-blue-600"
/>
<Bars3Icon
class="cursor-move w-6 h-6 text-zinc-400 handle"
/>
<button @click="() => deleteVersion(item.versionName)">
<TrashIcon class="w-5 h-5 text-red-600" />
</button>
</div>
</div>
</template>
</draggable>
<div
v-if="game.versions.length == 0"
class="text-center font-bold text-zinc-400 my-3"
>
{{ $t("library.admin.version.noVersionsAdded") }}
</div>
<div class="mt-2 text-center w-full text-sm text-zinc-600">
{{ $t("highest") }}
</div>
</div>
</div>
</div>
<div class="mt-4 text-center w-full text-sm text-zinc-600">
{{ $t("lowest") }}
</div>
<draggable
:list="game.versions"
handle=".handle"
class="mt-2 space-y-4"
@update="() => updateVersionOrder()"
<!-- cloud saves manager -->
<div class="rounded-lg bg-zinc-950 shadow-sm divide-y divide-zinc-800">
<div class="px-4 py-3 sm:px-6">
<h1
class="w-full inline-flex items-center justify gap-x-2 text-lg text-zinc-200 font-semibold"
>
<template #item="{ element: item }: { element: GameVersion }">
<div
class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between"
<CloudIcon class="size-5" />
{{ $t("library.admin.cloudSaves.title") }}
</h1>
<p class="text-sm text-zinc-400">
{{ $t("library.admin.cloudSaves.description") }}
</p>
</div>
<div class="bg-zinc-950/10 px-4 py-5 sm:p-6">
<LudusaviSearchbar
:default="game.cloudSaveConfiguration?.ludusaviEntry?.name"
@update="(name) => updateLudusaviEntry(name)"
/>
<dl
v-if="game.cloudSaveConfiguration?.ludusaviEntry"
class="mt-4 grid grid-cols-1 gap-0.5 overflow-hidden rounded-2xl text-center sm:grid-cols-3"
>
<div
v-for="stat in [
{ name: 'Name', value: game.cloudSaveConfiguration.ludusaviEntry.name },
{ name: 'Steam ID', value: game.cloudSaveConfiguration.ludusaviEntry.steamId },
]"
:key="stat.name"
class="flex flex-col bg-white/5 p-8"
>
<dt class="text-sm/6 font-semibold text-gray-300">
{{ stat.name }}
</dt>
<dd
class="order-first text-3xl font-semibold tracking-tight text-white"
>
<div class="text-zinc-100 font-semibold">
{{ item.versionName }}
</div>
<div class="text-zinc-400">
{{ item.delta ? $t("library.admin.version.delta") : "" }}
</div>
<div class="inline-flex items-center gap-x-2">
<component
:is="PLATFORM_ICONS[item.platform]"
class="size-6 text-blue-600"
/>
<Bars3Icon class="cursor-move w-6 h-6 text-zinc-400 handle" />
<button @click="() => deleteVersion(item.versionName)">
<TrashIcon class="w-5 h-5 text-red-600" />
</button>
</div>
</div>
</template>
</draggable>
<div
v-if="game.versions.length == 0"
class="text-center font-bold text-zinc-400 my-3"
>
{{ $t("library.admin.version.noVersionsAdded") }}
</div>
<div class="mt-2 text-center w-full text-sm text-zinc-600">
{{ $t("highest") }}
</div>
{{ stat.value }}
</dd>
</div>
<div class="flex flex-col bg-white/5 p-8">
<dt class="mt-1 text-sm/6 font-semibold text-gray-300">
Platforms
</dt>
<dd
class="inline-flex items-center justify-center gap-x-4 order-first text-3xl font-semibold tracking-tight text-white"
>
<component
:is="item"
v-for="item in game.cloudSaveConfiguration.ludusaviEntry.entries
.map((e) => e.platform as PlatformClient)
.map((e) => PLATFORM_ICONS[e])"
:key="item.__name"
class="size-8 text-zinc-100"
/>
</dd>
</div>
</dl>
</div>
</div>
</div>
@ -93,10 +154,17 @@
</template>
<script setup lang="ts">
import type { Game, GameVersion } from "~/prisma/client";
import type {
CloudSaveConfiguration,
Game,
GameVersion,
LudusaviEntry,
LudusaviPlatformEntry,
} from "~/prisma/client";
import { Bars3Icon, TrashIcon } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack";
import type { H3Error } from "h3";
import { CloudIcon, ServerStackIcon } from "@heroicons/vue/24/outline";
definePageMeta({
layout: "admin",
@ -108,16 +176,41 @@ defineProps<{ unimportedVersions: string[] }>();
const { t } = useI18n();
type GameAndVersions = Game & { versions: GameVersion[] };
const game = defineModel<SerializeObject<GameAndVersions>>() as Ref<
SerializeObject<GameAndVersions>
>;
type FullGame = Game & { versions: GameVersion[] } & {
cloudSaveConfiguration?: CloudSaveConfiguration & {
ludusaviEntry?: LudusaviEntry & { entries: Array<LudusaviPlatformEntry> } | null;
};
};
const game = defineModel() as Ref<SerializeObject<FullGame>>;
if (!game.value)
throw createError({
statusCode: 500,
statusMessage: "Game not provided to editor component",
});
async function updateLudusaviEntry(name: string) {
try {
const newConfig = await $dropFetch("/api/v1/admin/game/cloudsaves", {
method: "PATCH",
body: { id: game.value.id, name: name },
});
console.log(newConfig);
game.value.cloudSaveConfiguration = newConfig;
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.game.ludusavi.title"),
description: t("errors.game.ludusavi.description", [
// @ts-expect-error attempt to display statusMessage on error
e?.statusMessage ?? t("errors.unknown"),
]),
},
(_, c) => c(),
);
}
}
async function updateVersionOrder() {
try {
const newVersions = await $dropFetch("/api/v1/admin/game/version", {

View File

@ -0,0 +1,98 @@
<template>
<Combobox v-model="selectedGame" as="div" @update:model-value="query = ''">
<ComboboxLabel class="block text-sm/6 font-medium text-zinc-100">{{
$t("library.admin.cloudSaves.search")
}}</ComboboxLabel>
<div class="relative mt-2">
<ComboboxInput
class="block w-full rounded-md bg-zinc-900 py-1.5 pr-12 pl-3 text-base text-zinc-100 outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
:display-value="(value) => value as string"
@change="query = $event.target.value"
@blur="query = ''"
/>
<ComboboxButton
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-hidden"
>
<ChevronUpDownIcon class="size-5 text-gray-400" aria-hidden="true" />
</ComboboxButton>
<ComboboxOptions
v-if="searchResults.length > 0"
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-950 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-hidden sm:text-sm"
>
<ComboboxOption
v-for="result in searchResults"
:key="result"
v-slot="{ active, selected }"
:value="result"
as="template"
>
<li
:class="[
'relative cursor-default py-2 pr-9 pl-3 select-none',
active
? 'bg-blue-600 text-white outline-hidden'
: 'text-zinc-100',
]"
>
<span :class="['block truncate', selected && 'font-semibold']">
{{ result }}
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</Combobox>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxLabel,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/vue";
const emit = defineEmits<{
(e: "update", id: string): void;
}>();
const props = defineProps<{ default?: string }>();
const query = ref("");
const selectedGame = ref<string | null>(props.default ?? null);
const searchResults = ref<Array<string>>([]);
let searchTimeout: NodeJS.Timeout | null = null;
watch(query, (name) => {
if (searchTimeout) clearTimeout(searchTimeout);
if (!name) return;
searchResults.value = [];
searchTimeout = setTimeout(async () => {
const results = await $dropFetch("/api/v1/admin/game/cloudsaves/search", {
query: { name },
});
searchResults.value = results.map((e) => e.name);
}, 100);
});
watch(selectedGame, (v) => {
if (!v) return;
emit("update", v);
});
</script>

View File

@ -201,6 +201,10 @@
"carousel": {
"title": "Failed to update image carousel",
"description": "Drop failed to update the image carousel: {0}"
},
"ludusavi": {
"title": "Failed to update Ludusavi entry",
"description": "Drop failed to update the Ludusavi entry: {0}"
}
}
},
@ -382,7 +386,15 @@
},
"subheader": "As you add folders to your library sources, Drop will detect it and prompt you to import it. Each game needs to be imported before you can import a version.",
"title": "Libraries",
"versionPriority": "Version priority"
"versionPriority": {
"title": "Version priority",
"description": "Version priority is used to order games for upgrade mode versions."
},
"cloudSaves": {
"title": "Cloud Saves",
"description": "Specify the Ludusavi manifest used to back up this game to the cloud.",
"search": "Search"
}
},
"back": "Back to Library",
"collection": {

View File

@ -41,8 +41,8 @@
"micromark": "^4.0.1",
"nuxt": "^3.17.4",
"nuxt-security": "2.2.0",
"pg-tsquery": "^8.4.2",
"prisma": "^6.7.0",
"prisma-extension-pg-trgm": "^1.1.0",
"sanitize-filename": "^1.6.3",
"semver": "^7.7.1",
"stream-mime-type": "^2.0.0",

View File

@ -47,7 +47,7 @@
/>
</div>
<p class="mt-1 truncate text-sm text-zinc-400">
{{ task.value.log.at(-1) }}
{{ parseTaskLog(task.value.log.at(-1)).message }}
</p>
<NuxtLink
type="button"
@ -115,7 +115,7 @@
{{ task.id }}
</p>
<p class="mt-1 truncate text-sm text-zinc-400">
{{ parseTaskLog(task.log.at(-1) ?? "").message }}
{{ parseTaskLog(task.log.at(-1)).message }}
</p>
<NuxtLink
type="button"

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Game" ADD COLUMN "ludusaviEntryName" TEXT;
-- AddForeignKey
ALTER TABLE "Game" ADD CONSTRAINT "Game_ludusaviEntryName_fkey" FOREIGN KEY ("ludusaviEntryName") REFERENCES "LudusaviEntry"("name") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,30 @@
/*
Warnings:
- You are about to drop the column `ludusaviEntryName` on the `Game` table. All the data in the column will be lost.
*/
-- CreateEnum
CREATE TYPE "CloudSaveType" AS ENUM ('Ludusavi', 'LuaScript');
-- DropForeignKey
ALTER TABLE "Game" DROP CONSTRAINT "Game_ludusaviEntryName_fkey";
-- AlterTable
ALTER TABLE "Game" DROP COLUMN "ludusaviEntryName";
-- CreateTable
CREATE TABLE "CloudSaveConfiguration" (
"gameId" TEXT NOT NULL,
"type" "CloudSaveType" NOT NULL,
"ludusaviEntryName" TEXT,
"scriptContent" TEXT,
CONSTRAINT "CloudSaveConfiguration_pkey" PRIMARY KEY ("gameId")
);
-- AddForeignKey
ALTER TABLE "CloudSaveConfiguration" ADD CONSTRAINT "CloudSaveConfiguration_gameId_fkey" FOREIGN KEY ("gameId") REFERENCES "Game"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CloudSaveConfiguration" ADD CONSTRAINT "CloudSaveConfiguration_ludusaviEntryName_fkey" FOREIGN KEY ("ludusaviEntryName") REFERENCES "LudusaviEntry"("name") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,36 @@
enum CloudSaveType {
Ludusavi
LuaScript
}
model CloudSaveConfiguration {
gameId String @id
game Game @relation(fields: [gameId], references: [id])
type CloudSaveType
ludusaviEntryName String?
ludusaviEntry LudusaviEntry? @relation(fields: [ludusaviEntryName], references: [name])
scriptContent String?
}
model LudusaviEntry {
name String @id
steamId String?
entries LudusaviPlatformEntry[]
configurations CloudSaveConfiguration[]
}
model LudusaviPlatformEntry {
ludusaviEntryName String
ludusaviEntry LudusaviEntry @relation(fields: [ludusaviEntryName], references: [name])
platform Platform
files String[]
registry String[]
@@id([ludusaviEntryName, platform])
}

View File

@ -46,6 +46,8 @@ model Game {
developers Company[] @relation(name: "developers")
publishers Company[] @relation(name: "publishers")
cloudSaveConfiguration CloudSaveConfiguration?
@@unique([metadataSource, metadataId], name: "metadataKey")
@@unique([libraryId, libraryPath], name: "libraryKey")
}

View File

@ -1,18 +0,0 @@
model LudusaviEntry {
name String @id
steamId String?
entries LudusaviPlatformEntry[]
}
model LudusaviPlatformEntry {
ludusaviEntryName String
ludusaviEntry LudusaviEntry @relation(fields: [ludusaviEntryName], references: [name])
platform Platform
files String[]
registry String[]
@@id([ludusaviEntryName, platform])
}

View File

@ -0,0 +1,69 @@
import { type } from "arktype";
import { CloudSaveType } from "~/prisma/client";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
const UpdateEntry = type({
id: "string",
name: "string",
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, [
"game:cloudsaves:update",
]);
if (!allowed) throw createError({ statusCode: 403 });
const body = await readDropValidatedBody(h3, UpdateEntry);
const entry = await prisma.ludusaviEntry.findUnique({
where: {
name: body.name,
},
include: {
entries: true,
},
});
if (!entry)
throw createError({
statusCode: 400,
statusMessage: "Invalid Ludusavi name",
});
const configuration = await prisma.cloudSaveConfiguration.upsert({
where: {
gameId: body.id,
},
create: {
gameId: body.id,
type: CloudSaveType.Ludusavi,
ludusaviEntryName: entry.name,
},
update: {
type: CloudSaveType.Ludusavi,
ludusaviEntryName: entry.name,
},
include: {
ludusaviEntry: {
include: {
entries: true,
},
},
},
});
await prisma.game.update({
where: {
id: body.id,
},
data: {
cloudSaveConfiguration: {
connect: {
gameId: body.id,
},
},
},
});
return configuration;
});

View File

@ -0,0 +1,21 @@
/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */
/* eslint-disable @typescript-eslint/no-extra-non-null-assertion */
import type { LudusaviEntry } from "~/prisma/client";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["game:cloudsaves:read"]);
if (!allowed) throw createError({ statusCode: 403 });
const query = getQuery(h3);
const name = query.name?.toString()!!;
// Remove all non alphanumberical characters
const sanatisedName = name.replaceAll(/[^a-zA-Z\d\s:]/g, "");
const results = await prisma.$queryRaw`SELECT * FROM "LudusaviEntry" ORDER BY SIMILARITY(name, ${sanatisedName}) DESC LIMIT 20;`;
return results as Array<LudusaviEntry>;
});

View File

@ -30,6 +30,15 @@ export default defineEventHandler(async (h3) => {
delta: true,
},
},
cloudSaveConfiguration: {
include: {
ludusaviEntry: {
include: {
entries: true,
},
},
},
},
},
});

View File

@ -68,6 +68,10 @@ export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
"game:image:new": "Upload an image for a game.",
"game:image:delete": "Delete an image for a game.",
"game:cloudsaves:read":
"Read cloud save data and search through Ludusavi database.",
"game:cloudsaves:update": "Update the Ludusavi manifest entry for a game.",
"import:version:read":
"Fetch versions to be imported, and information about versions to be imported.",
"import:version:new": "Import a game version.",

View File

@ -63,6 +63,10 @@ export const systemACLs = [
"game:image:new",
"game:image:delete",
"game:cloudsaves:read",
"game:cloudsaves:update",
"import:version:read",
"import:version:new",

View File

@ -1,7 +1,8 @@
import { PrismaClient } from "~/prisma/client";
import { withPgTrgm } from "prisma-extension-pg-trgm";
const prismaClientSingleton = () => {
return new PrismaClient({});
return new PrismaClient({}).$extends(withPgTrgm({ logQueries: true }));
};
declare const globalThis: {

View File

@ -35,9 +35,7 @@ export default defineDropTask({
progress(currentProgress);
const entries = Object.entries(manifest).filter(
([, data]) => data.files || data.registry,
);
const entries = Object.entries(manifest);
const increment = 90 / entries.length;
for (const [name, data] of entries) {
const iterableFiles = data.files ? Object.entries(data.files) : undefined;
@ -55,7 +53,10 @@ export default defineDropTask({
files: findFilesForOperatingSystem("windows"),
};
if (windowsData.registry || windowsData.files) {
if (
windowsData.registry ||
(windowsData.files && windowsData.files.length > 0)
) {
const create: ConnectOrCreateShorthand = {
where: {
ludusaviEntryName_platform: {
@ -79,7 +80,7 @@ export default defineDropTask({
files: findFilesForOperatingSystem("linux"),
};
if (linuxData.files) {
if (linuxData.files && linuxData.files.length > 0) {
const create: ConnectOrCreateShorthand = {
where: {
ludusaviEntryName_platform: {
@ -101,7 +102,7 @@ export default defineDropTask({
files: findFilesForOperatingSystem("mac"),
};
if (macData.files) {
if (macData.files && macData.files.length > 0) {
const create: ConnectOrCreateShorthand = {
where: {
ludusaviEntryName_platform: {

View File

@ -1,32 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */
/* eslint-disable @typescript-eslint/no-extra-non-null-assertion */
import prisma from "../internal/db/database";
import { parsePlatform } from "../internal/utils/parseplatform";
import tsquery from "pg-tsquery";
export default defineEventHandler(async (h3) => {
const query = getQuery(h3);
const name = query.name?.toString()!!;
const platform = parsePlatform(query.platform?.toString()!!)!!;
const parser = tsquery({});
return await prisma.ludusaviEntry.findMany({
orderBy: {
_relevance: {
fields: ["name"],
search: parser(name),
sort: "desc",
},
},
include: {
entries: {
where: {
platform,
},
},
},
take: 20,
});
});

View File

@ -1,6 +1,7 @@
import type { TaskLog } from "~/server/internal/tasks";
export function parseTaskLog(logStr: string): typeof TaskLog.infer {
export function parseTaskLog(logStr: string | undefined): typeof TaskLog.infer {
if (!logStr) return { message: "", timestamp: "" };
const log = JSON.parse(logStr);
return {

View File

@ -6987,11 +6987,6 @@ perfect-debounce@^1.0.0:
resolved "https://registry.yarnpkg.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz#9c2e8bc30b169cc984a58b7d5b28049839591d2a"
integrity sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==
pg-tsquery@^8.4.2:
version "8.4.2"
resolved "https://registry.yarnpkg.com/pg-tsquery/-/pg-tsquery-8.4.2.tgz#f28e6242f15f4d8535ac08a0f9083ce04e42e1e4"
integrity sha512-waJSlBIKE+shDhuDpuQglTH6dG5zakDhnrnxu8XB8V5c7yoDSuy4pOxY6t2dyoxTjaKMcMmlByJN7n9jx9eqMA==
picocolors@^1.0.0, picocolors@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
@ -7330,6 +7325,11 @@ pretty-bytes@^6.1.1:
resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-6.1.1.tgz#38cd6bb46f47afbf667c202cfc754bffd2016a3b"
integrity sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==
prisma-extension-pg-trgm@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/prisma-extension-pg-trgm/-/prisma-extension-pg-trgm-1.1.0.tgz#79929264bbb4ceaf6d9ad186543f3b2f52b138bc"
integrity sha512-EXRsW0OMoQU/5aQax67FLkU0jonfiF++R3pylj5lYvXicfVD1GvkEDB5hDnGzPwfIMwryT2RLbRplndNFpZ49w==
prisma@^6.7.0:
version "6.9.0"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-6.9.0.tgz#c8bce4fc63f0c6972f3868692e649bb163fd807d"