From 3ce63a19cf69e382f438a1297b36b2e91b30361f Mon Sep 17 00:00:00 2001 From: Skyler Calaman <54462713+Blckbrry-Pi@users.noreply.github.com> Date: Thu, 28 Mar 2024 13:37:47 -0400 Subject: [PATCH] feat: Create `presence` module --- .../migration.sql | 14 ++ .../db/migrations/migration_lock.toml | 3 + modules/presence/db/schema.prisma | 24 ++++ modules/presence/module.yaml | 12 ++ modules/presence/scripts/clear.ts | 36 +++++ modules/presence/scripts/clear_all_game.ts | 26 ++++ .../presence/scripts/clear_all_identity.ts | 26 ++++ modules/presence/scripts/extend.ts | 63 +++++++++ modules/presence/scripts/get.ts | 39 +++++ modules/presence/scripts/get_by_game.ts | 31 ++++ modules/presence/scripts/get_by_identity.ts | 31 ++++ modules/presence/scripts/set.ts | 48 +++++++ modules/presence/tests/expire.ts | 93 ++++++++++++ modules/presence/tests/simple.ts | 133 ++++++++++++++++++ modules/presence/utils/types.ts | 50 +++++++ tests/basic/backend.yaml | 2 + 16 files changed, 631 insertions(+) create mode 100644 modules/presence/db/migrations/20240328153207_create_presences/migration.sql create mode 100644 modules/presence/db/migrations/migration_lock.toml create mode 100644 modules/presence/db/schema.prisma create mode 100644 modules/presence/module.yaml create mode 100644 modules/presence/scripts/clear.ts create mode 100644 modules/presence/scripts/clear_all_game.ts create mode 100644 modules/presence/scripts/clear_all_identity.ts create mode 100644 modules/presence/scripts/extend.ts create mode 100644 modules/presence/scripts/get.ts create mode 100644 modules/presence/scripts/get_by_game.ts create mode 100644 modules/presence/scripts/get_by_identity.ts create mode 100644 modules/presence/scripts/set.ts create mode 100644 modules/presence/tests/expire.ts create mode 100644 modules/presence/tests/simple.ts create mode 100644 modules/presence/utils/types.ts diff --git a/modules/presence/db/migrations/20240328153207_create_presences/migration.sql b/modules/presence/db/migrations/20240328153207_create_presences/migration.sql new file mode 100644 index 00000000..7430b813 --- /dev/null +++ b/modules/presence/db/migrations/20240328153207_create_presences/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "Presence" ( + "identityId" UUID NOT NULL, + "gameId" UUID NOT NULL, + "message" TEXT, + "publicMeta" JSONB NOT NULL, + "mutualMeta" JSONB NOT NULL, + "expires" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "removedAt" TIMESTAMP(3), + + CONSTRAINT "Presence_pkey" PRIMARY KEY ("identityId","gameId") +); diff --git a/modules/presence/db/migrations/migration_lock.toml b/modules/presence/db/migrations/migration_lock.toml new file mode 100644 index 00000000..fbffa92c --- /dev/null +++ b/modules/presence/db/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/modules/presence/db/schema.prisma b/modules/presence/db/schema.prisma new file mode 100644 index 00000000..0a348810 --- /dev/null +++ b/modules/presence/db/schema.prisma @@ -0,0 +1,24 @@ +// Do not modify this `datasource` block +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// Add your database schema here + +model Presence { + identityId String @db.Uuid + gameId String @db.Uuid + + message String? + publicMeta Json + mutualMeta Json + + expires DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + removedAt DateTime? + + @@id([identityId, gameId]) +} diff --git a/modules/presence/module.yaml b/modules/presence/module.yaml new file mode 100644 index 00000000..513ce2b6 --- /dev/null +++ b/modules/presence/module.yaml @@ -0,0 +1,12 @@ +scripts: + set: {} + extend: {} + + get: {} + get_by_game: {} + get_by_identity: {} + + clear: {} + clear_all_game: {} + clear_all_identity: {} +errors: {} diff --git a/modules/presence/scripts/clear.ts b/modules/presence/scripts/clear.ts new file mode 100644 index 00000000..d2e25dda --- /dev/null +++ b/modules/presence/scripts/clear.ts @@ -0,0 +1,36 @@ +import { ScriptContext, RuntimeError } from "../_gen/scripts/clear.ts"; + +export interface Request { + identityId: string; + gameId: string; + + errorIfNotPresent?: boolean; +} + +export type Response = Record; + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + const value = await ctx.db.presence.updateMany({ + where: { + identityId: req.identityId, + gameId: req.gameId, + }, + data: { + removedAt: new Date().toISOString(), + expires: null, + }, + }); + + if (value.count === 0) { + throw new RuntimeError( + "presence_not_found", + { cause: `Presence not found for identity ${req.identityId} and game ${req.gameId}` }, + ); + } + + return {}; +} + diff --git a/modules/presence/scripts/clear_all_game.ts b/modules/presence/scripts/clear_all_game.ts new file mode 100644 index 00000000..bfe31bd0 --- /dev/null +++ b/modules/presence/scripts/clear_all_game.ts @@ -0,0 +1,26 @@ +import { ScriptContext } from "../_gen/scripts/clear_all_game.ts"; + +export interface Request { + gameId: string; +} + +export interface Response { + cleared: number; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + const { count: cleared } = await ctx.db.presence.updateMany({ + where: { + gameId: req.gameId, + }, + data: { + removedAt: new Date().toISOString(), + expires: null, + }, + }); + + return { cleared }; +} diff --git a/modules/presence/scripts/clear_all_identity.ts b/modules/presence/scripts/clear_all_identity.ts new file mode 100644 index 00000000..e4899159 --- /dev/null +++ b/modules/presence/scripts/clear_all_identity.ts @@ -0,0 +1,26 @@ +import { ScriptContext } from "../_gen/scripts/clear_all_identity.ts"; + +export interface Request { + identityId: string; +} + +export interface Response { + cleared: number; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + const { count: cleared } = await ctx.db.presence.updateMany({ + where: { + identityId: req.identityId, + }, + data: { + removedAt: new Date().toISOString(), + expires: null, + }, + }); + + return { cleared }; +} diff --git a/modules/presence/scripts/extend.ts b/modules/presence/scripts/extend.ts new file mode 100644 index 00000000..3f648c1d --- /dev/null +++ b/modules/presence/scripts/extend.ts @@ -0,0 +1,63 @@ +import { ScriptContext, RuntimeError } from "../_gen/scripts/extend.ts"; +import { prismaToOutput } from "../utils/types.ts"; +import { Presence } from "../utils/types.ts"; + +export interface Request { + gameId: string; + identityId: string; + + expiresInMs: number | null; + reviveIfExpired: boolean; +} + +export interface Response { + presence: Presence +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + const { presence } = await ctx.db.$transaction(async (db) => { + const value = await ctx.db.presence.findFirst({ + where: { + identityId: req.identityId, + gameId: req.gameId, + removedAt: null, + }, + }); + + if (!value) { + throw new RuntimeError( + "presence_not_found", + { cause: `Presence not found for identity ${req.identityId} and game ${req.gameId}` }, + ) + } + + const isExpired = !!value.expires && new Date(value.expires).getTime() <= Date.now(); + if (!req.reviveIfExpired && isExpired) { + throw new RuntimeError( + "presence_expired", + { cause: `Presence expired for identity ${req.identityId} and game ${req.gameId}` }, + ) + } + + const presence = await db.presence.update({ + where: { + identityId_gameId: { + identityId: req.identityId, + gameId: req.gameId, + }, + }, + data: { + expires: req.expiresInMs ? new Date(Date.now() + req.expiresInMs).toISOString() : null, + }, + }); + + return { presence }; + }); + + return { + presence: prismaToOutput(presence), + }; +} diff --git a/modules/presence/scripts/get.ts b/modules/presence/scripts/get.ts new file mode 100644 index 00000000..61cf82ad --- /dev/null +++ b/modules/presence/scripts/get.ts @@ -0,0 +1,39 @@ +import { RuntimeError } from "../../auth/_gen/mod.ts"; +import { ScriptContext } from "../_gen/scripts/get_by_game.ts"; +import { prismaToOutput } from "../utils/types.ts"; +import { Presence } from "../utils/types.ts"; + +export interface Request { + gameId: string; + identityId: string; +} + +export interface Response { + presence: Presence; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + const presence = await ctx.db.presence.findFirst({ + where: { + gameId: req.gameId, + identityId: req.identityId, + removedAt: null, + OR: [ + { expires: { gt: new Date().toISOString() } }, + { expires: null }, + ], + }, + }); + + if (!presence) { + throw new RuntimeError( + "presence_not_found", + { cause: `Presence not found for identity ${req.identityId} and game ${req.gameId}` }, + ); + } + + return { presence: prismaToOutput(presence) }; +} diff --git a/modules/presence/scripts/get_by_game.ts b/modules/presence/scripts/get_by_game.ts new file mode 100644 index 00000000..f3d00ba5 --- /dev/null +++ b/modules/presence/scripts/get_by_game.ts @@ -0,0 +1,31 @@ +import { ScriptContext } from "../_gen/scripts/get_by_game.ts"; +import { prismaToOutput } from "../utils/types.ts"; +import { Presence } from "../utils/types.ts"; + +export interface Request { + gameId: string; +} + +export interface Response { + presences: Presence[] +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + const matchingIdentities = await ctx.db.presence.findMany({ + where: { + gameId: req.gameId, + removedAt: null, + OR: [ + { expires: { gt: new Date().toISOString() } }, + { expires: null }, + ], + }, + }); + + return { + presences: matchingIdentities.map(prismaToOutput), + }; +} diff --git a/modules/presence/scripts/get_by_identity.ts b/modules/presence/scripts/get_by_identity.ts new file mode 100644 index 00000000..a7a739fc --- /dev/null +++ b/modules/presence/scripts/get_by_identity.ts @@ -0,0 +1,31 @@ +import { ScriptContext } from "../_gen/scripts/get_by_identity.ts"; +import { prismaToOutput } from "../utils/types.ts"; +import { Presence } from "../utils/types.ts"; + +export interface Request { + identityId: string; +} + +export interface Response { + presences: Presence[] +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + const matchingIdentities = await ctx.db.presence.findMany({ + where: { + identityId: req.identityId, + removedAt: null, + OR: [ + { expires: { gt: new Date().toISOString() } }, + { expires: null }, + ], + }, + }); + + return { + presences: matchingIdentities.map(prismaToOutput), + }; +} diff --git a/modules/presence/scripts/set.ts b/modules/presence/scripts/set.ts new file mode 100644 index 00000000..8d712e66 --- /dev/null +++ b/modules/presence/scripts/set.ts @@ -0,0 +1,48 @@ +import { ScriptContext } from "../_gen/scripts/set.ts"; +import { inputToPrisma, prismaToOutput } from "../utils/types.ts"; +import { Presence } from "../utils/types.ts"; + +export type Request = Omit; + +export interface Response { + presence: Presence +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + const { presence } = await ctx.db.$transaction(async (db) => { + const resetCreatedAt = await db.presence.count({ + where: { + identityId: req.identityId, + gameId: req.gameId, + OR: [ + { removedAt: { not: null } }, + { expires: { lte: new Date().toISOString() } }, + ], + }, + }); + + const presence = await db.presence.upsert({ + where: { + identityId_gameId: { + identityId: req.identityId, + gameId: req.gameId, + }, + }, + update: { + ...inputToPrisma(req), + createdAt: resetCreatedAt ? new Date().toISOString() : undefined, + removedAt: null, + }, + create: inputToPrisma(req), + }); + + return { presence }; + }); + + return { + presence: prismaToOutput(presence), + }; +} diff --git a/modules/presence/tests/expire.ts b/modules/presence/tests/expire.ts new file mode 100644 index 00000000..981767a9 --- /dev/null +++ b/modules/presence/tests/expire.ts @@ -0,0 +1,93 @@ +import { test, TestContext, RuntimeError } from "../_gen/test.ts"; +import { assertEquals, assertGreater, assertRejects } from "https://deno.land/std@0.220.0/assert/mod.ts"; + +test("read before and after expire", async (ctx: TestContext) => { + const gameId = crypto.randomUUID(); + const identityId = crypto.randomUUID(); + + const initialMessage = "Hello!"; + + await ctx.modules.presence.set({ + gameId, + identityId, + message: initialMessage, + mutualMeta: {}, + publicMeta: {}, + expiresInMs: 200, // 0.2 second life + }); + + const { presence: readPresence } = await ctx.modules.presence.get({ + gameId, + identityId, + }); + + assertEquals(readPresence.gameId, gameId); + assertEquals(readPresence.identityId, identityId); + + // Wait for 0.5 seconds to guarantee the presence has expried + await new Promise(res => setTimeout(res, 500)); + + const err = await assertRejects(() => ( + ctx.modules.presence.get({ + gameId, + identityId, + }) + ), RuntimeError); + + assertEquals(err.code, "presence_not_found"); +}); + +test("extend expiration", async (ctx: TestContext) => { + const gameId = crypto.randomUUID(); + const identityId = crypto.randomUUID(); + + const initialMessage = "Hello!"; + + await ctx.modules.presence.set({ + gameId, + identityId, + message: initialMessage, + mutualMeta: {}, + publicMeta: {}, + expiresInMs: 200, // 0.2 second life + }); + + const { presence: readPresence } = await ctx.modules.presence.get({ + gameId, + identityId, + }); + + assertEquals(readPresence.gameId, gameId); + assertEquals(readPresence.identityId, identityId); + + await ctx.modules.presence.extend({ + gameId, + identityId, + expiresInMs: 10000, // 10 seconds + reviveIfExpired: false, + }); + + // Wait for 0.5 seconds to guarantee the presence would have expired without + // extension. + await new Promise(res => setTimeout(res, 500)); + + const { presence: readExtendedPresence } = await ctx.modules.presence.get({ + gameId, + identityId, + }); + + // Check that only the `expiresInMs` is different + assertGreater( + readExtendedPresence.expiresInMs, + readPresence.expiresInMs, + ); + assertEquals({ + ...readExtendedPresence, + expiresInMs: undefined, + updatedAt: undefined, + }, { + ...readPresence, + expiresInMs: undefined, + updatedAt: undefined, + }); +}); diff --git a/modules/presence/tests/simple.ts b/modules/presence/tests/simple.ts new file mode 100644 index 00000000..dbe09814 --- /dev/null +++ b/modules/presence/tests/simple.ts @@ -0,0 +1,133 @@ +import { test, TestContext, RuntimeError } from "../_gen/test.ts"; +import { assertEquals, assertRejects, assertStringIncludes } from "https://deno.land/std@0.220.0/assert/mod.ts"; + +test("create and read", async (ctx: TestContext) => { + const gameId = crypto.randomUUID(); + const identityId = crypto.randomUUID(); + + const initialMessage = "Hello!"; + + const { presence: createdPresence } = await ctx.modules.presence.set({ + gameId, + identityId, + message: initialMessage, + mutualMeta: {}, + publicMeta: {}, + }); + + assertEquals(createdPresence.gameId, gameId); + assertEquals(createdPresence.identityId, identityId); + assertEquals(createdPresence.message, initialMessage); + + const { presence: readPresence } = await ctx.modules.presence.get({ + gameId, + identityId, + }); + + assertEquals(readPresence.gameId, gameId); + assertEquals(readPresence.identityId, identityId); + + assertEquals(createdPresence, readPresence); +}); + +test("create and list", async (ctx: TestContext) => { + const gameIds = Array.from({ length: 3 }, () => crypto.randomUUID()); + const identityIds = Array.from({ length: 3 }, () => crypto.randomUUID()); + + const fmtMessage = (gameId: string, identityId: string) => `I'm a presence for game ${gameId} and identity ${identityId}`; + + const pairs: Set = new Set(); + + for (const gameId of gameIds) { + for (const identityId of identityIds) { + if (Math.random() > 0.5) { + await ctx.modules.presence.set({ + gameId, + identityId, + message: fmtMessage(gameId, identityId), + mutualMeta: {}, + publicMeta: {}, + }); + pairs.add(`${gameId}:${identityId}`); + } + } + } + + + for (const gameId of gameIds) { + const { presences } = await ctx.modules.presence.getByGame({ + gameId, + }); + + const actualPairs = new Set(presences.map((p) => `${p.gameId}:${p.identityId}`)); + const gameSet = new Set([...pairs].filter((pair) => pair.startsWith(gameId))); + assertEquals(actualPairs, gameSet); + + for (const presence of presences) { + assertEquals(presence.message, fmtMessage(presence.gameId, presence.identityId)); + } + } + + for (const identityId of identityIds) { + const { presences } = await ctx.modules.presence.getByIdentity({ + identityId, + }); + + const actualPairs = new Set(presences.map((p) => `${p.gameId}:${p.identityId}`)); + const identitySet = new Set([...pairs].filter((pair) => pair.endsWith(identityId))); + assertEquals(actualPairs, identitySet); + + for (const presence of presences) { + assertEquals(presence.message, fmtMessage(presence.gameId, presence.identityId)); + } + } + + const clears = await Promise.all( + gameIds.map((gameId) => ctx.modules.presence.clearAllGame({ gameId })), + ); + + const clearedPairs = clears.map(({ cleared }, i) => ({ count: cleared, gameId: gameIds[i] })); + + for (const { count, gameId } of clearedPairs) { + const gameSet = new Set([...pairs].filter((pair) => pair.startsWith(gameId))); + assertEquals(count, gameSet.size); + } +}); + +test("create and clear", async (ctx: TestContext) => { + const gameId = crypto.randomUUID(); + const identityId = crypto.randomUUID(); + + const initialMessage = "Hello!"; + + // Create + const { presence: createdPresence } = await ctx.modules.presence.set({ + gameId, + identityId, + message: initialMessage, + mutualMeta: {}, + publicMeta: {}, + }); + + assertEquals(createdPresence.gameId, gameId); + assertEquals(createdPresence.identityId, identityId); + assertEquals(createdPresence.message, initialMessage); + + // Clear + await ctx.modules.presence.clear({ + gameId, + identityId, + }); + + // Get after clear + const err = await assertRejects(() => ( + ctx.modules.presence.get({ + gameId, + identityId, + }) + ), RuntimeError); + + assertEquals(err.code, "presence_not_found"); + assertStringIncludes(String(err.cause), identityId); + assertStringIncludes(String(err.cause), gameId); +}); diff --git a/modules/presence/utils/types.ts b/modules/presence/utils/types.ts new file mode 100644 index 00000000..000722e1 --- /dev/null +++ b/modules/presence/utils/types.ts @@ -0,0 +1,50 @@ +import { JsonArray, JsonObject, JsonValue } from "../_gen/prisma/runtime/library.d.ts"; +import { Presence as PrismaPresence } from "../_gen/prisma/index.d.ts"; + +export interface Presence { + identityId: string; + gameId: string; + message?: string; + publicMeta: JsonObject | JsonArray; + mutualMeta: JsonObject | JsonArray; + + expiresInMs?: number; + + createdAt: number; + updatedAt: number; +} + +export type InputPresence = Omit; + +const coalesceToObjectOrArray = (value: JsonValue): JsonObject | JsonArray => { + if (typeof value === 'object' && value !== null) { + return value; + } + if (Array.isArray(value)) { + return value; + } + return {}; +} + +export const inputToPrisma = (presence: InputPresence) => ({ + ...presence, + expiresInMs: undefined, + expires: presence.expiresInMs === undefined ? null : new Date(Date.now() + presence.expiresInMs).toISOString(), + publicMeta: presence.publicMeta, + mutualMeta: presence.mutualMeta, + updatedAt: new Date().toISOString(), +}); + +export const prismaToOutput = (presence: PrismaPresence): Presence => ({ + ...presence, + message: presence.message ?? undefined, + + // @ts-expect-error: `expires` needs to be removed from PrismaPresence, but it isn't in Presence + expires: undefined, + expiresInMs: presence.expires === null ? undefined : presence.expires.getTime() - Date.now(), + + publicMeta: coalesceToObjectOrArray(presence.publicMeta), + mutualMeta: coalesceToObjectOrArray(presence.publicMeta), + createdAt: new Date(presence.createdAt).getTime(), + updatedAt: new Date(presence.updatedAt).getTime(), +}); diff --git a/tests/basic/backend.yaml b/tests/basic/backend.yaml index 19142bec..eac761fe 100644 --- a/tests/basic/backend.yaml +++ b/tests/basic/backend.yaml @@ -13,6 +13,8 @@ modules: registry: local users: registry: local + presence: + registry: local auth: registry: local config: