Skip to content

Directus Project Storage: Swap the Local JSON File for a Directus Collection

Goal

Reads and writes to project data (MapProject, its maps, POIs, lines, areas and their type definitions) go through a Directus projects collection instead of data/projects.json, selectable per-environment via a new PROJECTS_STORAGE variable, with no change to any route's request/response shape or UI behavior.

Prerequisites

Already in place, no changes needed:

  • @directus/sdk dependency (package.json)
  • The Directus projects collection already exists and already holds the migrated contents of data/projects.json — confirmed by the user. Primary key field id (string, matches MapProject.id), JSON field data (holds the MapProject payload). This repo has no directus/schema/*.json snapshot to verify this against, so it is trusted as stated rather than grounded in a file (see Risks).
  • directusAsUser/toDirectusFailure in app/lib/directus.server.ts — reused unchanged.
  • The session/token machinery in app/lib/session.server.ts (getActiveSession, requireSession, requireSessionMiddleware) — reused and extended (T2), not replaced.
  • Route nesting: every route that touches projects.server.ts today (home, create, project/:projectId, project/:projectId/map/new, project/:projectId/map/:mapId) is already nested under routes/protected-layout.tsx in app/routes.ts, so a signed-in session is already guaranteed before any of these loaders/ actions run.

Decisions

Decision Choice Why
Directus row shape One row per MapProject: id = the project's own id, data = the full MapProject JSON Confirmed with user
Auth model Every Directus call uses directusAsUser(accessToken) with the signed-in user's own token — no static/service token Confirmed with user; Directus role permissions become the authorization boundary
Data migration None — collection already holds the migrated data Confirmed with user
Schema provisioning Manual, already done outside this repo Confirmed with user
Local JSON fallback Kept permanently, chosen via a new PROJECTS_STORAGE env var (local | directus), default local Confirmed with user
Token propagation to nested loaders A React Router createContext is populated once by requireSessionMiddleware and read by each nested loader/action, instead of each loader calling getActiveSession/requireSession again requireSessionMiddleware already calls requireSession(request) once per request. If a nested loader called it a second time, it would re-evaluate the same (still-stale, pre-refresh) request cookie; if the first call already rotated the refresh token, the second call's refresh attempt uses an already-invalidated refresh token and would bounce a live session to /login. Sharing the one resolved session via context avoids a second Directus round-trip entirely.
Directus client typing A ProjectsSchema type is defined locally in projects.server.ts and passed as an explicit generic on individual SDK calls (readItem<ProjectsSchema, "projects", ...>(...), etc.) Keeps directus.server.ts domain-agnostic, per its own existing doc comment ("no app-specific collections exist yet") — no need to touch that file
Storage swap granularity Each mutation fetches the one Directus row it needs, mutates the resulting MapProject object with the same JS logic that runs today (id generation, slug checks, array splicing), then writes that one row back Smallest change; every existing business rule (slug uniqueness, min ⅔ points, etc.) is untouched. Concurrency behavior (read-modify-write, no locking) is explicitly the same shape as today's whole-file version — out of scope to improve here
Directus failure handling A Directus error during a read that today returns undefined (not found) continues to return undefined, logged via toDirectusFailure; a Directus error during a mutation that today returns { error: string } returns { error: failure.message } instead Reuses the exact 404/{error} handling every caller already has — no new UI or error-handling pattern needed

Scope

In:

Out:

  • Creating/altering the Directus collection or its fields
  • Migrating data/projects.json content into Directus
  • Any Directus permission/role configuration, or an "owner" field — this task assumes the signed-in user's role already has the access it needs (see Risks)
  • Removing data/projects.json or the local-file code path — it remains the default, chosen by PROJECTS_STORAGE
  • Concurrency/optimistic-locking changes
  • Any change to the MapProject/MapRef/PointOfInterest/LineFeature/ AreaFeature/type-definition shapes themselves, or to app/components/map.tsx, app/components/map-topbar.tsx, app/components/image-overlay-control.tsx (type-only imports from projects.server.ts, no changes needed)

Data model

Target shapes after this task (types only, bodies are implementation):

// app/lib/env.server.ts
type ProjectsStorage = "local" | "directus"
// env.PROJECTS_STORAGE: ProjectsStorage, defaults to "local" when unset/empty
// app/lib/session.server.ts
export const activeSessionContext: /* React Router Context<ActiveSession> */

/** Reads the session `requireSessionMiddleware` already resolved for this request. */
export function activeAccessToken(context: RouterContextProvider): string
// app/lib/projects.server.ts

// Directus collection shape — local to this file, not exported.
type ProjectRow = { id: string; data: MapProject }
type ProjectsSchema = { projects: ProjectRow[] }

// Internal storage primitives, branch on env.PROJECTS_STORAGE:
function listProjectRows(accessToken: string): Promise<MapProject[]>
function getProjectRow(accessToken: string, projectId: string): Promise<MapProject | undefined>
function createProjectRow(accessToken: string, project: MapProject): Promise<void>
function writeProjectRow(accessToken: string, project: MapProject): Promise<void>
function normalizeProject(project: MapProject): MapProject // applies the poiTypes/lineTypes/areaTypes/pois/lines/areas/imageOverlays defaulting `readProjectsFile` applies today

// Every existing export gains a leading `accessToken` and becomes async, e.g.:
export function listProjects(accessToken: string): Promise<MapProject[]>
export function getProject(accessToken: string, id: string): Promise<MapProject | undefined>
export function createProject(accessToken: string, input: { name: string; description?: string; previewImage?: string }): Promise<MapProject>
export function updateProjectSettings(accessToken: string, projectId: string, updates: { name?: string; description?: string; previewImage?: string }): Promise<MapProject | undefined>
export function addMap(accessToken: string, projectId: string, input: { name: string; description?: string; source: MapSource }): Promise<MapRef | undefined>
// ...same pattern (leading accessToken, Promise-wrapped return) for every
// remaining export: addPointOfInterest, updatePointOfInterest,
// movePointOfInterest, removePointOfInterest, addPoiType, updatePoiType,
// removePoiType, addLine, updateLine, updateLinePoints, removeLine,
// addLineType, updateLineType, removeLineType, addArea, updateArea,
// updateAreaPoints, removeArea, addAreaType, updateAreaType, removeAreaType

Tasks

Ordered so each leaves the repo type-checking; pnpm typecheck is clean after every task, not just the last one, since only the call sites of a just-converted function are ever touched in the same task as that function.

T1 — env.server.ts / .env.example: add PROJECTS_STORAGE

Goal: The storage backend is selectable per environment, defaulting to today's behavior.

Acceptance criteria:

  • env.server.ts exports PROJECTS_STORAGE: "local" | "directus", read from process.env.PROJECTS_STORAGE, defaulting to "local" for any unset, empty, or unrecognized value (invalid values fall back to "local" rather than throwing — this is a convenience toggle, not a required var, so it doesn't join REQUIRED).
  • .env.example documents PROJECTS_STORAGE with a comment naming the two accepted values and the default.
  • Nothing else in env.server.ts changes.
  • pnpm typecheck passes (nothing yet reads the new export).

T2 — session.server.ts: share the resolved session via context

Goal: A nested loader/action can read the current accessToken without triggering a second Directus token refresh in the same request.

Acceptance criteria:

  • A module-level activeSessionContext is created with React Router's createContext<ActiveSession>() (imported from "react-router", already a dependency — no new package).
  • requireSessionMiddleware calls context.set(activeSessionContext, active) after requireSession(request) succeeds and before calling next(). Its existing setCookie handling on the response is unchanged.
  • A new activeAccessToken(context: RouterContextProvider): string helper reads context.get(activeSessionContext).values.accessToken — this is the only shape nested routes need, so they don't have to know about ActiveSession's other fields.
  • No existing export's signature changes (getActiveSession, requireSession, requireSessionForResource, sanitizeReturnTo, etc. are untouched) — this task only adds.
  • pnpm typecheck passes (nothing yet calls activeAccessToken).

T3 — projects.server.ts: Directus schema type + storage primitives

Goal: The two storage backends exist side by side behind the same new internal primitives; nothing that already compiles is touched.

Acceptance criteria:

  • ProjectRow/ProjectsSchema types are added, matching the Data model above.
  • getProjectRow, writeProjectRow, createProjectRow, listProjectRows, normalizeProject are added as new internal (non-exported) async functions. Each branches on env.PROJECTS_STORAGE:
  • "local": same whole-file read/write as today's readProjectsFile/ writeProjectsFile (which stay as-is and are called from inside these new primitives — not deleted).
  • "directus": directusAsUser(accessToken).request(...) with readItem/readItems/createItem/updateItem from @directus/sdk, each called with an explicit ProjectsSchema generic. A readItem/ readItems failure is caught, logged via toDirectusFailure, and surfaces as undefined/[] the same way a missing row does — no unhandled rejection reaches a caller.
  • normalizeProject contains exactly the defaulting logic readProjectsFile applies today (poiTypes/lineTypes/areaTypes fallback to the DEFAULT_* constants; pois/lines/areas/ imageOverlays default to []), applied to a single MapProject instead of every project in a file.
  • None of the ~30 existing exported functions, and none of their callers, change in this task.
  • pnpm typecheck passes.

T4 — Project-level functions: listProjects, getProject, createProject, updateProjectSettings

Goal: Reading the project list, reading one project, creating a project, and editing its name/description/preview image all go through the new primitives.

Acceptance criteria:

  • listProjects, getProject, createProject, updateProjectSettings are converted to the async, accessToken-first signatures in the Data model above, implemented via listProjectRows/getProjectRow/ createProjectRow/writeProjectRow + normalizeProject. Their existing business logic (id generation, undefined on missing project) is unchanged.
  • readProjectsFile/writeProjectsFile are no longer called from these four functions (they're still used inside the T3 primitives for the "local" backend).
  • Callers updated in the same task, each now obtaining accessToken via activeAccessToken(context):
  • app/routes/home.tsx — loader becomes async, awaits listProjects.
  • app/routes/create.tsx — action awaits createProject.
  • app/routes/add-map.tsx — loader becomes async, awaits getProject.
  • app/routes/project.tsx — loader becomes async, awaits getProject; the settings branch of action awaits updateProjectSettings. The poi-type/line-type/area-type branches of action are untouched in this task (T7).
  • app/routes/project-map.tsx — loader becomes async, awaits getProject. action is untouched in this task (T5/T6).
  • pnpm typecheck passes.

T5 — Map + POI functions

Goal: Adding a map and every point-of-interest mutation go through the new primitives.

Acceptance criteria:

  • addMap, addPointOfInterest, updatePointOfInterest, movePointOfInterest, removePointOfInterest are converted to the async, accessToken-first signatures.
  • Callers updated in the same task:
  • app/routes/add-map.tsx — action awaits addMap.
  • app/routes/project-map.tsx — the "remove", "move", "add", "update" intent branches of action await their respective functions. The line/area/type intent branches are untouched in this task.
  • pnpm typecheck passes.

T6 — Line + area functions

Goal: Every line and area mutation (add/update/update-points/remove) goes through the new primitives.

Acceptance criteria:

  • addLine, updateLine, updateLinePoints, removeLine, addArea, updateArea, updateAreaPoints, removeArea are converted to the async, accessToken-first signatures.
  • app/routes/project-map.tsx — the "remove-line", "remove-area", "update-line-points", "update-area-points", "add-line"/"update-line", "add-area"/"update-area" intent branches of action await their respective functions.
  • pnpm typecheck passes.

T7 — POI/line/area type CRUD

Goal: Managing the per-project type definitions (POI types, line types, area types) goes through the new primitives.

Acceptance criteria:

  • addPoiType, updatePoiType, removePoiType, addLineType, updateLineType, removeLineType, addAreaType, updateAreaType, removeAreaType are converted to the async, accessToken-first signatures.
  • app/routes/project.tsx — every remaining branch of action (add-poi-type/update-poi-type, delete-poi-type, add-line-type/update-line-type, delete-line-type, add-area-type/update-area-type, delete-area-type) awaits its respective function.
  • No exported function in projects.server.ts is still synchronous or missing the accessToken parameter after this task.
  • readProjectsFile/writeProjectsFile now have exactly one remaining caller each: the "local" branch inside the T3 primitives. They stay — not exported, not deleted.
  • pnpm typecheck passes.

T8 — Final verification

Goal: Both storage backends work end to end.

Acceptance criteria:

  • pnpm typecheck and pnpm build pass with no errors anywhere in the repo.
  • pnpm format produces no diff.
  • Manual check, PROJECTS_STORAGE unset (or local): pnpm dev, sign in, create a project, add a map, add/move/remove a POI, add a line and an area, add/edit/delete a POI type — confirm every change is reflected on reload and that data/projects.json contains it, exactly as before this task.
  • Manual check, PROJECTS_STORAGE=directus: repeat the same walkthrough; confirm each change is visible in the Directus admin UI's projects collection (the row's data field), and that reloading the page (a fresh GET) reflects the change (proves it was actually read back from Directus, not served from an in-memory value).
  • Manual check: sign in, wait past SESSION_MAX_AGE_SECONDS's refresh skew (or temporarily shorten it for the test), and confirm a page navigation under PROJECTS_STORAGE=directus still succeeds — i.e. the token refresh in requireSessionMiddleware is not duplicated by any nested loader (the T2 risk this task is designed to avoid).

Risks / open questions

  • The Directus projects collection's field names (id, data) and its existing data are taken on the user's word — this repo has no directus/schema/*.json snapshot to verify them against. If either name is wrong, every Directus-backed call fails at once; worth a quick manual check in the Directus admin UI before starting T3.
  • The signed-in user's Directus role needs read/create/update permission on projects. This wasn't verified here (out of scope, per Decisions) — a missing permission would surface as a generic toDirectusFailure message with no pointer to "check Directus permissions," which could read as a bug during manual testing in T8.