Phase7
This commit is contained in:
43
packages/contracts/src/billing.ts
Normal file
43
packages/contracts/src/billing.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const idleStatusResponseSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
playerCount: z.number().int().nullable(),
|
||||
idleStopAt: z.string().datetime().nullable(),
|
||||
secondsRemaining: z.number().int().nullable(),
|
||||
lastPlayerSeenAt: z.string().datetime().nullable(),
|
||||
});
|
||||
|
||||
export const creditTransactionSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
amount: z.number().int(),
|
||||
type: z.enum(['GRANT', 'USAGE_DEBIT', 'ADJUSTMENT', 'EXPIRE']),
|
||||
referenceType: z.string().nullable(),
|
||||
referenceId: z.string().nullable(),
|
||||
createdAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export const walletResponseSchema = z.object({
|
||||
balance: z.number().int(),
|
||||
periodStart: z.string().datetime(),
|
||||
transactions: z.array(creditTransactionSchema),
|
||||
});
|
||||
|
||||
export const usageRecordSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
serverId: z.string().uuid(),
|
||||
ramMb: z.number().int(),
|
||||
durationSeconds: z.number().int(),
|
||||
creditsCharged: z.number().int(),
|
||||
periodStart: z.string().datetime(),
|
||||
periodEnd: z.string().datetime(),
|
||||
createdAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export const usageListResponseSchema = z.object({
|
||||
records: z.array(usageRecordSchema),
|
||||
});
|
||||
|
||||
export type IdleStatusResponse = z.infer<typeof idleStatusResponseSchema>;
|
||||
export type WalletResponse = z.infer<typeof walletResponseSchema>;
|
||||
export type UsageListResponse = z.infer<typeof usageListResponseSchema>;
|
||||
@@ -194,3 +194,14 @@ export {
|
||||
type StartQueueEntry,
|
||||
type StartQueueResponse,
|
||||
} from './nodes';
|
||||
|
||||
export {
|
||||
idleStatusResponseSchema,
|
||||
creditTransactionSchema,
|
||||
walletResponseSchema,
|
||||
usageRecordSchema,
|
||||
usageListResponseSchema,
|
||||
type IdleStatusResponse,
|
||||
type WalletResponse,
|
||||
type UsageListResponse,
|
||||
} from './billing';
|
||||
|
||||
@@ -59,6 +59,8 @@ export const serverResponseSchema = z.object({
|
||||
dataPath: z.string().nullable(),
|
||||
activeWorldName: z.string(),
|
||||
ramMb: z.number().int(),
|
||||
idleShutdownEnabled: z.boolean(),
|
||||
idleStopAt: z.string().datetime().nullable(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,79 @@
|
||||
-- Phase 7: Idle shutdown, credits, usage metering, plan quotas
|
||||
|
||||
CREATE TYPE "CreditTransactionType" AS ENUM ('GRANT', 'USAGE_DEBIT', 'ADJUSTMENT', 'EXPIRE');
|
||||
|
||||
ALTER TABLE "plans"
|
||||
ADD COLUMN IF NOT EXISTS "maxServersPerUser" INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS "idleTimeoutMinutes" INTEGER NOT NULL DEFAULT 15,
|
||||
ADD COLUMN IF NOT EXISTS "idleGraceAfterStartMinutes" INTEGER NOT NULL DEFAULT 3,
|
||||
ADD COLUMN IF NOT EXISTS "idleCountdownSeconds" INTEGER NOT NULL DEFAULT 120,
|
||||
ADD COLUMN IF NOT EXISTS "queuePriority" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS "monthlyCreditGrant" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE "game_servers"
|
||||
ADD COLUMN IF NOT EXISTS "idleShutdownEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN IF NOT EXISTS "lastPlayerSeenAt" TIMESTAMPTZ(3),
|
||||
ADD COLUMN IF NOT EXISTS "idleStopAt" TIMESTAMPTZ(3),
|
||||
ADD COLUMN IF NOT EXISTS "runningSince" TIMESTAMPTZ(3),
|
||||
ADD COLUMN IF NOT EXISTS "lastMeteredAt" TIMESTAMPTZ(3);
|
||||
|
||||
CREATE TABLE "credit_wallets" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"balance" INTEGER NOT NULL DEFAULT 0,
|
||||
"periodStart" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "credit_wallets_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "credit_wallets_userId_key" ON "credit_wallets"("userId");
|
||||
|
||||
ALTER TABLE "credit_wallets"
|
||||
ADD CONSTRAINT "credit_wallets_userId_fkey"
|
||||
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE TABLE "credit_transactions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"walletId" TEXT NOT NULL,
|
||||
"amount" INTEGER NOT NULL,
|
||||
"type" "CreditTransactionType" NOT NULL,
|
||||
"referenceType" TEXT,
|
||||
"referenceId" TEXT,
|
||||
"idempotencyKey" TEXT NOT NULL,
|
||||
"metadata" JSONB,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "credit_transactions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "credit_transactions_idempotencyKey_key" ON "credit_transactions"("idempotencyKey");
|
||||
CREATE INDEX "credit_transactions_walletId_createdAt_idx" ON "credit_transactions"("walletId", "createdAt");
|
||||
|
||||
ALTER TABLE "credit_transactions"
|
||||
ADD CONSTRAINT "credit_transactions_walletId_fkey"
|
||||
FOREIGN KEY ("walletId") REFERENCES "credit_wallets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE TABLE "usage_records" (
|
||||
"id" TEXT NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"ramMb" INTEGER NOT NULL,
|
||||
"durationSeconds" INTEGER NOT NULL,
|
||||
"creditsCharged" INTEGER NOT NULL,
|
||||
"periodStart" TIMESTAMPTZ(3) NOT NULL,
|
||||
"periodEnd" TIMESTAMPTZ(3) NOT NULL,
|
||||
"idempotencyKey" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "usage_records_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "usage_records_idempotencyKey_key" ON "usage_records"("idempotencyKey");
|
||||
CREATE INDEX "usage_records_serverId_periodStart_idx" ON "usage_records"("serverId", "periodStart");
|
||||
CREATE INDEX "usage_records_userId_periodStart_idx" ON "usage_records"("userId", "periodStart");
|
||||
|
||||
ALTER TABLE "usage_records"
|
||||
ADD CONSTRAINT "usage_records_serverId_fkey"
|
||||
FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -121,6 +121,13 @@ enum AddonInstallStatus {
|
||||
REMOVED
|
||||
}
|
||||
|
||||
enum CreditTransactionType {
|
||||
GRANT
|
||||
USAGE_DEBIT
|
||||
ADJUSTMENT
|
||||
EXPIRE
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
@@ -141,6 +148,7 @@ model User {
|
||||
emailVerificationTokens EmailVerificationToken[]
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
totpCredential TotpCredential?
|
||||
creditWallet CreditWallet?
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -276,10 +284,15 @@ model GameServer {
|
||||
hostPort Int?
|
||||
containerId String?
|
||||
dataPath String?
|
||||
ramMb Int @default(1024)
|
||||
activeWorldName String @default("world")
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
ramMb Int @default(1024)
|
||||
activeWorldName String @default("world")
|
||||
idleShutdownEnabled Boolean @default(true)
|
||||
lastPlayerSeenAt DateTime? @db.Timestamptz(3)
|
||||
idleStopAt DateTime? @db.Timestamptz(3)
|
||||
runningSince DateTime? @db.Timestamptz(3)
|
||||
lastMeteredAt DateTime? @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
plan Plan? @relation(fields: [planId], references: [id])
|
||||
@@ -292,6 +305,7 @@ model GameServer {
|
||||
worldUploads WorldUpload[]
|
||||
installedAddons InstalledAddon[]
|
||||
startQueueEntry ServerStartQueueEntry?
|
||||
usageRecords UsageRecord[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([nodeId])
|
||||
@@ -397,18 +411,24 @@ model ServerStartQueueEntry {
|
||||
}
|
||||
|
||||
model Plan {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
slug String @unique
|
||||
description String?
|
||||
isFree Boolean @default(false)
|
||||
isActive Boolean @default(true)
|
||||
maxRamMb Int
|
||||
maxCpuCores Float
|
||||
maxStorageMb Int
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
slug String @unique
|
||||
description String?
|
||||
isFree Boolean @default(false)
|
||||
isActive Boolean @default(true)
|
||||
maxRamMb Int
|
||||
maxCpuCores Float
|
||||
maxStorageMb Int
|
||||
maxServersPerUser Int?
|
||||
idleTimeoutMinutes Int @default(15)
|
||||
idleGraceAfterStartMinutes Int @default(3)
|
||||
idleCountdownSeconds Int @default(120)
|
||||
queuePriority Int @default(0)
|
||||
monthlyCreditGrant Int @default(0)
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
gameServers GameServer[]
|
||||
|
||||
@@ -475,6 +495,56 @@ model JobRecord {
|
||||
@@map("job_records")
|
||||
}
|
||||
|
||||
model CreditWallet {
|
||||
id String @id @default(uuid())
|
||||
userId String @unique
|
||||
balance Int @default(0)
|
||||
periodStart DateTime @default(now()) @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
transactions CreditTransaction[]
|
||||
|
||||
@@map("credit_wallets")
|
||||
}
|
||||
|
||||
model CreditTransaction {
|
||||
id String @id @default(uuid())
|
||||
walletId String
|
||||
amount Int
|
||||
type CreditTransactionType
|
||||
referenceType String?
|
||||
referenceId String?
|
||||
idempotencyKey String @unique
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
wallet CreditWallet @relation(fields: [walletId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([walletId, createdAt])
|
||||
@@map("credit_transactions")
|
||||
}
|
||||
|
||||
model UsageRecord {
|
||||
id String @id @default(uuid())
|
||||
serverId String
|
||||
userId String
|
||||
ramMb Int
|
||||
durationSeconds Int
|
||||
creditsCharged Int
|
||||
periodStart DateTime @db.Timestamptz(3)
|
||||
periodEnd DateTime @db.Timestamptz(3)
|
||||
idempotencyKey String @unique
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([serverId, periodStart])
|
||||
@@index([userId, periodStart])
|
||||
@@map("usage_records")
|
||||
}
|
||||
|
||||
model ServerBackup {
|
||||
id String @id @default(uuid())
|
||||
serverId String
|
||||
|
||||
@@ -29,9 +29,21 @@ async function main(): Promise<void> {
|
||||
maxRamMb: 2048,
|
||||
maxCpuCores: 1,
|
||||
maxStorageMb: 5120,
|
||||
maxServersPerUser: 3,
|
||||
idleTimeoutMinutes: 10,
|
||||
idleGraceAfterStartMinutes: 2,
|
||||
idleCountdownSeconds: 60,
|
||||
queuePriority: 0,
|
||||
monthlyCreditGrant: 5000,
|
||||
},
|
||||
update: {
|
||||
isActive: true,
|
||||
maxServersPerUser: 3,
|
||||
idleTimeoutMinutes: 10,
|
||||
idleGraceAfterStartMinutes: 2,
|
||||
idleCountdownSeconds: 60,
|
||||
queuePriority: 0,
|
||||
monthlyCreditGrant: 5000,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ export type {
|
||||
GameNode,
|
||||
GameNodeHeartbeat,
|
||||
ServerStartQueueEntry,
|
||||
CreditWallet,
|
||||
CreditTransaction,
|
||||
UsageRecord,
|
||||
Plan,
|
||||
FeatureFlag,
|
||||
SystemSetting,
|
||||
|
||||
File diff suppressed because one or more lines are too long
18
packages/metering/package.json
Normal file
18
packages/metering/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@hexahost/metering",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hexahost/typescript-config": "workspace:*",
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.8"
|
||||
}
|
||||
}
|
||||
48
packages/metering/src/index.ts
Normal file
48
packages/metering/src/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/** Credits charged for RAM usage: 1 credit per GB-minute (rounded up). */
|
||||
export function calculateUsageCredits(ramMb: number, durationSeconds: number): number {
|
||||
if (ramMb <= 0 || durationSeconds <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const gbMinutes = (ramMb / 1024) * (durationSeconds / 60);
|
||||
return Math.max(1, Math.ceil(gbMinutes));
|
||||
}
|
||||
|
||||
export function parseMinecraftListOutput(output: string): number {
|
||||
const headerMatch = output.match(
|
||||
/There are (\d+) of a max(?:imum)? of \d+ players? online/i,
|
||||
);
|
||||
|
||||
if (headerMatch?.[1]) {
|
||||
return Number.parseInt(headerMatch[1], 10);
|
||||
}
|
||||
|
||||
const legacyMatch = output.match(/^(\d+) players? online/i);
|
||||
if (legacyMatch?.[1]) {
|
||||
return Number.parseInt(legacyMatch[1], 10);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export interface IdlePolicy {
|
||||
idleTimeoutMinutes: number;
|
||||
idleGraceAfterStartMinutes: number;
|
||||
idleCountdownSeconds: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_IDLE_POLICY: IdlePolicy = {
|
||||
idleTimeoutMinutes: 15,
|
||||
idleGraceAfterStartMinutes: 3,
|
||||
idleCountdownSeconds: 120,
|
||||
};
|
||||
|
||||
export function resolveIdlePolicy(plan?: Partial<IdlePolicy> | null): IdlePolicy {
|
||||
return {
|
||||
idleTimeoutMinutes: plan?.idleTimeoutMinutes ?? DEFAULT_IDLE_POLICY.idleTimeoutMinutes,
|
||||
idleGraceAfterStartMinutes:
|
||||
plan?.idleGraceAfterStartMinutes ?? DEFAULT_IDLE_POLICY.idleGraceAfterStartMinutes,
|
||||
idleCountdownSeconds:
|
||||
plan?.idleCountdownSeconds ?? DEFAULT_IDLE_POLICY.idleCountdownSeconds,
|
||||
};
|
||||
}
|
||||
23
packages/metering/src/metering.test.ts
Normal file
23
packages/metering/src/metering.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
calculateUsageCredits,
|
||||
parseMinecraftListOutput,
|
||||
} from './index';
|
||||
|
||||
describe('metering', () => {
|
||||
it('charges credits by RAM-GB-minutes', () => {
|
||||
expect(calculateUsageCredits(1024, 60)).toBe(1);
|
||||
expect(calculateUsageCredits(2048, 60)).toBe(2);
|
||||
expect(calculateUsageCredits(512, 30)).toBe(1);
|
||||
});
|
||||
|
||||
it('parses minecraft list command output', () => {
|
||||
expect(
|
||||
parseMinecraftListOutput('There are 2 of a max of 20 players online: Steve, Alex'),
|
||||
).toBe(2);
|
||||
expect(
|
||||
parseMinecraftListOutput('There are 0 of a maximum of 10 players online:'),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
9
packages/metering/tsconfig.json
Normal file
9
packages/metering/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@hexahost/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user