diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..b7bef53 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(pnpm tsc *)", + "Bash(pnpm test *)" + ] + } +} diff --git a/package.json b/package.json index edce7c1..ba80d03 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@mantine/notifications": "9.0.2", "@mantine/spotlight": "9.0.2", "@mantine/tiptap": "9.0.2", + "@paralleldrive/cuid2": "3.3.0", "@prisma/adapter-pg": "7.4.2", "@prisma/client": "7.4.2", "@react-email/components": "1.0.12", @@ -90,6 +91,7 @@ "@vitejs/plugin-react": "6.0.1", "babel-plugin-react-compiler": "1.0.0", "dotenv-cli": "11.0.0", + "fake-indexeddb": "6.2.5", "jsdom": "28.1.0", "postcss": "8.5.10", "postcss-preset-mantine": "1.18.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e31cb5..4579883 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@mantine/tiptap': specifier: 9.0.2 version: 9.0.2(@mantine/core@9.0.2(@mantine/hooks@9.0.2(react@19.2.0))(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@mantine/hooks@9.0.2(react@19.2.0))(@tiptap/extension-link@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/react@3.22.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@paralleldrive/cuid2': + specifier: 3.3.0 + version: 3.3.0 '@prisma/adapter-pg': specifier: 7.4.2 version: 7.4.2 @@ -167,7 +170,7 @@ importers: version: 2.4.5 '@prisma-idb/idb-client-generator': specifier: 0.37.0 - version: 0.37.0(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.7.2))(typescript@5.7.2))(idb@8.0.3)(prisma@7.4.2(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.7.2))(uuid@10.0.0) + version: 0.37.0(@paralleldrive/cuid2@3.3.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.7.2))(typescript@5.7.2))(idb@8.0.3)(prisma@7.4.2(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.7.2))(uuid@10.0.0) '@tanstack/devtools-event-client': specifier: latest version: 0.4.3 @@ -198,6 +201,9 @@ importers: dotenv-cli: specifier: 11.0.0 version: 11.0.0 + fake-indexeddb: + specifier: 6.2.5 + version: 6.2.5 jsdom: specifier: 28.1.0 version: 28.1.0(@noble/hashes@2.2.0) @@ -1416,6 +1422,10 @@ packages: '@oxc-project/types@0.115.0': resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==} + '@paralleldrive/cuid2@3.3.0': + resolution: {integrity: sha512-OqiFvSOF0dBSesELYY2CAMa4YINvlLpvKOz/rv6NeZEqiyttlHgv98Juwv4Ch+GrEV7IZ8jfI2VcEoYUjXXCjw==} + hasBin: true + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -2711,6 +2721,9 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -3078,6 +3091,9 @@ packages: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + error-causes@3.0.2: + resolution: {integrity: sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw==} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -3125,6 +3141,10 @@ packages: exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-check@3.23.2: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} @@ -5609,9 +5629,15 @@ snapshots: '@oxc-project/types@0.115.0': {} + '@paralleldrive/cuid2@3.3.0': + dependencies: + '@noble/hashes': 2.2.0 + bignumber.js: 9.3.1 + error-causes: 3.0.2 + '@pinojs/redact@0.4.0': {} - '@prisma-idb/idb-client-generator@0.37.0(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.7.2))(typescript@5.7.2))(idb@8.0.3)(prisma@7.4.2(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.7.2))(uuid@10.0.0)': + '@prisma-idb/idb-client-generator@0.37.0(@paralleldrive/cuid2@3.3.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.7.2))(typescript@5.7.2))(idb@8.0.3)(prisma@7.4.2(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.7.2))(uuid@10.0.0)': dependencies: '@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.7.2))(typescript@5.7.2) '@prisma/generator-helper': 7.7.0 @@ -5620,6 +5646,7 @@ snapshots: prettier: 3.8.3 prisma: 7.4.2(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.7.2) optionalDependencies: + '@paralleldrive/cuid2': 3.3.0 uuid: 10.0.0 '@prisma/adapter-pg@7.4.2': @@ -6911,6 +6938,8 @@ snapshots: dependencies: require-from-string: 2.0.2 + bignumber.js@9.3.1: {} + binary-extensions@2.3.0: {} boolbase@1.0.0: {} @@ -7283,6 +7312,8 @@ snapshots: env-paths@3.0.0: {} + error-causes@3.0.2: {} + es-module-lexer@1.7.0: {} es-toolkit@1.45.1: {} @@ -7390,6 +7421,8 @@ snapshots: exsolve@1.0.8: {} + fake-indexeddb@6.2.5: {} + fast-check@3.23.2: dependencies: pure-rand: 6.1.0 diff --git a/prisma/models/remnant2.prisma b/prisma/models/remnant2.prisma index 1e1940e..0169423 100644 --- a/prisma/models/remnant2.prisma +++ b/prisma/models/remnant2.prisma @@ -50,7 +50,7 @@ enum REMNANT2_BUILD_TAG_CATEGORY { model Remnant2BuildTag { id String @id @default(cuid()) createdAt DateTime @default(now()) - updatedAt DateTime? @updatedAt + updatedAt DateTime @updatedAt category REMNANT2_BUILD_TAG_CATEGORY @default(Tag) build Remnant2Build @relation(fields: [buildId], references: [id], onDelete: Cascade) buildId String @@ -73,6 +73,8 @@ model Remnant2BuildCollection { } model Remnant2BuildsOnCollections { + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt build Remnant2Build @relation(fields: [buildId], references: [id], onDelete: Cascade) buildId String collection Remnant2BuildCollection @relation(fields: [collectionId], references: [id], onDelete: Cascade) @@ -170,10 +172,12 @@ model Remnant2Item { } model Remnant2BuildItem { - id String @id @default(cuid()) - level Int @default(1) - amount Int? - optional Boolean @default(false) + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + level Int @default(1) + amount Int? + optional Boolean @default(false) item Remnant2Item @relation(fields: [itemId], references: [id], onDelete: Cascade) itemId String @@ -196,6 +200,7 @@ model Remnant2CollectedItem { item Remnant2Item @relation(fields: [itemId], references: [id], onDelete: Cascade) itemId String + @@unique([userId, itemId]) @@index([userId]) @@index([itemId]) } diff --git a/prisma/models/slaythespire2.prisma b/prisma/models/slaythespire2.prisma index b45732b..a5c344b 100644 --- a/prisma/models/slaythespire2.prisma +++ b/prisma/models/slaythespire2.prisma @@ -63,6 +63,7 @@ model SlayTheSpire2CollectedItem { item SlayTheSpire2Item @relation(fields: [itemId], references: [id], onDelete: Cascade) itemId String + @@unique([userId, itemId]) @@index([userId]) @@index([itemId]) } \ No newline at end of file diff --git a/src/features/dal/actions/collected-items.ts b/src/features/dal/actions/collected-items.ts new file mode 100644 index 0000000..8db3247 --- /dev/null +++ b/src/features/dal/actions/collected-items.ts @@ -0,0 +1,70 @@ +import { + defineDalRead, + defineDalWrite, +} from "#/features/dal/core/define-action"; +import type { DalContext } from "#/features/dal/core/types"; +import { applyPendingOpServerFn } from "#/features/dal/server/sync"; +import { + collectItemServerFn, + listCollectedItemsServerFn, + uncollectItemServerFn, +} from "#/features/dal/server/collected-items"; +import { getIDBClient } from "#/integrations/prisma-idb/idb-client"; + +interface CollectedItemInput { + itemId: string; +} + +async function resolveLocalUserId(ctx: DalContext): Promise { + return ctx.authUserId ?? ctx.anonUserId; +} + +export const collectedItemActions = { + list: defineDalRead>({ + queryKey: () => ["remnant2CollectedItem", "list"] as const, + remote: async () => listCollectedItemsServerFn(), + local: async (_input, ctx) => { + const userId = await resolveLocalUserId(ctx); + if (!userId) return []; + const idb = await getIDBClient(); + return idb.remnant2CollectedItem.findMany({ where: { userId } }); + }, + }), + + collect: defineDalWrite({ + entity: "remnant2CollectedItem", + operation: "upsert", + invalidates: ["remnant2CollectedItem"], + buildIdempotencyKey: (input, ctx) => + `remnant2CollectedItem:upsert:${ctx.anonUserId}:${input.itemId}`, + remote: async (input) => collectItemServerFn({ data: input }), + local: async (input, ctx) => { + const userId = await resolveLocalUserId(ctx); + const idb = await getIDBClient(); + return idb.remnant2CollectedItem.upsert({ + where: { userId_itemId: { userId, itemId: input.itemId } }, + update: {}, + create: { userId, itemId: input.itemId }, + }); + }, + sync: (op) => applyPendingOpServerFn({ data: op }), + }), + + uncollect: defineDalWrite({ + entity: "remnant2CollectedItem", + operation: "delete", + invalidates: ["remnant2CollectedItem"], + buildIdempotencyKey: (input, ctx) => + `remnant2CollectedItem:delete:${ctx.anonUserId}:${input.itemId}`, + remote: async (input) => uncollectItemServerFn({ data: input }), + local: async (input, ctx) => { + const userId = await resolveLocalUserId(ctx); + const idb = await getIDBClient(); + await idb.remnant2CollectedItem.deleteMany({ + where: { userId, itemId: input.itemId }, + }); + return { ok: true as const }; + }, + sync: (op) => applyPendingOpServerFn({ data: op }), + }), +}; diff --git a/src/features/dal/actions/favorite-games.ts b/src/features/dal/actions/favorite-games.ts new file mode 100644 index 0000000..98a8321 --- /dev/null +++ b/src/features/dal/actions/favorite-games.ts @@ -0,0 +1,83 @@ +import { + defineDalRead, + defineDalWrite, +} from "#/features/dal/core/define-action"; +import type { DalContext } from "#/features/dal/core/types"; +import { + deleteLocalFavoriteGame, + listLocalFavoriteGames, + upsertLocalFavoriteGame, +} from "#/features/dal/local/favorite-games-idb"; +import type { LocalUserFavoriteGame } from "#/features/dal/local/local-db"; +import { + favoriteGameServerFn, + listFavoriteGamesServerFn, + unfavoriteGameServerFn, +} from "#/features/dal/server/favorite-games"; +import { applyPendingOpServerFn } from "#/features/dal/server/sync"; +import type { GameId } from "@/prisma"; + +interface FavoriteGameInput { + gameId: GameId; +} + +function resolveLocalUserId(ctx: DalContext): string { + return ctx.authUserId ?? ctx.anonUserId; +} + +export const favoriteGameActions = { + list: defineDalRead({ + queryKey: () => ["userFavoriteGame", "list"] as const, + remote: async () => { + const rows = await listFavoriteGamesServerFn(); + return rows.map((r) => ({ + userId: r.userId, + gameId: r.gameId, + createdAt: r.createdAt.toISOString(), + updatedAt: r.updatedAt.toISOString(), + })); + }, + local: async (_input, ctx) => { + const userId = resolveLocalUserId(ctx); + if (!userId) return []; + return listLocalFavoriteGames(userId); + }, + }), + + favorite: defineDalWrite({ + entity: "userFavoriteGame", + operation: "upsert", + invalidates: ["userFavoriteGame"], + buildIdempotencyKey: (input, ctx) => + `userFavoriteGame:upsert:${ctx.anonUserId}:${input.gameId}`, + remote: async (input) => { + const row = await favoriteGameServerFn({ data: input }); + return { + userId: row.userId, + gameId: row.gameId, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + }, + local: async (input, ctx) => { + const userId = resolveLocalUserId(ctx); + return upsertLocalFavoriteGame({ userId, gameId: input.gameId }); + }, + sync: (op) => applyPendingOpServerFn({ data: op }), + }), + + unfavorite: defineDalWrite({ + entity: "userFavoriteGame", + operation: "delete", + invalidates: ["userFavoriteGame"], + buildIdempotencyKey: (input, ctx) => + `userFavoriteGame:delete:${ctx.anonUserId}:${input.gameId}`, + remote: async (input) => unfavoriteGameServerFn({ data: input }), + local: async (input, ctx) => { + const userId = resolveLocalUserId(ctx); + await deleteLocalFavoriteGame({ userId, gameId: input.gameId }); + return { ok: true as const }; + }, + sync: (op) => applyPendingOpServerFn({ data: op }), + }), +}; diff --git a/src/features/dal/actions/registry.ts b/src/features/dal/actions/registry.ts new file mode 100644 index 0000000..e5f66ae --- /dev/null +++ b/src/features/dal/actions/registry.ts @@ -0,0 +1,16 @@ +import { collectedItemActions } from "#/features/dal/actions/collected-items"; +import { favoriteGameActions } from "#/features/dal/actions/favorite-games"; +import type { DalWriteAction } from "#/features/dal/core/types"; + +const writeActions: Array> = [ + collectedItemActions.collect as unknown as DalWriteAction, + collectedItemActions.uncollect as unknown as DalWriteAction, + favoriteGameActions.favorite as unknown as DalWriteAction, + favoriteGameActions.unfavorite as unknown as DalWriteAction, +]; + +export function resolveWriteAction( + entity: string, +): DalWriteAction | null { + return writeActions.find((a) => a.entity === entity) ?? null; +} diff --git a/src/features/dal/core/__tests__/choose-backend.test.ts b/src/features/dal/core/__tests__/choose-backend.test.ts new file mode 100644 index 0000000..7a52cf9 --- /dev/null +++ b/src/features/dal/core/__tests__/choose-backend.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { chooseBackend } from "#/features/dal/core/choose-backend"; + +describe("chooseBackend", () => { + it.each([ + { authed: true, online: true, expected: "remote" as const }, + { authed: true, online: false, expected: "local" as const }, + { authed: false, online: true, expected: "local" as const }, + { authed: false, online: false, expected: "local" as const }, + ])( + "authed=$authed online=$online -> $expected", + ({ authed, online, expected }) => { + expect(chooseBackend({ authed, online })).toBe(expected); + }, + ); +}); diff --git a/src/features/dal/core/choose-backend.ts b/src/features/dal/core/choose-backend.ts new file mode 100644 index 0000000..bca79cf --- /dev/null +++ b/src/features/dal/core/choose-backend.ts @@ -0,0 +1,10 @@ +export type Backend = "remote" | "local"; + +export interface DispatchInput { + authed: boolean; + online: boolean; +} + +export function chooseBackend({ authed, online }: DispatchInput): Backend { + return authed && online ? "remote" : "local"; +} diff --git a/src/features/dal/core/define-action.ts b/src/features/dal/core/define-action.ts new file mode 100644 index 0000000..87f9a23 --- /dev/null +++ b/src/features/dal/core/define-action.ts @@ -0,0 +1,16 @@ +import type { + DalReadAction, + DalWriteAction, +} from "#/features/dal/core/types"; + +export function defineDalRead( + config: Omit, "kind">, +): DalReadAction { + return { kind: "read", ...config }; +} + +export function defineDalWrite( + config: Omit, "kind">, +): DalWriteAction { + return { kind: "write", ...config }; +} diff --git a/src/features/dal/core/to-query-options.ts b/src/features/dal/core/to-query-options.ts new file mode 100644 index 0000000..95ead74 --- /dev/null +++ b/src/features/dal/core/to-query-options.ts @@ -0,0 +1,18 @@ +import { queryOptions } from "@tanstack/react-query"; +import type { DalReadAction } from "#/features/dal/core/types"; +import type { DalContextGetter } from "#/features/dal/hooks/use-dal-context-source"; + +export function toQueryOptions( + action: DalReadAction, + input: I, + ctxGetter: DalContextGetter, +) { + return queryOptions({ + queryKey: ["dal", ...(action.queryKey(input) as readonly unknown[])], + queryFn: async () => { + const ctx = ctxGetter(); + if (ctx.backend === "remote") return action.remote(input, ctx); + return action.local(input, ctx); + }, + }); +} diff --git a/src/features/dal/core/types.ts b/src/features/dal/core/types.ts new file mode 100644 index 0000000..f0e7923 --- /dev/null +++ b/src/features/dal/core/types.ts @@ -0,0 +1,40 @@ +import type { QueryKey } from "@tanstack/react-query"; +import type { Backend } from "#/features/dal/core/choose-backend"; +import type { + PendingOp, + PendingOpOperation, +} from "#/features/dal/queue/pending-ops"; + +export interface DalContext { + anonUserId: string; + authUserId: string | null; + backend: Backend; +} + +export type SyncResult = + | { status: "applied" } + | { status: "conflict"; serverRecordJson: string } + | { status: "noop" } + | { status: "error"; message: string }; + +export interface DalReadAction { + kind: "read"; + queryKey: (input: I) => QueryKey; + remote: (input: I, ctx: DalContext) => Promise; + local: (input: I, ctx: DalContext) => Promise; +} + +export interface DalWriteAction { + kind: "write"; + entity: string; + operation: PendingOpOperation; + buildIdempotencyKey: (input: I, ctx: DalContext) => string; + invalidates: readonly string[]; + remote: (input: I, ctx: DalContext) => Promise; + local: (input: I, ctx: DalContext) => Promise; + sync: (op: PendingOp) => Promise; +} + +export type DalAction = + | DalReadAction + | DalWriteAction; diff --git a/src/features/dal/core/use-backend.ts b/src/features/dal/core/use-backend.ts new file mode 100644 index 0000000..aaf0b10 --- /dev/null +++ b/src/features/dal/core/use-backend.ts @@ -0,0 +1,9 @@ +import { type Backend, chooseBackend } from "#/features/dal/core/choose-backend"; +import { useOnlineStatus } from "#/features/dal/online/use-online-status"; +import { useSession } from "#/integrations/better-auth/auth-client"; + +export function useBackend(): Backend { + const { data } = useSession(); + const online = useOnlineStatus(); + return chooseBackend({ authed: !!data?.user?.id, online }); +} diff --git a/src/features/dal/hooks/use-dal-context-source.ts b/src/features/dal/hooks/use-dal-context-source.ts new file mode 100644 index 0000000..a183e8e --- /dev/null +++ b/src/features/dal/hooks/use-dal-context-source.ts @@ -0,0 +1,27 @@ +import { useCallback, useRef } from "react"; +import { chooseBackend } from "#/features/dal/core/choose-backend"; +import type { DalContext } from "#/features/dal/core/types"; +import { getOrCreateAnonUserId } from "#/features/dal/identity/anon-id"; +import { useOnlineStatus } from "#/features/dal/online/use-online-status"; +import { useSession } from "#/integrations/better-auth/auth-client"; + +export type DalContextGetter = () => DalContext; + +export function useDalContextSource(): DalContextGetter { + const { data } = useSession(); + const online = useOnlineStatus(); + + const sourceRef = useRef({ + anonUserId: "", + authUserId: null, + backend: "local", + }); + + sourceRef.current = { + anonUserId: getOrCreateAnonUserId(), + authUserId: data?.user?.id ?? null, + backend: chooseBackend({ authed: !!data?.user?.id, online }), + }; + + return useCallback(() => sourceRef.current, []); +} diff --git a/src/features/dal/hooks/use-dal-mutation.ts b/src/features/dal/hooks/use-dal-mutation.ts new file mode 100644 index 0000000..3751e44 --- /dev/null +++ b/src/features/dal/hooks/use-dal-mutation.ts @@ -0,0 +1,51 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { DalContext, DalWriteAction } from "#/features/dal/core/types"; +import { useDalContextSource } from "#/features/dal/hooks/use-dal-context-source"; +import { enqueueOp } from "#/features/dal/queue/pending-ops"; + +export interface UseDalMutationResultMeta { + result: O; + branch: "remote" | "local"; + enqueuedOpId: string | null; +} + +export function useDalMutation(action: DalWriteAction) { + const ctxGetter = useDalContextSource(); + const queryClient = useQueryClient(); + + return useMutation, Error, I>({ + mutationKey: ["dal", action.entity, action.operation], + mutationFn: async (input: I) => { + const ctx = ctxGetter(); + if (ctx.backend === "remote") { + const result = await action.remote(input, ctx); + return { result, branch: "remote", enqueuedOpId: null }; + } + return runLocalWithEnqueue(action, input, ctx); + }, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["dal", action.entity] }), + ...action.invalidates.map((entity) => + queryClient.invalidateQueries({ queryKey: ["dal", entity] }), + ), + ]); + }, + }); +} + +async function runLocalWithEnqueue( + action: DalWriteAction, + input: I, + ctx: DalContext, +): Promise> { + const result = await action.local(input, ctx); + const op = await enqueueOp({ + anonUserId: ctx.anonUserId, + entity: action.entity, + operation: action.operation, + payload: input, + idempotencyKey: action.buildIdempotencyKey(input, ctx), + }); + return { result, branch: "local", enqueuedOpId: op?.id ?? null }; +} diff --git a/src/features/dal/hooks/use-dal-query.ts b/src/features/dal/hooks/use-dal-query.ts new file mode 100644 index 0000000..00e491e --- /dev/null +++ b/src/features/dal/hooks/use-dal-query.ts @@ -0,0 +1,17 @@ +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; +import { toQueryOptions } from "#/features/dal/core/to-query-options"; +import type { DalReadAction } from "#/features/dal/core/types"; +import { useDalContextSource } from "#/features/dal/hooks/use-dal-context-source"; + +export function useDalQuery(action: DalReadAction, input: I) { + const ctxGetter = useDalContextSource(); + return useQuery(toQueryOptions(action, input, ctxGetter)); +} + +export function useDalSuspenseQuery( + action: DalReadAction, + input: I, +) { + const ctxGetter = useDalContextSource(); + return useSuspenseQuery(toQueryOptions(action, input, ctxGetter)); +} diff --git a/src/features/dal/identity/anon-id.ts b/src/features/dal/identity/anon-id.ts new file mode 100644 index 0000000..f8de2f8 --- /dev/null +++ b/src/features/dal/identity/anon-id.ts @@ -0,0 +1,16 @@ +const STORAGE_KEY = "toolkitsgg.anonUserId"; + +export function getOrCreateAnonUserId(): string { + if (typeof window === "undefined") return ""; + let id = window.localStorage.getItem(STORAGE_KEY); + if (!id) { + id = crypto.randomUUID(); + window.localStorage.setItem(STORAGE_KEY, id); + } + return id; +} + +export function clearAnonUserId(): void { + if (typeof window === "undefined") return; + window.localStorage.removeItem(STORAGE_KEY); +} diff --git a/src/features/dal/identity/use-effective-user-id.ts b/src/features/dal/identity/use-effective-user-id.ts new file mode 100644 index 0000000..3b7f34d --- /dev/null +++ b/src/features/dal/identity/use-effective-user-id.ts @@ -0,0 +1,20 @@ +import { useMemo } from "react"; +import { getOrCreateAnonUserId } from "#/features/dal/identity/anon-id"; +import { useSession } from "#/integrations/better-auth/auth-client"; + +export type EffectiveUserId = { + id: string; + kind: "auth" | "anon" | "none"; +}; + +export function useEffectiveUserId(): EffectiveUserId { + const { data } = useSession(); + const authId = data?.user?.id ?? null; + const anonId = useMemo( + () => (authId ? null : getOrCreateAnonUserId()), + [authId], + ); + if (authId) return { id: authId, kind: "auth" }; + if (anonId) return { id: anonId, kind: "anon" }; + return { id: "", kind: "none" }; +} diff --git a/src/features/dal/local/favorite-games-idb.ts b/src/features/dal/local/favorite-games-idb.ts new file mode 100644 index 0000000..abf7281 --- /dev/null +++ b/src/features/dal/local/favorite-games-idb.ts @@ -0,0 +1,47 @@ +import { + getLocalDB, + type LocalUserFavoriteGame, + STORE_USER_FAVORITE_GAME, +} from "#/features/dal/local/local-db"; +import type { GameId } from "@/prisma"; + +export async function listLocalFavoriteGames( + userId: string, +): Promise { + const db = await getLocalDB(); + if (!db) return []; + return db.getAllFromIndex(STORE_USER_FAVORITE_GAME, "userId", userId); +} + +export async function upsertLocalFavoriteGame(input: { + userId: string; + gameId: GameId; +}): Promise { + const now = new Date().toISOString(); + const db = await getLocalDB(); + const record: LocalUserFavoriteGame = { + userId: input.userId, + gameId: input.gameId, + createdAt: now, + updatedAt: now, + }; + if (!db) return record; + const existing = await db.get(STORE_USER_FAVORITE_GAME, [ + input.userId, + input.gameId, + ]); + const next: LocalUserFavoriteGame = existing + ? { ...existing, updatedAt: now } + : record; + await db.put(STORE_USER_FAVORITE_GAME, next); + return next; +} + +export async function deleteLocalFavoriteGame(input: { + userId: string; + gameId: GameId; +}): Promise { + const db = await getLocalDB(); + if (!db) return; + await db.delete(STORE_USER_FAVORITE_GAME, [input.userId, input.gameId]); +} diff --git a/src/features/dal/local/local-db.ts b/src/features/dal/local/local-db.ts new file mode 100644 index 0000000..1b26040 --- /dev/null +++ b/src/features/dal/local/local-db.ts @@ -0,0 +1,77 @@ +import { type DBSchema, type IDBPDatabase, openDB } from "idb"; +import type { GameId } from "@/prisma"; + +const DB_NAME = "toolkitsgg-local"; +const DB_VERSION = 1; + +export const STORE_USER_FAVORITE_GAME = "userFavoriteGame"; +export const STORE_USER_AVATAR_OVERRIDE = "userAvatarOverride"; + +export interface LocalUserFavoriteGame { + userId: string; + gameId: GameId; + createdAt: string; + updatedAt: string; +} + +export interface LocalUserAvatarOverride { + id: string; + userProfileId: string; + gameId: GameId; + imageUrl: string; + createdAt: string; + updatedAt: string; +} + +interface LocalDB extends DBSchema { + userFavoriteGame: { + key: [string, string]; + value: LocalUserFavoriteGame; + indexes: { userId: string }; + }; + userAvatarOverride: { + key: string; + value: LocalUserAvatarOverride; + indexes: { userProfileId: string }; + }; +} + +let dbPromise: Promise> | null = null; + +export function getLocalDB(): Promise> | null { + if (typeof indexedDB === "undefined") return null; + if (!dbPromise) { + dbPromise = openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + if (!db.objectStoreNames.contains(STORE_USER_FAVORITE_GAME)) { + const store = db.createObjectStore(STORE_USER_FAVORITE_GAME, { + keyPath: ["userId", "gameId"], + }); + store.createIndex("userId", "userId"); + } + if (!db.objectStoreNames.contains(STORE_USER_AVATAR_OVERRIDE)) { + const store = db.createObjectStore(STORE_USER_AVATAR_OVERRIDE, { + keyPath: "id", + }); + store.createIndex("userProfileId", "userProfileId"); + } + }, + }); + } + return dbPromise; +} + +export async function _resetLocalDBForTests(): Promise { + if (dbPromise) { + const db = await dbPromise; + db.close(); + dbPromise = null; + } + if (typeof indexedDB === "undefined") return; + await new Promise((resolve) => { + const req = indexedDB.deleteDatabase(DB_NAME); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + }); +} diff --git a/src/features/dal/online/use-online-status.ts b/src/features/dal/online/use-online-status.ts new file mode 100644 index 0000000..14521d1 --- /dev/null +++ b/src/features/dal/online/use-online-status.ts @@ -0,0 +1,57 @@ +import { useEffect, useState } from "react"; + +const DEFAULT_HEARTBEAT_MS = 30_000; +const HEARTBEAT_PATH = "/api/health"; + +export type UseOnlineStatusOptions = { + heartbeatMs?: number; + enableHeartbeat?: boolean; +}; + +export function useOnlineStatus(options?: UseOnlineStatusOptions): boolean { + const heartbeatMs = options?.heartbeatMs ?? DEFAULT_HEARTBEAT_MS; + const enableHeartbeat = options?.enableHeartbeat ?? true; + + const [online, setOnline] = useState( + typeof navigator === "undefined" ? true : navigator.onLine, + ); + + useEffect(() => { + const up = () => setOnline(true); + const down = () => setOnline(false); + window.addEventListener("online", up); + window.addEventListener("offline", down); + return () => { + window.removeEventListener("online", up); + window.removeEventListener("offline", down); + }; + }, []); + + useEffect(() => { + if (!enableHeartbeat) return; + const controller = new AbortController(); + let stopped = false; + + const tick = async () => { + try { + const response = await fetch(HEARTBEAT_PATH, { + method: "GET", + signal: controller.signal, + cache: "no-store", + }); + if (!stopped) setOnline(response.ok); + } catch { + if (!stopped) setOnline(false); + } + }; + + const interval = window.setInterval(tick, heartbeatMs); + return () => { + stopped = true; + controller.abort(); + window.clearInterval(interval); + }; + }, [heartbeatMs, enableHeartbeat]); + + return online; +} diff --git a/src/features/dal/queue/__tests__/lww.test.ts b/src/features/dal/queue/__tests__/lww.test.ts new file mode 100644 index 0000000..7461131 --- /dev/null +++ b/src/features/dal/queue/__tests__/lww.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { compareTimestamps } from "#/features/dal/queue/lww"; + +const T1 = "2026-01-01T00:00:00.000Z"; +const T2 = "2026-01-02T00:00:00.000Z"; +const T3 = "2026-01-03T00:00:00.000Z"; + +describe("compareTimestamps", () => { + it("returns local-wins when server record is missing", () => { + expect(compareTimestamps(null, { serverUpdatedAt: T1, updatedAt: T2 })).toBe( + "local-wins", + ); + }); + + it("returns server-wins when server advanced past the baseline", () => { + expect( + compareTimestamps( + { updatedAt: T3 }, + { serverUpdatedAt: T1, updatedAt: T2 }, + ), + ).toBe("server-wins"); + }); + + it("returns local-wins when server matches baseline", () => { + expect( + compareTimestamps( + { updatedAt: T1 }, + { serverUpdatedAt: T1, updatedAt: T2 }, + ), + ).toBe("equal"); + }); + + it("falls back to comparing op.updatedAt when baseline is absent", () => { + expect( + compareTimestamps({ updatedAt: T1 }, { updatedAt: T2 }), + ).toBe("local-wins"); + expect( + compareTimestamps({ updatedAt: T3 }, { updatedAt: T2 }), + ).toBe("server-wins"); + }); + + it("treats invalid dates as missing", () => { + expect( + compareTimestamps({ updatedAt: "not-a-date" }, { updatedAt: T1 }), + ).toBe("local-wins"); + }); +}); diff --git a/src/features/dal/queue/__tests__/pending-ops.test.ts b/src/features/dal/queue/__tests__/pending-ops.test.ts new file mode 100644 index 0000000..b426ae4 --- /dev/null +++ b/src/features/dal/queue/__tests__/pending-ops.test.ts @@ -0,0 +1,86 @@ +// @vitest-environment jsdom +import "fake-indexeddb/auto"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + _resetForTests, + clearSynced, + deleteOp, + enqueueOp, + getOp, + listOps, + markStatus, +} from "#/features/dal/queue/pending-ops"; + +const BASE = { + anonUserId: "anon-1", + entity: "remnant2CollectedItem", + operation: "upsert" as const, + payload: { itemId: "item-1" }, + idempotencyKey: "remnant2CollectedItem:upsert:anon-1:item-1", +}; + +beforeEach(async () => { + await _resetForTests(); +}); + +afterEach(async () => { + await _resetForTests(); +}); + +describe("pending-ops queue", () => { + it("enqueues an op with generated id and default status", async () => { + const op = await enqueueOp(BASE); + expect(op).not.toBeNull(); + expect(op?.id).toMatch(/[-0-9a-f]{36}/); + expect(op?.status).toBe("pending"); + expect(op?.createdAt).toBeTypeOf("string"); + }); + + it("lists ops ordered by createdAt with filters", async () => { + const a = await enqueueOp({ ...BASE, idempotencyKey: "a" }); + await new Promise((r) => setTimeout(r, 2)); + const b = await enqueueOp({ + ...BASE, + entity: "userFavoriteGame", + idempotencyKey: "b", + }); + + const all = await listOps(); + expect(all.map((o) => o.id)).toEqual([a?.id, b?.id]); + + const onlyFav = await listOps({ entity: "userFavoriteGame" }); + expect(onlyFav.map((o) => o.id)).toEqual([b?.id]); + }); + + it("marks status and stores lastError", async () => { + const op = await enqueueOp(BASE); + if (!op) throw new Error("enqueue failed"); + const failed = await markStatus(op.id, "failed", "boom"); + expect(failed?.status).toBe("failed"); + expect(failed?.lastError).toBe("boom"); + }); + + it("deletes a single op", async () => { + const op = await enqueueOp(BASE); + if (!op) throw new Error("enqueue failed"); + await deleteOp(op.id); + expect(await getOp(op.id)).toBeNull(); + }); + + it("clearSynced removes only synced rows and returns count", async () => { + const a = await enqueueOp({ ...BASE, idempotencyKey: "a" }); + const b = await enqueueOp({ ...BASE, idempotencyKey: "b" }); + const c = await enqueueOp({ ...BASE, idempotencyKey: "c" }); + if (!a || !b || !c) throw new Error("enqueue failed"); + + await markStatus(a.id, "synced"); + await markStatus(b.id, "synced"); + await markStatus(c.id, "failed", "nope"); + + const removed = await clearSynced(); + expect(removed).toBe(2); + + const remaining = await listOps(); + expect(remaining.map((o) => o.id)).toEqual([c.id]); + }); +}); diff --git a/src/features/dal/queue/lww.ts b/src/features/dal/queue/lww.ts new file mode 100644 index 0000000..d6627dc --- /dev/null +++ b/src/features/dal/queue/lww.ts @@ -0,0 +1,32 @@ +export type LwwComparison = "local-wins" | "server-wins" | "equal"; + +export interface HasUpdatedAt { + updatedAt: string | Date | null | undefined; +} + +function toTime(value: string | Date | null | undefined): number | null { + if (!value) return null; + const date = typeof value === "string" ? new Date(value) : value; + const time = date.getTime(); + return Number.isFinite(time) ? time : null; +} + +export function compareTimestamps( + serverRecord: HasUpdatedAt | null | undefined, + op: { serverUpdatedAt?: string | null; updatedAt?: string | null }, +): LwwComparison { + const serverTime = toTime(serverRecord?.updatedAt); + const baselineTime = toTime(op.serverUpdatedAt); + const opTime = toTime(op.updatedAt); + + if (serverTime === null) return "local-wins"; + if (baselineTime === null) { + if (opTime === null) return "server-wins"; + if (opTime > serverTime) return "local-wins"; + if (opTime < serverTime) return "server-wins"; + return "equal"; + } + if (serverTime > baselineTime) return "server-wins"; + if (serverTime < baselineTime) return "local-wins"; + return "equal"; +} diff --git a/src/features/dal/queue/pending-ops.ts b/src/features/dal/queue/pending-ops.ts new file mode 100644 index 0000000..4f871fa --- /dev/null +++ b/src/features/dal/queue/pending-ops.ts @@ -0,0 +1,165 @@ +import { type DBSchema, type IDBPDatabase, openDB } from "idb"; + +const DB_NAME = "toolkitsgg-pending-ops"; +const DB_VERSION = 1; +const STORE_NAME = "ops"; + +export type PendingOpOperation = "create" | "update" | "upsert" | "delete"; +export type PendingOpStatus = + | "pending" + | "syncing" + | "synced" + | "failed" + | "conflict"; + +export interface PendingOp { + id: string; + createdAt: string; + updatedAt: string; + anonUserId: string; + entity: string; + operation: PendingOpOperation; + payload: unknown; + idempotencyKey: string; + status: PendingOpStatus; + lastError?: string; + serverUpdatedAt?: string; +} + +interface PendingOpsDB extends DBSchema { + ops: { + key: string; + value: PendingOp; + indexes: { + createdAt: string; + entity: string; + status: PendingOpStatus; + }; + }; +} + +let dbPromise: Promise> | null = null; + +function getDB(): Promise> | null { + if (typeof indexedDB === "undefined") return null; + if (!dbPromise) { + dbPromise = openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + const store = db.createObjectStore(STORE_NAME, { keyPath: "id" }); + store.createIndex("createdAt", "createdAt"); + store.createIndex("entity", "entity"); + store.createIndex("status", "status"); + }, + }); + } + return dbPromise; +} + +export type EnqueueInput = Omit< + PendingOp, + "id" | "createdAt" | "updatedAt" | "status" +> & { + id?: string; + status?: PendingOpStatus; +}; + +export async function enqueueOp(input: EnqueueInput): Promise { + const db = await getDB(); + if (!db) return null; + const now = new Date().toISOString(); + const op: PendingOp = { + id: input.id ?? crypto.randomUUID(), + createdAt: now, + updatedAt: now, + status: input.status ?? "pending", + anonUserId: input.anonUserId, + entity: input.entity, + operation: input.operation, + payload: input.payload, + idempotencyKey: input.idempotencyKey, + lastError: input.lastError, + serverUpdatedAt: input.serverUpdatedAt, + }; + await db.put(STORE_NAME, op); + return op; +} + +export interface ListOpsFilter { + status?: PendingOpStatus; + entity?: string; +} + +export async function listOps(filter?: ListOpsFilter): Promise { + const db = await getDB(); + if (!db) return []; + const all = await db.getAllFromIndex(STORE_NAME, "createdAt"); + if (!filter) return all; + return all.filter((op) => { + if (filter.status && op.status !== filter.status) return false; + if (filter.entity && op.entity !== filter.entity) return false; + return true; + }); +} + +export async function getOp(id: string): Promise { + const db = await getDB(); + if (!db) return null; + return (await db.get(STORE_NAME, id)) ?? null; +} + +export async function markStatus( + id: string, + status: PendingOpStatus, + lastError?: string, +): Promise { + const db = await getDB(); + if (!db) return null; + const existing = await db.get(STORE_NAME, id); + if (!existing) return null; + const next: PendingOp = { + ...existing, + status, + updatedAt: new Date().toISOString(), + lastError: lastError ?? (status === "failed" ? existing.lastError : undefined), + }; + await db.put(STORE_NAME, next); + return next; +} + +export async function deleteOp(id: string): Promise { + const db = await getDB(); + if (!db) return; + await db.delete(STORE_NAME, id); +} + +export async function clearSynced(): Promise { + const db = await getDB(); + if (!db) return 0; + const tx = db.transaction(STORE_NAME, "readwrite"); + const index = tx.store.index("status"); + let cursor = await index.openCursor("synced"); + let count = 0; + while (cursor) { + await cursor.delete(); + count += 1; + cursor = await cursor.continue(); + } + await tx.done; + return count; +} + +export async function _resetForTests(): Promise { + if (dbPromise) { + const db = await dbPromise; + db.close(); + dbPromise = null; + } + if (typeof indexedDB !== "undefined") { + await new Promise((resolve, reject) => { + const req = indexedDB.deleteDatabase(DB_NAME); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + req.onblocked = () => resolve(); + }); + } +} diff --git a/src/features/dal/queue/sync-runner.ts b/src/features/dal/queue/sync-runner.ts new file mode 100644 index 0000000..004197b --- /dev/null +++ b/src/features/dal/queue/sync-runner.ts @@ -0,0 +1,70 @@ +import type { DalWriteAction } from "#/features/dal/core/types"; +import { + deleteOp, + markStatus, + type PendingOp, +} from "#/features/dal/queue/pending-ops"; + +export interface SyncAllOptions { + resolveAction: (entity: string) => DalWriteAction | null; + onProgress?: (op: PendingOp, index: number, total: number) => void; +} + +export interface SyncAllReport { + applied: number; + conflicts: number; + noops: number; + errors: number; + skipped: number; +} + +export async function syncOps( + ops: PendingOp[], + options: SyncAllOptions, +): Promise { + const report: SyncAllReport = { + applied: 0, + conflicts: 0, + noops: 0, + errors: 0, + skipped: 0, + }; + for (let index = 0; index < ops.length; index += 1) { + const op = ops[index]; + options.onProgress?.(op, index, ops.length); + const action = options.resolveAction(op.entity); + if (!action) { + report.skipped += 1; + await markStatus(op.id, "failed", `no action for ${op.entity}`); + continue; + } + + await markStatus(op.id, "syncing"); + try { + const result = await action.sync(op); + switch (result.status) { + case "applied": + report.applied += 1; + await markStatus(op.id, "synced"); + break; + case "noop": + report.noops += 1; + await deleteOp(op.id); + break; + case "conflict": + report.conflicts += 1; + await markStatus(op.id, "conflict"); + break; + case "error": + report.errors += 1; + await markStatus(op.id, "failed", result.message); + break; + } + } catch (err) { + report.errors += 1; + const message = err instanceof Error ? err.message : String(err); + await markStatus(op.id, "failed", message); + } + } + return report; +} diff --git a/src/features/dal/queue/use-pending-ops.ts b/src/features/dal/queue/use-pending-ops.ts new file mode 100644 index 0000000..6060b4b --- /dev/null +++ b/src/features/dal/queue/use-pending-ops.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import { + type ListOpsFilter, + listOps, + type PendingOp, +} from "#/features/dal/queue/pending-ops"; + +export function usePendingOps(filter?: ListOpsFilter) { + return useQuery({ + queryKey: ["dal-queue", filter?.status ?? "all", filter?.entity ?? "all"], + queryFn: () => listOps(filter), + refetchOnWindowFocus: false, + }); +} diff --git a/src/features/dal/server/collected-items.ts b/src/features/dal/server/collected-items.ts new file mode 100644 index 0000000..5a87843 --- /dev/null +++ b/src/features/dal/server/collected-items.ts @@ -0,0 +1,33 @@ +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import { requireUserId } from "#/features/dal/server/require-user"; +import { prisma } from "@/prisma"; + +const CollectInput = z.object({ itemId: z.string().min(1) }); + +export const collectItemServerFn = createServerFn({ method: "POST" }) + .inputValidator((v: unknown) => CollectInput.parse(v)) + .handler(async ({ data }) => { + const userId = await requireUserId(); + return prisma.remnant2CollectedItem.upsert({ + where: { userId_itemId: { userId, itemId: data.itemId } }, + update: {}, + create: { userId, itemId: data.itemId }, + }); + }); + +export const uncollectItemServerFn = createServerFn({ method: "POST" }) + .inputValidator((v: unknown) => CollectInput.parse(v)) + .handler(async ({ data }) => { + const userId = await requireUserId(); + await prisma.remnant2CollectedItem.deleteMany({ + where: { userId, itemId: data.itemId }, + }); + return { ok: true as const }; + }); + +export const listCollectedItemsServerFn = createServerFn({ method: "GET" }) + .handler(async () => { + const userId = await requireUserId(); + return prisma.remnant2CollectedItem.findMany({ where: { userId } }); + }); diff --git a/src/features/dal/server/favorite-games.ts b/src/features/dal/server/favorite-games.ts new file mode 100644 index 0000000..a48fb0b --- /dev/null +++ b/src/features/dal/server/favorite-games.ts @@ -0,0 +1,33 @@ +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import { requireUserId } from "#/features/dal/server/require-user"; +import { GameId, prisma } from "@/prisma"; + +const FavoriteInput = z.object({ gameId: z.nativeEnum(GameId) }); + +export const favoriteGameServerFn = createServerFn({ method: "POST" }) + .inputValidator((v: unknown) => FavoriteInput.parse(v)) + .handler(async ({ data }) => { + const userId = await requireUserId(); + return prisma.userFavoriteGame.upsert({ + where: { userId_gameId: { userId, gameId: data.gameId } }, + update: {}, + create: { userId, gameId: data.gameId }, + }); + }); + +export const unfavoriteGameServerFn = createServerFn({ method: "POST" }) + .inputValidator((v: unknown) => FavoriteInput.parse(v)) + .handler(async ({ data }) => { + const userId = await requireUserId(); + await prisma.userFavoriteGame.deleteMany({ + where: { userId, gameId: data.gameId }, + }); + return { ok: true as const }; + }); + +export const listFavoriteGamesServerFn = createServerFn({ method: "GET" }) + .handler(async () => { + const userId = await requireUserId(); + return prisma.userFavoriteGame.findMany({ where: { userId } }); + }); diff --git a/src/features/dal/server/require-user.ts b/src/features/dal/server/require-user.ts new file mode 100644 index 0000000..19afdd6 --- /dev/null +++ b/src/features/dal/server/require-user.ts @@ -0,0 +1,16 @@ +import { getRequest } from "@tanstack/react-start/server"; +import { auth } from "#/integrations/better-auth/auth"; + +export async function requireUserId(): Promise { + const request = getRequest(); + const session = await auth.api.getSession({ headers: request.headers }); + const userId = session?.user?.id; + if (!userId) throw new Response("Unauthorized", { status: 401 }); + return userId; +} + +export async function getOptionalUserId(): Promise { + const request = getRequest(); + const session = await auth.api.getSession({ headers: request.headers }); + return session?.user?.id ?? null; +} diff --git a/src/features/dal/server/sync.ts b/src/features/dal/server/sync.ts new file mode 100644 index 0000000..4f4ebb9 --- /dev/null +++ b/src/features/dal/server/sync.ts @@ -0,0 +1,134 @@ +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import type { SyncResult } from "#/features/dal/core/types"; +import { compareTimestamps } from "#/features/dal/queue/lww"; +import { requireUserId } from "#/features/dal/server/require-user"; +import { GameId, prisma } from "@/prisma"; + +const PendingOpSchema = z.object({ + id: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + anonUserId: z.string(), + entity: z.string(), + operation: z.enum(["create", "update", "upsert", "delete"]), + payload: z.unknown(), + idempotencyKey: z.string(), + status: z.enum(["pending", "syncing", "synced", "failed", "conflict"]), + lastError: z.string().optional(), + serverUpdatedAt: z.string().optional(), +}); + +type PendingOpPayload = z.infer; + +type Handler = ( + op: PendingOpPayload, + userId: string, +) => Promise; + +const DEDUPE_TTL_MS = 10 * 60 * 1000; +const seen = new Map(); + +function rememberResult(key: string, result: SyncResult) { + seen.set(key, { result, seenAt: Date.now() }); +} + +function recallResult(key: string): SyncResult | null { + const entry = seen.get(key); + if (!entry) return null; + if (Date.now() - entry.seenAt > DEDUPE_TTL_MS) { + seen.delete(key); + return null; + } + return entry.result; +} + +const remnant2CollectedItemHandler: Handler = async (op, userId) => { + const payload = op.payload as { itemId?: string } | null; + const itemId = payload?.itemId; + if (!itemId) return { status: "error", message: "missing itemId" }; + + const record = await prisma.remnant2CollectedItem.findUnique({ + where: { userId_itemId: { userId, itemId } }, + }); + + if (op.operation === "delete") { + if (!record) return { status: "noop" }; + const cmp = compareTimestamps(record, op); + if (cmp === "server-wins") return { status: "conflict", serverRecordJson: JSON.stringify(record) }; + await prisma.remnant2CollectedItem.deleteMany({ + where: { userId, itemId }, + }); + return { status: "applied" }; + } + + if (record) { + const cmp = compareTimestamps(record, op); + if (cmp === "server-wins") return { status: "conflict", serverRecordJson: JSON.stringify(record) }; + return { status: "noop" }; + } + + await prisma.remnant2CollectedItem.create({ data: { userId, itemId } }); + return { status: "applied" }; +}; + +const userFavoriteGameHandler: Handler = async (op, userId) => { + const payload = op.payload as { gameId?: string } | null; + const rawGameId = payload?.gameId; + if (!rawGameId) return { status: "error", message: "missing gameId" }; + const gameId = GameId[rawGameId as keyof typeof GameId]; + if (!gameId) return { status: "error", message: `unknown gameId ${rawGameId}` }; + + const record = await prisma.userFavoriteGame.findUnique({ + where: { userId_gameId: { userId, gameId } }, + }); + + if (op.operation === "delete") { + if (!record) return { status: "noop" }; + const cmp = compareTimestamps(record, op); + if (cmp === "server-wins") + return { status: "conflict", serverRecordJson: JSON.stringify(record) }; + await prisma.userFavoriteGame.deleteMany({ where: { userId, gameId } }); + return { status: "applied" }; + } + + if (record) { + const cmp = compareTimestamps(record, op); + if (cmp === "server-wins") + return { status: "conflict", serverRecordJson: JSON.stringify(record) }; + return { status: "noop" }; + } + + await prisma.userFavoriteGame.create({ data: { userId, gameId } }); + return { status: "applied" }; +}; + +const handlers: Record = { + remnant2CollectedItem: remnant2CollectedItemHandler, + userFavoriteGame: userFavoriteGameHandler, +}; + +export const applyPendingOpServerFn = createServerFn({ method: "POST" }) + .inputValidator((v: unknown) => PendingOpSchema.parse(v)) + .handler(async ({ data }) => { + const userId = await requireUserId(); + const dedupeKey = `${userId}:${data.idempotencyKey}`; + const cached = recallResult(dedupeKey); + if (cached) return cached; + + const handler = handlers[data.entity]; + if (!handler) { + return { status: "error", message: `no sync handler for ${data.entity}` }; + } + + try { + const result = await handler(data, userId); + rememberResult(dedupeKey, result); + return result; + } catch (err) { + return { + status: "error", + message: err instanceof Error ? err.message : String(err), + }; + } + }); diff --git a/src/integrations/tanstack-query/get-context.ts b/src/integrations/tanstack-query/get-context.ts index dddea92..22edaab 100644 --- a/src/integrations/tanstack-query/get-context.ts +++ b/src/integrations/tanstack-query/get-context.ts @@ -1,7 +1,16 @@ import { QueryClient } from "@tanstack/react-query"; export function getContext() { - const queryClient = new QueryClient(); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + gcTime: 5 * 60_000, + retry: (count, err) => + count < 2 && !(err instanceof Response && err.status === 401), + }, + }, + }); return { queryClient, diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 5b9f590..94e29d3 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -9,10 +9,17 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as ProfileRouteImport } from './routes/profile' import { Route as GameIdRouteRouteImport } from './routes/$gameId/route' import { Route as IndexRouteImport } from './routes/index' +import { Route as ApiHealthRouteImport } from './routes/api/health' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' +const ProfileRoute = ProfileRouteImport.update({ + id: '/profile', + path: '/profile', + getParentRoute: () => rootRouteImport, +} as any) const GameIdRouteRoute = GameIdRouteRouteImport.update({ id: '/$gameId', path: '/$gameId', @@ -23,6 +30,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const ApiHealthRoute = ApiHealthRouteImport.update({ + id: '/api/health', + path: '/api/health', + getParentRoute: () => rootRouteImport, +} as any) const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ id: '/api/auth/$', path: '/api/auth/$', @@ -32,35 +44,50 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/$gameId': typeof GameIdRouteRoute + '/profile': typeof ProfileRoute + '/api/health': typeof ApiHealthRoute '/api/auth/$': typeof ApiAuthSplatRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/$gameId': typeof GameIdRouteRoute + '/profile': typeof ProfileRoute + '/api/health': typeof ApiHealthRoute '/api/auth/$': typeof ApiAuthSplatRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/$gameId': typeof GameIdRouteRoute + '/profile': typeof ProfileRoute + '/api/health': typeof ApiHealthRoute '/api/auth/$': typeof ApiAuthSplatRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/$gameId' | '/api/auth/$' + fullPaths: '/' | '/$gameId' | '/profile' | '/api/health' | '/api/auth/$' fileRoutesByTo: FileRoutesByTo - to: '/' | '/$gameId' | '/api/auth/$' - id: '__root__' | '/' | '/$gameId' | '/api/auth/$' + to: '/' | '/$gameId' | '/profile' | '/api/health' | '/api/auth/$' + id: '__root__' | '/' | '/$gameId' | '/profile' | '/api/health' | '/api/auth/$' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute GameIdRouteRoute: typeof GameIdRouteRoute + ProfileRoute: typeof ProfileRoute + ApiHealthRoute: typeof ApiHealthRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/profile': { + id: '/profile' + path: '/profile' + fullPath: '/profile' + preLoaderRoute: typeof ProfileRouteImport + parentRoute: typeof rootRouteImport + } '/$gameId': { id: '/$gameId' path: '/$gameId' @@ -75,6 +102,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/api/health': { + id: '/api/health' + path: '/api/health' + fullPath: '/api/health' + preLoaderRoute: typeof ApiHealthRouteImport + parentRoute: typeof rootRouteImport + } '/api/auth/$': { id: '/api/auth/$' path: '/api/auth/$' @@ -88,6 +122,8 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, GameIdRouteRoute: GameIdRouteRoute, + ProfileRoute: ProfileRoute, + ApiHealthRoute: ApiHealthRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, } export const routeTree = rootRouteImport diff --git a/src/routes/api/health.ts b/src/routes/api/health.ts new file mode 100644 index 0000000..bc3d5ff --- /dev/null +++ b/src/routes/api/health.ts @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/api/health")({ + server: { + handlers: { + GET: () => new Response("ok", { status: 200 }), + }, + }, +}); diff --git a/src/routes/profile.tsx b/src/routes/profile.tsx new file mode 100644 index 0000000..6f43258 --- /dev/null +++ b/src/routes/profile.tsx @@ -0,0 +1,189 @@ +import { + Badge, + Button, + Card, + Container, + Group, + Stack, + Text, + Title, +} from "@mantine/core"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { resolveWriteAction } from "#/features/dal/actions/registry"; +import { useEffectiveUserId } from "#/features/dal/identity/use-effective-user-id"; +import { useOnlineStatus } from "#/features/dal/online/use-online-status"; +import { + clearSynced, + deleteOp, + type PendingOp, +} from "#/features/dal/queue/pending-ops"; +import { syncOps } from "#/features/dal/queue/sync-runner"; +import { usePendingOps } from "#/features/dal/queue/use-pending-ops"; +import { useSession } from "#/integrations/better-auth/auth-client"; + +export const Route = createFileRoute("/profile")({ component: ProfilePage }); + +function ProfilePage() { + const { data: session } = useSession(); + const effective = useEffectiveUserId(); + const online = useOnlineStatus(); + const queryClient = useQueryClient(); + + const pending = usePendingOps(); + const canSync = !!session?.user?.id && online; + + const syncAll = useMutation({ + mutationFn: async () => { + if (!pending.data) return null; + return syncOps(pending.data, { resolveAction: resolveWriteAction }); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["dal-queue"] }); + queryClient.invalidateQueries({ queryKey: ["dal"] }); + }, + }); + + const clear = useMutation({ + mutationFn: () => clearSynced(), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["dal-queue"] }), + }); + + return ( + + + Profile + + + + + Identity + + {effective.kind} + + + {online ? "online" : "offline"} + + + + {session?.user + ? `Signed in as ${session.user.email}` + : "Signed out — writes are stored locally until you sign in."} + + + local id: {effective.id || "—"} + + + + + + + + Pending sync + + + + + + {!canSync ? ( + + Sign in and come online to push pending changes. + + ) : null} + { + deleteOp(id).then(() => + queryClient.invalidateQueries({ queryKey: ["dal-queue"] }), + ); + }} + /> + + + + + ); +} + +function PendingList({ + ops, + onDelete, +}: { + ops: PendingOp[]; + onDelete: (id: string) => void; +}) { + if (!ops.length) { + return ( + + No pending changes. + + ); + } + return ( + + {ops.map((op) => ( + + + + {op.entity} + {op.operation} + + {op.status} + + + + {op.idempotencyKey} + + {op.lastError ? ( + + {op.lastError} + + ) : null} + + + + ))} + + ); +} + +function statusColor(status: PendingOp["status"]) { + switch (status) { + case "pending": + return "gray"; + case "syncing": + return "blue"; + case "synced": + return "green"; + case "conflict": + return "orange"; + case "failed": + return "red"; + } +}