mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 08:45:38 +02:00
Compare commits
2 Commits
feat/home
...
feat/prici
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f92150cb9e | ||
|
|
c0c14d8c3c |
@@ -72,7 +72,7 @@ export default component$(() => {
|
||||
});
|
||||
|
||||
const lockPlay = $(async () => {
|
||||
if (!canvas.value || !playState.hasStream || playState.nestriLock) return;
|
||||
if (!canvas.value || !playState.hasStream) return;
|
||||
|
||||
try {
|
||||
await canvas.value.requestPointerLock();
|
||||
@@ -156,22 +156,18 @@ export default component$(() => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// eslint-disable-next-line qwik/no-use-visible-task
|
||||
useVisibleTask$(({ track }) => {
|
||||
track(() => canvas.value);
|
||||
if (!canvas.value) return; // Ensure canvas is available
|
||||
// Get query parameter "peerURL" from the URL
|
||||
let peerURL = new URLSearchParams(window.location.search).get("peerURL");
|
||||
if (!peerURL || peerURL.length <= 0) {
|
||||
peerURL = "/dnsaddr/relay.dathorse.com/p2p/12D3KooWPK4v5wKYNYx9oXWjqLM8Xix6nm13o91j1Feqq98fLBsw";
|
||||
}
|
||||
|
||||
setupPointerLockListener();
|
||||
try {
|
||||
if (!playState.video) {
|
||||
playState.video = document.createElement("video") as HTMLVideoElement;
|
||||
playState.video = document.createElement("video") as HTMLVideoElement
|
||||
playState.video.style.visibility = "hidden";
|
||||
playState.webrtc = noSerialize(new WebRTCStream(peerURL, id, async (mediaStream) => {
|
||||
playState.webrtc = noSerialize(new WebRTCStream("https://relay.dathorse.com", id, async (mediaStream) => {
|
||||
if (playState.video && mediaStream && playState.video.srcObject === null) {
|
||||
console.log("Setting mediastream");
|
||||
playState.video.srcObject = mediaStream;
|
||||
|
||||
@@ -116,8 +116,30 @@ export default component$(() => {
|
||||
|
||||
return (
|
||||
<div class="w-screen relative">
|
||||
<TitleSection client:load title="Pricing" description={"We're growing at the speed of trust. Choose a price that feels right for you and help support Nestri"} />
|
||||
<MotionComponent
|
||||
<TitleSection client:load title="Pricing" description={"The biggest bang, binge, and blast for your buck"} />
|
||||
|
||||
<Footer client:load>
|
||||
<div class="w-full flex justify-center flex-col items-center gap-3">
|
||||
<Link href="https://discord.gg/6um5K6jrYj" prefetch={false} class="flex font-bricolage text-sm sm:text-base rounded-full bg-primary-500 px-5 py-4 font-semibold text-white transition-all hover:scale-105 active:scale-95 sm:px-6" >
|
||||
Join our Discord
|
||||
</Link>
|
||||
<div class="mt-6 flex w-full items-center justify-center gap-2 text-xs sm:text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
<span class="hover:text-primary-500 transition-colors duration-200">
|
||||
<Link rel="noreferrer" href="/terms" >Terms of Service</Link></span>
|
||||
<span class="text-gray-400 dark:text-gray-600">•</span>
|
||||
<span class="hover:text-primary-500 transition-colors duration-200" >
|
||||
<Link href="/privacy">Privacy Policy</Link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Footer>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* <MotionComponent
|
||||
initial={{ opacity: 0, y: 100 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
@@ -144,7 +166,6 @@ export default component$(() => {
|
||||
</div>
|
||||
<div class="flex flex-col w-full">
|
||||
<p class="text-[4rem] leading-[1] font-medium font-title"> Free </p>
|
||||
{/**FIXME: Add the link to the docs here */}
|
||||
<a href={CONSTANTS.githubLink} ref={v => bookRef.value = v} class="h-[154px] w-full flex items-start pt-4 justify-center overflow-hidden">
|
||||
<Book textColor="#FFF"
|
||||
bgColor="#FF4F01"
|
||||
@@ -519,21 +540,4 @@ export default component$(() => {
|
||||
</section>
|
||||
</div>
|
||||
</MotionComponent>
|
||||
<Footer client:load>
|
||||
<div class="w-full flex justify-center flex-col items-center gap-3">
|
||||
<Link href="https://discord.gg/6um5K6jrYj" prefetch={false} class="flex font-bricolage text-sm sm:text-base rounded-full bg-primary-500 px-5 py-4 font-semibold text-white transition-all hover:scale-105 active:scale-95 sm:px-6" >
|
||||
Join our Discord
|
||||
</Link>
|
||||
<div class="mt-6 flex w-full items-center justify-center gap-2 text-xs sm:text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
<span class="hover:text-primary-500 transition-colors duration-200">
|
||||
<Link rel="noreferrer" href="/terms" >Terms of Service</Link></span>
|
||||
<span class="text-gray-400 dark:text-gray-600">•</span>
|
||||
<span class="hover:text-primary-500 transition-colors duration-200" >
|
||||
<Link href="/privacy">Privacy Policy</Link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Footer>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
*/
|
||||
@@ -10,19 +10,21 @@ WORKDIR /relay
|
||||
# TODO: Switch running layer to just alpine (doesn't need golang dev stack)
|
||||
|
||||
# ENV flags
|
||||
ENV REGEN_IDENTITY=false
|
||||
ENV VERBOSE=false
|
||||
ENV DEBUG=false
|
||||
ENV ENDPOINT_PORT=8088
|
||||
ENV WEBRTC_UDP_START=0
|
||||
ENV WEBRTC_UDP_END=0
|
||||
ENV MESH_PORT=8089
|
||||
ENV WEBRTC_UDP_START=10000
|
||||
ENV WEBRTC_UDP_END=20000
|
||||
ENV STUN_SERVER="stun.l.google.com:19302"
|
||||
ENV WEBRTC_UDP_MUX=8088
|
||||
ENV WEBRTC_NAT_IPS=""
|
||||
ENV AUTO_ADD_LOCAL_IP=true
|
||||
ENV PERSIST_DIR="./persist-data"
|
||||
ENV TLS_CERT=""
|
||||
ENV TLS_KEY=""
|
||||
|
||||
EXPOSE $ENDPOINT_PORT
|
||||
EXPOSE $MESH_PORT
|
||||
EXPOSE $WEBRTC_UDP_START-$WEBRTC_UDP_END/udp
|
||||
EXPOSE $WEBRTC_UDP_MUX/udp
|
||||
|
||||
|
||||
@@ -132,11 +132,9 @@ RUN sed -i \
|
||||
# Core system components
|
||||
RUN --mount=type=cache,target=/var/cache/pacman/pkg \
|
||||
pacman -Sy --needed --noconfirm \
|
||||
vulkan-intel lib32-vulkan-intel vpl-gpu-rt \
|
||||
vulkan-radeon lib32-vulkan-radeon \
|
||||
mesa \
|
||||
vulkan-intel lib32-vulkan-intel vpl-gpu-rt mesa \
|
||||
steam steam-native-runtime gtk3 lib32-gtk3 \
|
||||
sudo xorg-xwayland seatd libinput gamescope mangohud \
|
||||
sudo xorg-xwayland seatd libinput labwc wlr-randr gamescope mangohud \
|
||||
libssh2 curl wget \
|
||||
pipewire pipewire-pulse pipewire-alsa wireplumber \
|
||||
noto-fonts-cjk supervisor jq chwd lshw pacman-contrib && \
|
||||
@@ -168,7 +166,8 @@ ENV USER="nestri" \
|
||||
USER_PWD="nestri1234" \
|
||||
XDG_RUNTIME_DIR=/run/user/1000 \
|
||||
HOME=/home/nestri \
|
||||
NVIDIA_DRIVER_CAPABILITIES=all
|
||||
NVIDIA_DRIVER_CAPABILITIES=all \
|
||||
NVIDIA_VISIBLE_DEVICES=all
|
||||
|
||||
RUN mkdir -p /home/${USER} && \
|
||||
groupadd -g ${GID} ${USER} && \
|
||||
|
||||
1
docker-compose.yml
Normal file
1
docker-compose.yml
Normal file
@@ -0,0 +1 @@
|
||||
#FIXME: A simple docker-compose file for running the MoQ relay and the cachyos server
|
||||
117
infra/api.ts
117
infra/api.ts
@@ -1,28 +1,19 @@
|
||||
import { bus } from "./bus";
|
||||
import { vpc } from "./vpc";
|
||||
import { auth } from "./auth";
|
||||
import { domain } from "./dns";
|
||||
import { secret } from "./secret";
|
||||
import { cluster } from "./cluster";
|
||||
import { postgres } from "./postgres";
|
||||
|
||||
const urls = new sst.Linkable("Urls", {
|
||||
properties: {
|
||||
api: `https://api.${domain}`,
|
||||
auth: `https://auth.${domain}`,
|
||||
site: $dev ? "http://localhost:3000" : `https://console.${domain}`,
|
||||
}
|
||||
})
|
||||
|
||||
const apiFn = new sst.aws.Function("ApiFn", {
|
||||
vpc,
|
||||
handler: "packages/functions/src/api/index.handler",
|
||||
streaming: !$dev,
|
||||
export const api = new sst.aws.Service("Api", {
|
||||
cluster,
|
||||
cpu: $app.stage === "production" ? "2 vCPU" : undefined,
|
||||
memory: $app.stage === "production" ? "4 GB" : undefined,
|
||||
command: ["bun", "run", "./src/api/index.ts"],
|
||||
link: [
|
||||
bus,
|
||||
urls,
|
||||
auth,
|
||||
postgres,
|
||||
secret.SteamApiKey,
|
||||
secret.PolarSecret,
|
||||
secret.PolarWebhookSecret,
|
||||
secret.NestriFamilyMonthly,
|
||||
@@ -31,90 +22,42 @@ const apiFn = new sst.aws.Function("ApiFn", {
|
||||
secret.NestriProMonthly,
|
||||
secret.NestriProYearly,
|
||||
],
|
||||
url: true,
|
||||
});
|
||||
|
||||
const provider = new aws.Provider("UsEast1", { region: "us-east-1" });
|
||||
|
||||
const webAcl = new aws.wafv2.WebAcl(
|
||||
"ApiWaf",
|
||||
{
|
||||
scope: "CLOUDFRONT",
|
||||
defaultAction: {
|
||||
allow: {},
|
||||
image: {
|
||||
dockerfile: "packages/functions/Containerfile",
|
||||
},
|
||||
visibilityConfig: {
|
||||
cloudwatchMetricsEnabled: true,
|
||||
metricName: "api-rate-limit-metric",
|
||||
sampledRequestsEnabled: true,
|
||||
environment: {
|
||||
NO_COLOR: "1",
|
||||
},
|
||||
loadBalancer: {
|
||||
rules: [
|
||||
{
|
||||
name: "rate-limit-rule",
|
||||
priority: 1,
|
||||
action: {
|
||||
block: {
|
||||
customResponse: {
|
||||
responseCode: 429,
|
||||
customResponseBodyKey: "rate-limit-response",
|
||||
},
|
||||
},
|
||||
},
|
||||
statement: {
|
||||
rateBasedStatement: {
|
||||
limit: 2 * 60, // 2 rps per authorization header
|
||||
evaluationWindowSec: 60,
|
||||
aggregateKeyType: "CUSTOM_KEYS",
|
||||
customKeys: [
|
||||
{
|
||||
header: {
|
||||
name: "Authorization",
|
||||
textTransformations: [{ priority: 0, type: "NONE" }],
|
||||
},
|
||||
listen: "80/http",
|
||||
forward: "3001/http",
|
||||
},
|
||||
],
|
||||
},
|
||||
dev: {
|
||||
command: "bun dev:api",
|
||||
directory: "packages/functions",
|
||||
url: "http://localhost:3001",
|
||||
},
|
||||
visibilityConfig: {
|
||||
cloudwatchMetricsEnabled: true,
|
||||
metricName: "rate-limit-rule-metric",
|
||||
sampledRequestsEnabled: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
customResponseBodies: [
|
||||
{
|
||||
key: "rate-limit-response",
|
||||
content: JSON.stringify({
|
||||
type: "rate_limit",
|
||||
code: "too_many_requests",
|
||||
message: "Rate limit exceeded. Please try again later.",
|
||||
}),
|
||||
contentType: "APPLICATION_JSON",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ provider },
|
||||
);
|
||||
scaling:
|
||||
$app.stage === "production"
|
||||
? {
|
||||
min: 2,
|
||||
max: 10,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export const api = new sst.aws.Router("Api", {
|
||||
|
||||
export const apiRoute = new sst.aws.Router("ApiRoute", {
|
||||
routes: {
|
||||
"/*": apiFn.url,
|
||||
// I think api.url should work all the same
|
||||
"/*": api.nodes.loadBalancer.dnsName,
|
||||
},
|
||||
domain: {
|
||||
name: "api." + domain,
|
||||
dns: sst.cloudflare.dns(),
|
||||
},
|
||||
transform: {
|
||||
cdn(args) {
|
||||
if (!args.transform) {
|
||||
args.transform = {
|
||||
distribution: {},
|
||||
};
|
||||
}
|
||||
args.transform!.distribution = {
|
||||
webAclId: webAcl.arn,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
@@ -1,12 +1,15 @@
|
||||
import { bus } from "./bus";
|
||||
import { vpc } from "./vpc";
|
||||
import { domain } from "./dns";
|
||||
import { secret } from "./secret";
|
||||
import { cluster } from "./cluster";
|
||||
import { postgres } from "./postgres";
|
||||
|
||||
export const auth = new sst.aws.Auth("Auth", {
|
||||
authorizer: {
|
||||
vpc,
|
||||
//FIXME: Use a shared /tmp folder
|
||||
export const auth = new sst.aws.Service("Auth", {
|
||||
cluster,
|
||||
cpu: $app.stage === "production" ? "1 vCPU" : undefined,
|
||||
memory: $app.stage === "production" ? "2 GB" : undefined,
|
||||
command: ["bun", "run", "./src/auth.ts"],
|
||||
link: [
|
||||
bus,
|
||||
postgres,
|
||||
@@ -16,17 +19,48 @@ export const auth = new sst.aws.Auth("Auth", {
|
||||
secret.GithubClientSecret,
|
||||
secret.DiscordClientSecret,
|
||||
],
|
||||
image: {
|
||||
dockerfile: "packages/functions/Containerfile",
|
||||
},
|
||||
environment: {
|
||||
NO_COLOR: "1",
|
||||
STORAGE: $dev ? "/tmp/persist.json" : "/mnt/efs/persist.json"
|
||||
},
|
||||
loadBalancer: {
|
||||
rules: [
|
||||
{
|
||||
listen: "80/http",
|
||||
forward: "3002/http",
|
||||
},
|
||||
],
|
||||
},
|
||||
permissions: [
|
||||
{
|
||||
actions: ["ses:SendEmail"],
|
||||
resources: ["*"],
|
||||
},
|
||||
],
|
||||
handler: "packages/functions/src/auth/index.handler",
|
||||
dev: {
|
||||
command: "bun dev:auth",
|
||||
directory: "packages/functions",
|
||||
url: "http://localhost:3002",
|
||||
},
|
||||
scaling:
|
||||
$app.stage === "production"
|
||||
? {
|
||||
min: 2,
|
||||
max: 10,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export const authRoute = new sst.aws.Router("AuthRoute", {
|
||||
routes: {
|
||||
// I think auth.url should work all the same
|
||||
"/*": auth.nodes.loadBalancer.dnsName,
|
||||
},
|
||||
domain: {
|
||||
name: "auth." + domain,
|
||||
dns: sst.cloudflare.dns(),
|
||||
},
|
||||
forceUpgrade: "v2",
|
||||
});
|
||||
})
|
||||
63
infra/bus.ts
63
infra/bus.ts
@@ -1,70 +1,23 @@
|
||||
import { vpc } from "./vpc";
|
||||
import { secret } from "./secret";
|
||||
import { storage } from "./storage";
|
||||
// import { email } from "./email";
|
||||
import { allSecrets } from "./secret";
|
||||
import { postgres } from "./postgres";
|
||||
|
||||
export const dlq = new sst.aws.Queue("Dlq");
|
||||
|
||||
export const retryQueue = new sst.aws.Queue("RetryQueue");
|
||||
|
||||
export const bus = new sst.aws.Bus("Bus");
|
||||
|
||||
export const eventSub = bus.subscribe("Event", {
|
||||
bus.subscribe("Event", {
|
||||
vpc,
|
||||
handler: "packages/functions/src/events/index.handler",
|
||||
handler: "./packages/functions/src/event/event.handler",
|
||||
link: [
|
||||
// email,
|
||||
bus,
|
||||
storage,
|
||||
postgres,
|
||||
retryQueue,
|
||||
secret.PolarSecret,
|
||||
secret.SteamApiKey
|
||||
],
|
||||
environment: {
|
||||
RETRIES: "2",
|
||||
},
|
||||
memory: "3002 MB",// For faster processing of large(r) images
|
||||
timeout: "10 minutes",
|
||||
});
|
||||
|
||||
new aws.lambda.FunctionEventInvokeConfig("EventConfig", {
|
||||
functionName: $resolve([eventSub.nodes.function.name]).apply(
|
||||
([name]) => name,
|
||||
),
|
||||
maximumRetryAttempts: 1,
|
||||
destinationConfig: {
|
||||
onFailure: {
|
||||
destination: retryQueue.arn,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
retryQueue.subscribe({
|
||||
vpc,
|
||||
handler: "packages/functions/src/queues/retry.handler",
|
||||
timeout: "30 seconds",
|
||||
environment: {
|
||||
RETRIER_QUEUE_URL: retryQueue.url,
|
||||
},
|
||||
link: [
|
||||
dlq,
|
||||
retryQueue,
|
||||
eventSub.nodes.function,
|
||||
...allSecrets
|
||||
],
|
||||
timeout: "5 minutes",
|
||||
permissions: [
|
||||
{
|
||||
actions: ["lambda:GetFunction", "lambda:InvokeFunction"],
|
||||
resources: [
|
||||
$interpolate`arn:aws:lambda:${aws.getRegionOutput().name}:${aws.getCallerIdentityOutput().accountId}:function:*`,
|
||||
],
|
||||
actions: ["ses:SendEmail"],
|
||||
resources: ["*"],
|
||||
},
|
||||
],
|
||||
transform: {
|
||||
function: {
|
||||
deadLetterConfig: {
|
||||
targetArn: dlq.arn,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
18
infra/cdn.ts
18
infra/cdn.ts
@@ -1,18 +0,0 @@
|
||||
import { domain } from "./dns";
|
||||
import { storage } from "./storage";
|
||||
|
||||
export const cdn = new sst.aws.Router("CDNRouter", {
|
||||
routes: {
|
||||
"/*": {
|
||||
bucket: storage,
|
||||
rewrite: {
|
||||
regex: "^/([a-zA-Z0-9_-]+)$",
|
||||
to: "/public/$1"
|
||||
},
|
||||
},
|
||||
},
|
||||
domain: {
|
||||
name: "cdn." + domain,
|
||||
dns: sst.cloudflare.dns()
|
||||
}
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import { vpc } from "./vpc";
|
||||
import { isPermanentStage } from "./stage";
|
||||
|
||||
// TODO: Add a dev db to use, this will help with running zero locally... and testing it
|
||||
export const postgres = new sst.aws.Aurora("Database", {
|
||||
vpc,
|
||||
engine: "postgres",
|
||||
scaling: {
|
||||
scaling: isPermanentStage
|
||||
? undefined
|
||||
: {
|
||||
min: "0 ACU",
|
||||
max: "1 ACU",
|
||||
},
|
||||
@@ -45,20 +49,20 @@ new sst.x.DevCommand("Studio", {
|
||||
},
|
||||
});
|
||||
|
||||
// const migrator = new sst.aws.Function("DatabaseMigrator", {
|
||||
// handler: "packages/functions/src/migrator.handler",
|
||||
// link: [postgres],
|
||||
// copyFiles: [
|
||||
// {
|
||||
// from: "packages/core/migrations",
|
||||
// to: "./migrations",
|
||||
// },
|
||||
// ],
|
||||
// });
|
||||
const migrator = new sst.aws.Function("DatabaseMigrator", {
|
||||
handler: "packages/functions/src/migrator.handler",
|
||||
link: [postgres],
|
||||
copyFiles: [
|
||||
{
|
||||
from: "packages/core/migrations",
|
||||
to: "./migrations",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// if (!$dev) {
|
||||
// new aws.lambda.Invocation("DatabaseMigratorInvocation", {
|
||||
// input: Date.now().toString(),
|
||||
// functionName: migrator.name,
|
||||
// });
|
||||
// }
|
||||
if (!$dev) {
|
||||
new aws.lambda.Invocation("DatabaseMigratorInvocation", {
|
||||
input: Date.now().toString(),
|
||||
functionName: migrator.name,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// import { auth } from "./auth";
|
||||
import { auth } from "./auth";
|
||||
import { postgres } from "./postgres";
|
||||
|
||||
export const device = new sst.aws.Realtime("Realtime", {
|
||||
authorizer: {
|
||||
link: [ postgres],
|
||||
link: [auth, postgres],
|
||||
handler: "packages/functions/src/realtime/authorizer.handler"
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
export const secret = {
|
||||
PolarSecret: new sst.Secret("PolarSecret", process.env.POLAR_API_KEY),
|
||||
SteamApiKey: new sst.Secret("SteamApiKey"),
|
||||
GithubClientID: new sst.Secret("GithubClientID"),
|
||||
DiscordClientID: new sst.Secret("DiscordClientID"),
|
||||
PolarWebhookSecret: new sst.Secret("PolarWebhookSecret"),
|
||||
|
||||
7
infra/steam.ts
Normal file
7
infra/steam.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
new sst.x.DevCommand("Steam", {
|
||||
dev: {
|
||||
command: "bun dev",
|
||||
directory: "packages/steam",
|
||||
autostart: true,
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1 @@
|
||||
export const storage = new sst.aws.Bucket("Storage", {
|
||||
access: "cloudfront"
|
||||
});
|
||||
|
||||
export const zeroStorage = new sst.aws.Bucket("ZeroStorage");
|
||||
export const storage = new sst.aws.Bucket("Storage");
|
||||
@@ -1,6 +1,5 @@
|
||||
// This is the website part where people play and connect
|
||||
import { api } from "./api";
|
||||
import { cdn } from "./cdn";
|
||||
import { auth } from "./auth";
|
||||
import { zero } from "./zero";
|
||||
import { domain } from "./dns";
|
||||
@@ -17,7 +16,6 @@ new sst.aws.StaticSite("Web", {
|
||||
},
|
||||
environment: {
|
||||
VITE_API_URL: api.url,
|
||||
VITE_CDN_URL: cdn.url,
|
||||
VITE_STAGE: $app.stage,
|
||||
VITE_AUTH_URL: auth.url,
|
||||
VITE_ZERO_URL: zero.url,
|
||||
|
||||
110
infra/zero.ts
110
infra/zero.ts
@@ -1,10 +1,12 @@
|
||||
import { vpc } from "./vpc";
|
||||
import { auth } from "./auth";
|
||||
import { domain } from "./dns";
|
||||
import { readFileSync } from "fs";
|
||||
import { cluster } from "./cluster";
|
||||
import { storage } from "./storage";
|
||||
import { postgres } from "./postgres";
|
||||
import { zeroStorage } from "./storage";
|
||||
|
||||
// const connectionString = $interpolate`postgresql://${postgres.username}:${postgres.password}@${postgres.host}/${postgres.database}`
|
||||
const connectionString = $interpolate`postgresql://${postgres.username}:${postgres.password}@${postgres.host}:${postgres.port}/${postgres.database}`;
|
||||
|
||||
const tag = $dev
|
||||
@@ -24,15 +26,13 @@ const zeroEnv = {
|
||||
ZERO_CHANGE_DB: connectionString,
|
||||
ZERO_REPLICA_FILE: "/tmp/nestri.db",
|
||||
ZERO_LITESTREAM_RESTORE_PARALLELISM: "64",
|
||||
ZERO_APP_ID: $app.stage,
|
||||
ZERO_SHARD_ID: $app.stage,
|
||||
ZERO_AUTH_JWKS_URL: $interpolate`${auth.url}/.well-known/jwks.json`,
|
||||
ZERO_INITIAL_SYNC_ROW_BATCH_SIZE: "30000",
|
||||
NODE_OPTIONS: "--max-old-space-size=8192",
|
||||
...($dev
|
||||
? {
|
||||
}
|
||||
: {
|
||||
ZERO_LITESTREAM_BACKUP_URL: $interpolate`s3://${zeroStorage.name}/zero/0`,
|
||||
ZERO_LITESTREAM_BACKUP_URL: $interpolate`s3://${storage.name}/zero`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -41,12 +41,15 @@ const replicationManager = !$dev
|
||||
? new sst.aws.Service(`ZeroReplication`, {
|
||||
cluster,
|
||||
wait: true,
|
||||
cpu: "0.5 vCPU",
|
||||
memory: "1 GB",
|
||||
capacity: "spot",
|
||||
...($app.stage === "production"
|
||||
? {
|
||||
cpu: "2 vCPU",
|
||||
memory: "4 GB",
|
||||
}
|
||||
: {}),
|
||||
architecture: "arm64",
|
||||
image: zeroEnv.ZERO_IMAGE_URL,
|
||||
link: [zeroStorage, postgres],
|
||||
link: [storage, postgres],
|
||||
health: {
|
||||
command: ["CMD-SHELL", "curl -f http://localhost:4849/ || exit 1"],
|
||||
interval: "5 seconds",
|
||||
@@ -81,53 +84,59 @@ const replicationManager = !$dev
|
||||
}) : undefined;
|
||||
|
||||
// Permissions deployment
|
||||
// const permissions = new sst.aws.Function(
|
||||
// "ZeroPermissions",
|
||||
// {
|
||||
// vpc,
|
||||
// link: [postgres],
|
||||
// handler: "packages/functions/src/zero.handler",
|
||||
// // environment: { ["ZERO_UPSTREAM_DB"]: connectionString },
|
||||
// copyFiles: [{
|
||||
// from: "packages/zero/permissions.sql",
|
||||
// to: "./.permissions.sql"
|
||||
// }],
|
||||
// }
|
||||
// );
|
||||
const permissions = new sst.aws.Function(
|
||||
"ZeroPermissions",
|
||||
{
|
||||
vpc,
|
||||
link: [postgres],
|
||||
handler: "packages/functions/src/zero.handler",
|
||||
// environment: { ["ZERO_UPSTREAM_DB"]: connectionString },
|
||||
copyFiles: [{
|
||||
from: "packages/zero/.permissions.sql",
|
||||
to: "./.permissions.sql"
|
||||
}],
|
||||
}
|
||||
);
|
||||
|
||||
// if (replicationManager) {
|
||||
// new aws.lambda.Invocation(
|
||||
// "ZeroPermissionsInvocation",
|
||||
// {
|
||||
// input: Date.now().toString(),
|
||||
// functionName: permissions.name,
|
||||
// },
|
||||
// { dependsOn: replicationManager }
|
||||
// );
|
||||
// // new command.local.Command(
|
||||
// // "ZeroPermission",
|
||||
// // {
|
||||
// // dir: process.cwd() + "/packages/zero",
|
||||
// // environment: {
|
||||
// // ZERO_UPSTREAM_DB: connectionString,
|
||||
// // },
|
||||
// // create: "bun run zero-deploy-permissions",
|
||||
// // triggers: [Date.now()],
|
||||
// // },
|
||||
// // {
|
||||
// // dependsOn: [replicationManager],
|
||||
// // },
|
||||
// // );
|
||||
// }
|
||||
if (replicationManager) {
|
||||
new aws.lambda.Invocation(
|
||||
"ZeroPermissionsInvocation",
|
||||
{
|
||||
input: Date.now().toString(),
|
||||
functionName: permissions.name,
|
||||
},
|
||||
{ dependsOn: replicationManager }
|
||||
);
|
||||
// new command.local.Command(
|
||||
// "ZeroPermission",
|
||||
// {
|
||||
// dir: process.cwd() + "/packages/zero",
|
||||
// environment: {
|
||||
// ZERO_UPSTREAM_DB: connectionString,
|
||||
// },
|
||||
// create: "bun run zero-deploy-permissions",
|
||||
// triggers: [Date.now()],
|
||||
// },
|
||||
// {
|
||||
// dependsOn: [replicationManager],
|
||||
// },
|
||||
// );
|
||||
}
|
||||
|
||||
export const zero = new sst.aws.Service("Zero", {
|
||||
cluster,
|
||||
image: zeroEnv.ZERO_IMAGE_URL,
|
||||
link: [zeroStorage, postgres],
|
||||
link: [storage, postgres],
|
||||
architecture: "arm64",
|
||||
cpu: "0.5 vCPU",
|
||||
memory: "1 GB",
|
||||
capacity: "spot",
|
||||
...($app.stage === "production"
|
||||
? {
|
||||
cpu: "2 vCPU",
|
||||
memory: "4 GB",
|
||||
capacity: "spot"
|
||||
}
|
||||
: {
|
||||
capacity: "spot"
|
||||
}),
|
||||
environment: {
|
||||
...zeroEnv,
|
||||
...($dev
|
||||
@@ -142,6 +151,7 @@ export const zero = new sst.aws.Service("Zero", {
|
||||
ZERO_CVR_MAX_CONNS: "160",
|
||||
}),
|
||||
},
|
||||
wait: true,
|
||||
health: {
|
||||
retries: 3,
|
||||
command: ["CMD-SHELL", "curl -f http://localhost:4848/ || exit 1"],
|
||||
|
||||
29
nestri.sln
Normal file
29
nestri.sln
Normal file
@@ -0,0 +1,29 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.5.2.0
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "packages", "packages", "{809F86A1-1C4C-B159-0CD4-DF9D33D876CE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "steam", "packages\steam\steam.csproj", "{96118F95-BF02-0ED3-9042-36FA1B740D67}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{96118F95-BF02-0ED3-9042-36FA1B740D67} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {526AD703-4D15-43CF-B7C0-83F10D3158DB}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -3,7 +3,6 @@
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "4.20240821.1",
|
||||
"@pulumi/pulumi": "^3.134.0",
|
||||
"@tsconfig/node22": "^22.0.1",
|
||||
"@types/aws-lambda": "8.10.147",
|
||||
"prettier": "^3.2.5",
|
||||
"typescript": "^5.4.5"
|
||||
@@ -19,17 +18,17 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@openauthjs/openauth": "0.4.3",
|
||||
"steam-session": "1.9.3"
|
||||
"@rocicorp/zero": "0.16.2025022000"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@macaron-css/solid@1.5.3": "patches/@macaron-css%2Fsolid@1.5.3.patch",
|
||||
"drizzle-orm@0.36.1": "patches/drizzle-orm@0.36.1.patch",
|
||||
"steam-session@1.9.3": "patches/steam-session@1.9.3.patch"
|
||||
"drizzle-orm@0.36.1": "patches/drizzle-orm@0.36.1.patch"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"core-js-pure",
|
||||
"esbuild",
|
||||
"protobufjs",
|
||||
"@rocicorp/zero-sqlite3",
|
||||
"workerd"
|
||||
],
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
CREATE TYPE "public"."member_role" AS ENUM('child', 'adult');--> statement-breakpoint
|
||||
CREATE TYPE "public"."steam_status" AS ENUM('online', 'offline', 'dnd', 'playing');--> statement-breakpoint
|
||||
CREATE TABLE "steam_account_credentials" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"steam_id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"refresh_token" text NOT NULL,
|
||||
"expiry" timestamp with time zone NOT NULL,
|
||||
"username" varchar(255) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "friends_list" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"steam_id" varchar(255) NOT NULL,
|
||||
"friend_steam_id" varchar(255) NOT NULL,
|
||||
CONSTRAINT "friends_list_steam_id_friend_steam_id_pk" PRIMARY KEY("steam_id","friend_steam_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "members" (
|
||||
"id" char(30) NOT NULL,
|
||||
"team_id" char(30) NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"user_id" char(30),
|
||||
"steam_id" varchar(255) NOT NULL,
|
||||
"role" "member_role" NOT NULL,
|
||||
CONSTRAINT "members_id_team_id_pk" PRIMARY KEY("id","team_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "steam_accounts" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"steam_id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"user_id" char(30),
|
||||
"status" "steam_status" NOT NULL,
|
||||
"last_synced_at" timestamp with time zone NOT NULL,
|
||||
"real_name" varchar(255),
|
||||
"member_since" timestamp with time zone NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"profile_url" varchar(255),
|
||||
"username" varchar(255) NOT NULL,
|
||||
"avatar_hash" varchar(255) NOT NULL,
|
||||
"limitations" json NOT NULL,
|
||||
CONSTRAINT "idx_steam_username" UNIQUE("username")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "teams" (
|
||||
"id" char(30) PRIMARY KEY NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"owner_id" char(30) NOT NULL,
|
||||
"invite_code" varchar(10) NOT NULL,
|
||||
"slug" varchar(255) NOT NULL,
|
||||
"max_members" bigint NOT NULL,
|
||||
CONSTRAINT "idx_team_invite_code" UNIQUE("invite_code")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" char(30) PRIMARY KEY NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"email" varchar(255) NOT NULL,
|
||||
"avatar_url" text,
|
||||
"last_login" timestamp with time zone NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"polar_customer_id" varchar(255),
|
||||
CONSTRAINT "idx_user_email" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DROP TABLE "machine" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "member" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "steam" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "subscription" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "team" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "user" CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "steam_account_credentials" ADD CONSTRAINT "steam_account_credentials_steam_id_steam_accounts_steam_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("steam_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" ADD CONSTRAINT "friends_list_steam_id_steam_accounts_steam_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("steam_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" ADD CONSTRAINT "friends_list_friend_steam_id_steam_accounts_steam_id_fk" FOREIGN KEY ("friend_steam_id") REFERENCES "public"."steam_accounts"("steam_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "members" ADD CONSTRAINT "members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "members" ADD CONSTRAINT "members_steam_id_steam_accounts_steam_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("steam_id") ON DELETE cascade ON UPDATE restrict;--> statement-breakpoint
|
||||
ALTER TABLE "steam_accounts" ADD CONSTRAINT "steam_accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "teams" ADD CONSTRAINT "teams_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "teams" ADD CONSTRAINT "teams_slug_steam_accounts_username_fk" FOREIGN KEY ("slug") REFERENCES "public"."steam_accounts"("username") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_member_steam_id" ON "members" USING btree ("team_id","steam_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_member_user_id" ON "members" USING btree ("team_id","user_id") WHERE "members"."user_id" is not null;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_team_slug" ON "teams" USING btree ("slug");
|
||||
@@ -1,89 +0,0 @@
|
||||
CREATE TYPE "public"."compatibility" AS ENUM('high', 'mid', 'low', 'unknown');--> statement-breakpoint
|
||||
CREATE TYPE "public"."category_type" AS ENUM('tag', 'genre', 'publisher', 'developer');--> statement-breakpoint
|
||||
CREATE TYPE "public"."image_type" AS ENUM('heroArt', 'icon', 'logo', 'superHeroArt', 'poster', 'boxArt', 'screenshot', 'background');--> statement-breakpoint
|
||||
CREATE TABLE "base_games" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"slug" varchar(255) NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"release_date" timestamp with time zone NOT NULL,
|
||||
"size" json NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"primary_genre" text NOT NULL,
|
||||
"controller_support" text,
|
||||
"compatibility" "compatibility" DEFAULT 'unknown' NOT NULL,
|
||||
"score" numeric(2, 1) NOT NULL,
|
||||
CONSTRAINT "idx_base_games_slug" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "categories" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"slug" varchar(255) NOT NULL,
|
||||
"type" "category_type" NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
CONSTRAINT "categories_slug_type_pk" PRIMARY KEY("slug","type")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "games" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"base_game_id" varchar(255) NOT NULL,
|
||||
"category_slug" varchar(255) NOT NULL,
|
||||
"type" "category_type" NOT NULL,
|
||||
CONSTRAINT "games_base_game_id_category_slug_type_pk" PRIMARY KEY("base_game_id","category_slug","type")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "images" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"type" "image_type" NOT NULL,
|
||||
"image_hash" varchar(255) NOT NULL,
|
||||
"base_game_id" varchar(255) NOT NULL,
|
||||
"source_url" text NOT NULL,
|
||||
"position" integer DEFAULT 0 NOT NULL,
|
||||
"file_size" integer NOT NULL,
|
||||
"dimensions" json NOT NULL,
|
||||
"extracted_color" json NOT NULL,
|
||||
CONSTRAINT "images_image_hash_type_base_game_id_position_pk" PRIMARY KEY("image_hash","type","base_game_id","position")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "game_libraries" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"base_game_id" varchar(255) NOT NULL,
|
||||
"owner_id" varchar(255) NOT NULL,
|
||||
CONSTRAINT "game_libraries_base_game_id_owner_id_pk" PRIMARY KEY("base_game_id","owner_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "steam_accounts" RENAME COLUMN "steam_id" TO "id";--> statement-breakpoint
|
||||
ALTER TABLE "steam_account_credentials" DROP CONSTRAINT "steam_account_credentials_steam_id_steam_accounts_steam_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" DROP CONSTRAINT "friends_list_steam_id_steam_accounts_steam_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" DROP CONSTRAINT "friends_list_friend_steam_id_steam_accounts_steam_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "members" DROP CONSTRAINT "members_steam_id_steam_accounts_steam_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "games" ADD CONSTRAINT "games_base_game_id_base_games_id_fk" FOREIGN KEY ("base_game_id") REFERENCES "public"."base_games"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "games" ADD CONSTRAINT "games_categories_fkey" FOREIGN KEY ("category_slug","type") REFERENCES "public"."categories"("slug","type") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "images" ADD CONSTRAINT "images_base_game_id_base_games_id_fk" FOREIGN KEY ("base_game_id") REFERENCES "public"."base_games"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD CONSTRAINT "game_libraries_base_game_id_base_games_id_fk" FOREIGN KEY ("base_game_id") REFERENCES "public"."base_games"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD CONSTRAINT "game_libraries_owner_id_steam_accounts_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_categories_type" ON "categories" USING btree ("type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_games_category_slug" ON "games" USING btree ("category_slug");--> statement-breakpoint
|
||||
CREATE INDEX "idx_games_category_type" ON "games" USING btree ("type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_images_type" ON "images" USING btree ("type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_images_game_id" ON "images" USING btree ("base_game_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_game_libraries_owner_id" ON "game_libraries" USING btree ("owner_id");--> statement-breakpoint
|
||||
ALTER TABLE "steam_account_credentials" ADD CONSTRAINT "steam_account_credentials_steam_id_steam_accounts_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" ADD CONSTRAINT "friends_list_steam_id_steam_accounts_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" ADD CONSTRAINT "friends_list_friend_steam_id_steam_accounts_id_fk" FOREIGN KEY ("friend_steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "members" ADD CONSTRAINT "members_steam_id_steam_accounts_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE restrict;--> statement-breakpoint
|
||||
CREATE INDEX "idx_friends_list_friend_steam_id" ON "friends_list" USING btree ("friend_steam_id");
|
||||
@@ -1,4 +0,0 @@
|
||||
ALTER TABLE "games" DROP CONSTRAINT "games_categories_fkey";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "games" ADD CONSTRAINT "games_categories_fkey" FOREIGN KEY ("category_slug","type") REFERENCES "public"."categories"("slug","type") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_games_category_slug_type" ON "games" USING btree ("category_slug","type");
|
||||
@@ -1,3 +0,0 @@
|
||||
CREATE TYPE "public"."controller_support" AS ENUM('full', 'unknown');--> statement-breakpoint
|
||||
ALTER TABLE "base_games" ALTER COLUMN "controller_support" SET DATA TYPE controller_support;--> statement-breakpoint
|
||||
ALTER TABLE "base_games" ALTER COLUMN "controller_support" SET NOT NULL;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE "base_games" ALTER COLUMN "primary_genre" DROP NOT NULL;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TYPE "public"."controller_support" ADD VALUE 'partial' BEFORE 'unknown';
|
||||
@@ -1,4 +0,0 @@
|
||||
ALTER TABLE "game_libraries" ADD COLUMN "time_acquired" timestamp with time zone NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD COLUMN "last_played" timestamp with time zone NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD COLUMN "total_playtime" integer NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD COLUMN "is_family_shared" boolean NOT NULL;
|
||||
@@ -1,4 +0,0 @@
|
||||
ALTER TABLE "public"."images" ALTER COLUMN "type" SET DATA TYPE text;--> statement-breakpoint
|
||||
DROP TYPE "public"."image_type";--> statement-breakpoint
|
||||
CREATE TYPE "public"."image_type" AS ENUM('heroArt', 'icon', 'logo', 'superHeroArt', 'poster', 'boxArt', 'screenshot', 'backdrop');--> statement-breakpoint
|
||||
ALTER TABLE "public"."images" ALTER COLUMN "type" SET DATA TYPE "public"."image_type" USING "type"::"public"."image_type";
|
||||
@@ -1,4 +0,0 @@
|
||||
ALTER TABLE "public"."images" ALTER COLUMN "type" SET DATA TYPE text;--> statement-breakpoint
|
||||
DROP TYPE "public"."image_type";--> statement-breakpoint
|
||||
CREATE TYPE "public"."image_type" AS ENUM('heroArt', 'icon', 'logo', 'banner', 'poster', 'boxArt', 'screenshot', 'backdrop');--> statement-breakpoint
|
||||
ALTER TABLE "public"."images" ALTER COLUMN "type" SET DATA TYPE "public"."image_type" USING "type"::"public"."image_type";
|
||||
@@ -1,19 +0,0 @@
|
||||
/*
|
||||
Unfortunately in current drizzle-kit version we can't automatically get name for primary key.
|
||||
We are working on making it available!
|
||||
|
||||
Meanwhile you can:
|
||||
1. Check pk name in your database, by running
|
||||
SELECT constraint_name FROM information_schema.table_constraints
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'steam_account_credentials'
|
||||
AND constraint_type = 'PRIMARY KEY';
|
||||
2. Uncomment code below and paste pk name manually
|
||||
|
||||
Hope to release this update as soon as possible
|
||||
*/
|
||||
|
||||
-- ALTER TABLE "steam_account_credentials" DROP CONSTRAINT "<constraint_name>";--> statement-breakpoint
|
||||
ALTER TABLE "images" ALTER COLUMN "source_url" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "steam_account_credentials" ADD CONSTRAINT "steam_account_credentials_steam_id_id_pk" PRIMARY KEY("steam_id","id");--> statement-breakpoint
|
||||
ALTER TABLE "steam_account_credentials" ADD COLUMN "id" char(30) NOT NULL;
|
||||
@@ -1,23 +0,0 @@
|
||||
ALTER TABLE "steam_account_credentials" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
DROP TABLE "steam_account_credentials" CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" RENAME COLUMN "owner_id" TO "owner_steam_id";--> statement-breakpoint
|
||||
ALTER TABLE "teams" RENAME COLUMN "owner_id" TO "owner_steam_id";--> statement-breakpoint
|
||||
ALTER TABLE "steam_accounts" DROP CONSTRAINT "idx_steam_username";--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" DROP CONSTRAINT "game_libraries_owner_id_steam_accounts_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "teams" DROP CONSTRAINT "teams_owner_id_users_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "teams" DROP CONSTRAINT "teams_slug_steam_accounts_username_fk";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX "idx_team_slug";--> statement-breakpoint
|
||||
DROP INDEX "idx_game_libraries_owner_id";--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" DROP CONSTRAINT "game_libraries_base_game_id_owner_id_pk";--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ALTER COLUMN "last_played" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD CONSTRAINT "game_libraries_base_game_id_owner_steam_id_pk" PRIMARY KEY("base_game_id","owner_steam_id");--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD CONSTRAINT "game_libraries_owner_steam_id_steam_accounts_id_fk" FOREIGN KEY ("owner_steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "teams" ADD CONSTRAINT "teams_owner_steam_id_steam_accounts_id_fk" FOREIGN KEY ("owner_steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_game_libraries_owner_id" ON "game_libraries" USING btree ("owner_steam_id");--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" DROP COLUMN "time_acquired";--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" DROP COLUMN "is_family_shared";--> statement-breakpoint
|
||||
ALTER TABLE "steam_accounts" DROP COLUMN "username";--> statement-breakpoint
|
||||
ALTER TABLE "teams" DROP COLUMN "slug";
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TYPE "public"."category_type" ADD VALUE 'category';--> statement-breakpoint
|
||||
ALTER TYPE "public"."category_type" ADD VALUE 'franchise';
|
||||
@@ -1,6 +0,0 @@
|
||||
ALTER TABLE "public"."categories" ALTER COLUMN "type" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "public"."games" ALTER COLUMN "type" SET DATA TYPE text;--> statement-breakpoint
|
||||
DROP TYPE "public"."category_type";--> statement-breakpoint
|
||||
CREATE TYPE "public"."category_type" AS ENUM('tag', 'genre', 'publisher', 'developer', 'categorie', 'franchise');--> statement-breakpoint
|
||||
ALTER TABLE "public"."categories" ALTER COLUMN "type" SET DATA TYPE "public"."category_type" USING "type"::"public"."category_type";--> statement-breakpoint
|
||||
ALTER TABLE "public"."games" ALTER COLUMN "type" SET DATA TYPE "public"."category_type" USING "type"::"public"."category_type";
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE "base_games" ALTER COLUMN "description" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "base_games" ADD COLUMN "links" text[];
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE "base_games" ALTER COLUMN "links" SET DATA TYPE json;
|
||||
@@ -1,3 +0,0 @@
|
||||
DROP TABLE "members" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "teams" CASCADE;--> statement-breakpoint
|
||||
DROP TYPE "public"."member_role";
|
||||
@@ -1,651 +0,0 @@
|
||||
{
|
||||
"id": "56a4d60a-c062-47e5-a97e-625443411ad8",
|
||||
"prevId": "1717c769-cee0-4242-bcbb-9538c80d985c",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.steam_account_credentials": {
|
||||
"name": "steam_account_credentials",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"steam_id": {
|
||||
"name": "steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expiry": {
|
||||
"name": "expiry",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"steam_account_credentials_steam_id_steam_accounts_steam_id_fk": {
|
||||
"name": "steam_account_credentials_steam_id_steam_accounts_steam_id_fk",
|
||||
"tableFrom": "steam_account_credentials",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"steam_id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.friends_list": {
|
||||
"name": "friends_list",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"steam_id": {
|
||||
"name": "steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"friend_steam_id": {
|
||||
"name": "friend_steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"friends_list_steam_id_steam_accounts_steam_id_fk": {
|
||||
"name": "friends_list_steam_id_steam_accounts_steam_id_fk",
|
||||
"tableFrom": "friends_list",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"steam_id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"friends_list_friend_steam_id_steam_accounts_steam_id_fk": {
|
||||
"name": "friends_list_friend_steam_id_steam_accounts_steam_id_fk",
|
||||
"tableFrom": "friends_list",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"friend_steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"steam_id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"friends_list_steam_id_friend_steam_id_pk": {
|
||||
"name": "friends_list_steam_id_friend_steam_id_pk",
|
||||
"columns": [
|
||||
"steam_id",
|
||||
"friend_steam_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.members": {
|
||||
"name": "members",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"team_id": {
|
||||
"name": "team_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"steam_id": {
|
||||
"name": "steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "member_role",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_member_steam_id": {
|
||||
"name": "idx_member_steam_id",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "team_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "steam_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_member_user_id": {
|
||||
"name": "idx_member_user_id",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "team_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"where": "\"members\".\"user_id\" is not null",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"members_user_id_users_id_fk": {
|
||||
"name": "members_user_id_users_id_fk",
|
||||
"tableFrom": "members",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"members_steam_id_steam_accounts_steam_id_fk": {
|
||||
"name": "members_steam_id_steam_accounts_steam_id_fk",
|
||||
"tableFrom": "members",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"steam_id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "restrict"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"members_id_team_id_pk": {
|
||||
"name": "members_id_team_id_pk",
|
||||
"columns": [
|
||||
"id",
|
||||
"team_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.steam_accounts": {
|
||||
"name": "steam_accounts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"steam_id": {
|
||||
"name": "steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "steam_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"last_synced_at": {
|
||||
"name": "last_synced_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"real_name": {
|
||||
"name": "real_name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"member_since": {
|
||||
"name": "member_since",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"profile_url": {
|
||||
"name": "profile_url",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"avatar_hash": {
|
||||
"name": "avatar_hash",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"limitations": {
|
||||
"name": "limitations",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"steam_accounts_user_id_users_id_fk": {
|
||||
"name": "steam_accounts_user_id_users_id_fk",
|
||||
"tableFrom": "steam_accounts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"idx_steam_username": {
|
||||
"name": "idx_steam_username",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.teams": {
|
||||
"name": "teams",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"owner_id": {
|
||||
"name": "owner_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"invite_code": {
|
||||
"name": "invite_code",
|
||||
"type": "varchar(10)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"max_members": {
|
||||
"name": "max_members",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_team_slug": {
|
||||
"name": "idx_team_slug",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "slug",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"teams_owner_id_users_id_fk": {
|
||||
"name": "teams_owner_id_users_id_fk",
|
||||
"tableFrom": "teams",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"owner_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"teams_slug_steam_accounts_username_fk": {
|
||||
"name": "teams_slug_steam_accounts_username_fk",
|
||||
"tableFrom": "teams",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"slug"
|
||||
],
|
||||
"columnsTo": [
|
||||
"username"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"idx_team_invite_code": {
|
||||
"name": "idx_team_invite_code",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"invite_code"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"avatar_url": {
|
||||
"name": "avatar_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"last_login": {
|
||||
"name": "last_login",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"polar_customer_id": {
|
||||
"name": "polar_customer_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"idx_user_email": {
|
||||
"name": "idx_user_email",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.member_role": {
|
||||
"name": "member_role",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"child",
|
||||
"adult"
|
||||
]
|
||||
},
|
||||
"public.steam_status": {
|
||||
"name": "steam_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"online",
|
||||
"offline",
|
||||
"dnd",
|
||||
"playing"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,930 +0,0 @@
|
||||
{
|
||||
"id": "735d315b-40e1-46c1-814d-0fd3619b65de",
|
||||
"prevId": "d35aa09b-5739-46a5-86f3-3050913dc2f7",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.base_games": {
|
||||
"name": "base_games",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"links": {
|
||||
"name": "links",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"release_date": {
|
||||
"name": "release_date",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"primary_genre": {
|
||||
"name": "primary_genre",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"controller_support": {
|
||||
"name": "controller_support",
|
||||
"type": "controller_support",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"compatibility": {
|
||||
"name": "compatibility",
|
||||
"type": "compatibility",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'unknown'"
|
||||
},
|
||||
"score": {
|
||||
"name": "score",
|
||||
"type": "numeric(2, 1)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"idx_base_games_slug": {
|
||||
"name": "idx_base_games_slug",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.categories": {
|
||||
"name": "categories",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "category_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_categories_type": {
|
||||
"name": "idx_categories_type",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"categories_slug_type_pk": {
|
||||
"name": "categories_slug_type_pk",
|
||||
"columns": [
|
||||
"slug",
|
||||
"type"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.friends_list": {
|
||||
"name": "friends_list",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"steam_id": {
|
||||
"name": "steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"friend_steam_id": {
|
||||
"name": "friend_steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_friends_list_friend_steam_id": {
|
||||
"name": "idx_friends_list_friend_steam_id",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "friend_steam_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"friends_list_steam_id_steam_accounts_id_fk": {
|
||||
"name": "friends_list_steam_id_steam_accounts_id_fk",
|
||||
"tableFrom": "friends_list",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"friends_list_friend_steam_id_steam_accounts_id_fk": {
|
||||
"name": "friends_list_friend_steam_id_steam_accounts_id_fk",
|
||||
"tableFrom": "friends_list",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"friend_steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"friends_list_steam_id_friend_steam_id_pk": {
|
||||
"name": "friends_list_steam_id_friend_steam_id_pk",
|
||||
"columns": [
|
||||
"steam_id",
|
||||
"friend_steam_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.games": {
|
||||
"name": "games",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"base_game_id": {
|
||||
"name": "base_game_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"category_slug": {
|
||||
"name": "category_slug",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "category_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_games_category_slug": {
|
||||
"name": "idx_games_category_slug",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "category_slug",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_games_category_type": {
|
||||
"name": "idx_games_category_type",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_games_category_slug_type": {
|
||||
"name": "idx_games_category_slug_type",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "category_slug",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"games_base_game_id_base_games_id_fk": {
|
||||
"name": "games_base_game_id_base_games_id_fk",
|
||||
"tableFrom": "games",
|
||||
"tableTo": "base_games",
|
||||
"columnsFrom": [
|
||||
"base_game_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"games_categories_fkey": {
|
||||
"name": "games_categories_fkey",
|
||||
"tableFrom": "games",
|
||||
"tableTo": "categories",
|
||||
"columnsFrom": [
|
||||
"category_slug",
|
||||
"type"
|
||||
],
|
||||
"columnsTo": [
|
||||
"slug",
|
||||
"type"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"games_base_game_id_category_slug_type_pk": {
|
||||
"name": "games_base_game_id_category_slug_type_pk",
|
||||
"columns": [
|
||||
"base_game_id",
|
||||
"category_slug",
|
||||
"type"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.images": {
|
||||
"name": "images",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "image_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image_hash": {
|
||||
"name": "image_hash",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"base_game_id": {
|
||||
"name": "base_game_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"source_url": {
|
||||
"name": "source_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"position": {
|
||||
"name": "position",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"file_size": {
|
||||
"name": "file_size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"dimensions": {
|
||||
"name": "dimensions",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"extracted_color": {
|
||||
"name": "extracted_color",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_images_type": {
|
||||
"name": "idx_images_type",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_images_game_id": {
|
||||
"name": "idx_images_game_id",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "base_game_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"images_base_game_id_base_games_id_fk": {
|
||||
"name": "images_base_game_id_base_games_id_fk",
|
||||
"tableFrom": "images",
|
||||
"tableTo": "base_games",
|
||||
"columnsFrom": [
|
||||
"base_game_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"images_image_hash_type_base_game_id_position_pk": {
|
||||
"name": "images_image_hash_type_base_game_id_position_pk",
|
||||
"columns": [
|
||||
"image_hash",
|
||||
"type",
|
||||
"base_game_id",
|
||||
"position"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.game_libraries": {
|
||||
"name": "game_libraries",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"base_game_id": {
|
||||
"name": "base_game_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"owner_steam_id": {
|
||||
"name": "owner_steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"last_played": {
|
||||
"name": "last_played",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"total_playtime": {
|
||||
"name": "total_playtime",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_game_libraries_owner_id": {
|
||||
"name": "idx_game_libraries_owner_id",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "owner_steam_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"game_libraries_base_game_id_base_games_id_fk": {
|
||||
"name": "game_libraries_base_game_id_base_games_id_fk",
|
||||
"tableFrom": "game_libraries",
|
||||
"tableTo": "base_games",
|
||||
"columnsFrom": [
|
||||
"base_game_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"game_libraries_owner_steam_id_steam_accounts_id_fk": {
|
||||
"name": "game_libraries_owner_steam_id_steam_accounts_id_fk",
|
||||
"tableFrom": "game_libraries",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"owner_steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"game_libraries_base_game_id_owner_steam_id_pk": {
|
||||
"name": "game_libraries_base_game_id_owner_steam_id_pk",
|
||||
"columns": [
|
||||
"base_game_id",
|
||||
"owner_steam_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.steam_accounts": {
|
||||
"name": "steam_accounts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "steam_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"last_synced_at": {
|
||||
"name": "last_synced_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"real_name": {
|
||||
"name": "real_name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"member_since": {
|
||||
"name": "member_since",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"profile_url": {
|
||||
"name": "profile_url",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"avatar_hash": {
|
||||
"name": "avatar_hash",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"limitations": {
|
||||
"name": "limitations",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"steam_accounts_user_id_users_id_fk": {
|
||||
"name": "steam_accounts_user_id_users_id_fk",
|
||||
"tableFrom": "steam_accounts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"avatar_url": {
|
||||
"name": "avatar_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"last_login": {
|
||||
"name": "last_login",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"polar_customer_id": {
|
||||
"name": "polar_customer_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"idx_user_email": {
|
||||
"name": "idx_user_email",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.compatibility": {
|
||||
"name": "compatibility",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"high",
|
||||
"mid",
|
||||
"low",
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"public.controller_support": {
|
||||
"name": "controller_support",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"full",
|
||||
"partial",
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"public.category_type": {
|
||||
"name": "category_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"tag",
|
||||
"genre",
|
||||
"publisher",
|
||||
"developer",
|
||||
"categorie",
|
||||
"franchise"
|
||||
]
|
||||
},
|
||||
"public.image_type": {
|
||||
"name": "image_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"heroArt",
|
||||
"icon",
|
||||
"logo",
|
||||
"banner",
|
||||
"poster",
|
||||
"boxArt",
|
||||
"screenshot",
|
||||
"backdrop"
|
||||
]
|
||||
},
|
||||
"public.steam_status": {
|
||||
"name": "steam_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"online",
|
||||
"offline",
|
||||
"dnd",
|
||||
"playing"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -71,118 +71,6 @@
|
||||
"when": 1744651817581,
|
||||
"tag": "0009_luxuriant_wraith",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1746726715456,
|
||||
"tag": "0010_certain_dust",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1746904821461,
|
||||
"tag": "0011_simple_azazel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1746905730079,
|
||||
"tag": "0012_glorious_jetstream",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1746925065142,
|
||||
"tag": "0013_neat_colleen_wing",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1746926498096,
|
||||
"tag": "0014_thin_groot",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1746928882281,
|
||||
"tag": "0015_handy_giant_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1747032794033,
|
||||
"tag": "0016_melted_johnny_storm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1747034424687,
|
||||
"tag": "0017_zippy_nico_minoru",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1747073173196,
|
||||
"tag": "0018_solid_enchantress",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1747202158003,
|
||||
"tag": "0019_charming_namorita",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1747795508868,
|
||||
"tag": "0020_vengeful_wallop",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1747975397543,
|
||||
"tag": "0021_real_skreet",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "7",
|
||||
"when": 1748099972605,
|
||||
"tag": "0022_clean_living_lightning",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1748411845939,
|
||||
"tag": "0023_flawless_steel_serpent",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "7",
|
||||
"when": 1748414049463,
|
||||
"tag": "0024_damp_cerise",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "7",
|
||||
"when": 1748845818197,
|
||||
"tag": "0025_bitter_jack_flag",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,35 +15,26 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node20": "^20.1.4",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"aws-iot-device-sdk-v2": "^1.21.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"loops": "^3.4.1",
|
||||
"mqtt": "^5.10.3",
|
||||
"remeda": "^2.21.2",
|
||||
"ulid": "^2.3.0",
|
||||
"uuid": "^11.0.3",
|
||||
"zod": "^3.24.1",
|
||||
"zod-openapi": "^4.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-iot-data-plane": "^3.758.0",
|
||||
"@aws-sdk/client-rds-data": "^3.758.0",
|
||||
"@aws-sdk/client-sesv2": "^3.753.0",
|
||||
"@instantdb/admin": "^0.17.7",
|
||||
"@openauthjs/openauth": "*",
|
||||
"@openauthjs/openevent": "^0.0.27",
|
||||
"@polar-sh/sdk": "^0.26.1",
|
||||
"drizzle-kit": "^0.30.5",
|
||||
"drizzle-orm": "^0.40.0",
|
||||
"drizzle-zod": "^0.7.1",
|
||||
"fast-average-color": "^9.5.0",
|
||||
"lru-cache": "^11.1.0",
|
||||
"p-limit": "^6.2.0",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"pngjs": "^7.0.0",
|
||||
"postgres": "^3.4.5",
|
||||
"sanitize-html": "^2.16.0",
|
||||
"sharp": "^0.34.1",
|
||||
"steam-session": "*",
|
||||
"xml2js": "^0.6.2"
|
||||
"postgres": "^3.4.5"
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { z } from "zod"
|
||||
import { User } from "../user";
|
||||
import { Steam } from "../steam";
|
||||
import { Actor } from "../actor";
|
||||
import { Examples } from "../examples";
|
||||
import { ErrorCodes, VisibleError } from "../error";
|
||||
|
||||
export namespace Account {
|
||||
export const Info =
|
||||
User.Info
|
||||
.extend({
|
||||
profiles: Steam.Info
|
||||
.array()
|
||||
.openapi({
|
||||
description: "The Steam accounts this user owns",
|
||||
example: [Examples.SteamAccount]
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Account",
|
||||
description: "Represents an account's information stored on Nestri",
|
||||
example: { ...Examples.User, profiles: [Examples.SteamAccount] },
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const list = async (): Promise<Info> => {
|
||||
const [userResult, steamResult] =
|
||||
await Promise.allSettled([
|
||||
User.fromID(Actor.userID()),
|
||||
Steam.list()
|
||||
])
|
||||
|
||||
if (userResult.status === "rejected" || !userResult.value)
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
"User not found",
|
||||
);
|
||||
|
||||
return {
|
||||
...userResult.value,
|
||||
profiles: steamResult.status === "rejected" ? [] : steamResult.value
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,130 +1,142 @@
|
||||
import { Log } from "./utils";
|
||||
import { createContext } from "./context";
|
||||
import { z } from "zod";
|
||||
import { eq } from "./drizzle";
|
||||
import { ErrorCodes, VisibleError } from "./error";
|
||||
import { createContext } from "./context";
|
||||
import { UserFlags, userTable } from "./user/user.sql";
|
||||
import { useTransaction } from "./drizzle/transaction";
|
||||
|
||||
export namespace Actor {
|
||||
export const PublicActor = z.object({
|
||||
type: z.literal("public"),
|
||||
properties: z.object({}),
|
||||
});
|
||||
export type PublicActor = z.infer<typeof PublicActor>;
|
||||
|
||||
export interface User {
|
||||
type: "user";
|
||||
properties: {
|
||||
userID: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
export const UserActor = z.object({
|
||||
type: z.literal("user"),
|
||||
properties: z.object({
|
||||
userID: z.string(),
|
||||
email: z.string().nonempty(),
|
||||
}),
|
||||
});
|
||||
export type UserActor = z.infer<typeof UserActor>;
|
||||
|
||||
export interface Steam {
|
||||
type: "steam";
|
||||
properties: {
|
||||
steamID: string;
|
||||
};
|
||||
}
|
||||
export const MemberActor = z.object({
|
||||
type: z.literal("member"),
|
||||
properties: z.object({
|
||||
memberID: z.string(),
|
||||
teamID: z.string(),
|
||||
}),
|
||||
});
|
||||
export type MemberActor = z.infer<typeof MemberActor>;
|
||||
|
||||
export interface Machine {
|
||||
type: "machine";
|
||||
properties: {
|
||||
machineID: string;
|
||||
fingerprint: string;
|
||||
};
|
||||
}
|
||||
export const SystemActor = z.object({
|
||||
type: z.literal("system"),
|
||||
properties: z.object({
|
||||
teamID: z.string(),
|
||||
}),
|
||||
});
|
||||
export type SystemActor = z.infer<typeof SystemActor>;
|
||||
|
||||
export interface Token {
|
||||
type: "member";
|
||||
properties: {
|
||||
userID: string;
|
||||
steamID: string;
|
||||
};
|
||||
}
|
||||
export const MachineActor = z.object({
|
||||
type: z.literal("machine"),
|
||||
properties: z.object({
|
||||
fingerprint: z.string(),
|
||||
machineID: z.string(),
|
||||
}),
|
||||
});
|
||||
export type MachineActor = z.infer<typeof MachineActor>;
|
||||
|
||||
export interface Public {
|
||||
type: "public";
|
||||
properties: {};
|
||||
}
|
||||
export const Actor = z.discriminatedUnion("type", [
|
||||
MemberActor,
|
||||
UserActor,
|
||||
PublicActor,
|
||||
SystemActor,
|
||||
MachineActor
|
||||
]);
|
||||
export type Actor = z.infer<typeof Actor>;
|
||||
|
||||
export type Info = User | Public | Token | Machine | Steam;
|
||||
export const ActorContext = createContext<Actor>("actor");
|
||||
|
||||
export const Context = createContext<Info>();
|
||||
export const useActor = ActorContext.use;
|
||||
export const withActor = ActorContext.with;
|
||||
|
||||
export function userID() {
|
||||
const actor = Context.use();
|
||||
if ("userID" in actor.properties) return actor.properties.userID;
|
||||
/**
|
||||
* Retrieves the user ID of the current actor.
|
||||
*
|
||||
* This function accesses the actor context and returns the `userID` if the current
|
||||
* actor is of type "user". If the actor is not a user, it throws a `VisibleError`
|
||||
* with an authentication error code, indicating that the caller is not authorized
|
||||
* to access user-specific resources.
|
||||
*
|
||||
* @throws {VisibleError} When the current actor is not of type "user".
|
||||
*/
|
||||
export function useUserID() {
|
||||
const actor = ActorContext.use();
|
||||
if (actor.type === "user") return actor.properties.userID;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`You don't have permission to access this resource.`,
|
||||
`You don't have permission to access this resource`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function steamID() {
|
||||
const actor = Context.use();
|
||||
if ("steamID" in actor.properties) return actor.properties.steamID;
|
||||
/**
|
||||
* Retrieves the properties of the current user actor.
|
||||
*
|
||||
* This function obtains the current actor from the context and returns its properties if the actor is identified as a user.
|
||||
* If the actor is not of type "user", it throws a {@link VisibleError} with an authentication error code,
|
||||
* indicating that the user is not authorized to access user-specific resources.
|
||||
*
|
||||
* @returns The properties of the current user actor, typically including user-specific details such as userID and email.
|
||||
* @throws {VisibleError} If the current actor is not a user.
|
||||
*/
|
||||
export function useUser() {
|
||||
const actor = ActorContext.use();
|
||||
if (actor.type === "user") return actor.properties;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`You don't have permission to access this resource.`,
|
||||
`You don't have permission to access this resource`,
|
||||
);
|
||||
}
|
||||
|
||||
export function assertActor<T extends Actor["type"]>(type: T) {
|
||||
const actor = useActor();
|
||||
if (actor.type !== type) {
|
||||
throw new Error(`Expected actor type ${type}, got ${actor.type}`);
|
||||
}
|
||||
|
||||
export function user() {
|
||||
const actor = Context.use();
|
||||
if (actor.type == "user") return actor.properties;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`You don't have permission to access this resource.`,
|
||||
);
|
||||
}
|
||||
return actor as Extract<Actor, { type: T }>;
|
||||
}
|
||||
|
||||
export function teamID() {
|
||||
const actor = Context.use();
|
||||
/**
|
||||
* Returns the current actor's team ID.
|
||||
*
|
||||
* @returns The team ID associated with the current actor.
|
||||
* @throws {VisibleError} If the current actor does not have a {@link teamID} property.
|
||||
*/
|
||||
export function useTeam() {
|
||||
const actor = useActor();
|
||||
if ("teamID" in actor.properties) return actor.properties.teamID;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`You don't have permission to access this resource.`,
|
||||
`Expected actor to have teamID`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the fingerprint of the current actor if the actor has a machine identity.
|
||||
*
|
||||
* @returns The fingerprint of the current machine actor.
|
||||
* @throws {VisibleError} If the current actor does not have a machine identity.
|
||||
*/
|
||||
export function useMachine() {
|
||||
const actor = useActor();
|
||||
if ("machineID" in actor.properties) return actor.properties.fingerprint;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`Expected actor to have fingerprint`
|
||||
);
|
||||
}
|
||||
|
||||
export function fingerprint() {
|
||||
const actor = Context.use();
|
||||
if ("fingerprint" in actor.properties) return actor.properties.fingerprint;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`You don't have permission to access this resource.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function use() {
|
||||
try {
|
||||
return Context.use();
|
||||
} catch {
|
||||
return { type: "public", properties: {} } as Public;
|
||||
}
|
||||
}
|
||||
|
||||
export function assert<T extends Info["type"]>(type: T) {
|
||||
const actor = use();
|
||||
if (actor.type !== type)
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`Actor is not "${type}"`,
|
||||
);
|
||||
return actor as Extract<Info, { type: T }>;
|
||||
}
|
||||
|
||||
export function provide<
|
||||
T extends Info["type"],
|
||||
Next extends (...args: any) => any,
|
||||
>(type: T, properties: Extract<Info, { type: T }>["properties"], fn: Next) {
|
||||
return Context.provide({ type, properties } as any, () =>
|
||||
Log.provide(
|
||||
{
|
||||
actor: type,
|
||||
...properties,
|
||||
},
|
||||
fn,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { timestamps, utc } from "../drizzle/types";
|
||||
import { json, numeric, pgEnum, pgTable, text, unique, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const CompatibilityEnum = pgEnum("compatibility", ["high", "mid", "low", "unknown"])
|
||||
export const ControllerEnum = pgEnum("controller_support", ["full", "partial", "unknown"])
|
||||
|
||||
export const Size =
|
||||
z.object({
|
||||
downloadSize: z.number().positive().int(),
|
||||
sizeOnDisk: z.number().positive().int()
|
||||
});
|
||||
|
||||
export const Links = z.string().array();
|
||||
|
||||
export type Size = z.infer<typeof Size>;
|
||||
export type Links = z.infer<typeof Links>;
|
||||
|
||||
export const baseGamesTable = pgTable(
|
||||
"base_games",
|
||||
{
|
||||
...timestamps,
|
||||
id: varchar("id", { length: 255 })
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
links: json("links").$type<Links>(),
|
||||
slug: varchar("slug", { length: 255 })
|
||||
.notNull(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
releaseDate: utc("release_date").notNull(),
|
||||
size: json("size").$type<Size>().notNull(),
|
||||
primaryGenre: text("primary_genre"),
|
||||
controllerSupport: ControllerEnum("controller_support").notNull(),
|
||||
compatibility: CompatibilityEnum("compatibility").notNull().default("unknown"),
|
||||
// Score ranges from 0.0 to 5.0
|
||||
score: numeric("score", { precision: 2, scale: 1 })
|
||||
.$type<number>()
|
||||
.notNull()
|
||||
},
|
||||
(table) => [
|
||||
unique("idx_base_games_slug").on(table.slug),
|
||||
]
|
||||
)
|
||||
@@ -1,162 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import { createEvent } from "../event";
|
||||
import { eq, isNull, and } from "drizzle-orm";
|
||||
import { ImageTypeEnum } from "../images/images.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { CompatibilityEnum, baseGamesTable, Size, ControllerEnum, Links } from "./base-game.sql";
|
||||
|
||||
export namespace BaseGame {
|
||||
export const Info = z.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.BaseGame.id
|
||||
}),
|
||||
slug: z.string().openapi({
|
||||
description: "A URL-friendly unique identifier for the game, used in web addresses and API endpoints",
|
||||
example: Examples.BaseGame.slug
|
||||
}),
|
||||
name: z.string().openapi({
|
||||
description: "The official title of the game as listed on Steam",
|
||||
example: Examples.BaseGame.name
|
||||
}),
|
||||
size: Size.openapi({
|
||||
description: "Storage requirements in bytes: downloadSize represents the compressed download, and sizeOnDisk represents the installed size",
|
||||
example: Examples.BaseGame.size
|
||||
}),
|
||||
releaseDate: z.date().openapi({
|
||||
description: "The initial public release date of the game on Steam",
|
||||
example: Examples.BaseGame.releaseDate
|
||||
}),
|
||||
description: z.string().nullable().openapi({
|
||||
description: "A comprehensive overview of the game, including its features, storyline, and gameplay elements",
|
||||
example: Examples.BaseGame.description
|
||||
}),
|
||||
score: z.number().openapi({
|
||||
description: "The aggregate user review score on Steam, represented as a percentage of positive reviews",
|
||||
example: Examples.BaseGame.score
|
||||
}),
|
||||
links: Links
|
||||
.nullable()
|
||||
.openapi({
|
||||
description: "The social links of this game",
|
||||
example: Examples.BaseGame.links
|
||||
}),
|
||||
primaryGenre: z.string().nullable().openapi({
|
||||
description: "The main category or genre that best represents the game's content and gameplay style",
|
||||
example: Examples.BaseGame.primaryGenre
|
||||
}),
|
||||
controllerSupport: z.enum(ControllerEnum.enumValues).openapi({
|
||||
description: "Indicates the level of gamepad/controller compatibility: 'Full', 'Partial', or 'Unkown' for no support",
|
||||
example: Examples.BaseGame.controllerSupport
|
||||
}),
|
||||
compatibility: z.enum(CompatibilityEnum.enumValues).openapi({
|
||||
description: "Steam Deck/Proton compatibility rating indicating how well the game runs on Linux systems",
|
||||
example: Examples.BaseGame.compatibility
|
||||
}),
|
||||
}).openapi({
|
||||
ref: "BaseGame",
|
||||
description: "Detailed information about a game available in the Nestri library, including technical specifications and metadata",
|
||||
example: Examples.BaseGame
|
||||
})
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const Events = {
|
||||
New: createEvent(
|
||||
"new_image.save",
|
||||
z.object({
|
||||
appID: Info.shape.id,
|
||||
url: z.string().url(),
|
||||
type: z.enum(ImageTypeEnum.enumValues)
|
||||
}),
|
||||
),
|
||||
NewBoxArt: createEvent(
|
||||
"new_box_art_image.save",
|
||||
z.object({
|
||||
appID: Info.shape.id,
|
||||
logoUrl: z.string().url(),
|
||||
backgroundUrl: z.string().url(),
|
||||
}),
|
||||
),
|
||||
NewHeroArt: createEvent(
|
||||
"new_hero_art_image.save",
|
||||
z.object({
|
||||
appID: Info.shape.id,
|
||||
backdropUrl: z.string().url(),
|
||||
screenshots: z.string().url().array(),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export const create = fn(
|
||||
Info,
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const result = await tx
|
||||
.select()
|
||||
.from(baseGamesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(baseGamesTable.id, input.id),
|
||||
isNull(baseGamesTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.then(rows => rows.at(0))
|
||||
|
||||
if (result) return result.id
|
||||
|
||||
await tx
|
||||
.insert(baseGamesTable)
|
||||
.values(input)
|
||||
.onConflictDoUpdate({
|
||||
target: baseGamesTable.id,
|
||||
set: {
|
||||
timeDeleted: null
|
||||
}
|
||||
})
|
||||
|
||||
return input.id
|
||||
})
|
||||
)
|
||||
|
||||
export const fromID = fn(
|
||||
Info.shape.id,
|
||||
(id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(baseGamesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(baseGamesTable.id, id),
|
||||
isNull(baseGamesTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.then(rows => rows.map(serialize).at(0))
|
||||
)
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: typeof baseGamesTable.$inferSelect,
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
size: input.size,
|
||||
links: input.links,
|
||||
score: input.score,
|
||||
description: input.description,
|
||||
releaseDate: input.releaseDate,
|
||||
primaryGenre: input.primaryGenre,
|
||||
compatibility: input.compatibility,
|
||||
controllerSupport: input.controllerSupport,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { timestamps } from "../drizzle/types";
|
||||
import { index, pgEnum, pgTable, primaryKey, text, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
// Intentional grammatical error on category
|
||||
export const CategoryTypeEnum = pgEnum("category_type", ["tag", "genre", "publisher", "developer", "categorie", "franchise"])
|
||||
|
||||
export const categoriesTable = pgTable(
|
||||
"categories",
|
||||
{
|
||||
...timestamps,
|
||||
slug: varchar("slug", { length: 255 })
|
||||
.notNull(),
|
||||
type: CategoryTypeEnum("type").notNull(),
|
||||
name: text("name").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.slug, table.type]
|
||||
}),
|
||||
index("idx_categories_type").on(table.type),
|
||||
]
|
||||
)
|
||||
@@ -1,128 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Examples } from "../examples";
|
||||
import { eq, isNull, and } from "drizzle-orm";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { categoriesTable } from "./categories.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Categories {
|
||||
|
||||
const Category = z.object({
|
||||
slug: z.string().openapi({
|
||||
description: "A URL-friendly unique identifier for the category",
|
||||
example: "action-adventure"
|
||||
}),
|
||||
name: z.string().openapi({
|
||||
description: "The human-readable display name of the category",
|
||||
example: "Action Adventure"
|
||||
})
|
||||
})
|
||||
|
||||
export const Info =
|
||||
z.object({
|
||||
publishers: Category.array().openapi({
|
||||
description: "List of companies or organizations responsible for publishing and distributing the game",
|
||||
example: Examples.Categories.publishers
|
||||
}),
|
||||
developers: Category.array().openapi({
|
||||
description: "List of studios, teams, or individuals who created and developed the game",
|
||||
example: Examples.Categories.developers
|
||||
}),
|
||||
tags: Category.array().openapi({
|
||||
description: "User-defined labels that describe specific features, themes, or characteristics of the game",
|
||||
example: Examples.Categories.tags
|
||||
}),
|
||||
genres: Category.array().openapi({
|
||||
description: "Primary classification categories that define the game's style and type of gameplay",
|
||||
example: Examples.Categories.genres
|
||||
}),
|
||||
categories: Category.array().openapi({
|
||||
description: "Primary classification categories that define the game's categorisation on Steam",
|
||||
example: Examples.Categories.genres
|
||||
}),
|
||||
franchises: Category.array().openapi({
|
||||
description: "The franchise this game belongs belongs to on Steam",
|
||||
example: Examples.Categories.genres
|
||||
}),
|
||||
|
||||
}).openapi({
|
||||
ref: "Categories",
|
||||
description: "A comprehensive categorization system for games, including publishing details, development credits, and content classification",
|
||||
example: Examples.Categories
|
||||
})
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const InputInfo = createSelectSchema(categoriesTable)
|
||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
||||
|
||||
export const create = fn(
|
||||
InputInfo,
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const result = await tx
|
||||
.select()
|
||||
.from(categoriesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(categoriesTable.slug, input.slug),
|
||||
eq(categoriesTable.type, input.type),
|
||||
isNull(categoriesTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.then(rows => rows.at(0))
|
||||
|
||||
if (result) return result.slug
|
||||
|
||||
await tx
|
||||
.insert(categoriesTable)
|
||||
.values(input)
|
||||
.onConflictDoUpdate({
|
||||
target: [categoriesTable.slug, categoriesTable.type],
|
||||
set: { timeDeleted: null }
|
||||
})
|
||||
|
||||
return input.slug
|
||||
})
|
||||
)
|
||||
|
||||
export const get = fn(
|
||||
InputInfo.pick({ slug: true, type: true }),
|
||||
(input) =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(categoriesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(categoriesTable.slug, input.slug),
|
||||
eq(categoriesTable.type, input.type),
|
||||
isNull(categoriesTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.then(rows => serialize(rows))
|
||||
)
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: typeof categoriesTable.$inferSelect[],
|
||||
): z.infer<typeof Info> {
|
||||
return input.reduce<Record<`${typeof categoriesTable.$inferSelect["type"]}s`, { slug: string; name: string }[]>>((acc, cat) => {
|
||||
const key = `${cat.type}s` as `${typeof cat.type}s`
|
||||
acc[key]!.push({ slug: cat.slug, name: cat.name })
|
||||
return acc
|
||||
}, {
|
||||
tags: [],
|
||||
genres: [],
|
||||
publishers: [],
|
||||
developers: [],
|
||||
categories: [],
|
||||
franchises: []
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
import type {
|
||||
Shot,
|
||||
AppInfo,
|
||||
ImageInfo,
|
||||
ImageType,
|
||||
SteamAccount,
|
||||
GameTagsResponse,
|
||||
GameDetailsResponse,
|
||||
SteamAppDataResponse,
|
||||
SteamOwnedGamesResponse,
|
||||
SteamPlayerBansResponse,
|
||||
SteamFriendsListResponse,
|
||||
SteamPlayerSummaryResponse,
|
||||
SteamStoreResponse,
|
||||
} from "./types";
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Resource } from "sst";
|
||||
import { Steam } from "./steam";
|
||||
import { Utils } from "./utils";
|
||||
import { ImageTypeEnum } from "../images/images.sql";
|
||||
|
||||
export namespace Client {
|
||||
export const getUserLibrary = fn(
|
||||
z.string(),
|
||||
async (steamID) =>
|
||||
await Utils.fetchApi<SteamOwnedGamesResponse>(`https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key=${Resource.SteamApiKey.value}&steamid=${steamID}&include_appinfo=1&format=json&include_played_free_games=1&skip_unvetted_apps=0`)
|
||||
)
|
||||
|
||||
export const getFriendsList = fn(
|
||||
z.string(),
|
||||
async (steamID) =>
|
||||
await Utils.fetchApi<SteamFriendsListResponse>(`https://api.steampowered.com/ISteamUser/GetFriendList/v0001/?key=${Resource.SteamApiKey.value}&steamid=${steamID}&relationship=friend`)
|
||||
);
|
||||
|
||||
export const getUserInfo = fn(
|
||||
z.string().array(),
|
||||
async (steamIDs) => {
|
||||
const [userInfo, banInfo, profileInfo] = await Promise.all([
|
||||
Utils.fetchApi<SteamPlayerSummaryResponse>(`https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${Resource.SteamApiKey.value}&steamids=${steamIDs.join(",")}`),
|
||||
Utils.fetchApi<SteamPlayerBansResponse>(`https://api.steampowered.com/ISteamUser/GetPlayerBans/v1/?key=${Resource.SteamApiKey.value}&steamids=${steamIDs.join(",")}`),
|
||||
Utils.fetchProfilesInfo(steamIDs)
|
||||
])
|
||||
|
||||
// Create a map of bans by steamID for fast lookup
|
||||
const bansBySteamID = new Map(
|
||||
banInfo.players.map((b) => [b.SteamId, b])
|
||||
);
|
||||
|
||||
// Map userInfo.players to your desired output using Promise.allSettled
|
||||
// to prevent one error from closing down the whole pipeline
|
||||
const steamAccounts = await Promise.allSettled(
|
||||
userInfo.response.players.map(async (player) => {
|
||||
const ban = bansBySteamID.get(player.steamid);
|
||||
const info = profileInfo.get(player.steamid);
|
||||
|
||||
if (!info) {
|
||||
throw new Error(`[userInfo] profile info missing for ${player.steamid}`)
|
||||
}
|
||||
|
||||
if ('error' in info) {
|
||||
throw new Error(`error handling profile info for: ${player.steamid}:${info.error}`)
|
||||
} else {
|
||||
return {
|
||||
id: player.steamid,
|
||||
name: player.personaname,
|
||||
realName: player.realname ?? null,
|
||||
steamMemberSince: new Date(player.timecreated * 1000),
|
||||
avatarHash: player.avatarhash,
|
||||
limitations: {
|
||||
isLimited: info.isLimited,
|
||||
privacyState: info.privacyState,
|
||||
isVacBanned: ban?.VACBanned ?? false,
|
||||
tradeBanState: ban?.EconomyBan ?? "none",
|
||||
visibilityState: player.communityvisibilitystate,
|
||||
},
|
||||
lastSyncedAt: new Date(),
|
||||
profileUrl: player.profileurl,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
steamAccounts
|
||||
.filter(result => result.status === 'rejected')
|
||||
.forEach(result => console.warn('[userInfo] failed:', (result as PromiseRejectedResult).reason))
|
||||
|
||||
return steamAccounts.filter(result => result.status === "fulfilled").map(result => (result as PromiseFulfilledResult<SteamAccount>).value)
|
||||
})
|
||||
|
||||
export const getAppInfo = fn(
|
||||
z.string(),
|
||||
async (appid) => {
|
||||
try {
|
||||
const info = await Promise.all([
|
||||
Utils.fetchApi<SteamAppDataResponse>(`https://api.steamcmd.net/v1/info/${appid}`),
|
||||
Utils.fetchApi<SteamStoreResponse>(`https://api.steampowered.com/IStoreBrowseService/GetItems/v1/?key=${Resource.SteamApiKey.value}&input_json={"ids":[{"appid":"${appid}"}],"context":{"language":"english","country_code":"US","steam_realm":"1"},"data_request":{"include_assets":true,"include_release":true,"include_platforms":true,"include_all_purchase_options":true,"include_screenshots":true,"include_trailers":true,"include_ratings":true,"include_tag_count":"40","include_reviews":true,"include_basic_info":true,"include_supported_languages":true,"include_full_description":true,"include_included_items":true,"include_assets_without_overrides":true,"apply_user_filters":true,"include_links":true}}`),
|
||||
]);
|
||||
|
||||
const cmd = info[0].data[appid]
|
||||
const store = info[1].response.store_items[0]
|
||||
|
||||
if (!cmd) {
|
||||
throw new Error(`App data not found for appid: ${appid}`)
|
||||
}
|
||||
|
||||
if (!store || store.success !== 1) {
|
||||
throw new Error(`Could not get store information or appid: ${appid}`)
|
||||
}
|
||||
|
||||
const tags = store.tagids
|
||||
.map(id => Steam.tags[id.toString() as keyof typeof Steam.tags])
|
||||
.filter((name): name is string => typeof name === 'string')
|
||||
|
||||
const publishers = store.basic_info.publishers
|
||||
.map(i => i.name)
|
||||
|
||||
const developers = store.basic_info.developers
|
||||
.map(i => i.name)
|
||||
|
||||
const franchises = store.basic_info.franchises
|
||||
?.map(i => i.name)
|
||||
|
||||
const genres = cmd?.common.genres &&
|
||||
Object.keys(cmd?.common.genres)
|
||||
.map(id => Steam.genres[id.toString() as keyof typeof Steam.genres])
|
||||
.filter((name): name is string => typeof name === 'string')
|
||||
|
||||
const categories = [
|
||||
...(store.categories?.controller_categoryids?.map(i => Steam.categories[i.toString() as keyof typeof Steam.categories]) ?? []),
|
||||
...(store.categories?.supported_player_categoryids?.map(i => Steam.categories[i.toString() as keyof typeof Steam.categories]) ?? [])
|
||||
].filter((name): name is string => typeof name === 'string')
|
||||
|
||||
const assetUrls = Utils.getAssetUrls(cmd?.common.library_assets_full, appid, cmd?.common.header_image.english);
|
||||
|
||||
const screenshots = store.screenshots.all_ages_screenshots?.map(i => `https://shared.cloudflare.steamstatic.com/store_item_assets/${i.filename}`) ?? [];
|
||||
|
||||
const icon = `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${cmd?.common.icon}.jpg`;
|
||||
|
||||
const data: AppInfo = {
|
||||
id: appid,
|
||||
name: cmd?.common.name.trim(),
|
||||
tags: Utils.createType(tags, "tag"),
|
||||
images: { screenshots, icon, ...assetUrls },
|
||||
size: Utils.getPublicDepotSizes(cmd?.depots!),
|
||||
slug: Utils.createSlug(cmd?.common.name.trim()),
|
||||
publishers: Utils.createType(publishers, "publisher"),
|
||||
developers: Utils.createType(developers, "developer"),
|
||||
categories: Utils.createType(categories, "categorie"),
|
||||
links: store.links ? store.links.map(i => i.url) : null,
|
||||
genres: genres ? Utils.createType(genres, "genre") : [],
|
||||
franchises: franchises ? Utils.createType(franchises, "franchise") : [],
|
||||
description: store.basic_info.short_description ? Utils.cleanDescription(store.basic_info.short_description) : null,
|
||||
controllerSupport: cmd?.common.controller_support ?? "unknown" as any,
|
||||
releaseDate: new Date(Number(cmd?.common.steam_release_date) * 1000),
|
||||
primaryGenre: !!cmd?.common.primary_genre && Steam.genres[cmd?.common.primary_genre as keyof typeof Steam.genres] ? Steam.genres[cmd?.common.primary_genre as keyof typeof Steam.genres] : null,
|
||||
compatibility: store?.platforms.steam_os_compat_category ? Utils.compatibilityType(store?.platforms.steam_os_compat_category.toString() as any).toLowerCase() : "unknown" as any,
|
||||
score: Utils.estimateRatingFromSummary(store.reviews.summary_filtered.review_count, store.reviews.summary_filtered.percent_positive)
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (err) {
|
||||
console.log(`Error handling: ${appid}`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const getImageUrls = fn(
|
||||
z.string(),
|
||||
async (appid) => {
|
||||
const [appData, details] = await Promise.all([
|
||||
Utils.fetchApi<SteamAppDataResponse>(`https://api.steamcmd.net/v1/info/${appid}`),
|
||||
Utils.fetchApi<GameDetailsResponse>(
|
||||
`https://store.steampowered.com/apphover/${appid}?full=1&review_score_preference=1&pagev6=true&json=1`
|
||||
),
|
||||
]);
|
||||
|
||||
const game = appData.data[appid]?.common;
|
||||
if (!game) throw new Error('Game info missing');
|
||||
|
||||
// 2. Prepare URLs
|
||||
const screenshots = Utils.getScreenshotUrls(details.rgScreenshots || []);
|
||||
const assetUrls = Utils.getAssetUrls(game.library_assets_full, appid, game.header_image.english);
|
||||
const icon = `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${game.icon}.jpg`;
|
||||
|
||||
return { screenshots, icon, ...assetUrls }
|
||||
}
|
||||
)
|
||||
|
||||
export const getImageInfo = fn(
|
||||
z.object({
|
||||
type: z.enum(ImageTypeEnum.enumValues),
|
||||
url: z.string()
|
||||
}),
|
||||
async (input) =>
|
||||
Utils.fetchBuffer(input.url)
|
||||
.then(buf => Utils.getImageMetadata(buf))
|
||||
.then(meta => ({ ...meta, position: 0, sourceUrl: input.url, type: input.type } as ImageInfo))
|
||||
)
|
||||
|
||||
export const createBoxArt = fn(
|
||||
z.object({
|
||||
backgroundUrl: z.string(),
|
||||
logoUrl: z.string(),
|
||||
}),
|
||||
async (input) =>
|
||||
Utils.createBoxArtBuffer(input.logoUrl, input.backgroundUrl)
|
||||
.then(buf => Utils.getImageMetadata(buf))
|
||||
.then(meta => ({ ...meta, position: 0, sourceUrl: null, type: 'boxArt' as const }) as ImageInfo)
|
||||
)
|
||||
|
||||
export const createHeroArt = fn(
|
||||
z.object({
|
||||
screenshots: z.string().array(),
|
||||
backdropUrl: z.string()
|
||||
}),
|
||||
async (input) => {
|
||||
// Download screenshot buffers in parallel
|
||||
const shots: Shot[] = await Promise.all(
|
||||
input.screenshots.map(async url => ({ url, buffer: await Utils.fetchBuffer(url) }))
|
||||
);
|
||||
|
||||
const baselineBuffer = await Utils.fetchBuffer(input.backdropUrl);
|
||||
|
||||
// 4. Score screenshots (or pick single)
|
||||
const scores =
|
||||
shots.length === 1
|
||||
? [{ url: shots[0].url, score: 0 }]
|
||||
: (await Utils.rankScreenshots(baselineBuffer, shots, {
|
||||
threshold: 0.08,
|
||||
}))
|
||||
|
||||
// Build url->rank map
|
||||
const rankMap = new Map<string, number>();
|
||||
scores.forEach((s, i) => rankMap.set(s.url, i));
|
||||
|
||||
// 5. Create tasks for all images
|
||||
const tasks: Array<Promise<ImageInfo>> = [];
|
||||
|
||||
// 5a. Screenshots and heroArt metadata (top 4)
|
||||
for (const { url, buffer } of shots) {
|
||||
const rank = rankMap.get(url);
|
||||
if (rank === undefined || rank >= 4) continue;
|
||||
const type: ImageType = rank === 0 ? 'heroArt' : 'screenshot';
|
||||
tasks.push(
|
||||
Utils.getImageMetadata(buffer).then(meta => ({ ...meta, sourceUrl: url, position: type == "screenshot" ? rank - 1 : rank, type } as ImageInfo))
|
||||
);
|
||||
}
|
||||
|
||||
const settled = await Promise.allSettled(tasks);
|
||||
|
||||
settled
|
||||
.filter(r => r.status === "rejected")
|
||||
.forEach(r => console.warn("[getHeroArt] failed:", (r as PromiseRejectedResult).reason));
|
||||
|
||||
// Await all and return
|
||||
return settled.filter(s => s.status === "fulfilled").map(r => (r as PromiseFulfilledResult<ImageInfo>).value)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Verifies a Steam OpenID response by sending a request back to Steam
|
||||
* with mode=check_authentication
|
||||
*/
|
||||
export async function verifyOpenIDResponse(params: URLSearchParams): Promise<string | null> {
|
||||
try {
|
||||
// Create a new URLSearchParams with all the original parameters
|
||||
const verificationParams = new URLSearchParams();
|
||||
|
||||
// Copy all parameters from the original request
|
||||
for (const [key, value] of params.entries()) {
|
||||
verificationParams.append(key, value);
|
||||
}
|
||||
|
||||
// Change mode to check_authentication for verification
|
||||
verificationParams.set('openid.mode', 'check_authentication');
|
||||
|
||||
// Send verification request to Steam
|
||||
const verificationResponse = await fetch('https://steamcommunity.com/openid/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: verificationParams.toString()
|
||||
});
|
||||
|
||||
const responseText = await verificationResponse.text();
|
||||
|
||||
// Check if verification was successful
|
||||
if (!responseText.includes('is_valid:true')) {
|
||||
console.error('OpenID verification failed: Invalid response from Steam', responseText);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract steamID from the claimed_id
|
||||
const claimedId = params.get('openid.claimed_id');
|
||||
if (!claimedId) {
|
||||
console.error('OpenID verification failed: Missing claimed_id');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract the Steam ID from the claimed_id
|
||||
const steamID = claimedId.split('/').pop();
|
||||
if (!steamID || !/^\d+$/.test(steamID)) {
|
||||
console.error('OpenID verification failed: Invalid steamID format', steamID);
|
||||
return null;
|
||||
}
|
||||
|
||||
return steamID;
|
||||
} catch (error) {
|
||||
console.error('OpenID verification error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,544 +0,0 @@
|
||||
export namespace Steam {
|
||||
//Source: https://github.com/woctezuma/steam-api/blob/master/data/genres.json
|
||||
export const genres = {
|
||||
"1": "Action",
|
||||
"2": "Strategy",
|
||||
"3": "RPG",
|
||||
"4": "Casual",
|
||||
"9": "Racing",
|
||||
"18": "Sports",
|
||||
"23": "Indie",
|
||||
"25": "Adventure",
|
||||
"28": "Simulation",
|
||||
"29": "Massively Multiplayer",
|
||||
"37": "Free to Play",
|
||||
"50": "Accounting",
|
||||
"51": "Animation & Modeling",
|
||||
"52": "Audio Production",
|
||||
"53": "Design & Illustration",
|
||||
"54": "Education",
|
||||
"55": "Photo Editing",
|
||||
"56": "Software Training",
|
||||
"57": "Utilities",
|
||||
"58": "Video Production",
|
||||
"59": "Web Publishing",
|
||||
"60": "Game Development",
|
||||
"70": "Early Access",
|
||||
"71": "Sexual Content",
|
||||
"72": "Nudity",
|
||||
"73": "Violent",
|
||||
"74": "Gore",
|
||||
"80": "Movie",
|
||||
"81": "Documentary",
|
||||
"82": "Episodic",
|
||||
"83": "Short",
|
||||
"84": "Tutorial",
|
||||
"85": "360 Video"
|
||||
}
|
||||
|
||||
//Source: https://github.com/woctezuma/steam-api/blob/master/data/categories.json
|
||||
export const categories = {
|
||||
"1": "Multi-player",
|
||||
"2": "Single-player",
|
||||
"6": "Mods (require HL2)",
|
||||
"7": "Mods (require HL1)",
|
||||
"8": "Valve Anti-Cheat enabled",
|
||||
"9": "Co-op",
|
||||
"10": "Demos",
|
||||
"12": "HDR available",
|
||||
"13": "Captions available",
|
||||
"14": "Commentary available",
|
||||
"15": "Stats",
|
||||
"16": "Includes Source SDK",
|
||||
"17": "Includes level editor",
|
||||
"18": "Partial Controller Support",
|
||||
"19": "Mods",
|
||||
"20": "MMO",
|
||||
"21": "Downloadable Content",
|
||||
"22": "Steam Achievements",
|
||||
"23": "Steam Cloud",
|
||||
"24": "Shared/Split Screen",
|
||||
"25": "Steam Leaderboards",
|
||||
"27": "Cross-Platform Multiplayer",
|
||||
"28": "Full controller support",
|
||||
"29": "Steam Trading Cards",
|
||||
"30": "Steam Workshop",
|
||||
"31": "VR Support",
|
||||
"32": "Steam Turn Notifications",
|
||||
"33": "Native Steam Controller",
|
||||
"35": "In-App Purchases",
|
||||
"36": "Online PvP",
|
||||
"37": "Shared/Split Screen PvP",
|
||||
"38": "Online Co-op",
|
||||
"39": "Shared/Split Screen Co-op",
|
||||
"40": "SteamVR Collectibles",
|
||||
"41": "Remote Play on Phone",
|
||||
"42": "Remote Play on Tablet",
|
||||
"43": "Remote Play on TV",
|
||||
"44": "Remote Play Together",
|
||||
"45": "Cloud Gaming",
|
||||
"46": "Cloud Gaming (NVIDIA)",
|
||||
"47": "LAN PvP",
|
||||
"48": "LAN Co-op",
|
||||
"49": "PvP",
|
||||
"50": "Additional High-Quality Audio",
|
||||
"51": "Steam Workshop",
|
||||
"52": "Tracked Controller Support",
|
||||
"53": "VR Supported",
|
||||
"54": "VR Only"
|
||||
}
|
||||
|
||||
// Source: https://files.catbox.moe/96bty7.json
|
||||
export const tags = {
|
||||
"9": "Strategy",
|
||||
"19": "Action",
|
||||
"21": "Adventure",
|
||||
"84": "Design & Illustration",
|
||||
"87": "Utilities",
|
||||
"113": "Free to Play",
|
||||
"122": "RPG",
|
||||
"128": "Massively Multiplayer",
|
||||
"492": "Indie",
|
||||
"493": "Early Access",
|
||||
"597": "Casual",
|
||||
"599": "Simulation",
|
||||
"699": "Racing",
|
||||
"701": "Sports",
|
||||
"784": "Video Production",
|
||||
"809": "Photo Editing",
|
||||
"872": "Animation & Modeling",
|
||||
"1027": "Audio Production",
|
||||
"1036": "Education",
|
||||
"1038": "Web Publishing",
|
||||
"1445": "Software Training",
|
||||
"1616": "Trains",
|
||||
"1621": "Music",
|
||||
"1625": "Platformer",
|
||||
"1628": "Metroidvania",
|
||||
"1638": "Dog",
|
||||
"1643": "Building",
|
||||
"1644": "Driving",
|
||||
"1645": "Tower Defense",
|
||||
"1646": "Hack and Slash",
|
||||
"1647": "Western",
|
||||
"1649": "GameMaker",
|
||||
"1651": "Satire",
|
||||
"1654": "Relaxing",
|
||||
"1659": "Zombies",
|
||||
"1662": "Survival",
|
||||
"1663": "FPS",
|
||||
"1664": "Puzzle",
|
||||
"1665": "Match 3",
|
||||
"1666": "Card Game",
|
||||
"1667": "Horror",
|
||||
"1669": "Moddable",
|
||||
"1670": "4X",
|
||||
"1671": "Superhero",
|
||||
"1673": "Aliens",
|
||||
"1674": "Typing",
|
||||
"1676": "RTS",
|
||||
"1677": "Turn-Based",
|
||||
"1678": "War",
|
||||
"1680": "Heist",
|
||||
"1681": "Pirates",
|
||||
"1684": "Fantasy",
|
||||
"1685": "Co-op",
|
||||
"1687": "Stealth",
|
||||
"1688": "Ninja",
|
||||
"1693": "Classic",
|
||||
"1695": "Open World",
|
||||
"1697": "Third Person",
|
||||
"1698": "Point & Click",
|
||||
"1702": "Crafting",
|
||||
"1708": "Tactical",
|
||||
"1710": "Surreal",
|
||||
"1714": "Psychedelic",
|
||||
"1716": "Roguelike",
|
||||
"1717": "Hex Grid",
|
||||
"1718": "MOBA",
|
||||
"1719": "Comedy",
|
||||
"1720": "Dungeon Crawler",
|
||||
"1721": "Psychological Horror",
|
||||
"1723": "Action RTS",
|
||||
"1730": "Sokoban",
|
||||
"1732": "Voxel",
|
||||
"1733": "Unforgiving",
|
||||
"1734": "Fast-Paced",
|
||||
"1736": "LEGO",
|
||||
"1738": "Hidden Object",
|
||||
"1741": "Turn-Based Strategy",
|
||||
"1742": "Story Rich",
|
||||
"1743": "Fighting",
|
||||
"1746": "Basketball",
|
||||
"1751": "Comic Book",
|
||||
"1752": "Rhythm",
|
||||
"1753": "Skateboarding",
|
||||
"1754": "MMORPG",
|
||||
"1755": "Space",
|
||||
"1756": "Great Soundtrack",
|
||||
"1759": "Perma Death",
|
||||
"1770": "Board Game",
|
||||
"1773": "Arcade",
|
||||
"1774": "Shooter",
|
||||
"1775": "PvP",
|
||||
"1777": "Steampunk",
|
||||
"3796": "Based On A Novel",
|
||||
"3798": "Side Scroller",
|
||||
"3799": "Visual Novel",
|
||||
"3810": "Sandbox",
|
||||
"3813": "Real Time Tactics",
|
||||
"3814": "Third-Person Shooter",
|
||||
"3834": "Exploration",
|
||||
"3835": "Post-apocalyptic",
|
||||
"3839": "First-Person",
|
||||
"3841": "Local Co-Op",
|
||||
"3843": "Online Co-Op",
|
||||
"3854": "Lore-Rich",
|
||||
"3859": "Multiplayer",
|
||||
"3871": "2D",
|
||||
"3877": "Precision Platformer",
|
||||
"3878": "Competitive",
|
||||
"3916": "Old School",
|
||||
"3920": "Cooking",
|
||||
"3934": "Immersive",
|
||||
"3942": "Sci-fi",
|
||||
"3952": "Gothic",
|
||||
"3955": "Character Action Game",
|
||||
"3959": "Roguelite",
|
||||
"3964": "Pixel Graphics",
|
||||
"3965": "Epic",
|
||||
"3968": "Physics",
|
||||
"3978": "Survival Horror",
|
||||
"3987": "Historical",
|
||||
"3993": "Combat",
|
||||
"4004": "Retro",
|
||||
"4018": "Vampire",
|
||||
"4026": "Difficult",
|
||||
"4036": "Parkour",
|
||||
"4046": "Dragons",
|
||||
"4057": "Magic",
|
||||
"4064": "Thriller",
|
||||
"4085": "Anime",
|
||||
"4094": "Minimalist",
|
||||
"4102": "Combat Racing",
|
||||
"4106": "Action-Adventure",
|
||||
"4115": "Cyberpunk",
|
||||
"4136": "Funny",
|
||||
"4137": "Transhumanism",
|
||||
"4145": "Cinematic",
|
||||
"4150": "World War II",
|
||||
"4155": "Class-Based",
|
||||
"4158": "Beat 'em up",
|
||||
"4161": "Real-Time",
|
||||
"4166": "Atmospheric",
|
||||
"4168": "Military",
|
||||
"4172": "Medieval",
|
||||
"4175": "Realistic",
|
||||
"4182": "Singleplayer",
|
||||
"4184": "Chess",
|
||||
"4190": "Addictive",
|
||||
"4191": "3D",
|
||||
"4195": "Cartoony",
|
||||
"4202": "Trading",
|
||||
"4231": "Action RPG",
|
||||
"4234": "Short",
|
||||
"4236": "Loot",
|
||||
"4242": "Episodic",
|
||||
"4252": "Stylized",
|
||||
"4255": "Shoot 'Em Up",
|
||||
"4291": "Spaceships",
|
||||
"4295": "Futuristic",
|
||||
"4305": "Colorful",
|
||||
"4325": "Turn-Based Combat",
|
||||
"4328": "City Builder",
|
||||
"4342": "Dark",
|
||||
"4345": "Gore",
|
||||
"4364": "Grand Strategy",
|
||||
"4376": "Assassin",
|
||||
"4400": "Abstract",
|
||||
"4434": "JRPG",
|
||||
"4474": "CRPG",
|
||||
"4486": "Choose Your Own Adventure",
|
||||
"4508": "Co-op Campaign",
|
||||
"4520": "Farming",
|
||||
"4559": "Quick-Time Events",
|
||||
"4562": "Cartoon",
|
||||
"4598": "Alternate History",
|
||||
"4604": "Dark Fantasy",
|
||||
"4608": "Swordplay",
|
||||
"4637": "Top-Down Shooter",
|
||||
"4667": "Violent",
|
||||
"4684": "Wargame",
|
||||
"4695": "Economy",
|
||||
"4700": "Movie",
|
||||
"4711": "Replay Value",
|
||||
"4726": "Cute",
|
||||
"4736": "2D Fighter",
|
||||
"4747": "Character Customization",
|
||||
"4754": "Politics",
|
||||
"4758": "Twin Stick Shooter",
|
||||
"4777": "Spectacle fighter",
|
||||
"4791": "Top-Down",
|
||||
"4821": "Mechs",
|
||||
"4835": "6DOF",
|
||||
"4840": "4 Player Local",
|
||||
"4845": "Capitalism",
|
||||
"4853": "Political",
|
||||
"4878": "Parody",
|
||||
"4885": "Bullet Hell",
|
||||
"4947": "Romance",
|
||||
"4975": "2.5D",
|
||||
"4994": "Naval Combat",
|
||||
"5030": "Dystopian",
|
||||
"5055": "eSports",
|
||||
"5094": "Narration",
|
||||
"5125": "Procedural Generation",
|
||||
"5153": "Kickstarter",
|
||||
"5154": "Score Attack",
|
||||
"5160": "Dinosaurs",
|
||||
"5179": "Cold War",
|
||||
"5186": "Psychological",
|
||||
"5228": "Blood",
|
||||
"5230": "Sequel",
|
||||
"5300": "God Game",
|
||||
"5310": "Games Workshop",
|
||||
"5348": "Mod",
|
||||
"5350": "Family Friendly",
|
||||
"5363": "Destruction",
|
||||
"5372": "Conspiracy",
|
||||
"5379": "2D Platformer",
|
||||
"5382": "World War I",
|
||||
"5390": "Time Attack",
|
||||
"5395": "3D Platformer",
|
||||
"5407": "Benchmark",
|
||||
"5411": "Beautiful",
|
||||
"5432": "Programming",
|
||||
"5502": "Hacking",
|
||||
"5537": "Puzzle Platformer",
|
||||
"5547": "Arena Shooter",
|
||||
"5577": "RPGMaker",
|
||||
"5608": "Emotional",
|
||||
"5611": "Mature",
|
||||
"5613": "Detective",
|
||||
"5652": "Collectathon",
|
||||
"5673": "Modern",
|
||||
"5708": "Remake",
|
||||
"5711": "Team-Based",
|
||||
"5716": "Mystery",
|
||||
"5727": "Baseball",
|
||||
"5752": "Robots",
|
||||
"5765": "Gun Customization",
|
||||
"5794": "Science",
|
||||
"5796": "Bullet Time",
|
||||
"5851": "Isometric",
|
||||
"5900": "Walking Simulator",
|
||||
"5914": "Tennis",
|
||||
"5923": "Dark Humor",
|
||||
"5941": "Reboot",
|
||||
"5981": "Mining",
|
||||
"5984": "Drama",
|
||||
"6041": "Horses",
|
||||
"6052": "Noir",
|
||||
"6129": "Logic",
|
||||
"6214": "Birds",
|
||||
"6276": "Inventory Management",
|
||||
"6310": "Diplomacy",
|
||||
"6378": "Crime",
|
||||
"6426": "Choices Matter",
|
||||
"6506": "3D Fighter",
|
||||
"6621": "Pinball",
|
||||
"6625": "Time Manipulation",
|
||||
"6650": "Nudity",
|
||||
"6691": "1990's",
|
||||
"6702": "Mars",
|
||||
"6730": "PvE",
|
||||
"6815": "Hand-drawn",
|
||||
"6869": "Nonlinear",
|
||||
"6910": "Naval",
|
||||
"6915": "Martial Arts",
|
||||
"6948": "Rome",
|
||||
"6971": "Multiple Endings",
|
||||
"7038": "Golf",
|
||||
"7107": "Real-Time with Pause",
|
||||
"7108": "Party",
|
||||
"7113": "Crowdfunded",
|
||||
"7178": "Party Game",
|
||||
"7208": "Female Protagonist",
|
||||
"7250": "Linear",
|
||||
"7309": "Skiing",
|
||||
"7328": "Bowling",
|
||||
"7332": "Base Building",
|
||||
"7368": "Local Multiplayer",
|
||||
"7423": "Sniper",
|
||||
"7432": "Lovecraftian",
|
||||
"7478": "Illuminati",
|
||||
"7481": "Controller",
|
||||
"7556": "Dice",
|
||||
"7569": "Grid-Based Movement",
|
||||
"7622": "Offroad",
|
||||
"7702": "Narrative",
|
||||
"7743": "1980s",
|
||||
"7782": "Cult Classic",
|
||||
"7918": "Dwarf",
|
||||
"7926": "Artificial Intelligence",
|
||||
"7948": "Soundtrack",
|
||||
"8013": "Software",
|
||||
"8075": "TrackIR",
|
||||
"8093": "Minigames",
|
||||
"8122": "Level Editor",
|
||||
"8253": "Music-Based Procedural Generation",
|
||||
"8369": "Investigation",
|
||||
"8461": "Well-Written",
|
||||
"8666": "Runner",
|
||||
"8945": "Resource Management",
|
||||
"9130": "Hentai",
|
||||
"9157": "Underwater",
|
||||
"9204": "Immersive Sim",
|
||||
"9271": "Trading Card Game",
|
||||
"9541": "Demons",
|
||||
"9551": "Dating Sim",
|
||||
"9564": "Hunting",
|
||||
"9592": "Dynamic Narration",
|
||||
"9803": "Snow",
|
||||
"9994": "Experience",
|
||||
"10235": "Life Sim",
|
||||
"10383": "Transportation",
|
||||
"10397": "Memes",
|
||||
"10437": "Trivia",
|
||||
"10679": "Time Travel",
|
||||
"10695": "Party-Based RPG",
|
||||
"10808": "Supernatural",
|
||||
"10816": "Split Screen",
|
||||
"11014": "Interactive Fiction",
|
||||
"11095": "Boss Rush",
|
||||
"11104": "Vehicular Combat",
|
||||
"11123": "Mouse only",
|
||||
"11333": "Villain Protagonist",
|
||||
"11634": "Vikings",
|
||||
"12057": "Tutorial",
|
||||
"12095": "Sexual Content",
|
||||
"12190": "Boxing",
|
||||
"12286": "Warhammer 40K",
|
||||
"12472": "Management",
|
||||
"13070": "Solitaire",
|
||||
"13190": "America",
|
||||
"13276": "Tanks",
|
||||
"13382": "Archery",
|
||||
"13577": "Sailing",
|
||||
"13782": "Experimental",
|
||||
"13906": "Game Development",
|
||||
"14139": "Turn-Based Tactics",
|
||||
"14153": "Dungeons & Dragons",
|
||||
"14720": "Nostalgia",
|
||||
"14906": "Intentionally Awkward Controls",
|
||||
"15045": "Flight",
|
||||
"15172": "Conversation",
|
||||
"15277": "Philosophical",
|
||||
"15339": "Documentary",
|
||||
"15564": "Fishing",
|
||||
"15868": "Motocross",
|
||||
"15954": "Silent Protagonist",
|
||||
"16094": "Mythology",
|
||||
"16250": "Gambling",
|
||||
"16598": "Space Sim",
|
||||
"16689": "Time Management",
|
||||
"17015": "Werewolves",
|
||||
"17305": "Strategy RPG",
|
||||
"17337": "Lemmings",
|
||||
"17389": "Tabletop",
|
||||
"17770": "Asynchronous Multiplayer",
|
||||
"17894": "Cats",
|
||||
"17927": "Pool",
|
||||
"18594": "FMV",
|
||||
"19568": "Cycling",
|
||||
"19780": "Submarine",
|
||||
"19995": "Dark Comedy",
|
||||
"21006": "Underground",
|
||||
"21491": "Demo Available",
|
||||
"21725": "Tactical RPG",
|
||||
"21978": "VR",
|
||||
"22602": "Agriculture",
|
||||
"22955": "Mini Golf",
|
||||
"24003": "Word Game",
|
||||
"24904": "NSFW",
|
||||
"25085": "Touch-Friendly",
|
||||
"26921": "Political Sim",
|
||||
"27758": "Voice Control",
|
||||
"28444": "Snowboarding",
|
||||
"29363": "3D Vision",
|
||||
"29482": "Souls-like",
|
||||
"29855": "Ambient",
|
||||
"30358": "Nature",
|
||||
"30927": "Fox",
|
||||
"31275": "Text-Based",
|
||||
"31579": "Otome",
|
||||
"32322": "Deckbuilding",
|
||||
"33572": "Mahjong",
|
||||
"35079": "Job Simulator",
|
||||
"42089": "Jump Scare",
|
||||
"42329": "Coding",
|
||||
"42804": "Action Roguelike",
|
||||
"44868": "LGBTQ+",
|
||||
"47827": "Wrestling",
|
||||
"49213": "Rugby",
|
||||
"51306": "Foreign",
|
||||
"56690": "On-Rails Shooter",
|
||||
"61357": "Electronic Music",
|
||||
"65443": "Adult Content",
|
||||
"71389": "Spelling",
|
||||
"87918": "Farming Sim",
|
||||
"91114": "Shop Keeper",
|
||||
"92092": "Jet",
|
||||
"96359": "Skating",
|
||||
"97376": "Cozy",
|
||||
"102530": "Elf",
|
||||
"117648": "8-bit Music",
|
||||
"123332": "Bikes",
|
||||
"129761": "ATV",
|
||||
"143739": "Electronic",
|
||||
"150626": "Gaming",
|
||||
"158638": "Cricket",
|
||||
"176981": "Battle Royale",
|
||||
"180368": "Faith",
|
||||
"189941": "Instrumental Music",
|
||||
"198631": "Mystery Dungeon",
|
||||
"198913": "Motorbike",
|
||||
"220585": "Colony Sim",
|
||||
"233824": "Feature Film",
|
||||
"252854": "BMX",
|
||||
"255534": "Automation",
|
||||
"323922": "Musou",
|
||||
"324176": "Hockey",
|
||||
"337964": "Rock Music",
|
||||
"348922": "Steam Machine",
|
||||
"353880": "Looter Shooter",
|
||||
"363767": "Snooker",
|
||||
"379975": "Clicker",
|
||||
"454187": "Traditional Roguelike",
|
||||
"552282": "Wholesome",
|
||||
"603297": "Hardware",
|
||||
"615955": "Idler",
|
||||
"620519": "Hero Shooter",
|
||||
"745697": "Social Deduction",
|
||||
"769306": "Escape Room",
|
||||
"776177": "360 Video",
|
||||
"791774": "Card Battler",
|
||||
"847164": "Volleyball",
|
||||
"856791": "Asymmetric VR",
|
||||
"916648": "Creature Collector",
|
||||
"922563": "Roguevania",
|
||||
"1003823": "Profile Features Limited",
|
||||
"1023537": "Boomer Shooter",
|
||||
"1084988": "Auto Battler",
|
||||
"1091588": "Roguelike Deckbuilder",
|
||||
"1100686": "Outbreak Sim",
|
||||
"1100687": "Automobile Sim",
|
||||
"1100688": "Medical Sim",
|
||||
"1100689": "Open World Survival Craft",
|
||||
"1199779": "Extraction Shooter",
|
||||
"1220528": "Hobby Sim",
|
||||
"1254546": "Football (Soccer)",
|
||||
"1254552": "Football (American)",
|
||||
"1368160": "AI Content Disclosed",
|
||||
}
|
||||
}
|
||||
@@ -1,600 +0,0 @@
|
||||
export interface SteamApp {
|
||||
/** Steam application ID */
|
||||
appid: number;
|
||||
|
||||
/** Array of Steam IDs that own this app */
|
||||
owner_steamids: string[];
|
||||
|
||||
/** Name of the game/application */
|
||||
name: string;
|
||||
|
||||
/** Filename of the game's capsule image */
|
||||
capsule_filename: string;
|
||||
|
||||
/** Hash value for the game's icon */
|
||||
img_icon_hash: string;
|
||||
|
||||
/** Reason code for exclusion (0 indicates no exclusion) */
|
||||
exclude_reason: number;
|
||||
|
||||
/** Unix timestamp when the app was acquired */
|
||||
rt_time_acquired: number;
|
||||
|
||||
/** Unix timestamp when the app was last played */
|
||||
rt_last_played: number;
|
||||
|
||||
/** Total playtime in seconds */
|
||||
rt_playtime: number;
|
||||
|
||||
/** Type identifier for the app (1 = game) */
|
||||
app_type: number;
|
||||
|
||||
/** Array of content descriptor IDs */
|
||||
content_descriptors?: number[];
|
||||
}
|
||||
|
||||
export interface SteamApiResponse {
|
||||
response: {
|
||||
apps: SteamApp[];
|
||||
owner_steamid: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SteamAppDataResponse {
|
||||
data: Record<string, SteamAppEntry>;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface SteamAppEntry {
|
||||
_change_number: number;
|
||||
_missing_token: boolean;
|
||||
_sha: string;
|
||||
_size: number;
|
||||
appid: string;
|
||||
common: CommonData;
|
||||
config: AppConfig;
|
||||
depots: AppDepots;
|
||||
extended: AppExtended;
|
||||
ufs: UFSData;
|
||||
}
|
||||
|
||||
export interface CommonData {
|
||||
associations: Record<string, { name: string; type: string }>;
|
||||
category: Record<string, string>;
|
||||
clienticon: string;
|
||||
clienttga: string;
|
||||
community_hub_visible: string;
|
||||
community_visible_stats: string;
|
||||
content_descriptors: Record<string, string>;
|
||||
controller_support?: string;
|
||||
controllertagwizard: string;
|
||||
gameid: string;
|
||||
genres: Record<string, string>;
|
||||
header_image: Record<string, string>;
|
||||
icon: string;
|
||||
languages: Record<string, string>;
|
||||
library_assets: LibraryAssets;
|
||||
library_assets_full: LibraryAssetsFull;
|
||||
metacritic_fullurl: string;
|
||||
metacritic_name: string;
|
||||
metacritic_score: string;
|
||||
name: string;
|
||||
name_localized: Partial<Record<LanguageCode, string>>;
|
||||
osarch: string;
|
||||
osextended: string;
|
||||
oslist: string;
|
||||
primary_genre: string;
|
||||
releasestate: string;
|
||||
review_percentage: string;
|
||||
review_score: string;
|
||||
small_capsule: Record<string, string>;
|
||||
steam_deck_compatibility: SteamDeckCompatibility;
|
||||
steam_release_date: string;
|
||||
store_asset_mtime: string;
|
||||
store_tags: Record<string, string>;
|
||||
supported_languages: Record<
|
||||
string,
|
||||
{
|
||||
full_audio?: string;
|
||||
subtitles?: string;
|
||||
supported?: string;
|
||||
}
|
||||
>;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface LibraryAssets {
|
||||
library_capsule: string;
|
||||
library_header: string;
|
||||
library_hero: string;
|
||||
library_logo: string;
|
||||
logo_position: LogoPosition;
|
||||
}
|
||||
|
||||
export interface LogoPosition {
|
||||
height_pct: string;
|
||||
pinned_position: string;
|
||||
width_pct: string;
|
||||
}
|
||||
|
||||
export interface LibraryAssetsFull {
|
||||
library_capsule: ImageSet;
|
||||
library_header: ImageSet;
|
||||
library_hero: ImageSet;
|
||||
library_logo: ImageSet & { logo_position: LogoPosition };
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface ImageSet {
|
||||
image: Record<string, string>;
|
||||
image2x?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SteamDeckCompatibility {
|
||||
category: string;
|
||||
configuration: Record<string, string>;
|
||||
test_timestamp: string;
|
||||
tested_build_id: string;
|
||||
tests: Record<string, { display: string; token: string }>;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
installdir: string;
|
||||
launch: Record<
|
||||
string,
|
||||
{
|
||||
executable: string;
|
||||
type: string;
|
||||
arguments?: string;
|
||||
description?: string;
|
||||
description_loc?: Record<string, string>;
|
||||
config?: {
|
||||
betakey: string;
|
||||
};
|
||||
}
|
||||
>;
|
||||
steamcontrollertemplateindex: string;
|
||||
steamdecktouchscreen: string;
|
||||
}
|
||||
|
||||
export interface AppDepots {
|
||||
branches: AppDepotBranches;
|
||||
privatebranches: Record<string, AppDepotBranches>;
|
||||
[depotId: string]: DepotEntry
|
||||
| AppDepotBranches
|
||||
| Record<string, AppDepotBranches>;
|
||||
}
|
||||
|
||||
|
||||
export interface DepotEntry {
|
||||
manifests: {
|
||||
public: {
|
||||
download: string;
|
||||
gid: string;
|
||||
size: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppDepotBranches {
|
||||
[branchName: string]: {
|
||||
buildid: string;
|
||||
timeupdated: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppExtended {
|
||||
additional_dependencies: Array<{
|
||||
dest_os: string;
|
||||
h264: string;
|
||||
src_os: string;
|
||||
}>;
|
||||
developer: string;
|
||||
dlcavailableonstore: string;
|
||||
homepage: string;
|
||||
listofdlc: string;
|
||||
publisher: string;
|
||||
}
|
||||
|
||||
export interface UFSData {
|
||||
maxnumfiles: string;
|
||||
quota: string;
|
||||
savefiles: Array<{
|
||||
path: string;
|
||||
pattern: string;
|
||||
recursive: string;
|
||||
root: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type LanguageCode =
|
||||
| "english"
|
||||
| "french"
|
||||
| "german"
|
||||
| "italian"
|
||||
| "japanese"
|
||||
| "koreana"
|
||||
| "polish"
|
||||
| "russian"
|
||||
| "schinese"
|
||||
| "tchinese"
|
||||
| "brazilian"
|
||||
| "spanish";
|
||||
|
||||
export interface Screenshot {
|
||||
appid: number;
|
||||
id: number;
|
||||
filename: string;
|
||||
all_ages: string;
|
||||
normalized_name: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
strDisplayName: string;
|
||||
}
|
||||
|
||||
export interface ReviewSummary {
|
||||
strReviewSummary: string;
|
||||
cReviews: number;
|
||||
cRecommendationsPositive: number;
|
||||
cRecommendationsNegative: number;
|
||||
nReviewScore: number;
|
||||
}
|
||||
|
||||
export interface GameDetailsResponse {
|
||||
strReleaseDate: string;
|
||||
strDescription: string;
|
||||
rgScreenshots: Screenshot[];
|
||||
rgCategories: Category[];
|
||||
strGenres?: string;
|
||||
strFullDescription: string;
|
||||
strMicroTrailerURL: string;
|
||||
ReviewSummary: ReviewSummary;
|
||||
}
|
||||
|
||||
// Define the TypeScript interfaces
|
||||
export interface Tag {
|
||||
tagid: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TagWithSlug {
|
||||
name: string;
|
||||
slug: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface StoreTags {
|
||||
[key: string]: string; // Index signature for numeric string keys to tag ID strings
|
||||
}
|
||||
|
||||
|
||||
export interface GameTagsResponse {
|
||||
tags: Tag[];
|
||||
success: number;
|
||||
rwgrsn: number;
|
||||
}
|
||||
|
||||
export type GenreType = {
|
||||
type: 'genre';
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export interface AppInfo {
|
||||
name: string;
|
||||
slug: string;
|
||||
images: {
|
||||
logo: string;
|
||||
backdrop: string;
|
||||
poster: string;
|
||||
banner: string;
|
||||
screenshots: string[];
|
||||
icon: string;
|
||||
}
|
||||
links: string[] | null;
|
||||
score: number;
|
||||
id: string;
|
||||
releaseDate: Date;
|
||||
description: string | null;
|
||||
compatibility: "low" | "mid" | "high" | "unknown";
|
||||
controllerSupport: "partial" | "full" | "unknown";
|
||||
primaryGenre: string | null;
|
||||
size: { downloadSize: number; sizeOnDisk: number };
|
||||
tags: Array<{ name: string; slug: string; type: "tag" }>;
|
||||
genres: Array<{ type: "genre"; name: string; slug: string }>;
|
||||
categories: Array<{ name: string; slug: string; type: "categorie" }>;
|
||||
franchises: Array<{ name: string; slug: string; type: "franchise" }>;
|
||||
developers: Array<{ name: string; slug: string; type: "developer" }>;
|
||||
publishers: Array<{ name: string; slug: string; type: "publisher" }>;
|
||||
}
|
||||
|
||||
export type ImageType =
|
||||
| 'screenshot'
|
||||
| 'boxArt'
|
||||
| 'banner'
|
||||
| 'backdrop'
|
||||
| 'icon'
|
||||
| 'logo'
|
||||
| 'poster'
|
||||
| 'heroArt';
|
||||
|
||||
export interface ImageInfo {
|
||||
type: ImageType;
|
||||
position: number;
|
||||
hash: string;
|
||||
sourceUrl: string | null;
|
||||
format?: string;
|
||||
averageColor: { hex: string; isDark: boolean };
|
||||
dimensions: { width: number; height: number };
|
||||
fileSize: number;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
export interface CompareOpts {
|
||||
/** Pixelmatch color threshold (0–1). Default: 0.1 */
|
||||
threshold?: number;
|
||||
/** If true, return an image buffer of the diff map. Default: false */
|
||||
diffOutput?: boolean;
|
||||
}
|
||||
|
||||
export interface CompareResult {
|
||||
diffRatio: number;
|
||||
/** Present only if `diffOutput: true` */
|
||||
diffBuffer?: Buffer;
|
||||
}
|
||||
|
||||
export interface Shot {
|
||||
url: string;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
export interface RankedShot {
|
||||
url: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface SteamPlayerSummaryResponse {
|
||||
response: {
|
||||
players: SteamPlayerSummary[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SteamPlayerSummary {
|
||||
steamid: string;
|
||||
communityvisibilitystate: number;
|
||||
profilestate?: number;
|
||||
personaname: string;
|
||||
profileurl: string;
|
||||
avatar: string;
|
||||
avatarmedium: string;
|
||||
avatarfull: string;
|
||||
avatarhash: string;
|
||||
lastlogoff?: number;
|
||||
personastate: number;
|
||||
realname?: string;
|
||||
primaryclanid?: string;
|
||||
timecreated: number;
|
||||
personastateflags?: number;
|
||||
loccountrycode?: string;
|
||||
}
|
||||
|
||||
export interface SteamPlayerBansResponse {
|
||||
players: SteamPlayerBan[];
|
||||
}
|
||||
|
||||
export interface SteamPlayerBan {
|
||||
SteamId: string;
|
||||
CommunityBanned: boolean;
|
||||
VACBanned: boolean;
|
||||
NumberOfVACBans: number;
|
||||
DaysSinceLastBan: number;
|
||||
NumberOfGameBans: number;
|
||||
EconomyBan: 'none' | 'probation' | 'banned'; // Enum based on known possible values
|
||||
}
|
||||
|
||||
export type SteamAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
realName: string | null;
|
||||
steamMemberSince: Date;
|
||||
avatarHash: string;
|
||||
limitations: {
|
||||
isLimited: boolean;
|
||||
tradeBanState: 'none' | 'probation' | 'banned';
|
||||
isVacBanned: boolean;
|
||||
visibilityState: number;
|
||||
privacyState: 'public' | 'private' | 'friendsonly';
|
||||
};
|
||||
profileUrl: string;
|
||||
lastSyncedAt: Date;
|
||||
};
|
||||
|
||||
export interface SteamFriendsListResponse {
|
||||
friendslist: {
|
||||
friends: SteamFriend[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SteamFriend {
|
||||
steamid: string;
|
||||
relationship: 'friend'; // could expand this if Steam ever adds more types
|
||||
friend_since: number; // Unix timestamp (seconds)
|
||||
}
|
||||
|
||||
export interface SteamOwnedGamesResponse {
|
||||
response: {
|
||||
game_count: number;
|
||||
games: SteamOwnedGame[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SteamOwnedGame {
|
||||
appid: number;
|
||||
name: string;
|
||||
playtime_forever: number;
|
||||
img_icon_url: string;
|
||||
|
||||
playtime_windows_forever?: number;
|
||||
playtime_mac_forever?: number;
|
||||
playtime_linux_forever?: number;
|
||||
playtime_deck_forever?: number;
|
||||
|
||||
rtime_last_played?: number; // Unix timestamp
|
||||
content_descriptorids?: number[];
|
||||
playtime_disconnected?: number;
|
||||
has_community_visible_stats?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape of the parsed Steam profile information.
|
||||
*/
|
||||
export interface ProfileInfo {
|
||||
steamID64: string;
|
||||
isLimited: boolean;
|
||||
privacyState: 'public' | 'private' | 'friendsonly' | string;
|
||||
visibility: string;
|
||||
}
|
||||
|
||||
export interface SteamStoreResponse {
|
||||
response: {
|
||||
store_items: SteamStoreItem[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SteamStoreItem {
|
||||
item_type: number;
|
||||
id: number;
|
||||
success: number;
|
||||
visible: boolean;
|
||||
name: string;
|
||||
store_url_path: string;
|
||||
appid: number;
|
||||
type: number;
|
||||
tagids: number[];
|
||||
categories: {
|
||||
supported_player_categoryids?: number[];
|
||||
feature_categoryids?: number[];
|
||||
controller_categoryids?: number[];
|
||||
};
|
||||
reviews: {
|
||||
summary_filtered: {
|
||||
review_count: number;
|
||||
percent_positive: number;
|
||||
review_score: number;
|
||||
review_score_label: string;
|
||||
};
|
||||
};
|
||||
basic_info: {
|
||||
short_description?: string;
|
||||
publishers: SteamCreator[];
|
||||
developers: SteamCreator[];
|
||||
franchises?: SteamCreator[];
|
||||
};
|
||||
tags: {
|
||||
tagid: number;
|
||||
weight: number;
|
||||
}[];
|
||||
assets: SteamAssets;
|
||||
assets_without_overrides: SteamAssets;
|
||||
release: {
|
||||
steam_release_date: number;
|
||||
};
|
||||
platforms: {
|
||||
windows: boolean;
|
||||
mac: boolean;
|
||||
steamos_linux: boolean;
|
||||
vr_support: Record<string, never>;
|
||||
steam_deck_compat_category?: number;
|
||||
steam_os_compat_category?: number;
|
||||
};
|
||||
best_purchase_option: PurchaseOption;
|
||||
purchase_options: PurchaseOption[];
|
||||
screenshots: {
|
||||
all_ages_screenshots: {
|
||||
filename: string;
|
||||
ordinal: number;
|
||||
}[];
|
||||
};
|
||||
trailers: {
|
||||
highlights: Trailer[];
|
||||
};
|
||||
supported_languages: SupportedLanguage[];
|
||||
full_description: string;
|
||||
links?: {
|
||||
link_type: number;
|
||||
url: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface SteamCreator {
|
||||
name: string;
|
||||
creator_clan_account_id: number;
|
||||
}
|
||||
|
||||
export interface SteamAssets {
|
||||
asset_url_format: string;
|
||||
main_capsule: string;
|
||||
small_capsule: string;
|
||||
header: string;
|
||||
page_background: string;
|
||||
hero_capsule: string;
|
||||
hero_capsule_2x: string;
|
||||
library_capsule: string;
|
||||
library_capsule_2x: string;
|
||||
library_hero: string;
|
||||
library_hero_2x: string;
|
||||
community_icon: string;
|
||||
page_background_path: string;
|
||||
raw_page_background: string;
|
||||
}
|
||||
|
||||
export interface PurchaseOption {
|
||||
packageid?: number;
|
||||
bundleid?: number;
|
||||
purchase_option_name: string;
|
||||
final_price_in_cents: string;
|
||||
original_price_in_cents: string;
|
||||
formatted_final_price: string;
|
||||
formatted_original_price: string;
|
||||
discount_pct: number;
|
||||
active_discounts: ActiveDiscount[];
|
||||
user_can_purchase_as_gift: boolean;
|
||||
hide_discount_pct_for_compliance: boolean;
|
||||
included_game_count: number;
|
||||
bundle_discount_pct?: number;
|
||||
price_before_bundle_discount?: string;
|
||||
formatted_price_before_bundle_discount?: string;
|
||||
}
|
||||
|
||||
export interface ActiveDiscount {
|
||||
discount_amount: string;
|
||||
discount_description: string;
|
||||
discount_end_date: number;
|
||||
}
|
||||
|
||||
export interface Trailer {
|
||||
trailer_name: string;
|
||||
trailer_url_format: string;
|
||||
trailer_category: number;
|
||||
trailer_480p: TrailerFile[];
|
||||
trailer_max: TrailerFile[];
|
||||
microtrailer: TrailerFile[];
|
||||
screenshot_medium: string;
|
||||
screenshot_full: string;
|
||||
trailer_base_id: number;
|
||||
all_ages: boolean;
|
||||
}
|
||||
|
||||
export interface TrailerFile {
|
||||
filename: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface SupportedLanguage {
|
||||
elanguage: number;
|
||||
eadditionallanguage: number;
|
||||
supported: boolean;
|
||||
full_audio: boolean;
|
||||
subtitles: boolean;
|
||||
}
|
||||
@@ -1,524 +0,0 @@
|
||||
import type {
|
||||
Tag,
|
||||
StoreTags,
|
||||
AppDepots,
|
||||
GenreType,
|
||||
LibraryAssetsFull,
|
||||
DepotEntry,
|
||||
CompareOpts,
|
||||
CompareResult,
|
||||
RankedShot,
|
||||
Shot,
|
||||
ProfileInfo,
|
||||
} from "./types";
|
||||
import crypto from 'crypto';
|
||||
import pLimit from 'p-limit';
|
||||
import { PNG } from 'pngjs';
|
||||
import pixelmatch from 'pixelmatch';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import { Agent as HttpAgent } from 'http';
|
||||
import { Agent as HttpsAgent } from 'https';
|
||||
import { parseStringPromise } from "xml2js";
|
||||
import sharp, { type Metadata } from 'sharp';
|
||||
import AbortController from 'abort-controller';
|
||||
import fetch, { RequestInit } from 'node-fetch';
|
||||
import { FastAverageColor } from 'fast-average-color';
|
||||
|
||||
const fac = new FastAverageColor()
|
||||
// --- Configuration ---
|
||||
const httpAgent = new HttpAgent({ keepAlive: true, maxSockets: 50 });
|
||||
const httpsAgent = new HttpsAgent({ keepAlive: true, maxSockets: 50 });
|
||||
const downloadCache = new LRUCache<string, Buffer>({
|
||||
max: 100,
|
||||
ttl: 1000 * 60 * 30, // 30-minute expiry
|
||||
allowStale: false,
|
||||
});
|
||||
const downloadLimit = pLimit(10); // max concurrent downloads
|
||||
const compareCache = new LRUCache<string, CompareResult>({
|
||||
max: 50,
|
||||
ttl: 1000 * 60 * 10, // 10-minute expiry
|
||||
});
|
||||
|
||||
export namespace Utils {
|
||||
export async function fetchBuffer(url: string, retries = 3): Promise<Buffer> {
|
||||
if (downloadCache.has(url)) {
|
||||
return downloadCache.get(url)!;
|
||||
}
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const id = setTimeout(() => controller.abort(), 15_000);
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
agent: (_parsed) => _parsed.protocol === 'http:' ? httpAgent : httpsAgent
|
||||
} as RequestInit);
|
||||
clearTimeout(id);
|
||||
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
downloadCache.set(url, buf);
|
||||
return buf;
|
||||
} catch (error: any) {
|
||||
lastError = error as Error;
|
||||
console.warn(`Attempt ${attempt + 1} failed for ${url}: ${error.message}`);
|
||||
if (attempt < retries - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error(`Failed to fetch ${url} after ${retries} attempts`);
|
||||
}
|
||||
|
||||
export async function getImageMetadata(buffer: Buffer) {
|
||||
const hash = crypto.createHash('sha256').update(buffer).digest('hex');
|
||||
const { width, height, format, size: fileSize } = await sharp(buffer).metadata();
|
||||
if (!width || !height) throw new Error('Invalid dimensions');
|
||||
|
||||
const slice = await sharp(buffer)
|
||||
.resize({ width: Math.min(width, 256) }) // cheap shrink
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
const pixelArray = new Uint8Array(slice.buffer);
|
||||
const { hex, isDark } = fac.prepareResult(fac.getColorFromArray4(pixelArray, { mode: "precision" }));
|
||||
|
||||
return { hash, format, averageColor: { hex, isDark }, dimensions: { width, height }, fileSize, buffer };
|
||||
}
|
||||
|
||||
// --- Optimized Box Art creation ---
|
||||
export async function createBoxArtBuffer(
|
||||
logoUrl: string,
|
||||
backgroundUrl: string,
|
||||
logoPercent = 0.9
|
||||
): Promise<Buffer> {
|
||||
const [bgBuf, logoBuf] = await Promise.all([
|
||||
downloadLimit(() =>
|
||||
fetchBuffer(backgroundUrl)
|
||||
.catch(error => {
|
||||
console.error(`Failed to download hero image from ${backgroundUrl}:`, error);
|
||||
throw new Error(`Failed to create box art: hero image unavailable`);
|
||||
}),
|
||||
),
|
||||
downloadLimit(() => fetchBuffer(logoUrl)
|
||||
.catch(error => {
|
||||
console.error(`Failed to download logo image from ${logoUrl}:`, error);
|
||||
throw new Error(`Failed to create box art: logo image unavailable`);
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
const bgImage = sharp(bgBuf);
|
||||
const meta = await bgImage.metadata();
|
||||
if (!meta.width || !meta.height) throw new Error('Invalid background dimensions');
|
||||
const size = Math.min(meta.width, meta.height);
|
||||
const left = Math.floor((meta.width - size) / 2);
|
||||
const top = Math.floor((meta.height - size) / 2);
|
||||
const squareBg = bgImage.extract({ left, top, width: size, height: size });
|
||||
|
||||
// Resize logo
|
||||
const logoTarget = Math.floor(size * logoPercent);
|
||||
const logoResized = await sharp(logoBuf).resize({ width: logoTarget }).toBuffer();
|
||||
const logoMeta = await sharp(logoResized).metadata();
|
||||
if (!logoMeta.width || !logoMeta.height) throw new Error('Invalid logo dimensions');
|
||||
const logoLeft = Math.floor((size - logoMeta.width) / 2);
|
||||
const logoTop = Math.floor((size - logoMeta.height) / 2);
|
||||
|
||||
return await squareBg
|
||||
.composite([{ input: logoResized, left: logoLeft, top: logoTop }])
|
||||
.jpeg({ quality: 100 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch JSON from the given URL, with Steam-like headers
|
||||
*/
|
||||
export async function fetchApi<T>(url: string, retries = 3): Promise<T> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
agent: (_parsed) => _parsed.protocol === 'http:' ? httpAgent : httpsAgent,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"User-Agent": "Steam 1291812 / iPhone",
|
||||
"Accept-Language": "en-us",
|
||||
},
|
||||
} as RequestInit);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
} catch (error: any) {
|
||||
lastError = error as Error;
|
||||
// Only retry on network errors or 5xx status codes
|
||||
if (error.message.includes('API error: 5') || !error.message.includes('API error')) {
|
||||
console.warn(`Attempt ${attempt + 1} failed for ${url}: ${error.message}`);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error(`Failed to fetch ${url} after ${retries} attempts`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a slug from a name
|
||||
*/
|
||||
export function createSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.normalize("NFKD") // Normalize to decompose accented characters
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, '') // Keep Unicode letters, numbers, spaces, and hyphens
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Collapse multiple hyphens
|
||||
.replace(/^-+|-+$/g, '') // Trim leading/trailing hyphens
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a candidate screenshot against a UI-free baseline to find how much UI/HUD remains.
|
||||
*
|
||||
* @param baselineBuffer - PNG/JPEG buffer of the clean background.
|
||||
* @param candidateBuffer - PNG/JPEG buffer of the screenshot to test.
|
||||
* @param opts - Options.
|
||||
* @returns Promise resolving to diff ratio (and optional diff image).
|
||||
*/
|
||||
export async function compareWithBaseline(
|
||||
baselineBuffer: Buffer,
|
||||
candidateBuffer: Buffer,
|
||||
opts: CompareOpts = {}
|
||||
): Promise<CompareResult> {
|
||||
// Generate cache key from buffer hashes
|
||||
const baseHash = crypto.createHash('md5').update(baselineBuffer).digest('hex');
|
||||
const candHash = crypto.createHash('md5').update(candidateBuffer).digest('hex');
|
||||
const optsKey = JSON.stringify(opts);
|
||||
const cacheKey = `${baseHash}:${candHash}:${optsKey}`;
|
||||
|
||||
// Check cache
|
||||
if (compareCache.has(cacheKey)) {
|
||||
return compareCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
const { threshold = 0.1, diffOutput = false } = opts;
|
||||
|
||||
// Get dimensions of baseline
|
||||
const baseMeta: Metadata = await sharp(baselineBuffer).metadata();
|
||||
if (!baseMeta.width || !baseMeta.height) {
|
||||
throw new Error('Invalid baseline dimensions');
|
||||
}
|
||||
|
||||
// Produce PNG buffers of same size
|
||||
const [pngBaseBuf, pngCandBuf] = await Promise.all([
|
||||
sharp(baselineBuffer).png().toBuffer(),
|
||||
sharp(candidateBuffer)
|
||||
.resize(baseMeta.width, baseMeta.height)
|
||||
.png()
|
||||
.toBuffer(),
|
||||
]);
|
||||
|
||||
const imgBase = PNG.sync.read(pngBaseBuf);
|
||||
const imgCand = PNG.sync.read(pngCandBuf);
|
||||
const diffImg = new PNG({ width: baseMeta.width, height: baseMeta.height });
|
||||
|
||||
const numDiff = pixelmatch(
|
||||
imgBase.data,
|
||||
imgCand.data,
|
||||
diffImg.data,
|
||||
baseMeta.width,
|
||||
baseMeta.height,
|
||||
{ threshold }
|
||||
);
|
||||
|
||||
const total = baseMeta.width * baseMeta.height;
|
||||
const diffRatio = numDiff / total;
|
||||
|
||||
const result: CompareResult = { diffRatio };
|
||||
if (diffOutput) {
|
||||
result.diffBuffer = PNG.sync.write(diffImg);
|
||||
}
|
||||
|
||||
compareCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a baseline buffer and an array of screenshots, returns them sorted
|
||||
* ascending by diffRatio (least UI first).
|
||||
*/
|
||||
export async function rankScreenshots(
|
||||
baselineBuffer: Buffer,
|
||||
shots: Shot[],
|
||||
opts: CompareOpts = {}
|
||||
): Promise<RankedShot[]> {
|
||||
// Process up to 5 comparisons in parallel
|
||||
const compareLimit = pLimit(5);
|
||||
|
||||
// Run all comparisons with limited concurrency
|
||||
const results = await Promise.all(
|
||||
shots.map(shot =>
|
||||
compareLimit(async () => {
|
||||
const { diffRatio } = await compareWithBaseline(
|
||||
baselineBuffer,
|
||||
shot.buffer,
|
||||
opts
|
||||
);
|
||||
return { url: shot.url, score: diffRatio };
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return results.sort((a, b) => a.score - b.score);
|
||||
}
|
||||
|
||||
// --- Helpers for URLs ---
|
||||
export function getScreenshotUrls(screenshots: { appid: number; filename: string }[]): string[] {
|
||||
return screenshots.map(s => `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${s.appid}/${s.filename}`);
|
||||
}
|
||||
|
||||
export function getAssetUrls(assets: LibraryAssetsFull, appid: number | string, header: string) {
|
||||
const base = `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${appid}`;
|
||||
return {
|
||||
logo: `${base}/${assets.library_logo?.image2x?.english || assets.library_logo?.image?.english}`,
|
||||
backdrop: `${base}/${assets.library_hero?.image2x?.english || assets.library_hero?.image?.english}`,
|
||||
poster: `${base}/${assets.library_capsule?.image2x?.english || assets.library_capsule?.image?.english}`,
|
||||
banner: `${base}/${assets.library_header?.image2x?.english || assets.library_header?.image?.english || header}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a 0–5 score from positive/negative votes using a Wilson score confidence interval.
|
||||
* This formula adjusts the raw ratio based on the total number of votes to account for
|
||||
* statistical confidence. With few votes, the score regresses toward 2.5 (neutral).
|
||||
*
|
||||
* Compute a 0–5 score from positive/negative votes
|
||||
*/
|
||||
export function getRating(positive: number, negative: number): number {
|
||||
const total = positive + negative;
|
||||
if (!total) return 0;
|
||||
const avg = positive / total;
|
||||
// Apply Wilson score confidence adjustment and scale to 0-5 range
|
||||
const score = avg - (avg - 0.5) * Math.pow(2, -Math.log10(total + 1));
|
||||
return Math.round(score * 5 * 10) / 10;
|
||||
}
|
||||
|
||||
export function getAssociationsByTypeWithSlug<
|
||||
T extends "developer" | "publisher"
|
||||
>(
|
||||
associations: Record<string, { name: string; type: string }>,
|
||||
type: T
|
||||
): Array<{ name: string; slug: string; type: T }> {
|
||||
return Object.values(associations)
|
||||
.filter((a) => a.type === type)
|
||||
.map((a) => ({ name: a.name.trim(), slug: createSlug(a.name.trim()), type }));
|
||||
}
|
||||
|
||||
export function compatibilityType(type?: string): "low" | "mid" | "high" | "unknown" {
|
||||
switch (type) {
|
||||
case "1":
|
||||
return "high";
|
||||
case "2":
|
||||
return "mid";
|
||||
case "3":
|
||||
return "low";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function estimateRatingFromSummary(
|
||||
reviewCount: number,
|
||||
percentPositive: number
|
||||
): number {
|
||||
const positiveVotes = Math.round((percentPositive / 100) * reviewCount);
|
||||
const negativeVotes = reviewCount - positiveVotes;
|
||||
return getRating(positiveVotes, negativeVotes);
|
||||
}
|
||||
|
||||
export function mapGameTags<
|
||||
T extends string = "tag"
|
||||
>(
|
||||
available: Tag[],
|
||||
storeTags: StoreTags,
|
||||
): Array<{ name: string; slug: string; type: T }> {
|
||||
const tagMap = new Map<number, Tag>(available.map((t) => [t.tagid, t]));
|
||||
const result: Array<{ name: string; slug: string; type: T }> = Object.values(storeTags)
|
||||
.map((id) => tagMap.get(Number(id)))
|
||||
.filter((t): t is Tag => Boolean(t))
|
||||
.map((t) => ({ name: t.name.trim(), slug: createSlug(t.name), type: 'tag' as T }));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createType<
|
||||
T extends "developer" | "publisher" | "franchise" | "tag" | "categorie" | "genre"
|
||||
>(
|
||||
names: string[],
|
||||
type: T
|
||||
) {
|
||||
return names
|
||||
.map(name => ({
|
||||
type,
|
||||
name: name.trim(),
|
||||
slug: createSlug(name.trim())
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tag object with name, slug, and type
|
||||
* @typeparam T Literal type of the `type` field (defaults to 'tag')
|
||||
*/
|
||||
export function createTag<
|
||||
T extends string = 'tag'
|
||||
>(
|
||||
name: string,
|
||||
type?: T
|
||||
): { name: string; slug: string; type: T } {
|
||||
const tagType = (type ?? 'tag') as T;
|
||||
return {
|
||||
name: name.trim(),
|
||||
slug: createSlug(name),
|
||||
type: tagType,
|
||||
};
|
||||
}
|
||||
|
||||
export function capitalise(name: string) {
|
||||
return name
|
||||
.charAt(0) // first character
|
||||
.toUpperCase() // make it uppercase
|
||||
+ name
|
||||
.slice(1) // rest of the string
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function isDepotEntry(e: any): e is DepotEntry {
|
||||
return (
|
||||
e != null &&
|
||||
typeof e === 'object' &&
|
||||
'manifests' in e &&
|
||||
e.manifests != null &&
|
||||
typeof e.manifests.public?.download === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export function getPublicDepotSizes(depots: AppDepots) {
|
||||
let download = 0;
|
||||
let size = 0;
|
||||
|
||||
for (const key of Object.keys(depots)) {
|
||||
if (key === 'branches' || key === 'privatebranches') continue;
|
||||
const entry = depots[key] as DepotEntry;
|
||||
if (!isDepotEntry(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dl = Number(entry.manifests.public.download);
|
||||
const sz = Number(entry.manifests.public.size);
|
||||
if (!Number.isFinite(dl) || !Number.isFinite(sz)) {
|
||||
console.warn(`[getPublicDepotSizes] non-numeric size for depot ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
download += dl;
|
||||
size += sz;
|
||||
}
|
||||
|
||||
return { downloadSize: download, sizeOnDisk: size };
|
||||
}
|
||||
|
||||
export function parseGenres(str: string): GenreType[] {
|
||||
return str.split(',')
|
||||
.map((g) => g.trim())
|
||||
.filter(Boolean)
|
||||
.map((g) => ({ type: 'genre', name: g.trim(), slug: createSlug(g) }));
|
||||
}
|
||||
|
||||
export function getPrimaryGenre(
|
||||
genres: GenreType[],
|
||||
map: Record<string, string>,
|
||||
primaryId: string
|
||||
): string | null {
|
||||
const idx = Object.keys(map).find((k) => map[k] === primaryId);
|
||||
return idx !== undefined ? genres[Number(idx)]?.name : null;
|
||||
}
|
||||
|
||||
export function cleanDescription(input: string): string {
|
||||
|
||||
const cleaned = sanitizeHtml(input, {
|
||||
allowedTags: [], // no tags allowed
|
||||
allowedAttributes: {}, // no attributes anywhere
|
||||
textFilter: (text) => text.replace(/\s+/g, ' '), // collapse runs of whitespace
|
||||
});
|
||||
|
||||
return cleaned.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and parses a single Steam community profile XML.
|
||||
* @param steamIdOrVanity - The 64-bit SteamID or vanity name.
|
||||
* @returns Promise resolving to ProfileInfo.
|
||||
*/
|
||||
export async function fetchProfileInfo(
|
||||
steamIdOrVanity: string
|
||||
): Promise<ProfileInfo> {
|
||||
const isNumericId = /^\d+$/.test(steamIdOrVanity);
|
||||
const path = isNumericId ? `profiles/${steamIdOrVanity}` : `id/${steamIdOrVanity}`;
|
||||
const url = `https://steamcommunity.com/${path}/?xml=1`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${steamIdOrVanity}: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
const { profile } = await parseStringPromise(xml, {
|
||||
explicitArray: false,
|
||||
trim: true,
|
||||
mergeAttrs: true
|
||||
}) as { profile: any };
|
||||
|
||||
// Extract fields (fall back to limitedAccount tag if needed)
|
||||
const limitedFlag = profile.isLimitedAccount ?? profile.limitedAccount;
|
||||
const isLimited = limitedFlag === '1';
|
||||
|
||||
return {
|
||||
isLimited,
|
||||
steamID64: profile.steamID64,
|
||||
privacyState: profile.privacyState,
|
||||
visibility: profile.visibilityState
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-fetches multiple Steam profiles in parallel.
|
||||
* @param idsOrVanities - Array of SteamID64 strings or vanity names.
|
||||
* @returns Promise resolving to a record mapping each input to its ProfileInfo or an error.
|
||||
*/
|
||||
export async function fetchProfilesInfo(
|
||||
idsOrVanities: string[]
|
||||
): Promise<Map<string, ProfileInfo | { error: string }>> {
|
||||
const results = await Promise.all(
|
||||
idsOrVanities.map(async (input) => {
|
||||
try {
|
||||
const info = await fetchProfileInfo(input);
|
||||
return { input, result: info };
|
||||
} catch (err) {
|
||||
return { input, result: { error: (err as Error).message } };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return new Map(
|
||||
results.map(({ input, result }) => [input, result] as [string, ProfileInfo | { error: string }])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import "zod-openapi/extend";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import "zod-openapi/extend";
|
||||
|
||||
export namespace Common {
|
||||
export const IdDescription = `Unique object identifier.
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
export function createContext<T>() {
|
||||
export function createContext<T>(name: string) {
|
||||
const storage = new AsyncLocalStorage<T>();
|
||||
return {
|
||||
use() {
|
||||
const result = storage.getStore();
|
||||
if (!result) {
|
||||
throw new Error("No context available");
|
||||
throw new Error("Context not provided: " + name);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
provide<R>(value: T, fn: () => R) {
|
||||
return storage.run<R>(value, fn);
|
||||
with<R>(value: T, fn: () => R) {
|
||||
return storage.run<R, any[]>(value, fn);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "drizzle-orm";
|
||||
import { Resource } from "sst";
|
||||
import postgres from "postgres";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
|
||||
@@ -20,7 +20,7 @@ type TxOrDb = Transaction | typeof db;
|
||||
const TransactionContext = createContext<{
|
||||
tx: Transaction;
|
||||
effects: (() => void | Promise<void>)[];
|
||||
}>();
|
||||
}>("TransactionContext");
|
||||
|
||||
export async function useTransaction<T>(callback: (trx: TxOrDb) => Promise<T>) {
|
||||
try {
|
||||
@@ -51,7 +51,7 @@ export async function createTransaction<T>(
|
||||
const effects: (() => void | Promise<void>)[] = [];
|
||||
const result = await db.transaction(
|
||||
async (tx) => {
|
||||
return TransactionContext.provide({ tx, effects }, () => callback(tx));
|
||||
return TransactionContext.with({ tx, effects }, () => callback(tx));
|
||||
},
|
||||
{
|
||||
isolationLevel: isolationLevel || "read committed",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { char, timestamp as rawTs } from "drizzle-orm/pg-core";
|
||||
import { teamTable } from "../team/team.sql";
|
||||
|
||||
export const ulid = (name: string) => char(name, { length: 26 + 4 });
|
||||
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { Actor } from "./actor";
|
||||
import { event } from "sst/event";
|
||||
import { useActor } from "./actor";
|
||||
import { event as sstEvent } from "sst/event";
|
||||
import { ZodValidator } from "sst/event/validator";
|
||||
|
||||
export const createEvent = event.builder({
|
||||
export const createEvent = sstEvent.builder({
|
||||
validator: ZodValidator,
|
||||
metadata() {
|
||||
return {
|
||||
actor: Actor.use(),
|
||||
actor: useActor(),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
import { openevent } from "@openauthjs/openevent/event";
|
||||
export { publish } from "@openauthjs/openevent/publisher/drizzle";
|
||||
|
||||
export const event = openevent({
|
||||
metadata() {
|
||||
return {
|
||||
actor: useActor(),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,30 +1,76 @@
|
||||
import { prefixes } from "./utils";
|
||||
|
||||
export namespace Examples {
|
||||
export const Id = (prefix: keyof typeof prefixes) =>
|
||||
`${prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`;
|
||||
|
||||
export const User = {
|
||||
id: Id("user"),// Primary key
|
||||
name: "John Doe", // Name (not null)
|
||||
email: "johndoe@example.com",// Unique email or login (not null)
|
||||
avatarUrl: "https://cdn.discordapp.com/avatars/xxxxxxx/xxxxxxx.png",
|
||||
lastLogin: new Date("2025-04-26T20:11:08.155Z"),
|
||||
polarCustomerID: "0bfcb712-df13-4454-81a8-fbee66eddca4"
|
||||
export const Steam = {
|
||||
id: Id("steam"),
|
||||
userID: Id("user"),
|
||||
countryCode: "KE",
|
||||
steamID: 74839300282033,
|
||||
limitation: {
|
||||
isLimited: false,
|
||||
isBanned: false,
|
||||
isLocked: false,
|
||||
isAllowedToInviteFriends: false,
|
||||
},
|
||||
lastGame: {
|
||||
gameID: 2531310,
|
||||
gameName: "The Last of Us™ Part II Remastered",
|
||||
},
|
||||
personaName: "John",
|
||||
username: "johnsteamaccount",
|
||||
steamEmail: "john@example.com",
|
||||
avatarUrl: "https://avatars.akamai.steamstatic.com/XXXXXXXXXXXX_full.jpg",
|
||||
}
|
||||
|
||||
export const GPUType = {
|
||||
id: Id("gpu"),
|
||||
type: "hosted" as const, //or BYOG - Bring Your Own GPU
|
||||
name: "RTX 4090" as const, // or RTX 3090, Intel Arc
|
||||
performanceTier: 3,
|
||||
maxResolution: "4k"
|
||||
export const User = {
|
||||
id: Id("user"),
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
discriminator: 47,
|
||||
avatarUrl: "https://cdn.discordapp.com/avatars/xxxxxxx/xxxxxxx.png",
|
||||
polarCustomerID: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
steamAccounts: [Steam]
|
||||
};
|
||||
|
||||
export const Product = {
|
||||
id: Id("product"),
|
||||
name: "RTX 4090",
|
||||
description: "Ideal for dedicated gamers who crave more flexibility and social gaming experiences.",
|
||||
tokensPerHour: 20,
|
||||
}
|
||||
|
||||
export const Subscription = {
|
||||
tokens: 100,
|
||||
id: Id("subscription"),
|
||||
userID: Id("user"),
|
||||
teamID: Id("team"),
|
||||
planType: "pro" as const, // free, pro, family, enterprise
|
||||
standing: "new" as const, // new, good, overdue, cancelled
|
||||
polarProductID: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
polarSubscriptionID: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
}
|
||||
|
||||
export const Member = {
|
||||
id: Id("member"),
|
||||
email: "john@example.com",
|
||||
teamID: Id("team"),
|
||||
role: "admin" as const,
|
||||
timeSeen: new Date("2025-02-23T13:39:52.249Z"),
|
||||
}
|
||||
|
||||
export const Team = {
|
||||
id: Id("team"),
|
||||
name: "John Does' Team",
|
||||
slug: "john_doe",
|
||||
subscriptions: [Subscription],
|
||||
members: [Member]
|
||||
}
|
||||
|
||||
export const Machine = {
|
||||
id: Id("machine"),
|
||||
ownerID: User.id, //or null if hosted
|
||||
gpuID: GPUType.id, // or hosted
|
||||
userID: Id("user"),
|
||||
country: "Kenya",
|
||||
countryCode: "KE",
|
||||
timezone: "Africa/Nairobi",
|
||||
@@ -32,244 +78,4 @@ export namespace Examples {
|
||||
fingerprint: "fc27f428f9ca47d4b41b707ae0c62090",
|
||||
}
|
||||
|
||||
export const SteamAccount = {
|
||||
status: "online" as const, //offline,dnd(do not disturb) or playing
|
||||
id: "74839300282033",// Steam ID
|
||||
userID: User.id,// | null FK to User (null if not linked)
|
||||
name: "JD The 65th",
|
||||
username: "jdoe",
|
||||
realName: "John Doe",
|
||||
steamMemberSince: new Date("2010-01-26T21:00:00.000Z"),
|
||||
avatarHash: "3a5e805fd4c1e04e26a97af0b9c6fab2dee91a19",
|
||||
accountStatus: "new" as const, //active or pending
|
||||
limitations: {
|
||||
isLimited: false,
|
||||
tradeBanState: "none" as const,
|
||||
isVacBanned: false,
|
||||
visibilityState: 3,
|
||||
privacyState: "public" as const,
|
||||
},
|
||||
profileUrl: "The65thJD", //"https://steamcommunity.com/id/XXXXXXXXXXXXXXXX/",
|
||||
lastSyncedAt: new Date("2025-04-26T20:11:08.155Z")
|
||||
};
|
||||
|
||||
export const Team = {
|
||||
id: Id("team"),// Primary key
|
||||
name: "John", // Team name (not null, unique)
|
||||
maxMembers: 3,
|
||||
inviteCode: "xwydjf",
|
||||
ownerSteamID: SteamAccount.id, // FK to User who owns/created the team
|
||||
members: [SteamAccount]
|
||||
};
|
||||
|
||||
export const Member = {
|
||||
id: Id("member"),
|
||||
userID: User.id,//FK to Users (member)
|
||||
steamID: SteamAccount.id, // FK to the Steam Account this member is used
|
||||
teamID: Team.id,// FK to Teams
|
||||
role: "adult" as const, // Role on the team, adult or child
|
||||
};
|
||||
|
||||
export const ProductVariant = {
|
||||
id: Id("variant"),
|
||||
productID: Id("product"),// the product this variant is under
|
||||
type: "fixed" as const, // or yearly or monthly,
|
||||
price: 1999,
|
||||
minutesPerDay: 3600,
|
||||
polarProductID: "0bfcb712-df13-4454-81a8-fbee66eddca4"
|
||||
}
|
||||
|
||||
export const Product = {
|
||||
id: Id("product"),
|
||||
name: "Pro",
|
||||
description: "For gamers who want to play on a better GPU and with 2 more friends",
|
||||
maxMembers: Team.maxMembers,// Total number of people who can share this sub
|
||||
isActive: true,
|
||||
order: 2,
|
||||
variants: [ProductVariant]
|
||||
}
|
||||
|
||||
export const Friend = {
|
||||
...Examples.SteamAccount,
|
||||
user: Examples.User
|
||||
}
|
||||
|
||||
export const Subscription = {
|
||||
id: Id("subscription"),
|
||||
teamID: Team.id,
|
||||
standing: "active" as const, //incomplete, incomplete_expired, trialing, active, past_due, canceled, unpaid
|
||||
ownerID: User.id,
|
||||
price: ProductVariant.price,
|
||||
productVariantID: ProductVariant.id,
|
||||
polarSubscriptionID: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
}
|
||||
|
||||
export const SubscriptionUsage = {
|
||||
id: Id("usage"),
|
||||
machineID: Machine.id, // machine this session was used on
|
||||
memberID: Member.id, // the team member who used it
|
||||
subscriptionID: Subscription.id,
|
||||
sessionID: Id("session"),
|
||||
minutesUsed: 20, // Minutes used on the session
|
||||
}
|
||||
|
||||
export const Session = {
|
||||
id: Id("session"),
|
||||
memberID: Member.id,
|
||||
machineID: Machine.id,
|
||||
startTime: new Date("2025-02-23T23:39:52.249Z"),
|
||||
endTime: null, // null if session is ongoing
|
||||
gameID: Id("game"),
|
||||
status: "active" as const, // active, completed, crashed
|
||||
}
|
||||
|
||||
export const GameGenre = {
|
||||
type: "genre" as const,
|
||||
slug: "action",
|
||||
name: "Action"
|
||||
}
|
||||
|
||||
export const GameTag = {
|
||||
type: "tag" as const,
|
||||
slug: "single-player",
|
||||
name: "Single Player"
|
||||
}
|
||||
|
||||
export const GameRating = {
|
||||
body: "ESRB" as const, // or PEGI
|
||||
age: 16,
|
||||
descriptors: ["Blood", "Violence", "Strong Language"],
|
||||
}
|
||||
|
||||
export const DevelopmentTeam = {
|
||||
type: "developer" as const,
|
||||
name: "Remedy Entertainment",
|
||||
slug: "remedy_entertainment",
|
||||
}
|
||||
|
||||
export const BaseGame = {
|
||||
id: "1809540",
|
||||
slug: "nine-sols",
|
||||
name: "Nine Sols",
|
||||
links:[
|
||||
"https://example.com"
|
||||
],
|
||||
controllerSupport: "full" as const,
|
||||
releaseDate: new Date("2024-05-29T06:53:24.000Z"),
|
||||
compatibility: "high" as const,
|
||||
size: {
|
||||
downloadSize: 7907568608,// 7.91 GB
|
||||
sizeOnDisk: 13176088178,// 13.18 GB
|
||||
},
|
||||
primaryGenre: "Action",
|
||||
score: 4.7,
|
||||
description: "Nine Sols is a lore rich, hand-drawn 2D action-platformer featuring Sekiro-inspired deflection focused combat. Embark on a journey of eastern fantasy, explore the land once home to an ancient alien race, and follow a vengeful hero’s quest to slay the 9 Sols, formidable rulers of this forsaken realm.",
|
||||
}
|
||||
|
||||
export const Categories = {
|
||||
genres: [
|
||||
{
|
||||
name: "Action",
|
||||
slug: "action"
|
||||
},
|
||||
{
|
||||
name: "Adventure",
|
||||
slug: "adventure"
|
||||
},
|
||||
{
|
||||
name: "Indie",
|
||||
slug: "indie"
|
||||
}
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: "Metroidvania",
|
||||
slug: "metroidvania",
|
||||
},
|
||||
{
|
||||
name: "Souls-like",
|
||||
slug: "souls-like",
|
||||
},
|
||||
{
|
||||
name: "Difficult",
|
||||
slug: "difficult",
|
||||
},
|
||||
],
|
||||
developers: [
|
||||
{
|
||||
name: "RedCandleGames",
|
||||
slug: "redcandlegames"
|
||||
}
|
||||
],
|
||||
publishers: [
|
||||
{
|
||||
name: "RedCandleGames",
|
||||
slug: "redcandlegames"
|
||||
}
|
||||
],
|
||||
franchises: [],
|
||||
categories: [
|
||||
{
|
||||
name: "Partial Controller",
|
||||
slug: "partial-controller"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export const CommonImg = [
|
||||
{
|
||||
hash: "db880dc2f0187bfe0c5d3c44a06d1002351eb3107970a83bf5667ffd3b369acd",
|
||||
averageColor: {
|
||||
hex: "#352c36",
|
||||
isDark: true
|
||||
},
|
||||
dimensions: {
|
||||
width: 3840,
|
||||
height: 2160
|
||||
},
|
||||
fileSize: 976004
|
||||
},
|
||||
{
|
||||
hash: "99f603e41dd3efde21a145fd00c9f107025c09433c084a5e5005bc2ac30e46ea",
|
||||
averageColor: {
|
||||
hex: "#596774",
|
||||
isDark: true
|
||||
},
|
||||
dimensions: {
|
||||
width: 2560,
|
||||
height: 1440
|
||||
},
|
||||
fileSize: 895134
|
||||
},
|
||||
{
|
||||
hash: "2c4193c19160392be01d08e6957ed682649117742c5abaa8c469e7408382572f",
|
||||
averageColor: {
|
||||
hex: "#444b5b",
|
||||
isDark: true
|
||||
},
|
||||
dimensions: {
|
||||
width: 2560,
|
||||
height: 1440
|
||||
},
|
||||
fileSize: 738701
|
||||
}
|
||||
]
|
||||
|
||||
// type: "screenshots" as const, // or boxart(square), poster(vertical), superheroart(background), heroart(horizontal), logo, icon
|
||||
export const Images = {
|
||||
screenshots: CommonImg,
|
||||
boxArts: CommonImg,
|
||||
posters: CommonImg,
|
||||
banners: CommonImg,
|
||||
heroArts: CommonImg,
|
||||
backdrops: CommonImg,
|
||||
logos: CommonImg,
|
||||
icons: CommonImg,
|
||||
}
|
||||
|
||||
export const Game = {
|
||||
...BaseGame,
|
||||
...Categories,
|
||||
...Images
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { timestamps, } from "../drizzle/types";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { index, pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const friendTable = pgTable(
|
||||
"friends_list",
|
||||
{
|
||||
...timestamps,
|
||||
steamID: varchar("steam_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => steamTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
friendSteamID: varchar("friend_steam_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => steamTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.steamID, table.friendSteamID]
|
||||
}),
|
||||
index("idx_friends_list_friend_steam_id").on(table.friendSteamID),
|
||||
]
|
||||
);
|
||||
@@ -1,190 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { User } from "../user";
|
||||
import { Steam } from "../steam";
|
||||
import { Actor } from "../actor";
|
||||
import { Examples } from "../examples";
|
||||
import { friendTable } from "./friend.sql";
|
||||
import { userTable } from "../user/user.sql";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { groupBy, map, pipe, values } from "remeda";
|
||||
import { ErrorCodes, VisibleError } from "../error";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Friend {
|
||||
export const Info = Steam.Info
|
||||
.extend({
|
||||
user: User.Info.nullable().openapi({
|
||||
description: "The user account that owns this Steam account",
|
||||
example: Examples.User
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Friend",
|
||||
description: "Represents a friend's information stored on Nestri",
|
||||
example: Examples.Friend,
|
||||
});
|
||||
|
||||
|
||||
export const InputInfo = createSelectSchema(friendTable)
|
||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
export type InputInfo = z.infer<typeof InputInfo>;
|
||||
|
||||
export const add = fn(
|
||||
InputInfo.partial({ steamID: true }),
|
||||
async (input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const steamID = input.steamID ?? Actor.steamID()
|
||||
if (steamID === input.friendSteamID) {
|
||||
throw new VisibleError(
|
||||
"forbidden",
|
||||
ErrorCodes.Validation.INVALID_PARAMETER,
|
||||
"Cannot add yourself as a friend"
|
||||
);
|
||||
}
|
||||
|
||||
const results =
|
||||
await tx
|
||||
.select()
|
||||
.from(friendTable)
|
||||
.where(
|
||||
and(
|
||||
eq(friendTable.steamID, steamID),
|
||||
eq(friendTable.friendSteamID, input.friendSteamID),
|
||||
isNull(friendTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
|
||||
if (results.length > 0) return null
|
||||
|
||||
await tx
|
||||
.insert(friendTable)
|
||||
.values({
|
||||
steamID,
|
||||
friendSteamID: input.friendSteamID
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [friendTable.steamID, friendTable.friendSteamID],
|
||||
set: { timeDeleted: null }
|
||||
})
|
||||
|
||||
return steamID
|
||||
}),
|
||||
)
|
||||
|
||||
export const end = fn(
|
||||
InputInfo,
|
||||
(input) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.update(friendTable)
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(
|
||||
and(
|
||||
eq(friendTable.steamID, input.steamID),
|
||||
eq(friendTable.friendSteamID, input.friendSteamID),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
export const list = () =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select({
|
||||
steam: steamTable,
|
||||
user: userTable,
|
||||
})
|
||||
.from(friendTable)
|
||||
.innerJoin(
|
||||
steamTable,
|
||||
eq(friendTable.friendSteamID, steamTable.id)
|
||||
)
|
||||
.leftJoin(
|
||||
userTable,
|
||||
eq(steamTable.userID, userTable.id)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(friendTable.steamID, Actor.steamID()),
|
||||
isNull(friendTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(100)
|
||||
.execute()
|
||||
.then(rows => serialize(rows))
|
||||
)
|
||||
|
||||
export const fromFriendID = fn(
|
||||
InputInfo.shape.friendSteamID,
|
||||
(friendSteamID) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select({
|
||||
steam: steamTable,
|
||||
user: userTable,
|
||||
})
|
||||
.from(friendTable)
|
||||
.innerJoin(
|
||||
steamTable,
|
||||
eq(friendTable.friendSteamID, steamTable.id)
|
||||
)
|
||||
.leftJoin(
|
||||
userTable,
|
||||
eq(steamTable.userID, userTable.id)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(friendTable.steamID, Actor.steamID()),
|
||||
eq(friendTable.friendSteamID, friendSteamID),
|
||||
isNull(friendTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.then(rows => serialize(rows).at(0))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
export const areFriends = fn(
|
||||
InputInfo.shape.friendSteamID,
|
||||
(friendSteamID) =>
|
||||
useTransaction(async (tx) => {
|
||||
const result = await tx
|
||||
.select()
|
||||
.from(friendTable)
|
||||
.where(
|
||||
and(
|
||||
eq(friendTable.steamID, Actor.steamID()),
|
||||
eq(friendTable.friendSteamID, friendSteamID),
|
||||
isNull(friendTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.execute()
|
||||
|
||||
return result.length > 0
|
||||
})
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: { user: typeof userTable.$inferSelect | null; steam: typeof steamTable.$inferSelect }[],
|
||||
): z.infer<typeof Info>[] {
|
||||
return pipe(
|
||||
input,
|
||||
groupBy((row) => row.steam.id),
|
||||
values(),
|
||||
map((group) => ({
|
||||
...Steam.serialize(group[0].steam),
|
||||
user: group[0].user ? User.serialize(group[0].user!) : null
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { timestamps } from "../drizzle/types";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { categoriesTable, CategoryTypeEnum } from "../categories/categories.sql";
|
||||
import { foreignKey, index, pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const gamesTable = pgTable(
|
||||
'games',
|
||||
{
|
||||
...timestamps,
|
||||
baseGameID: varchar('base_game_id', { length: 255 })
|
||||
.notNull()
|
||||
.references(() => baseGamesTable.id,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
categorySlug: varchar('category_slug', { length: 255 })
|
||||
.notNull(),
|
||||
categoryType: CategoryTypeEnum("type").notNull()
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.baseGameID, table.categorySlug, table.categoryType]
|
||||
}),
|
||||
foreignKey({
|
||||
name: "games_categories_fkey",
|
||||
columns: [table.categorySlug, table.categoryType],
|
||||
foreignColumns: [categoriesTable.slug, categoriesTable.type],
|
||||
}).onDelete("cascade"),
|
||||
index("idx_games_category_slug").on(table.categorySlug),
|
||||
index("idx_games_category_type").on(table.categoryType),
|
||||
index("idx_games_category_slug_type").on(
|
||||
table.categorySlug,
|
||||
table.categoryType
|
||||
)
|
||||
]
|
||||
);
|
||||
@@ -1,129 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Images } from "../images";
|
||||
import { Examples } from "../examples";
|
||||
import { BaseGame } from "../base-game";
|
||||
import { gamesTable } from "./game.sql";
|
||||
import { Categories } from "../categories";
|
||||
import { eq, and, isNull } from "drizzle-orm";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { imagesTable } from "../images/images.sql";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { groupBy, map, pipe, uniqueBy, values } from "remeda";
|
||||
import { categoriesTable } from "../categories/categories.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Game {
|
||||
export const Info = z
|
||||
.intersection(BaseGame.Info, Categories.Info, Images.Info)
|
||||
.openapi({
|
||||
ref: "Game",
|
||||
description: "Detailed information about a game available in the Nestri library, including technical specifications, categories and metadata",
|
||||
example: Examples.Game
|
||||
})
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const InputInfo = createSelectSchema(gamesTable)
|
||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
||||
|
||||
export const create = fn(
|
||||
InputInfo,
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const result =
|
||||
await tx
|
||||
.select()
|
||||
.from(gamesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(gamesTable.categorySlug, input.categorySlug),
|
||||
eq(gamesTable.categoryType, input.categoryType),
|
||||
eq(gamesTable.baseGameID, input.baseGameID),
|
||||
isNull(gamesTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.then(rows => rows.at(0))
|
||||
|
||||
if (result) return result.baseGameID
|
||||
|
||||
await tx
|
||||
.insert(gamesTable)
|
||||
.values(input)
|
||||
.onConflictDoUpdate({
|
||||
target: [gamesTable.categorySlug, gamesTable.categoryType, gamesTable.baseGameID],
|
||||
set: { timeDeleted: null }
|
||||
})
|
||||
|
||||
return input.baseGameID
|
||||
})
|
||||
)
|
||||
|
||||
export const fromID = fn(
|
||||
InputInfo.shape.baseGameID,
|
||||
(gameID) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select({
|
||||
games: baseGamesTable,
|
||||
categories: categoriesTable,
|
||||
images: imagesTable
|
||||
})
|
||||
.from(gamesTable)
|
||||
.innerJoin(baseGamesTable,
|
||||
eq(baseGamesTable.id, gamesTable.baseGameID)
|
||||
)
|
||||
.leftJoin(categoriesTable,
|
||||
and(
|
||||
eq(categoriesTable.slug, gamesTable.categorySlug),
|
||||
eq(categoriesTable.type, gamesTable.categoryType),
|
||||
)
|
||||
)
|
||||
.leftJoin(imagesTable,
|
||||
and(
|
||||
eq(imagesTable.baseGameID, gamesTable.baseGameID),
|
||||
isNull(imagesTable.timeDeleted),
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(gamesTable.baseGameID, gameID),
|
||||
isNull(gamesTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
.then((rows) => serialize(rows).at(0))
|
||||
)
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: { games: typeof baseGamesTable.$inferSelect; categories: typeof categoriesTable.$inferSelect | null; images: typeof imagesTable.$inferSelect | null }[],
|
||||
): z.infer<typeof Info>[] {
|
||||
return pipe(
|
||||
input,
|
||||
groupBy((row) => row.games.id),
|
||||
values(),
|
||||
map((group) => {
|
||||
const game = BaseGame.serialize(group[0].games)
|
||||
const cats = uniqueBy(
|
||||
group.map(r => r.categories).filter((c): c is typeof categoriesTable.$inferSelect => Boolean(c)),
|
||||
(c) => `${c.slug}:${c.type}`
|
||||
)
|
||||
const imgs = uniqueBy(
|
||||
group.map(r => r.images).filter((c): c is typeof imagesTable.$inferSelect => Boolean(c)),
|
||||
(c) => `${c.type}:${c.imageHash}:${c.position}`
|
||||
)
|
||||
const byType = Categories.serialize(cats)
|
||||
const byImg = Images.serialize(imgs)
|
||||
return {
|
||||
...game,
|
||||
...byType,
|
||||
...byImg
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { timestamps } from "../drizzle/types";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { index, integer, json, pgEnum, pgTable, primaryKey, text, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const ImageTypeEnum = pgEnum("image_type", ["heroArt", "icon", "logo", "banner", "poster", "boxArt", "screenshot", "backdrop"])
|
||||
|
||||
export const ImageDimensions = z.object({
|
||||
width: z.number().int(),
|
||||
height: z.number().int(),
|
||||
})
|
||||
|
||||
export const ImageColor = z.object({
|
||||
hex: z.string(),
|
||||
isDark: z.boolean()
|
||||
})
|
||||
|
||||
export type ImageColor = z.infer<typeof ImageColor>;
|
||||
export type ImageDimensions = z.infer<typeof ImageDimensions>;
|
||||
|
||||
export const imagesTable = pgTable(
|
||||
"images",
|
||||
{
|
||||
...timestamps,
|
||||
type: ImageTypeEnum("type").notNull(),
|
||||
imageHash: varchar("image_hash", { length: 255 })
|
||||
.notNull(),
|
||||
baseGameID: varchar("base_game_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => baseGamesTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
sourceUrl: text("source_url"), // The BoxArt is source Url will always be null;
|
||||
position: integer("position").notNull().default(0),
|
||||
fileSize: integer("file_size").notNull(),
|
||||
dimensions: json("dimensions").$type<ImageDimensions>().notNull(),
|
||||
extractedColor: json("extracted_color").$type<ImageColor>().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.imageHash, table.type, table.baseGameID, table.position]
|
||||
}),
|
||||
index("idx_images_type").on(table.type),
|
||||
index("idx_images_game_id").on(table.baseGameID),
|
||||
]
|
||||
)
|
||||
@@ -1,119 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Examples } from "../examples";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { createTransaction } from "../drizzle/transaction";
|
||||
import { ImageColor, ImageDimensions, imagesTable } from "./images.sql";
|
||||
|
||||
export namespace Images {
|
||||
const Image = z.object({
|
||||
hash: z.string().openapi({
|
||||
description: "A unique cryptographic hash identifier for the image, used for deduplication and URL generation",
|
||||
example: Examples.CommonImg[0].hash
|
||||
}),
|
||||
averageColor: ImageColor.openapi({
|
||||
description: "The calculated dominant color of the image with light/dark classification, used for UI theming",
|
||||
example: Examples.CommonImg[0].averageColor
|
||||
}),
|
||||
dimensions: ImageDimensions.openapi({
|
||||
description: "The width and height dimensions of the image in pixels",
|
||||
example: Examples.CommonImg[0].dimensions
|
||||
}),
|
||||
fileSize: z.number().int().openapi({
|
||||
description: "The size of the image file in bytes, used for storage and bandwidth calculations",
|
||||
example: Examples.CommonImg[0].fileSize
|
||||
})
|
||||
})
|
||||
|
||||
export const Info = z.object({
|
||||
screenshots: Image.array().openapi({
|
||||
description: "In-game captured images showing actual gameplay, user interface, and key moments",
|
||||
example: Examples.Images.screenshots
|
||||
}),
|
||||
boxArts: Image.array().openapi({
|
||||
description: "Square 1:1 aspect ratio artwork, typically used for store listings and thumbnails",
|
||||
example: Examples.Images.boxArts
|
||||
}),
|
||||
posters: Image.array().openapi({
|
||||
description: "Vertical 2:3 aspect ratio promotional artwork, similar to movie posters",
|
||||
example: Examples.Images.posters
|
||||
}),
|
||||
banners: Image.array().openapi({
|
||||
description: "Horizontal promotional artwork optimized for header displays and banners",
|
||||
example: Examples.Images.banners
|
||||
}),
|
||||
heroArts: Image.array().openapi({
|
||||
description: "High-resolution, wide-format artwork designed for featured content and main entries",
|
||||
example: Examples.Images.heroArts
|
||||
}),
|
||||
backdrops: Image.array().openapi({
|
||||
description: "Full-width backdrop images optimized for page layouts and decorative purposes",
|
||||
example: Examples.Images.backdrops
|
||||
}),
|
||||
logos: Image.array().openapi({
|
||||
description: "Official game logo artwork, typically with transparent backgrounds for flexible placement",
|
||||
example: Examples.Images.logos
|
||||
}),
|
||||
icons: Image.array().openapi({
|
||||
description: "Small-format identifiers used for application shortcuts and compact displays",
|
||||
example: Examples.Images.icons
|
||||
}),
|
||||
}).openapi({
|
||||
ref: "Images",
|
||||
description: "Complete collection of game-related visual assets, including promotional materials, UI elements, and store assets",
|
||||
example: Examples.Images
|
||||
})
|
||||
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const InputInfo = createSelectSchema(imagesTable)
|
||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
||||
|
||||
export const create = fn(
|
||||
InputInfo,
|
||||
(input) =>
|
||||
createTransaction(async (tx) =>
|
||||
tx
|
||||
.insert(imagesTable)
|
||||
.values(input)
|
||||
.onConflictDoUpdate({
|
||||
target: [imagesTable.imageHash, imagesTable.type, imagesTable.baseGameID, imagesTable.position],
|
||||
set: { timeDeleted: null }
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: typeof imagesTable.$inferSelect[],
|
||||
): z.infer<typeof Info> {
|
||||
return input
|
||||
.sort((a, b) => {
|
||||
if (a.type === b.type) {
|
||||
return a.position - b.position;
|
||||
}
|
||||
return a.type.localeCompare(b.type);
|
||||
})
|
||||
.reduce<Record<`${typeof imagesTable.$inferSelect["type"]}s`, { hash: string; averageColor: ImageColor; dimensions: ImageDimensions; fileSize: number }[]>>((acc, img) => {
|
||||
const key = `${img.type}s` as `${typeof img.type}s`
|
||||
if (Array.isArray(acc[key])) {
|
||||
acc[key]!.push({
|
||||
hash: img.imageHash,
|
||||
averageColor: img.extractedColor,
|
||||
dimensions: img.dimensions,
|
||||
fileSize: img.fileSize
|
||||
})
|
||||
}
|
||||
return acc
|
||||
}, {
|
||||
screenshots: [],
|
||||
boxArts: [],
|
||||
banners: [],
|
||||
heroArts: [],
|
||||
posters: [],
|
||||
backdrops: [],
|
||||
icons: [],
|
||||
logos: [],
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Game } from "../game";
|
||||
import { Actor } from "../actor";
|
||||
import { createEvent } from "../event";
|
||||
import { gamesTable } from "../game/game.sql";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { steamLibraryTable } from "./library.sql";
|
||||
import { imagesTable } from "../images/images.sql";
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { categoriesTable } from "../categories/categories.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Library {
|
||||
export const Info = createSelectSchema(steamLibraryTable)
|
||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const Events = {
|
||||
Add: createEvent(
|
||||
"library.add",
|
||||
z.object({
|
||||
appID: z.number(),
|
||||
lastPlayed: z.date().nullable(),
|
||||
totalPlaytime: z.number(),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export const add = fn(
|
||||
Info.partial({ ownerSteamID: true }),
|
||||
async (input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const ownerSteamID = input.ownerSteamID ?? Actor.steamID()
|
||||
const result =
|
||||
await tx
|
||||
.select()
|
||||
.from(steamLibraryTable)
|
||||
.where(
|
||||
and(
|
||||
eq(steamLibraryTable.baseGameID, input.baseGameID),
|
||||
eq(steamLibraryTable.ownerSteamID, ownerSteamID),
|
||||
isNull(steamLibraryTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.then(rows => rows.at(0))
|
||||
|
||||
if (result) return result.baseGameID
|
||||
|
||||
await tx
|
||||
.insert(steamLibraryTable)
|
||||
.values({
|
||||
ownerSteamID: ownerSteamID,
|
||||
baseGameID: input.baseGameID,
|
||||
lastPlayed: input.lastPlayed,
|
||||
totalPlaytime: input.totalPlaytime,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [steamLibraryTable.ownerSteamID, steamLibraryTable.baseGameID],
|
||||
set: {
|
||||
timeDeleted: null,
|
||||
lastPlayed: input.lastPlayed,
|
||||
totalPlaytime: input.totalPlaytime,
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
)
|
||||
|
||||
export const remove = fn(
|
||||
Info,
|
||||
(input) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.update(steamLibraryTable)
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(
|
||||
and(
|
||||
eq(steamLibraryTable.ownerSteamID, input.ownerSteamID),
|
||||
eq(steamLibraryTable.baseGameID, input.baseGameID),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
export const list = () =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select({
|
||||
games: baseGamesTable,
|
||||
categories: categoriesTable,
|
||||
images: imagesTable
|
||||
})
|
||||
.from(steamLibraryTable)
|
||||
.where(
|
||||
and(
|
||||
eq(steamLibraryTable.ownerSteamID, Actor.steamID()),
|
||||
isNull(steamLibraryTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.innerJoin(
|
||||
baseGamesTable,
|
||||
eq(baseGamesTable.id, steamLibraryTable.baseGameID),
|
||||
)
|
||||
.leftJoin(
|
||||
gamesTable,
|
||||
eq(gamesTable.baseGameID, baseGamesTable.id),
|
||||
)
|
||||
.leftJoin(
|
||||
categoriesTable,
|
||||
and(
|
||||
eq(categoriesTable.slug, gamesTable.categorySlug),
|
||||
eq(categoriesTable.type, gamesTable.categoryType),
|
||||
)
|
||||
)
|
||||
// Joining imagesTable 1-N with gamesTable multiplies rows; the subsequent Game.serialize has to uniqueBy to undo this.
|
||||
// For large libraries with many screenshots the Cartesian effect can significantly bloat the result and network payload.
|
||||
// One option is to aggregate the images in SQL before joining to keep exactly one row per game:
|
||||
// .leftJoin(
|
||||
// sql<typeof imagesTable.$inferSelect[]>`(SELECT * FROM images WHERE base_game_id = ${gamesTable.baseGameID} AND time_deleted IS NULL ORDER BY type, position)`.as("images"),
|
||||
// sql`TRUE`
|
||||
// )
|
||||
.leftJoin(
|
||||
imagesTable,
|
||||
and(
|
||||
eq(imagesTable.baseGameID, gamesTable.baseGameID),
|
||||
isNull(imagesTable.timeDeleted),
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
.then(rows => Game.serialize(rows))
|
||||
)
|
||||
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { timestamps, utc, } from "../drizzle/types";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { index, integer, pgTable, primaryKey, varchar, } from "drizzle-orm/pg-core";
|
||||
|
||||
export const steamLibraryTable = pgTable(
|
||||
"game_libraries",
|
||||
{
|
||||
...timestamps,
|
||||
baseGameID: varchar("base_game_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => baseGamesTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
ownerSteamID: varchar("owner_steam_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => steamTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
lastPlayed: utc("last_played"),
|
||||
totalPlaytime: integer("total_playtime").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.baseGameID, table.ownerSteamID]
|
||||
}),
|
||||
index("idx_game_libraries_owner_id").on(table.ownerSteamID),
|
||||
],
|
||||
);
|
||||
155
packages/core/src/machine/index.ts
Normal file
155
packages/core/src/machine/index.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { z } from "zod";
|
||||
import { Common } from "../common";
|
||||
import { createID, fn } from "../utils";
|
||||
import { Examples } from "../examples";
|
||||
import { machineTable } from "./machine.sql";
|
||||
import { getTableColumns, eq, sql, and, isNull } from "../drizzle";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Machine {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Machine.id,
|
||||
}),
|
||||
// userID: z.string().nullable().openapi({
|
||||
// description: "The userID of the user who owns this machine, in the case of BYOG",
|
||||
// example: Examples.Machine.userID
|
||||
// }),
|
||||
country: z.string().openapi({
|
||||
description: "The fullname of the country this machine is running in",
|
||||
example: Examples.Machine.country
|
||||
}),
|
||||
fingerprint: z.string().openapi({
|
||||
description: "The fingerprint of this machine, deduced from the host machine's machine id - /etc/machine-id",
|
||||
example: Examples.Machine.fingerprint
|
||||
}),
|
||||
location: z.object({ longitude: z.number(), latitude: z.number() }).openapi({
|
||||
description: "This is the 2d location of this machine, they might not be accurate",
|
||||
example: Examples.Machine.location
|
||||
}),
|
||||
countryCode: z.string().openapi({
|
||||
description: "This is the 2 character country code of the country this machine [ISO 3166-1 alpha-2] ",
|
||||
example: Examples.Machine.countryCode
|
||||
}),
|
||||
timezone: z.string().openapi({
|
||||
description: "The IANA timezone formatted string of the timezone of the location where the machine is running",
|
||||
example: Examples.Machine.timezone
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Machine",
|
||||
description: "Represents a hosted or BYOG machine connected to Nestri",
|
||||
example: Examples.Machine,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(Info.partial({ id: true }), async (input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const id = input.id ?? createID("machine");
|
||||
await tx.insert(machineTable).values({
|
||||
id,
|
||||
country: input.country,
|
||||
timezone: input.timezone,
|
||||
fingerprint: input.fingerprint,
|
||||
countryCode: input.countryCode,
|
||||
// userID: input.userID,
|
||||
location: { x: input.location.longitude, y: input.location.latitude },
|
||||
})
|
||||
|
||||
// await afterTx(() =>
|
||||
// bus.publish(Resource.Bus, Events.Created, {
|
||||
// teamID: id,
|
||||
// }),
|
||||
// );
|
||||
return id;
|
||||
})
|
||||
)
|
||||
|
||||
// export const fromUserID = fn(z.string(), async (userID) =>
|
||||
// useTransaction(async (tx) =>
|
||||
// tx
|
||||
// .select()
|
||||
// .from(machineTable)
|
||||
// .where(and(eq(machineTable.userID, userID), isNull(machineTable.timeDeleted)))
|
||||
// .then((rows) => rows.map(serialize))
|
||||
// )
|
||||
// )
|
||||
|
||||
// export const list = fn(z.void(), async () =>
|
||||
// useTransaction(async (tx) =>
|
||||
// tx
|
||||
// .select()
|
||||
// .from(machineTable)
|
||||
// // Show only hosted machines, not BYOG machines
|
||||
// .where(and(isNull(machineTable.userID), isNull(machineTable.timeDeleted)))
|
||||
// .then((rows) => rows.map(serialize))
|
||||
// )
|
||||
// )
|
||||
|
||||
export const fromID = fn(Info.shape.id, async (id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(machineTable)
|
||||
.where(and(eq(machineTable.id, id), isNull(machineTable.timeDeleted)))
|
||||
.then((rows) => rows.map(serialize).at(0))
|
||||
)
|
||||
)
|
||||
|
||||
export const fromFingerprint = fn(Info.shape.fingerprint, async (fingerprint) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(machineTable)
|
||||
.where(and(eq(machineTable.fingerprint, fingerprint), isNull(machineTable.timeDeleted)))
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize).at(0))
|
||||
)
|
||||
)
|
||||
|
||||
export const remove = fn(Info.shape.id, (id) =>
|
||||
useTransaction(async (tx) => {
|
||||
await tx
|
||||
.update(machineTable)
|
||||
.set({
|
||||
timeDeleted: sql`now()`,
|
||||
})
|
||||
.where(and(eq(machineTable.id, id)))
|
||||
.execute();
|
||||
return id;
|
||||
}),
|
||||
);
|
||||
|
||||
export const fromLocation = fn(Info.shape.location, async (location) =>
|
||||
useTransaction(async (tx) => {
|
||||
const sqlDistance = sql`location <-> point(${location.longitude}, ${location.latitude})`;
|
||||
return tx
|
||||
.select({
|
||||
...getTableColumns(machineTable),
|
||||
distance: sql`round((${sqlDistance})::numeric, 2)`
|
||||
})
|
||||
.from(machineTable)
|
||||
.where(isNull(machineTable.timeDeleted))
|
||||
.orderBy(sqlDistance)
|
||||
.limit(3)
|
||||
.then((rows) => rows.map(serialize))
|
||||
})
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: typeof machineTable.$inferSelect,
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
// userID: input.userID,
|
||||
country: input.country,
|
||||
timezone: input.timezone,
|
||||
fingerprint: input.fingerprint,
|
||||
countryCode: input.countryCode,
|
||||
location: { latitude: input.location.y, longitude: input.location.x },
|
||||
};
|
||||
}
|
||||
}
|
||||
40
packages/core/src/machine/machine.sql.ts
Normal file
40
packages/core/src/machine/machine.sql.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { } from "drizzle-orm/postgres-js";
|
||||
import { timestamps, id, ulid } from "../drizzle/types";
|
||||
import {
|
||||
text,
|
||||
varchar,
|
||||
pgTable,
|
||||
uniqueIndex,
|
||||
point,
|
||||
primaryKey,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const machineTable = pgTable(
|
||||
"machine",
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
// userID: ulid("user_id"),
|
||||
country: text('country').notNull(),
|
||||
timezone: text('timezone').notNull(),
|
||||
location: point('location', { mode: 'xy' }).notNull(),
|
||||
fingerprint: varchar('fingerprint', { length: 32 }).notNull(),
|
||||
countryCode: varchar('country_code', { length: 2 }).notNull(),
|
||||
// provider: text("provider").notNull(),
|
||||
// gpuType: text("gpu_type").notNull(),
|
||||
// storage: numeric("storage").notNull(),
|
||||
// ipaddress: text("ipaddress").notNull(),
|
||||
// gpuNumber: integer("gpu_number").notNull(),
|
||||
// computePrice: numeric("compute_price").notNull(),
|
||||
// driverVersion: integer("driver_version").notNull(),
|
||||
// operatingSystem: text("operating_system").notNull(),
|
||||
// fingerprint: varchar("fingerprint", { length: 32 }).notNull(),
|
||||
// externalID: varchar("external_id", { length: 255 }).notNull(),
|
||||
// cudaVersion: numeric("cuda_version", { precision: 4, scale: 2 }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
// uniqueIndex("external_id").on(table.externalID),
|
||||
uniqueIndex("machine_fingerprint").on(table.fingerprint),
|
||||
// primaryKey({ columns: [table.userID, table.id], }),
|
||||
],
|
||||
);
|
||||
139
packages/core/src/member/index.ts
Normal file
139
packages/core/src/member/index.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { z } from "zod";
|
||||
import { Resource } from "sst";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { useTeam } from "../actor";
|
||||
import { Common } from "../common";
|
||||
import { createID, fn } from "../utils";
|
||||
import { createEvent } from "../event";
|
||||
import { Examples } from "../examples";
|
||||
import { memberTable, role } from "./member.sql";
|
||||
import { and, eq, sql, asc, isNull } from "../drizzle";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Member {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Member.id,
|
||||
}),
|
||||
timeSeen: z.date().nullable().or(z.undefined()).openapi({
|
||||
description: "The last time this team member was active",
|
||||
example: Examples.Member.timeSeen
|
||||
}),
|
||||
teamID: z.string().openapi({
|
||||
description: "The unique id of the team this member is on",
|
||||
example: Examples.Member.teamID
|
||||
}),
|
||||
role: z.enum(role).openapi({
|
||||
description: "The role of this team member",
|
||||
example: Examples.Member.role
|
||||
}),
|
||||
email: z.string().openapi({
|
||||
description: "The email of this team member",
|
||||
example: Examples.Member.email
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Member",
|
||||
description: "Represents a team member on Nestri",
|
||||
example: Examples.Member,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const Events = {
|
||||
Created: createEvent(
|
||||
"member.created",
|
||||
z.object({
|
||||
memberID: Info.shape.id,
|
||||
}),
|
||||
),
|
||||
Updated: createEvent(
|
||||
"member.updated",
|
||||
z.object({
|
||||
memberID: Info.shape.id,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export const create = fn(
|
||||
Info.pick({ email: true, id: true })
|
||||
.partial({
|
||||
id: true,
|
||||
})
|
||||
.extend({
|
||||
first: z.boolean().optional(),
|
||||
}),
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const id = input.id ?? createID("member");
|
||||
await tx.insert(memberTable).values({
|
||||
id,
|
||||
teamID: useTeam(),
|
||||
email: input.email,
|
||||
role: input.first ? "owner" : "member",
|
||||
timeSeen: input.first ? sql`now()` : null,
|
||||
})
|
||||
|
||||
await afterTx(() =>
|
||||
async () => bus.publish(Resource.Bus, Events.Created, { memberID: id }),
|
||||
);
|
||||
return id;
|
||||
}),
|
||||
);
|
||||
|
||||
export const remove = fn(Info.shape.id, (id) =>
|
||||
useTransaction(async (tx) => {
|
||||
await tx
|
||||
.update(memberTable)
|
||||
.set({
|
||||
timeDeleted: sql`now()`,
|
||||
})
|
||||
.where(and(eq(memberTable.id, id), eq(memberTable.teamID, useTeam())))
|
||||
.execute();
|
||||
return id;
|
||||
}),
|
||||
);
|
||||
|
||||
export const fromEmail = fn(z.string(), async (email) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(and(eq(memberTable.email, email), isNull(memberTable.timeDeleted)))
|
||||
.orderBy(asc(memberTable.timeCreated))
|
||||
.then((rows) => rows.map(serialize).at(0))
|
||||
)
|
||||
)
|
||||
|
||||
export const fromID = fn(z.string(), async (id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(and(eq(memberTable.id, id), isNull(memberTable.timeDeleted)))
|
||||
.orderBy(asc(memberTable.timeCreated))
|
||||
.then((rows) => rows.map(serialize).at(0))
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Converts a raw member database row into a standardized {@link Member.Info} object.
|
||||
*
|
||||
* @param input - The database row representing a member.
|
||||
* @returns The member information formatted as a {@link Member.Info} object.
|
||||
*/
|
||||
export function serialize(
|
||||
input: typeof memberTable.$inferSelect,
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
role: input.role,
|
||||
email: input.email,
|
||||
teamID: input.teamID,
|
||||
timeSeen: input.timeSeen
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
21
packages/core/src/member/member.sql.ts
Normal file
21
packages/core/src/member/member.sql.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { teamIndexes } from "../team/team.sql";
|
||||
import { timestamps, utc, teamID } from "../drizzle/types";
|
||||
import { index, pgTable, text, uniqueIndex, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const role = ["admin", "member", "owner"] as const;
|
||||
|
||||
export const memberTable = pgTable(
|
||||
"member",
|
||||
{
|
||||
...teamID,
|
||||
...timestamps,
|
||||
role: text("role", { enum: role }).notNull(),
|
||||
timeSeen: utc("time_seen"),
|
||||
email: varchar("email", { length: 255 }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
...teamIndexes(table),
|
||||
index("email_global").on(table.email),
|
||||
uniqueIndex("member_email").on(table.teamID, table.email),
|
||||
],
|
||||
);
|
||||
@@ -1,14 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Resource } from "sst";
|
||||
import { useTeam, useUserID } from "../actor";
|
||||
import { Polar as PolarSdk } from "@polar-sh/sdk";
|
||||
import { validateEvent } from "@polar-sh/sdk/webhooks";
|
||||
import { PlanType } from "../subscription/subscription.sql";
|
||||
|
||||
const polar = new PolarSdk({
|
||||
accessToken: Resource.PolarSecret.value,
|
||||
server: Resource.App.stage !== "production" ? "sandbox" : "production"
|
||||
});
|
||||
|
||||
const polar = new PolarSdk({ accessToken: Resource.PolarSecret.value, server: Resource.App.stage !== "production" ? "sandbox" : "production" });
|
||||
const planType = z.enum(PlanType)
|
||||
export namespace Polar {
|
||||
export const client = polar;
|
||||
|
||||
@@ -17,7 +16,7 @@ export namespace Polar {
|
||||
const customers = await client.customers.list({ email })
|
||||
|
||||
if (customers.result.items.length === 0) {
|
||||
return await client.customers.create({ email})
|
||||
return await client.customers.create({ email })
|
||||
} else {
|
||||
return customers.result.items[0]
|
||||
}
|
||||
@@ -29,18 +28,18 @@ export namespace Polar {
|
||||
}
|
||||
})
|
||||
|
||||
// const getProductIDs = (plan: z.infer<typeof planType>) => {
|
||||
// switch (plan) {
|
||||
// case "free":
|
||||
// return [Resource.NestriFreeMonthly.value]
|
||||
// case "pro":
|
||||
// return [Resource.NestriProYearly.value, Resource.NestriProMonthly.value]
|
||||
// case "family":
|
||||
// return [Resource.NestriFamilyYearly.value, Resource.NestriFamilyMonthly.value]
|
||||
// default:
|
||||
// return [Resource.NestriFreeMonthly.value]
|
||||
// }
|
||||
// }
|
||||
const getProductIDs = (plan: z.infer<typeof planType>) => {
|
||||
switch (plan) {
|
||||
case "free":
|
||||
return [Resource.NestriFreeMonthly.value]
|
||||
case "pro":
|
||||
return [Resource.NestriProYearly.value, Resource.NestriProMonthly.value]
|
||||
case "family":
|
||||
return [Resource.NestriFamilyYearly.value, Resource.NestriFamilyMonthly.value]
|
||||
default:
|
||||
return [Resource.NestriFreeMonthly.value]
|
||||
}
|
||||
}
|
||||
|
||||
export const createPortal = fn(
|
||||
z.string(),
|
||||
@@ -54,10 +53,44 @@ export namespace Polar {
|
||||
)
|
||||
|
||||
//TODO: Implement this
|
||||
export const handleWebhook = async (payload: ReturnType<typeof validateEvent>) => {
|
||||
export const handleWebhook = async(payload: ReturnType<typeof validateEvent>) => {
|
||||
switch (payload.type) {
|
||||
case "subscription.created":
|
||||
const teamID = payload.data.metadata.teamID
|
||||
}
|
||||
}
|
||||
|
||||
export const createCheckout = fn(
|
||||
z
|
||||
.object({
|
||||
planType: z.enum(PlanType),
|
||||
customerEmail: z.string(),
|
||||
successUrl: z.string(),
|
||||
customerID: z.string(),
|
||||
allowDiscountCodes: z.boolean(),
|
||||
teamID: z.string()
|
||||
})
|
||||
.partial({
|
||||
customerEmail: true,
|
||||
allowDiscountCodes: true,
|
||||
customerID: true,
|
||||
teamID: true
|
||||
}),
|
||||
async (input) => {
|
||||
const productIDs = getProductIDs(input.planType)
|
||||
|
||||
const checkoutUrl =
|
||||
await client.checkouts.create({
|
||||
products: productIDs,
|
||||
customerEmail: input.customerEmail ?? useUserID(),
|
||||
successUrl: `${input.successUrl}?checkout={CHECKOUT_ID}`,
|
||||
allowDiscountCodes: input.allowDiscountCodes ?? false,
|
||||
customerId: input.customerID,
|
||||
customerMetadata: {
|
||||
teamID: input.teamID ?? useTeam()
|
||||
}
|
||||
})
|
||||
|
||||
return checkoutUrl.url
|
||||
})
|
||||
}
|
||||
@@ -2,14 +2,14 @@ import {
|
||||
IoTDataPlaneClient,
|
||||
PublishCommand,
|
||||
} from "@aws-sdk/client-iot-data-plane";
|
||||
import { Actor } from "../actor";
|
||||
import { useMachine } from "../actor";
|
||||
import { Resource } from "sst";
|
||||
|
||||
export namespace Realtime {
|
||||
const client = new IoTDataPlaneClient({});
|
||||
|
||||
export async function publish(message: any, subTopic?: string) {
|
||||
const fingerprint = Actor.assert("machine").properties.fingerprint;
|
||||
const fingerprint = useMachine();
|
||||
let topic = `${Resource.App.name}/${Resource.App.stage}/${fingerprint}/`;
|
||||
if (subTopic)
|
||||
topic = `${topic}${subTopic}`;
|
||||
|
||||
@@ -1,210 +1,101 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Resource } from "sst";
|
||||
import { Actor } from "../actor";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import { createEvent } from "../event";
|
||||
import { eq, and, isNull, desc } from "drizzle-orm";
|
||||
import { steamTable, StatusEnum, Limitations } from "./steam.sql";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { createID, fn } from "../utils";
|
||||
import { useUser, useUserID } from "../actor";
|
||||
import { eq, and, isNull, sql } from "../drizzle";
|
||||
import { steamTable, AccountLimitation, LastGame } from "./steam.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Steam {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.SteamAccount.id
|
||||
example: Examples.Steam.id,
|
||||
}),
|
||||
avatarHash: z.string().openapi({
|
||||
description: "The Steam avatar hash that this account owns",
|
||||
example: Examples.SteamAccount.avatarHash
|
||||
avatarUrl: z.string().openapi({
|
||||
description: "The avatar url of this Steam account",
|
||||
example: Examples.Steam.avatarUrl
|
||||
}),
|
||||
status: z.enum(StatusEnum.enumValues).openapi({
|
||||
description: "The current connection status of this Steam account",
|
||||
example: Examples.SteamAccount.status
|
||||
steamEmail: z.string().openapi({
|
||||
description: "The email regisered with this Steam account",
|
||||
example: Examples.Steam.steamEmail
|
||||
}),
|
||||
userID: z.string().nullable().openapi({
|
||||
description: "The user id of which account owns this steam account",
|
||||
example: Examples.SteamAccount.userID
|
||||
steamID: z.number().openapi({
|
||||
description: "The Steam ID this Steam account",
|
||||
example: Examples.Steam.steamID
|
||||
}),
|
||||
profileUrl: z.string().nullable().openapi({
|
||||
description: "The steam community url of this account",
|
||||
example: Examples.SteamAccount.profileUrl
|
||||
limitation: AccountLimitation.openapi({
|
||||
description: " The limitations of this Steam account",
|
||||
example: Examples.Steam.limitation
|
||||
}),
|
||||
realName: z.string().nullable().openapi({
|
||||
description: "The real name behind of this Steam account",
|
||||
example: Examples.SteamAccount.realName
|
||||
lastGame: LastGame.openapi({
|
||||
description: "The last game played on this Steam account",
|
||||
example: Examples.Steam.lastGame
|
||||
}),
|
||||
name: z.string().openapi({
|
||||
description: "The name used by this account",
|
||||
example: Examples.SteamAccount.name
|
||||
userID: z.string().openapi({
|
||||
description: "The unique id of the user who owns this steam account",
|
||||
example: Examples.Steam.userID
|
||||
}),
|
||||
lastSyncedAt: z.date().openapi({
|
||||
description: "The last time this account was synced to Steam",
|
||||
example: Examples.SteamAccount.lastSyncedAt
|
||||
username: z.string().openapi({
|
||||
description: "The unique username of this steam user",
|
||||
example: Examples.Steam.username
|
||||
}),
|
||||
limitations: Limitations.openapi({
|
||||
description: "The limitations bestowed on this Steam account by Steam",
|
||||
example: Examples.SteamAccount.limitations
|
||||
personaName: z.string().openapi({
|
||||
description: "The last recorded persona name used by this account",
|
||||
example: Examples.Steam.personaName
|
||||
}),
|
||||
steamMemberSince: z.date().openapi({
|
||||
description: "When this Steam community account was created",
|
||||
example: Examples.SteamAccount.steamMemberSince
|
||||
countryCode: z.string().openapi({
|
||||
description: "The country this account is connected from",
|
||||
example: Examples.Steam.countryCode
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Steam",
|
||||
description: "Represents a steam user's information stored on Nestri",
|
||||
example: Examples.SteamAccount,
|
||||
example: Examples.Steam,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const Events = {
|
||||
Created: createEvent(
|
||||
"steam_account.created",
|
||||
z.object({
|
||||
steamID: Info.shape.id,
|
||||
userID: Info.shape.userID,
|
||||
}),
|
||||
),
|
||||
Updated: createEvent(
|
||||
"steam_account.updated",
|
||||
z.object({
|
||||
steamID: Info.shape.id,
|
||||
userID: Info.shape.userID
|
||||
}),
|
||||
)
|
||||
};
|
||||
|
||||
export const create = fn(
|
||||
Info
|
||||
.extend({
|
||||
useUser: z.boolean(),
|
||||
})
|
||||
.partial({
|
||||
Info.partial({
|
||||
id: true,
|
||||
userID: true,
|
||||
status: true,
|
||||
useUser: true,
|
||||
lastSyncedAt: true
|
||||
}),
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const accounts =
|
||||
await tx
|
||||
.select()
|
||||
.from(steamTable)
|
||||
.where(
|
||||
and(
|
||||
isNull(steamTable.timeDeleted),
|
||||
eq(steamTable.id, input.id)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize))
|
||||
|
||||
// Update instead of create
|
||||
if (accounts.length > 0) return null
|
||||
|
||||
const userID = typeof input.userID === "string" ? input.userID : input.useUser ? Actor.userID() : null;
|
||||
await tx
|
||||
.insert(steamTable)
|
||||
.values({
|
||||
userID,
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
realName: input.realName,
|
||||
profileUrl: input.profileUrl,
|
||||
avatarHash: input.avatarHash,
|
||||
limitations: input.limitations,
|
||||
status: input.status ?? "offline",
|
||||
steamMemberSince: input.steamMemberSince,
|
||||
lastSyncedAt: input.lastSyncedAt ?? Common.utc(),
|
||||
const id = input.id ?? createID("steam");
|
||||
const user = useUser()
|
||||
await tx.insert(steamTable).values({
|
||||
id,
|
||||
lastSeen: sql`now()`,
|
||||
userID: input.userID ?? user.userID,
|
||||
countryCode: input.countryCode,
|
||||
username: input.username,
|
||||
steamID: input.steamID,
|
||||
lastGame: input.lastGame,
|
||||
limitation: input.limitation,
|
||||
steamEmail: input.steamEmail,
|
||||
avatarUrl: input.avatarUrl,
|
||||
personaName: input.personaName,
|
||||
})
|
||||
|
||||
await afterTx(async () =>
|
||||
bus.publish(Resource.Bus, Events.Created, { userID, steamID: input.id })
|
||||
);
|
||||
|
||||
return input.id
|
||||
return id;
|
||||
}),
|
||||
);
|
||||
|
||||
export const updateOwner = fn(
|
||||
z
|
||||
.object({
|
||||
userID: z.string(),
|
||||
steamID: z.string()
|
||||
})
|
||||
.partial({
|
||||
userID: true
|
||||
}),
|
||||
async (input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const userID = input.userID ?? Actor.userID()
|
||||
await tx
|
||||
.update(steamTable)
|
||||
.set({
|
||||
userID
|
||||
})
|
||||
.where(eq(steamTable.id, input.steamID));
|
||||
|
||||
await afterTx(async () =>
|
||||
bus.publish(Resource.Bus, Events.Updated, { userID, steamID: input.steamID })
|
||||
);
|
||||
|
||||
return input.steamID
|
||||
})
|
||||
)
|
||||
|
||||
export const fromUserID = fn(
|
||||
z.string().min(1),
|
||||
z.string(),
|
||||
(userID) =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(steamTable)
|
||||
.where(and(eq(steamTable.userID, userID), isNull(steamTable.timeDeleted)))
|
||||
.orderBy(desc(steamTable.timeCreated))
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize))
|
||||
)
|
||||
)
|
||||
|
||||
export const confirmOwnerShip = fn(
|
||||
z.string().min(1),
|
||||
(userID) =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(steamTable)
|
||||
.where(
|
||||
and(
|
||||
eq(steamTable.userID, userID),
|
||||
eq(steamTable.id, Actor.steamID()),
|
||||
isNull(steamTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(steamTable.timeCreated))
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize).at(0))
|
||||
)
|
||||
)
|
||||
|
||||
export const fromSteamID = fn(
|
||||
z.string(),
|
||||
(steamID) =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(steamTable)
|
||||
.where(and(eq(steamTable.id, steamID), isNull(steamTable.timeDeleted)))
|
||||
.orderBy(desc(steamTable.timeCreated))
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize).at(0))
|
||||
)
|
||||
.then((rows) => rows.map(serialize).at(0)),
|
||||
),
|
||||
)
|
||||
|
||||
export const list = () =>
|
||||
@@ -212,26 +103,34 @@ export namespace Steam {
|
||||
tx
|
||||
.select()
|
||||
.from(steamTable)
|
||||
.where(and(eq(steamTable.userID, Actor.userID()), isNull(steamTable.timeDeleted)))
|
||||
.orderBy(desc(steamTable.timeCreated))
|
||||
.where(and(eq(steamTable.userID, useUserID()), isNull(steamTable.timeDeleted)))
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize))
|
||||
.then((rows) => rows.map(serialize)),
|
||||
)
|
||||
|
||||
/**
|
||||
* Serializes a raw Steam table record into a standardized Info object.
|
||||
*
|
||||
* This function maps the fields from a database record (retrieved from the Steam table) to the
|
||||
* corresponding properties defined in the Info schema.
|
||||
*
|
||||
* @param input - A raw record from the Steam table containing user information.
|
||||
* @returns An object conforming to the Info schema.
|
||||
*/
|
||||
export function serialize(
|
||||
input: typeof steamTable.$inferSelect,
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
status: input.status,
|
||||
userID: input.userID,
|
||||
realName: input.realName,
|
||||
profileUrl: input.profileUrl,
|
||||
avatarHash: input.avatarHash,
|
||||
limitations: input.limitations,
|
||||
lastSyncedAt: input.lastSyncedAt,
|
||||
steamMemberSince: input.steamMemberSince,
|
||||
countryCode: input.countryCode,
|
||||
username: input.username,
|
||||
avatarUrl: input.avatarUrl,
|
||||
personaName: input.personaName,
|
||||
steamEmail: input.steamEmail,
|
||||
steamID: input.steamID,
|
||||
limitation: input.limitation,
|
||||
lastGame: input.lastGame,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +1,45 @@
|
||||
import { z } from "zod";
|
||||
import { userTable } from "../user/user.sql";
|
||||
import { id, timestamps, ulid, utc } from "../drizzle/types";
|
||||
import { pgTable, varchar, pgEnum, json, unique } from "drizzle-orm/pg-core";
|
||||
import { index, pgTable, integer, uniqueIndex, varchar, text, json } from "drizzle-orm/pg-core";
|
||||
|
||||
export const StatusEnum = pgEnum("steam_status", ["online", "offline", "dnd", "playing"])
|
||||
export const LastGame = z.object({
|
||||
gameID: z.number(),
|
||||
gameName: z.string()
|
||||
});
|
||||
|
||||
export const Limitations = z.object({
|
||||
isLimited: z.boolean(),
|
||||
tradeBanState: z.enum(["none", "probation", "banned"]),
|
||||
isVacBanned: z.boolean(),
|
||||
visibilityState: z.number(),
|
||||
privacyState: z.enum(["public", "private", "friendsfriendsonly", "friendsonly"]),
|
||||
})
|
||||
export const AccountLimitation = z.object({
|
||||
isLimited: z.boolean().nullable(),
|
||||
isBanned: z.boolean().nullable(),
|
||||
isLocked: z.boolean().nullable(),
|
||||
isAllowedToInviteFriends: z.boolean().nullable(),
|
||||
});
|
||||
|
||||
export type Limitations = z.infer<typeof Limitations>;
|
||||
export type LastGame = z.infer<typeof LastGame>;
|
||||
export type AccountLimitation = z.infer<typeof AccountLimitation>;
|
||||
|
||||
export const steamTable = pgTable(
|
||||
"steam_accounts",
|
||||
"steam",
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
id: varchar("id", { length: 255 })
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
userID: ulid("user_id")
|
||||
.notNull()
|
||||
.references(() => userTable.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
status: StatusEnum("status").notNull(),
|
||||
lastSyncedAt: utc("last_synced_at").notNull(),
|
||||
realName: varchar("real_name", { length: 255 }),
|
||||
steamMemberSince: utc("member_since").notNull(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
profileUrl: varchar("profile_url", { length: 255 }),
|
||||
avatarHash: varchar("avatar_hash", { length: 255 }).notNull(),
|
||||
limitations: json("limitations").$type<Limitations>().notNull(),
|
||||
}
|
||||
lastSeen: utc("last_seen").notNull(),
|
||||
steamID: integer("steam_id").notNull(),
|
||||
avatarUrl: text("avatar_url").notNull(),
|
||||
lastGame: json("last_game").$type<LastGame>().notNull(),
|
||||
username: varchar("username", { length: 255 }).notNull(),
|
||||
countryCode: varchar('country_code', { length: 2 }).notNull(),
|
||||
steamEmail: varchar("steam_email", { length: 255 }).notNull(),
|
||||
personaName: varchar("persona_name", { length: 255 }).notNull(),
|
||||
limitation: json("limitation").$type<AccountLimitation>().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("steam_id").on(table.steamID),
|
||||
index("steam_user_id").on(table.userID),
|
||||
],
|
||||
);
|
||||
192
packages/core/src/subscription/index.ts
Normal file
192
packages/core/src/subscription/index.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { z } from "zod";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import { createID, fn } from "../utils";
|
||||
import { eq, and, isNull } from "../drizzle";
|
||||
import { useTeam, useUserID } from "../actor";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { PlanType, Standing, subscriptionTable } from "./subscription.sql";
|
||||
|
||||
export namespace Subscription {
|
||||
export const Info = z.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Subscription.id,
|
||||
}),
|
||||
polarSubscriptionID: z.string().nullable().or(z.undefined()).openapi({
|
||||
description: "The unique id of the plan this subscription is on",
|
||||
example: Examples.Subscription.polarSubscriptionID,
|
||||
}),
|
||||
teamID: z.string().openapi({
|
||||
description: "The unique id of the team this subscription is for",
|
||||
example: Examples.Subscription.teamID,
|
||||
}),
|
||||
userID: z.string().openapi({
|
||||
description: "The unique id of the user who is paying this subscription",
|
||||
example: Examples.Subscription.userID,
|
||||
}),
|
||||
polarProductID: z.string().nullable().or(z.undefined()).openapi({
|
||||
description: "The unique id of the product this subscription is for",
|
||||
example: Examples.Subscription.polarProductID,
|
||||
}),
|
||||
tokens: z.number().openapi({
|
||||
description: "The number of tokens this subscription has left",
|
||||
example: Examples.Subscription.tokens,
|
||||
}),
|
||||
planType: z.enum(PlanType).openapi({
|
||||
description: "The type of plan this subscription is for",
|
||||
example: Examples.Subscription.planType,
|
||||
}),
|
||||
standing: z.enum(Standing).openapi({
|
||||
description: "The standing of this subscription",
|
||||
example: Examples.Subscription.standing,
|
||||
}),
|
||||
}).openapi({
|
||||
ref: "Subscription",
|
||||
description: "Represents a subscription on Nestri",
|
||||
example: Examples.Subscription
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(
|
||||
Info
|
||||
.partial({
|
||||
teamID: true,
|
||||
userID: true,
|
||||
id: true,
|
||||
standing: true,
|
||||
planType: true,
|
||||
polarProductID: true,
|
||||
polarSubscriptionID: true,
|
||||
}),
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const id = input.id ?? createID("subscription");
|
||||
|
||||
await tx.insert(subscriptionTable).values({
|
||||
id,
|
||||
tokens: input.tokens,
|
||||
polarProductID: input.polarProductID ?? null,
|
||||
polarSubscriptionID: input.polarSubscriptionID ?? null,
|
||||
standing: input.standing ?? "new",
|
||||
planType: input.planType ?? "free",
|
||||
userID: input.userID ?? useUserID(),
|
||||
teamID: input.teamID ?? useTeam(),
|
||||
});
|
||||
|
||||
return id;
|
||||
})
|
||||
)
|
||||
|
||||
export const setPolarProductID = fn(
|
||||
Info.pick({
|
||||
id: true,
|
||||
polarProductID: true,
|
||||
}),
|
||||
(input) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx.update(subscriptionTable)
|
||||
.set({
|
||||
polarProductID: input.polarProductID,
|
||||
})
|
||||
.where(eq(subscriptionTable.id, input.id))
|
||||
)
|
||||
)
|
||||
|
||||
export const setPolarSubscriptionID = fn(
|
||||
Info.pick({
|
||||
id: true,
|
||||
polarSubscriptionID: true,
|
||||
}),
|
||||
(input) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx.update(subscriptionTable)
|
||||
.set({
|
||||
polarSubscriptionID: input.polarSubscriptionID,
|
||||
})
|
||||
.where(eq(subscriptionTable.id, input.id))
|
||||
)
|
||||
)
|
||||
|
||||
export const fromID = fn(z.string(), async (id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionTable.id, id),
|
||||
isNull(subscriptionTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.orderBy(subscriptionTable.timeCreated)
|
||||
.then((rows) => rows.map(serialize))
|
||||
)
|
||||
)
|
||||
export const fromTeamID = fn(z.string(), async (teamID) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionTable.teamID, teamID),
|
||||
isNull(subscriptionTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.orderBy(subscriptionTable.timeCreated)
|
||||
.then((rows) => rows.map(serialize))
|
||||
)
|
||||
)
|
||||
|
||||
export const fromUserID = fn(z.string(), async (userID) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionTable.userID, userID),
|
||||
isNull(subscriptionTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.orderBy(subscriptionTable.timeCreated)
|
||||
.then((rows) => rows.map(serialize))
|
||||
)
|
||||
)
|
||||
export const remove = fn(Info.shape.id, (id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.update(subscriptionTable)
|
||||
.set({
|
||||
timeDeleted: Common.now(),
|
||||
})
|
||||
.where(eq(subscriptionTable.id, id))
|
||||
.execute()
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* Converts a raw subscription database record into a structured {@link Info} object.
|
||||
*
|
||||
* @param input - The subscription record retrieved from the database.
|
||||
* @returns The subscription data formatted according to the {@link Info} schema.
|
||||
*/
|
||||
export function serialize(
|
||||
input: typeof subscriptionTable.$inferSelect
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
userID: input.userID,
|
||||
teamID: input.teamID,
|
||||
standing: input.standing,
|
||||
planType: input.planType,
|
||||
tokens: input.tokens,
|
||||
polarProductID: input.polarProductID,
|
||||
polarSubscriptionID: input.polarSubscriptionID,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
31
packages/core/src/subscription/subscription.sql.ts
Normal file
31
packages/core/src/subscription/subscription.sql.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { teamTable } from "../team/team.sql";
|
||||
import { ulid, userID, timestamps } from "../drizzle/types";
|
||||
import { index, integer, pgTable, primaryKey, text, uniqueIndex, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const Standing = ["new", "good", "overdue", "cancelled"] as const;
|
||||
export const PlanType = ["free", "pro", "family", "enterprise"] as const;
|
||||
|
||||
export const subscriptionTable = pgTable(
|
||||
"subscription",
|
||||
{
|
||||
...userID,
|
||||
...timestamps,
|
||||
teamID: ulid("team_id")
|
||||
.references(() => teamTable.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
standing: text("standing", { enum: Standing })
|
||||
.notNull(),
|
||||
planType: text("plan_type", { enum: PlanType })
|
||||
.notNull(),
|
||||
tokens: integer("tokens").notNull(),
|
||||
polarProductID: varchar("product_id", { length: 255 }),
|
||||
polarSubscriptionID: varchar("subscription_id", { length: 255 }),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("subscription_id").on(table.id),
|
||||
index("subscription_user_id").on(table.userID),
|
||||
primaryKey({
|
||||
columns: [table.id, table.teamID]
|
||||
}),
|
||||
]
|
||||
)
|
||||
20
packages/core/src/task/task.sql.todo
Normal file
20
packages/core/src/task/task.sql.todo
Normal file
@@ -0,0 +1,20 @@
|
||||
import { id, timestamps } from "../drizzle/types";
|
||||
import { pgTable, uniqueIndex, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
//This represents a task created on a machine for running a game
|
||||
//Add billing info here?
|
||||
//Add who owns the task here
|
||||
// Add the session ID here
|
||||
//Add which machine owns this task
|
||||
|
||||
export const taskTable = pgTable(
|
||||
"task",
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
fingerprint: varchar('fingerprint', { length: 32 }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("task_fingerprint").on(table.fingerprint),
|
||||
],
|
||||
);
|
||||
218
packages/core/src/team/index.ts
Normal file
218
packages/core/src/team/index.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { z } from "zod";
|
||||
import { Common } from "../common";
|
||||
import { Member } from "../member";
|
||||
import { teamTable } from "./team.sql";
|
||||
import { Examples } from "../examples";
|
||||
import { assertActor } from "../actor";
|
||||
import { createEvent } from "../event";
|
||||
import { createID, fn } from "../utils";
|
||||
import { Subscription } from "../subscription";
|
||||
import { and, eq, sql, isNull } from "../drizzle";
|
||||
import { memberTable } from "../member/member.sql";
|
||||
import { ErrorCodes, VisibleError } from "../error";
|
||||
import { groupBy, map, pipe, values } from "remeda";
|
||||
import { subscriptionTable } from "../subscription/subscription.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Team {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Team.id,
|
||||
}),
|
||||
// Remove spaces and make sure it is lowercase (this is just to make sure the frontend did this)
|
||||
slug: z.string().regex(/^[a-z0-9\-]+$/, "Use a URL friendly name.").openapi({
|
||||
description: "The unique and url-friendly slug of this team",
|
||||
example: Examples.Team.slug
|
||||
}),
|
||||
name: z.string().openapi({
|
||||
description: "The name of this team",
|
||||
example: Examples.Team.name
|
||||
}),
|
||||
members: Member.Info.array().openapi({
|
||||
description: "The members of this team",
|
||||
example: Examples.Team.members
|
||||
}),
|
||||
subscriptions: Subscription.Info.array().openapi({
|
||||
description: "The subscriptions of this team",
|
||||
example: Examples.Team.subscriptions
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Team",
|
||||
description: "Represents a team on Nestri",
|
||||
example: Examples.Team,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const Events = {
|
||||
Created: createEvent(
|
||||
"team.created",
|
||||
z.object({
|
||||
teamID: z.string().nonempty(),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export class TeamExistsError extends VisibleError {
|
||||
constructor(slug: string) {
|
||||
super(
|
||||
"already_exists",
|
||||
ErrorCodes.Validation.TEAM_ALREADY_EXISTS,
|
||||
`There is already a team named "${slug}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const create = fn(
|
||||
Info.pick({ slug: true, id: true, name: true, }).partial({
|
||||
id: true,
|
||||
}), (input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const id = input.id ?? createID("team");
|
||||
const result = await tx.insert(teamTable).values({
|
||||
id,
|
||||
slug: input.slug,
|
||||
name: input.name
|
||||
})
|
||||
.onConflictDoNothing({ target: teamTable.slug })
|
||||
|
||||
if (result.count === 0) throw new TeamExistsError(input.slug);
|
||||
|
||||
return id;
|
||||
})
|
||||
)
|
||||
|
||||
//TODO: "Delete" subscription and member(s) as well
|
||||
export const remove = fn(Info.shape.id, (input) =>
|
||||
useTransaction(async (tx) => {
|
||||
const account = assertActor("user");
|
||||
const row = await tx
|
||||
.select({
|
||||
teamID: memberTable.teamID,
|
||||
})
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.teamID, input),
|
||||
eq(memberTable.email, account.properties.email),
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
.then((rows) => rows.at(0));
|
||||
if (!row) return;
|
||||
await tx
|
||||
.update(teamTable)
|
||||
.set({
|
||||
timeDeleted: sql`now()`,
|
||||
})
|
||||
.where(eq(teamTable.id, row.teamID));
|
||||
}),
|
||||
);
|
||||
|
||||
export const list = fn(z.void(), () => {
|
||||
const actor = assertActor("user");
|
||||
return useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.leftJoin(subscriptionTable, eq(subscriptionTable.teamID, teamTable.id))
|
||||
.innerJoin(memberTable, eq(memberTable.teamID, teamTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.email, actor.properties.email),
|
||||
isNull(memberTable.timeDeleted),
|
||||
isNull(teamTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
.then((rows) => serialize(rows))
|
||||
)
|
||||
});
|
||||
|
||||
export const fromID = fn(z.string().min(1), async (id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.leftJoin(subscriptionTable, eq(subscriptionTable.teamID, teamTable.id))
|
||||
.innerJoin(memberTable, eq(memberTable.teamID, teamTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(teamTable.id, id),
|
||||
isNull(memberTable.timeDeleted),
|
||||
isNull(teamTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
.then((rows) => serialize(rows).at(0))
|
||||
),
|
||||
);
|
||||
|
||||
export const fromSlug = fn(z.string().min(1), async (slug) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.leftJoin(subscriptionTable, eq(subscriptionTable.teamID, teamTable.id))
|
||||
.innerJoin(memberTable, eq(memberTable.teamID, teamTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(teamTable.slug, slug),
|
||||
isNull(memberTable.timeDeleted),
|
||||
isNull(teamTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
.then((rows) => serialize(rows).at(0))
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Transforms an array of team, subscription, and member records into structured team objects.
|
||||
*
|
||||
* Groups input rows by team ID and constructs an array of team objects, each including its associated members and subscriptions.
|
||||
*
|
||||
* @param input - Array of objects containing team, subscription, and member data.
|
||||
* @returns An array of team objects with their members and subscriptions.
|
||||
*/
|
||||
export function serialize(
|
||||
input: { team: typeof teamTable.$inferSelect, subscription: typeof subscriptionTable.$inferInsert | null, member: typeof memberTable.$inferInsert | null }[],
|
||||
): z.infer<typeof Info>[] {
|
||||
console.log("serialize", input)
|
||||
return pipe(
|
||||
input,
|
||||
groupBy((row) => row.team.id),
|
||||
values(),
|
||||
map((group) => ({
|
||||
name: group[0].team.name,
|
||||
id: group[0].team.id,
|
||||
slug: group[0].team.slug,
|
||||
subscriptions: !group[0].subscription ?
|
||||
[] :
|
||||
group.map((row) => ({
|
||||
planType: row.subscription!.planType,
|
||||
polarProductID: row.subscription!.polarProductID,
|
||||
polarSubscriptionID: row.subscription!.polarSubscriptionID,
|
||||
standing: row.subscription!.standing,
|
||||
tokens: row.subscription!.tokens,
|
||||
teamID: row.subscription!.teamID,
|
||||
userID: row.subscription!.userID,
|
||||
id: row.subscription!.id,
|
||||
})),
|
||||
members:
|
||||
!group[0].member ?
|
||||
[] :
|
||||
group.map((row) => ({
|
||||
id: row.member!.id,
|
||||
email: row.member!.email,
|
||||
role: row.member!.role,
|
||||
teamID: row.member!.teamID,
|
||||
timeSeen: row.member!.timeSeen,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
28
packages/core/src/team/team.sql.ts
Normal file
28
packages/core/src/team/team.sql.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { timestamps, id } from "../drizzle/types";
|
||||
import {
|
||||
varchar,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const teamTable = pgTable(
|
||||
"team",
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
slug: varchar("slug", { length: 255 }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("slug").on(table.slug)
|
||||
],
|
||||
);
|
||||
|
||||
export function teamIndexes(table: any) {
|
||||
return [
|
||||
primaryKey({
|
||||
columns: [table.teamID, table.id],
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -1,60 +1,66 @@
|
||||
import { z } from "zod";
|
||||
import { Team } from "../team";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { Steam } from "../steam";
|
||||
import { Common } from "../common";
|
||||
import { createEvent } from "../event";
|
||||
import { Polar } from "../polar/index";
|
||||
import { createID, fn } from "../utils";
|
||||
import { userTable } from "./user.sql";
|
||||
import { createEvent } from "../event";
|
||||
import { Examples } from "../examples";
|
||||
import { and, eq, isNull, asc } from "drizzle-orm";
|
||||
import { ErrorCodes, VisibleError } from "../error";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { Resource } from "sst/resource";
|
||||
import { teamTable } from "../team/team.sql";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { assertActor, withActor } from "../actor";
|
||||
import { memberTable } from "../member/member.sql";
|
||||
import { pipe, groupBy, values, map } from "remeda";
|
||||
import { and, eq, isNull, asc, sql } from "../drizzle";
|
||||
import { subscriptionTable } from "../subscription/subscription.sql";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
|
||||
export namespace User {
|
||||
const MAX_ATTEMPTS = 50;
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.User.id,
|
||||
}),
|
||||
name: z.string().regex(/^[a-zA-Z ]{1,32}$/, "Use a friendly name.").openapi({
|
||||
description: "The name of this account",
|
||||
example: Examples.User.name
|
||||
name: z.string().openapi({
|
||||
description: "The user's unique username",
|
||||
example: Examples.User.name,
|
||||
}),
|
||||
polarCustomerID: z.string().nullable().openapi({
|
||||
description: "Associated Polar.sh customer identifier",
|
||||
polarCustomerID: z.string().or(z.null()).openapi({
|
||||
description: "The polar customer id for this user",
|
||||
example: Examples.User.polarCustomerID,
|
||||
}),
|
||||
avatarUrl: z.string().url().nullable().openapi({
|
||||
description: "The url to the profile picture",
|
||||
example: Examples.User.avatarUrl
|
||||
}),
|
||||
email: z.string().openapi({
|
||||
description: "Primary email address for user notifications and authentication",
|
||||
description: "The email address of this user",
|
||||
example: Examples.User.email,
|
||||
}),
|
||||
lastLogin: z.date().openapi({
|
||||
description: "Timestamp of user's most recent authentication",
|
||||
example: Examples.User.lastLogin
|
||||
})
|
||||
avatarUrl: z.string().or(z.null()).openapi({
|
||||
description: "The url to the profile picture.",
|
||||
example: Examples.User.name,
|
||||
}),
|
||||
discriminator: z.string().or(z.number()).openapi({
|
||||
description: "The (number) discriminator for this user",
|
||||
example: Examples.User.discriminator,
|
||||
}),
|
||||
steamAccounts: Steam.Info.array().openapi({
|
||||
description: "The steam accounts for this user",
|
||||
example: Examples.User.steamAccounts,
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "User",
|
||||
description: "User account entity with core identification and authentication details",
|
||||
description: "Represents a user on Nestri",
|
||||
example: Examples.User,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export class UserExistsError extends VisibleError {
|
||||
constructor(username: string) {
|
||||
super(
|
||||
"already_exists",
|
||||
ErrorCodes.Validation.ALREADY_EXISTS,
|
||||
`A user with this email ${username} already exists`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const Events = {
|
||||
Created: createEvent(
|
||||
"user.created",
|
||||
@@ -62,93 +68,122 @@ export namespace User {
|
||||
userID: Info.shape.id,
|
||||
}),
|
||||
),
|
||||
Updated: createEvent(
|
||||
"user.updated",
|
||||
z.object({
|
||||
userID: Info.shape.id,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export const create = fn(
|
||||
Info
|
||||
.omit({
|
||||
lastLogin: true,
|
||||
polarCustomerID: true,
|
||||
}).partial({
|
||||
avatarUrl: true,
|
||||
id: true
|
||||
}),
|
||||
async (input) => {
|
||||
export const sanitizeUsername = (username: string): string => {
|
||||
// Remove spaces and numbers
|
||||
return username.replace(/[\s0-9]/g, '');
|
||||
};
|
||||
|
||||
export const generateDiscriminator = (): string => {
|
||||
return Math.floor(Math.random() * 100).toString().padStart(2, '0');
|
||||
};
|
||||
|
||||
export const isValidDiscriminator = (discriminator: string): boolean => {
|
||||
return /^\d{2}$/.test(discriminator);
|
||||
};
|
||||
|
||||
export const findAvailableDiscriminator = fn(z.string(), async (input) => {
|
||||
const username = sanitizeUsername(input);
|
||||
|
||||
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
||||
const discriminator = generateDiscriminator();
|
||||
|
||||
const users = await useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(userTable)
|
||||
.where(and(eq(userTable.name, username), eq(userTable.discriminator, Number(discriminator))))
|
||||
)
|
||||
|
||||
if (users.length === 0) {
|
||||
return discriminator;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
|
||||
export const create = fn(Info.omit({ polarCustomerID: true, discriminator: true, steamAccounts: true }).partial({ avatarUrl: true, id: true }), async (input) => {
|
||||
const userID = createID("user")
|
||||
|
||||
//FIXME: Do this much later, as Polar.sh has so many inconsistencies for fuck's sake
|
||||
|
||||
const customer = await Polar.fromUserEmail(input.email)
|
||||
console.log("customer", customer)
|
||||
|
||||
const name = sanitizeUsername(input.name);
|
||||
|
||||
// Generate a random available discriminator
|
||||
const discriminator = await findAvailableDiscriminator(name);
|
||||
|
||||
if (!discriminator) {
|
||||
console.error("No available discriminators for this username ")
|
||||
return null
|
||||
}
|
||||
|
||||
createTransaction(async (tx) => {
|
||||
const id = input.id ?? userID;
|
||||
|
||||
await createTransaction(async (tx) => {
|
||||
const result = await tx
|
||||
.insert(userTable)
|
||||
.values({
|
||||
await tx.insert(userTable).values({
|
||||
id,
|
||||
name: input.name,
|
||||
avatarUrl: input.avatarUrl,
|
||||
email: input.email,
|
||||
name: input.name,
|
||||
polarCustomerID: customer?.id,
|
||||
lastLogin: Common.utc()
|
||||
discriminator: Number(discriminator),
|
||||
polarCustomerID: customer?.id
|
||||
})
|
||||
.onConflictDoNothing({
|
||||
target: [userTable.email]
|
||||
await afterTx(() =>
|
||||
withActor({
|
||||
type: "user",
|
||||
properties: {
|
||||
userID: id,
|
||||
email: input.email
|
||||
},
|
||||
},
|
||||
async () => bus.publish(Resource.Bus, Events.Created, { userID: id }),
|
||||
)
|
||||
);
|
||||
})
|
||||
|
||||
if (result.count === 0) {
|
||||
throw new UserExistsError(input.email)
|
||||
}
|
||||
return userID;
|
||||
})
|
||||
|
||||
return id;
|
||||
})
|
||||
|
||||
export const fromEmail = fn(
|
||||
Info.shape.email.min(1),
|
||||
async (email) =>
|
||||
export const fromEmail = fn(z.string(), async (email) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(userTable)
|
||||
.where(
|
||||
and(
|
||||
eq(userTable.email, email),
|
||||
isNull(userTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.leftJoin(steamTable, eq(userTable.id, steamTable.userID))
|
||||
.where(and(eq(userTable.email, email), isNull(userTable.timeDeleted)))
|
||||
.orderBy(asc(userTable.timeCreated))
|
||||
.execute()
|
||||
.then(rows => rows.map(serialize).at(0))
|
||||
.then((rows => serialize(rows).at(0)))
|
||||
)
|
||||
)
|
||||
|
||||
export const fromID = fn(
|
||||
Info.shape.id.min(1),
|
||||
(id) =>
|
||||
export const fromID = fn(z.string(), (id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(userTable)
|
||||
.where(
|
||||
and(
|
||||
eq(userTable.id, id),
|
||||
isNull(userTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.leftJoin(steamTable, eq(userTable.id, steamTable.userID))
|
||||
.where(and(eq(userTable.id, id), isNull(userTable.timeDeleted), isNull(steamTable.timeDeleted)))
|
||||
.orderBy(asc(userTable.timeCreated))
|
||||
.execute()
|
||||
.then(rows => rows.map(serialize).at(0))
|
||||
.then((rows) => serialize(rows).at(0))
|
||||
),
|
||||
)
|
||||
|
||||
export const remove = fn(
|
||||
Info.shape.id.min(1),
|
||||
(id) =>
|
||||
export const remove = fn(Info.shape.id, (id) =>
|
||||
useTransaction(async (tx) => {
|
||||
await tx
|
||||
.update(userTable)
|
||||
.set({
|
||||
timeDeleted: Common.utc(),
|
||||
timeDeleted: sql`now()`,
|
||||
})
|
||||
.where(and(eq(userTable.id, id)))
|
||||
.execute();
|
||||
@@ -156,31 +191,64 @@ export namespace User {
|
||||
}),
|
||||
);
|
||||
|
||||
export const acknowledgeLogin = fn(
|
||||
Info.shape.id,
|
||||
(id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.update(userTable)
|
||||
.set({
|
||||
lastLogin: Common.utc(),
|
||||
})
|
||||
.where(and(eq(userTable.id, id)))
|
||||
.execute()
|
||||
/**
|
||||
* Converts an array of user and Steam account records into structured user objects with associated Steam accounts.
|
||||
*
|
||||
* @param input - An array of objects containing user data and optional Steam account data.
|
||||
* @returns An array of user objects, each including a list of their associated Steam accounts.
|
||||
*/
|
||||
export function serialize(
|
||||
input: { user: typeof userTable.$inferSelect; steam: typeof steamTable.$inferSelect | null }[],
|
||||
): z.infer<typeof Info>[] {
|
||||
return pipe(
|
||||
input,
|
||||
groupBy((row) => row.user.id),
|
||||
values(),
|
||||
map((group) => ({
|
||||
...group[0].user,
|
||||
steamAccounts: !group[0].steam ?
|
||||
[] :
|
||||
group.map((row) => ({
|
||||
id: row.steam!.id,
|
||||
lastSeen: row.steam!.lastSeen,
|
||||
countryCode: row.steam!.countryCode,
|
||||
username: row.steam!.username,
|
||||
steamID: row.steam!.steamID,
|
||||
lastGame: row.steam!.lastGame,
|
||||
limitation: row.steam!.limitation,
|
||||
steamEmail: row.steam!.steamEmail,
|
||||
userID: row.steam!.userID,
|
||||
personaName: row.steam!.personaName,
|
||||
avatarUrl: row.steam!.avatarUrl,
|
||||
})),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the list of teams that the current user belongs to.
|
||||
*
|
||||
* @returns An array of team information objects representing the user's active team memberships.
|
||||
*
|
||||
* @remark Only teams and memberships that have not been deleted are included in the result.
|
||||
*/
|
||||
export function teams() {
|
||||
const actor = assertActor("user");
|
||||
return useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.leftJoin(subscriptionTable, eq(subscriptionTable.teamID, teamTable.id))
|
||||
.innerJoin(memberTable, eq(memberTable.teamID, teamTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.email, actor.properties.email),
|
||||
isNull(memberTable.timeDeleted),
|
||||
isNull(teamTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: typeof userTable.$inferSelect
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
email: input.email,
|
||||
avatarUrl: input.avatarUrl,
|
||||
lastLogin: input.lastLogin,
|
||||
polarCustomerID: input.polarCustomerID,
|
||||
}
|
||||
.execute()
|
||||
.then((rows) => Team.serialize(rows))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,27 @@
|
||||
import { id, timestamps, utc } from "../drizzle/types";
|
||||
import { pgTable, text, unique, varchar } from "drizzle-orm/pg-core";
|
||||
import { z } from "zod";
|
||||
import { id, timestamps } from "../drizzle/types";
|
||||
import { integer, pgTable, text, uniqueIndex, varchar, json } from "drizzle-orm/pg-core";
|
||||
|
||||
// Whether this user is part of the Nestri Team, comes with privileges
|
||||
export const UserFlags = z.object({
|
||||
team: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type UserFlags = z.infer<typeof UserFlags>;
|
||||
|
||||
export const userTable = pgTable(
|
||||
"users",
|
||||
"user",
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
email: varchar("email", { length: 255 }).notNull(),
|
||||
avatarUrl: text("avatar_url"),
|
||||
lastLogin: utc("last_login").notNull(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
polarCustomerID: varchar("polar_customer_id", { length: 255 }),
|
||||
discriminator: integer("discriminator").notNull(),
|
||||
email: varchar("email", { length: 255 }).notNull(),
|
||||
polarCustomerID: varchar("polar_customer_id", { length: 255 }).unique(),
|
||||
// flags: json("flags").$type<UserFlags>().default({}),
|
||||
},
|
||||
(user) => [
|
||||
unique("idx_user_email").on(user.email),
|
||||
uniqueIndex("user_email").on(user.email),
|
||||
]
|
||||
);
|
||||
@@ -1,10 +0,0 @@
|
||||
export function chunkArray<T>(arr: T[], chunkSize: number): T[][] {
|
||||
if (chunkSize <= 0) {
|
||||
throw new Error("chunkSize must be a positive integer");
|
||||
}
|
||||
const chunks: T[][] = [];
|
||||
for (let i = 0; i < arr.length; i += chunkSize) {
|
||||
chunks.push(arr.slice(i, i + chunkSize));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
@@ -2,20 +2,14 @@ import { ulid } from "ulid";
|
||||
|
||||
export const prefixes = {
|
||||
user: "usr",
|
||||
credentials:"crd",
|
||||
team: "tem",
|
||||
product: "prd",
|
||||
session: "ses",
|
||||
task: "tsk",
|
||||
machine: "mch",
|
||||
member: "mbr",
|
||||
variant: "var",
|
||||
gpu: "gpu",
|
||||
game: "gme",
|
||||
usage: "usg",
|
||||
steam: "stm",
|
||||
subscription: "sub",
|
||||
// task: "tsk",
|
||||
// invite: "inv",
|
||||
// product: "prd",
|
||||
invite: "inv",
|
||||
product: "prd",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,2 @@
|
||||
export * from "./id"
|
||||
export * from "./fn"
|
||||
export * from "./log"
|
||||
export * from "./invite"
|
||||
export * from "./helper"
|
||||
export * from "./id"
|
||||
@@ -1,32 +0,0 @@
|
||||
export namespace Invite {
|
||||
/**
|
||||
* Generates a random invite code for teams
|
||||
* @param length The length of the invite code (default: 8)
|
||||
* @returns A string containing alphanumeric characters (excluding confusing characters)
|
||||
*/
|
||||
export function generateCode(length: number = 8): string {
|
||||
// Use only unambiguous characters (no 0/O, 1/l/I confusion)
|
||||
const characters = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
let result = '';
|
||||
|
||||
// Create a Uint32Array of the required length for randomness
|
||||
const randomValues = new Uint32Array(length);
|
||||
|
||||
// Fill with cryptographically strong random values if available
|
||||
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
||||
crypto.getRandomValues(randomValues);
|
||||
} else {
|
||||
// Fallback for environments without crypto
|
||||
for (let i = 0; i < length; i++) {
|
||||
randomValues[i] = Math.floor(Math.random() * 2 ** 32);
|
||||
}
|
||||
}
|
||||
|
||||
// Use the random values to select characters
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += characters.charAt(randomValues[i] % characters.length);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { createContext } from "../context";
|
||||
|
||||
export namespace Log {
|
||||
const ctx = createContext<{
|
||||
tags: Record<string, any>;
|
||||
}>();
|
||||
|
||||
export function create(tags?: Record<string, any>) {
|
||||
tags = tags || {};
|
||||
|
||||
const result = {
|
||||
info(msg: string, extra?: Record<string, any>) {
|
||||
const prefix = Object.entries({
|
||||
...use().tags,
|
||||
...tags,
|
||||
...extra,
|
||||
})
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(" ");
|
||||
console.log(prefix, msg);
|
||||
return result;
|
||||
},
|
||||
warn(msg: string, extra?: Record<string, any>) {
|
||||
const prefix = Object.entries({
|
||||
...use().tags,
|
||||
...tags,
|
||||
...extra,
|
||||
})
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(" ");
|
||||
console.warn(prefix, msg);
|
||||
return result;
|
||||
},
|
||||
error(error: Error) {
|
||||
const prefix = Object.entries({
|
||||
...use().tags,
|
||||
...tags,
|
||||
})
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(" ");
|
||||
console.error(prefix, error);
|
||||
return result;
|
||||
},
|
||||
tag(key: string, value: string) {
|
||||
// Immutable update: return a fresh logger with updated tags
|
||||
return Log.create({ ...tags, [key]: value });
|
||||
},
|
||||
clone() {
|
||||
return Log.create({ ...tags });
|
||||
},
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function provide<R>(tags: Record<string, any>, cb: () => R) {
|
||||
const existing = use();
|
||||
return ctx.provide(
|
||||
{
|
||||
tags: {
|
||||
...existing.tags,
|
||||
...tags,
|
||||
},
|
||||
},
|
||||
cb,
|
||||
);
|
||||
}
|
||||
|
||||
function use() {
|
||||
try {
|
||||
return ctx.use();
|
||||
} catch (e) {
|
||||
return { tags: {} };
|
||||
}
|
||||
}
|
||||
}
|
||||
171
packages/functions/.gitignore
vendored
171
packages/functions/.gitignore
vendored
@@ -1,31 +1,172 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Caches
|
||||
|
||||
.cache
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
17
packages/functions/Containerfile
Normal file
17
packages/functions/Containerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM mirror.gcr.io/oven/bun:1.2
|
||||
|
||||
# TODO: Add a way to build C# Steam.exe and start it to run in the container before the API
|
||||
|
||||
ADD ./package.json .
|
||||
ADD ./bun.lock .
|
||||
ADD ./packages/core/package.json ./packages/core/package.json
|
||||
ADD ./packages/functions/package.json ./packages/functions/package.json
|
||||
ADD ./patches ./patches
|
||||
RUN bun install --ignore-scripts
|
||||
|
||||
ADD ./packages/functions ./packages/functions
|
||||
ADD ./packages/core ./packages/core
|
||||
|
||||
WORKDIR ./packages/functions
|
||||
|
||||
CMD ["bun", "run", "./src/api/index.ts"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user