diff --git a/documentation/DAL.md b/documentation/DAL.md index eead9fa..59e5e6e 100644 --- a/documentation/DAL.md +++ b/documentation/DAL.md @@ -22,27 +22,30 @@ Otherwise it picks `local`. That covers: `src/features/dal/` contains: ``` -core/ - define-action.ts # defineDalRead / defineDalWrite - the action factories - choose-backend.ts # remote-vs-local selection logic - to-query-options.ts # adapts a DalRead to TanStack Query options - presence-sync-handler.ts # shared SyncHandler factory for presence-toggle entities - types.ts # DalContext, DalRead, DalWrite, PendingOp, etc. -hooks/ - useDalQuery / useDalSuspenseQuery - useDalMutation - useBackend # exposes the current backend choice - useDalContextSource # used internally to build the DalContext +define-action.ts # defineDalRead / defineDalWrite - the action factories +choose-backend.ts # remote-vs-local selection logic +to-query-options.ts # adapts a DalRead to TanStack Query options +presence-sync-handler.ts # shared SyncHandler factory for presence-toggle entities +types.ts + +useDalQuery / useDalSuspenseQuery +useDalMutation +useBackend # exposes the current backend choice +useDalContextSource # used internally to build the DalContext + identity/ Anon-id generation/persistence, useEffectiveUserId + local/ - IndexedDB constants, local-client.ts (prisma-idb wrapper), shared local row types + IndexedDB constants, local-db.ts (prisma-idb wrapper), shared local row types + queue/ pending-ops.ts # PendingOp storage in IndexedDB sync-runner.ts # syncOps() / forceSyncOp() - drains the queue last-write-wins.ts # last-write-wins conflict resolution apply-pending-ops.ts # applyPendingOpServerFn the runner calls; dispatches by entity usePendingOps # observe pending ops from React + ``` The sync runner uploads every queued op through the single `applyPendingOpServerFn`, which dispatches to the right `SyncHandler` by `op.entity`. There is no client-side write-action registry - server-side dispatch is the only routing layer. @@ -78,12 +81,12 @@ Use `ctx.authUserId ?? ctx.anonUserId` whenever you need a stable local user ID ## Defining actions -Actions are declared with two factory helpers from `#/features/dal/core/define-action`. Reads and writes have different shapes. +Actions are declared with two factory helpers from `#/features/dal/define-action`. Reads and writes have different shapes. ### Read action ```typescript -import { defineDalRead } from "#/features/dal/core/define-action"; +import { defineDalRead } from "#/features/dal/define-action"; const list = defineDalRead({ queryKey: () => ["myEntity", "list"], @@ -97,12 +100,12 @@ The `queryKey` is what TanStack Query uses for caching. The `remote` and `local` ### Write action ```typescript -import { defineDalWrite } from "#/features/dal/core/define-action"; +import { defineDalWrite } from "#/features/dal/define-action"; const upsert = defineDalWrite({ entity: "myEntity", operation: "upsert", - invalidates: ["myEntity"], + invalidates: ["anotherEntity"], buildIdempotencyKey: (input, ctx) => `myEntity:upsert:${ctx.anonUserId}:${input.id}`, remote: async (input, _ctx) => myUpsertServerFn({ data: input }), local: async (input, ctx) => @@ -115,17 +118,18 @@ const upsert = defineDalWrite({ Write actions carry more metadata because they need to participate in the sync queue: - **`entity` + `operation`** - categorize the op. `entity` must match a key in the server-side sync-handler map (see below). -- **`invalidates`** - query keys to invalidate after the write succeeds. This is what makes related lists re-fetch. -- **`buildIdempotencyKey`** - used for de-duplication on the server when the same op flushes twice (network retry, etc.). Include the user id and a stable identifier from the input. +- **`invalidates`** - entity names to invalidate after the write succeeds. This is what makes related lists re-fetch. The primary entity is always invalidated automatically. +- **`buildIdempotencyKey`** - used for deduplication on the server when the same op flushes twice (network retry, etc.). Include the user id and a stable identifier from the input. - **`describe`** _(optional)_ - a `PendingOpSummary` snapshot so the data-sync UI can show a friendly description of the queued op. -- **`getServerUpdatedAt`** _(optional)_ - reads the server record's `updatedAt` before the local write and stores it on the op as the last-write-wins baseline (resolution lives in `queue/last-write-wins.ts`). Omit it for pure creates; actions without it fall back to comparing the op's own creation time. +- **`getServerUpdatedAt`** _(optional)_ – reads the server record's `updatedAt` before the local write and stores it on the op as the last-write-wins baseline (resolution lives in `queue/last-write-wins.ts`). Omit it for pure creates; actions without it fall back to comparing the op's own creation time. There is **no `sync` field**. Every queued op is uploaded by the sync runner through the same `applyPendingOpServerFn`, which dispatches by `entity` - so wiring sync is just registering the server-side handler. ## Using actions in components ```typescript -import { useDalQuery, useDalMutation } from "#/features/dal/hooks"; +import { useDalQuery } from "#/features/dal/use-dal-query.ts"; +import { useDalMutation } from "#/features/dal/use-dal-mutation.ts"; import { myActions } from "..."; const { data, isLoading } = useDalQuery(myActions.list, undefined); @@ -146,7 +150,7 @@ That's the entire surface area for most consumers. The DAL takes care of: Inside server functions you have two helpers for resolving the authenticated user: ```typescript -import { requireUserId, getOptionalUserId } from "#/features/auth/dal/require-user.server"; +import { requireUserId, getOptionalUserId } from "#/features/auth/require-user.server"; const userId = await requireUserId(); // throws 401 if no session const maybeUserId = await getOptionalUserId(); // returns null if unauthenticated @@ -158,20 +162,20 @@ Reach for `requireUserId()` whenever a write absolutely requires a logged-in use DAL actions split by scope: -| Scope | Location | -|--------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------| -| Cross-game (e.g. `favoriteGames`, `userProfile`) | `src/features//dal//` - currently all under `src/features/auth/dal/` since both belong to the authenticated-user surface | -| Game-specific (e.g. `collectedItems`) | `src/games//dal/` | +| Scope | Location | +|--------------------------------------------------|------------------------------------| +| Cross-game (e.g. `favoriteGames`, `userProfile`) | `src/features/game/dal//` | +| Game-specific (e.g. `collectedItems`) | `src/games//dal/` | Within each cross-game DAL folder, files follow a consistent suffix convention: - **`.ts`** - TanStack Start server functions (Postgres reads/writes via Prisma) - **`.idb.ts`** - IndexedDB layer (local reads/writes via the prisma-idb client) -- **`.actions.ts`** - `defineDalRead` / `defineDalWrite` action definitions, wiring `remote` to the server functions and `local` to the IDB helpers +- **`.dal.ts`** - `defineDalRead` / `defineDalWrite` action definitions, wiring `remote` to the server functions and `local` to the IDB helpers - **`sync-handler.ts`** - server-side sync handler invoked by `applyPendingOpServerFn` > [!IMPORTANT] -> Game-specific DAL logic must live under `src/games//dal/`, **never** as a branch inside `src/features/`. The shared `createCollectedItemsDal()` factory in `#/features/game/dal/collected-items/collected-items.actions` is how per-game DAL files stay short - they pass in the model accessor and server functions and get a fully-wired DAL back. +> Game-specific DAL logic must live under `src/games//dal/`, **never** as a branch inside `src/features/`. The shared `createCollectedItemsDal()` factory in `#/features/game/dal/collected-items/collected-items.dal` is how per-game DAL files stay short – they pass in the model accessor and server functions and get a fully-wired DAL back. ## Adding a new persisted entity @@ -183,9 +187,9 @@ The shortest path: 3. **Write the IDB helpers** (`.idb.ts`) - read/write via the prisma-idb client. The local row type usually mirrors the Postgres row but with extra bookkeeping (e.g. `userId` scoping). -4. **Define the actions** (`.actions.ts`) with `defineDalRead` / `defineDalWrite` - see the examples above. +4. **Define the actions** (`.dal.ts`) with `defineDalRead` / `defineDalWrite` - see the examples above. -5. **(Writes only)** Add the sync handler in `sync-handler.ts`. It receives the `PendingOp` and applies it to Postgres, with last-write-wins on conflicts. If the entity is a simple presence toggle (a row that either exists or not, with no mutable fields - like collected items or favorited games), reuse `createPresenceToggleSyncHandler` from `#/features/dal/core/presence-sync-handler` instead of hand-writing the delete/upsert + LWW branching. +5. **(Writes only)** Add the sync handler in `sync-handler.ts`. It receives the `PendingOp` and applies it to Postgres, with last-write-wins on conflicts. If the entity is a simple presence toggle (a row that either exists or not, with no mutable fields - like collected items or favorited games), reuse `createPresenceToggleSyncHandler` from `#/features/dal/presence-sync-handler` instead of hand-writing the delete/upsert + LWW branching. 6. **(Writes only)** Register the handler under its `entity` key so `applyPendingOpServerFn` can dispatch to it: - **Game-scoped** - add it to `src/features/game/registry/game-sync-handler-registry.ts`.